From 3438a4f063f5a4d04b824531ca4e07c1700bfa4e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 26 May 2025 20:31:18 +0000 Subject: [PATCH 0001/1664] Bump version to 2025.6.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5b299fd0187..a36aa88ca84 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 1fc4a28b9da..6a1fa4d49b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0.dev0" +version = "2025.6.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 9483a88ee1fda9f0dfc3c1dca56022432913aff0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 26 May 2025 23:47:53 +0200 Subject: [PATCH 0002/1664] Fix translation for sensor measurement angle state class (#145649) --- homeassistant/components/sensor/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 2268d2797e4..ecaeb2504d9 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -135,7 +135,7 @@ "name": "State class", "state": { "measurement": "Measurement", - "measurement_angle": "Measurement Angle", + "measurement_angle": "Measurement angle", "total": "Total", "total_increasing": "Total increasing" } From 77031d1ae4235c13a7a8e768a954cb84ae0bce44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 26 May 2025 23:08:07 +0200 Subject: [PATCH 0003/1664] Fix Aquacell snapshot (#145651) --- tests/components/aquacell/snapshots/test_sensor.ambr | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/aquacell/snapshots/test_sensor.ambr b/tests/components/aquacell/snapshots/test_sensor.ambr index f512b2a824d..c24a7f43cfe 100644 --- a/tests/components/aquacell/snapshots/test_sensor.ambr +++ b/tests/components/aquacell/snapshots/test_sensor.ambr @@ -77,6 +77,7 @@ 'original_name': 'Last update', 'platform': 'aquacell', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': 'last_update', 'unique_id': 'DSN-last_update', From f60de45b52987c34f3c2de193b37e6be556be363 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 27 May 2025 08:49:25 +0200 Subject: [PATCH 0004/1664] Fix Amazon devices offline handling (#145656) --- .../components/amazon_devices/entity.py | 6 ++- .../amazon_devices/test_binary_sensor.py | 32 +++++++++++++++ .../components/amazon_devices/test_notify.py | 37 +++++++++++++++++- .../components/amazon_devices/test_switch.py | 39 ++++++++++++++++++- 4 files changed, 110 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/amazon_devices/entity.py b/homeassistant/components/amazon_devices/entity.py index 825a63db476..bab8009ceb0 100644 --- a/homeassistant/components/amazon_devices/entity.py +++ b/homeassistant/components/amazon_devices/entity.py @@ -50,4 +50,8 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]): @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self._serial_num in self.coordinator.data + return ( + super().available + and self._serial_num in self.coordinator.data + and self.device.online + ) diff --git a/tests/components/amazon_devices/test_binary_sensor.py b/tests/components/amazon_devices/test_binary_sensor.py index bbe8af17a8e..b31d85e06aa 100644 --- a/tests/components/amazon_devices/test_binary_sensor.py +++ b/tests/components/amazon_devices/test_binary_sensor.py @@ -17,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration +from .const import TEST_SERIAL_NUMBER from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -69,3 +70,34 @@ async def test_coordinator_data_update_fails( assert (state := hass.states.get(entity_id)) assert state.state == STATE_UNAVAILABLE + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "binary_sensor.echo_test_connectivity" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/amazon_devices/test_notify.py b/tests/components/amazon_devices/test_notify.py index c1147af94c7..b486380fd07 100644 --- a/tests/components/amazon_devices/test_notify.py +++ b/tests/components/amazon_devices/test_notify.py @@ -6,19 +6,21 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL from homeassistant.components.notify import ( ATTR_MESSAGE, DOMAIN as NOTIFY_DOMAIN, SERVICE_SEND_MESSAGE, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util from . import setup_integration +from .const import TEST_SERIAL_NUMBER -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -68,3 +70,34 @@ async def test_notify_send_message( assert (state := hass.states.get(entity_id)) assert state.state == now.isoformat() + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "notify.echo_test_announce" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/amazon_devices/test_switch.py b/tests/components/amazon_devices/test_switch.py index 004d6cce842..24af96db280 100644 --- a/tests/components/amazon_devices/test_switch.py +++ b/tests/components/amazon_devices/test_switch.py @@ -12,7 +12,13 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -89,3 +95,34 @@ async def test_switch_dnd( assert mock_amazon_devices_client.set_do_not_disturb.call_count == 2 assert (state := hass.states.get(entity_id)) assert state.state == STATE_OFF + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "switch.echo_test_do_not_disturb" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE From 20a6a3f195f62e530c483f6b22bca806b00cd8cd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 27 May 2025 00:01:35 +0200 Subject: [PATCH 0005/1664] Handle Google Nest DHCP flows (#145658) * Handle Google Nest DHCP flows * Handle Google Nest DHCP flows --- homeassistant/components/nest/config_flow.py | 1 + tests/components/nest/test_config_flow.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 6ed43066fe3..1513a039407 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -446,4 +446,5 @@ class NestFlowHandler( self, discovery_info: DhcpServiceInfo ) -> ConfigFlowResult: """Handle a flow initialized by discovery.""" + await self._async_handle_discovery_without_unique_id() return await self.async_step_user() diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 0e6ec290841..3f369f3e127 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -1002,6 +1002,24 @@ async def test_dhcp_discovery( assert result.get("reason") == "missing_credentials" +@pytest.mark.parametrize( + ("nest_test_config", "sdm_managed_topic", "device_access_project_id"), + [(TEST_CONFIG_APP_CREDS, True, "project-id-2")], +) +async def test_dhcp_discovery_already_setup( + hass: HomeAssistant, oauth: OAuthFixture, setup_platform +) -> None: + """Exercise discovery dhcp with existing config entry.""" + await setup_platform() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=FAKE_DHCP_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.parametrize(("sdm_managed_topic"), [(True)]) async def test_dhcp_discovery_with_creds( hass: HomeAssistant, From d6cadc1e3ff0c1d600a475cb4f1311f74324b3f8 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 27 May 2025 18:53:45 +0200 Subject: [PATCH 0006/1664] Support addresses with comma in google_travel_time (#145663) Support addresses with comma --- .../components/google_travel_time/helpers.py | 2 +- .../google_travel_time/test_helpers.py | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 tests/components/google_travel_time/test_helpers.py diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 49294455a49..2e36abd62b1 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -37,7 +37,7 @@ def convert_to_waypoint(hass: HomeAssistant, location: str) -> Waypoint | None: try: formatted_coordinates = coordinates.split(",") vol.Schema(cv.gps(formatted_coordinates)) - except (AttributeError, vol.ExactSequenceInvalid): + except (AttributeError, vol.Invalid): return Waypoint(address=location) return Waypoint( location=Location( diff --git a/tests/components/google_travel_time/test_helpers.py b/tests/components/google_travel_time/test_helpers.py new file mode 100644 index 00000000000..058cb214ed7 --- /dev/null +++ b/tests/components/google_travel_time/test_helpers.py @@ -0,0 +1,46 @@ +"""Tests for google_travel_time.helpers.""" + +from google.maps.routing_v2 import Location, Waypoint +from google.type import latlng_pb2 +import pytest + +from homeassistant.components.google_travel_time import helpers +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("location", "expected_result"), + [ + ( + "12.34,56.78", + Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=12.34, + longitude=56.78, + ) + ) + ), + ), + ( + "12.34, 56.78", + Waypoint( + location=Location( + lat_lng=latlng_pb2.LatLng( + latitude=12.34, + longitude=56.78, + ) + ) + ), + ), + ("Some Address", Waypoint(address="Some Address")), + ("Some Street 1, 12345 City", Waypoint(address="Some Street 1, 12345 City")), + ], +) +def test_convert_to_waypoint_coordinates( + hass: HomeAssistant, location: str, expected_result: Waypoint +) -> None: + """Test convert_to_waypoint returns correct Waypoint for coordinates or address.""" + waypoint = helpers.convert_to_waypoint(hass, location) + + assert waypoint == expected_result From bfdba7713e6e6f022cbb71ef2493bfb0da85a297 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 26 May 2025 21:58:46 -0500 Subject: [PATCH 0007/1664] Bump aiohttp to 3.12.2 (#145671) --- 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 7da421526de..ae59ce94200 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.12.1 +aiohttp==3.12.2 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 6a1fa4d49b6..7df9230b7ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.1", + "aiohttp==3.12.2", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index b89c164188e..e4d1cc5ba30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.1 +aiohttp==3.12.2 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From f09c28e61f338d2f34f369a8daef52f640434cf5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 27 May 2025 08:48:06 +0200 Subject: [PATCH 0008/1664] Fix justnimbus CI test (#145681) --- tests/components/justnimbus/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/justnimbus/test_config_flow.py b/tests/components/justnimbus/test_config_flow.py index 330b05bf48c..cc3a7a88285 100644 --- a/tests/components/justnimbus/test_config_flow.py +++ b/tests/components/justnimbus/test_config_flow.py @@ -1,6 +1,6 @@ """Test the JustNimbus config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch from justnimbus.exceptions import InvalidClientID, JustNimbusError import pytest @@ -132,7 +132,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: with patch( "homeassistant.components.justnimbus.config_flow.justnimbus.JustNimbusClient.get_data", - return_value=True, + return_value=MagicMock(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], From bfa919d078a5ac81c17763d8dc4c2980669d6367 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 27 May 2025 13:53:30 +0300 Subject: [PATCH 0009/1664] Remove confirm screen after Z-Wave usb discovery (#145682) * Remove confirm screen after Z-Wave usb discovery * Simplify async_step_usb --- .../components/zwave_js/config_flow.py | 30 +----- .../components/zwave_js/strings.json | 3 - tests/components/zwave_js/test_config_flow.py | 91 +++---------------- 3 files changed, 14 insertions(+), 110 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 3e899da0538..e2941b52522 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -170,8 +170,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - _title: str - def __init__(self) -> None: """Set up flow instance.""" self.s0_legacy_key: str | None = None @@ -446,7 +444,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # at least for a short time. return self.async_abort(reason="already_in_progress") if current_config_entries := self._async_current_entries(include_ignore=False): - config_entry = next( + self._reconfigure_config_entry = next( ( entry for entry in current_config_entries @@ -454,7 +452,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ), None, ) - if not config_entry: + if not self._reconfigure_config_entry: return self.async_abort(reason="addon_required") vid = discovery_info.vid @@ -503,31 +501,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): ) title = human_name.split(" - ")[0].strip() self.context["title_placeholders"] = {CONF_NAME: title} - self._title = title - return await self.async_step_usb_confirm() - - async def async_step_usb_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle USB Discovery confirmation.""" - if user_input is None: - return self.async_show_form( - step_id="usb_confirm", - description_placeholders={CONF_NAME: self._title}, - ) self._usb_discovery = True - if current_config_entries := self._async_current_entries(include_ignore=False): - self._reconfigure_config_entry = next( - ( - entry - for entry in current_config_entries - if entry.data.get(CONF_USE_ADDON) - ), - None, - ) - if not self._reconfigure_config_entry: - return self.async_abort(reason="addon_required") + if current_config_entries: return await self.async_step_intent_migrate() return await self.async_step_installation_type() diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index fbe43af1f6f..ee6efcb2fb6 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -98,9 +98,6 @@ "start_addon": { "title": "The Z-Wave add-on is starting." }, - "usb_confirm": { - "description": "Do you want to set up {name} with the Z-Wave add-on?" - }, "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 bae8ae55034..c9929759a49 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -585,8 +585,8 @@ async def test_abort_hassio_discovery_with_existing_flow(hass: HomeAssistant) -> context={"source": config_entries.SOURCE_USB}, data=USB_DISCOVERY_INFO, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" result2 = await hass.config_entries.flow.async_init( DOMAIN, @@ -664,13 +664,8 @@ async def test_usb_discovery( context={"source": config_entries.SOURCE_USB}, data=usb_discovery_info, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" - assert result["description_placeholders"] == {"name": discovery_name} + assert mock_usb_serial_by_id.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.MENU assert result["step_id"] == "installation_type" assert result["menu_options"] == ["intent_recommended", "intent_custom"] @@ -771,12 +766,8 @@ async def test_usb_discovery_addon_not_running( context={"source": config_entries.SOURCE_USB}, data=USB_DISCOVERY_INFO, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.MENU assert result["step_id"] == "installation_type" @@ -932,12 +923,8 @@ async def test_usb_discovery_migration( context={"source": config_entries.SOURCE_USB}, data=USB_DISCOVERY_INFO, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "intent_migrate" @@ -1063,12 +1050,8 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( context={"source": config_entries.SOURCE_USB}, data=USB_DISCOVERY_INFO, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" + assert mock_usb_serial_by_id.call_count == 2 - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.FORM assert result["step_id"] == "intent_migrate" @@ -1366,16 +1349,16 @@ async def test_usb_discovery_with_existing_usb_flow(hass: HomeAssistant) -> None data=first_usb_info, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "installation_type" result2 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USB}, data=USB_DISCOVERY_INFO, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "usb_confirm" + assert result2["type"] is FlowResultType.MENU + assert result2["step_id"] == "installation_type" usb_flows_in_progress = hass.config_entries.flow.async_progress_by_handler( DOMAIN, match_context={"source": config_entries.SOURCE_USB} @@ -1409,53 +1392,6 @@ async def test_abort_usb_discovery_addon_required(hass: HomeAssistant) -> None: assert result["reason"] == "addon_required" -@pytest.mark.usefixtures( - "supervisor", - "addon_running", -) -async def test_abort_usb_discovery_confirm_addon_required( - hass: HomeAssistant, - addon_options: dict[str, Any], - mock_usb_serial_by_id: MagicMock, -) -> None: - """Test usb discovery confirm aborted when existing entry not using add-on.""" - addon_options["device"] = "/dev/another_device" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - "url": "ws://localhost:3000", - "usb_path": "/dev/another_device", - "use_addon": True, - }, - title=TITLE, - unique_id="1234", - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USB}, - data=USB_DISCOVERY_INFO, - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" - assert mock_usb_serial_by_id.call_count == 2 - - hass.config_entries.async_update_entry( - entry, - data={ - **entry.data, - "use_addon": False, - }, - ) - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "addon_required" - - async def test_usb_discovery_requires_supervisor(hass: HomeAssistant) -> None: """Test usb discovery flow is aborted when there is no supervisor.""" result = await hass.config_entries.flow.async_init( @@ -4635,13 +4571,8 @@ async def test_recommended_usb_discovery( context={"source": config_entries.SOURCE_USB}, data=usb_discovery_info, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "usb_confirm" - assert result["description_placeholders"] == {"name": discovery_name} + assert mock_usb_serial_by_id.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.MENU assert result["step_id"] == "installation_type" assert result["menu_options"] == ["intent_recommended", "intent_custom"] From 2830ed61471a8e1adde182d4abe4e320e83ff05a Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 27 May 2025 11:04:29 +0300 Subject: [PATCH 0010/1664] Change description on recommended/custom Z-Wave install step (#145688) Change description on recommended/custom Z-WaveJS step --- 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 ee6efcb2fb6..439fc7b1aad 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -131,7 +131,7 @@ }, "installation_type": { "title": "Set up Z-Wave", - "description": "Choose the installation type for your Z-Wave integration.", + "description": "In a few steps, we’re going to set up your Home Assistant Connect ZWA-2. Home Assistant can automatically install and configure the recommended Z-Wave setup, or you can customize it.", "menu_options": { "intent_recommended": "Recommended installation", "intent_custom": "Custom installation" From 9e7dc1d11d702eae57f57a5d65bb472c4a062c0c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 27 May 2025 12:33:02 +0200 Subject: [PATCH 0011/1664] Use string type for amazon devices OTP code (#145698) --- homeassistant/components/amazon_devices/config_flow.py | 2 +- tests/components/amazon_devices/const.py | 2 +- tests/components/amazon_devices/test_config_flow.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amazon_devices/config_flow.py b/homeassistant/components/amazon_devices/config_flow.py index 5566c16602b..d0c3d067cee 100644 --- a/homeassistant/components/amazon_devices/config_flow.py +++ b/homeassistant/components/amazon_devices/config_flow.py @@ -57,7 +57,7 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): ): CountrySelector(), vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_CODE): cv.positive_int, + vol.Required(CONF_CODE): cv.string, } ), ) diff --git a/tests/components/amazon_devices/const.py b/tests/components/amazon_devices/const.py index 94b5b7052e6..a2600ba98a6 100644 --- a/tests/components/amazon_devices/const.py +++ b/tests/components/amazon_devices/const.py @@ -1,6 +1,6 @@ """Amazon Devices tests const.""" -TEST_CODE = 123123 +TEST_CODE = "023123" TEST_COUNTRY = "IT" TEST_PASSWORD = "fake_password" TEST_SERIAL_NUMBER = "echo_test_serial_number" diff --git a/tests/components/amazon_devices/test_config_flow.py b/tests/components/amazon_devices/test_config_flow.py index 68ab7f4ffa6..41b65c33bd5 100644 --- a/tests/components/amazon_devices/test_config_flow.py +++ b/tests/components/amazon_devices/test_config_flow.py @@ -56,6 +56,7 @@ async def test_full_flow( }, } assert result["result"].unique_id == TEST_USERNAME + mock_amazon_devices_client.login_mode_interactive.assert_called_once_with("023123") @pytest.mark.parametrize( From b84850df9f6683fdccbdd57a1f3d3920629a2e9b Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 27 May 2025 12:54:57 +0200 Subject: [PATCH 0012/1664] Fix error stack trace for HomeAssistantError in websocket service call (#145699) * Add test * Fix error stack trace for HomeAssistantError in websocket service call --- homeassistant/components/websocket_api/commands.py | 4 +++- tests/components/websocket_api/test_commands.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index ddcdd4f1cf8..9c371a8399d 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -300,7 +300,9 @@ async def handle_call_service( translation_placeholders=err.translation_placeholders, ) except HomeAssistantError as err: - connection.logger.exception("Unexpected exception") + connection.logger.error( + "Error during service call to %s.%s: %s", msg["domain"], msg["service"], err + ) connection.send_error( msg["id"], const.ERR_HOME_ASSISTANT_ERROR, diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4ca2098550b..2c9cc19c84b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -514,9 +514,12 @@ async def test_call_service_schema_validation_error( @pytest.mark.parametrize("ignore_translations_for_mock_domains", ["test"]) async def test_call_service_error( - hass: HomeAssistant, websocket_client: MockHAClientWebSocket + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + websocket_client: MockHAClientWebSocket, ) -> None: """Test call service command with error.""" + caplog.set_level(logging.ERROR) @callback def ha_error_call(_): @@ -561,6 +564,7 @@ async def test_call_service_error( assert msg["error"]["translation_placeholders"] == {"option": "bla"} assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" + assert "Traceback" not in caplog.text await websocket_client.send_json_auto_id( { @@ -578,6 +582,7 @@ async def test_call_service_error( assert msg["error"]["translation_placeholders"] == {"option": "bla"} assert msg["error"]["translation_key"] == "custom_error" assert msg["error"]["translation_domain"] == "test" + assert "Traceback" not in caplog.text await websocket_client.send_json_auto_id( { @@ -592,6 +597,7 @@ async def test_call_service_error( assert msg["success"] is False assert msg["error"]["code"] == "unknown_error" assert msg["error"]["message"] == "value_error" + assert "Traceback" in caplog.text async def test_subscribe_unsubscribe_events( From 923530972a5e25c78065df1e4cc39afb3bf44a24 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 27 May 2025 17:35:11 +0200 Subject: [PATCH 0013/1664] Remove static pin code length Matter sensors (#145711) * Remove static Matter sensors * Clean up translation strings --- homeassistant/components/matter/sensor.py | 22 -- homeassistant/components/matter/strings.json | 6 - .../matter/snapshots/test_sensor.ambr | 192 ------------------ 3 files changed, 220 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 2197f81e134..e1fbf1c5a82 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -967,28 +967,6 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), - MatterDiscoverySchema( - platform=Platform.SENSOR, - entity_description=MatterSensorEntityDescription( - key="MinPINCodeLength", - translation_key="min_pin_code_length", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=None, - ), - entity_class=MatterSensor, - required_attributes=(clusters.DoorLock.Attributes.MinPINCodeLength,), - ), - MatterDiscoverySchema( - platform=Platform.SENSOR, - entity_description=MatterSensorEntityDescription( - key="MaxPINCodeLength", - translation_key="max_pin_code_length", - entity_category=EntityCategory.DIAGNOSTIC, - device_class=None, - ), - entity_class=MatterSensor, - required_attributes=(clusters.DoorLock.Attributes.MaxPINCodeLength,), - ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index a04f1d86880..7cae16c5e9b 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -390,12 +390,6 @@ "evse_user_max_charge_current": { "name": "User max charge current" }, - "min_pin_code_length": { - "name": "Min PIN code length" - }, - "max_pin_code_length": { - "name": "Max PIN code length" - }, "window_covering_target_position": { "name": "Target opening position" } diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 3af00db623e..685d3c0022d 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1307,198 +1307,6 @@ 'state': '180.0', }) # --- -# name: test_sensors[door_lock][sensor.mock_door_lock_max_pin_code_length-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.mock_door_lock_max_pin_code_length', - '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': 'Max PIN code length', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'max_pin_code_length', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MaxPINCodeLength-257-23', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock][sensor.mock_door_lock_max_pin_code_length-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Max PIN code length', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_max_pin_code_length', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_sensors[door_lock][sensor.mock_door_lock_min_pin_code_length-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.mock_door_lock_min_pin_code_length', - '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': 'Min PIN code length', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'min_pin_code_length', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MinPINCodeLength-257-24', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock][sensor.mock_door_lock_min_pin_code_length-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Min PIN code length', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_min_pin_code_length', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '6', - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_max_pin_code_length-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.mock_door_lock_max_pin_code_length', - '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': 'Max PIN code length', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'max_pin_code_length', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MaxPINCodeLength-257-23', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_max_pin_code_length-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Max PIN code length', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_max_pin_code_length', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_min_pin_code_length-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.mock_door_lock_min_pin_code_length', - '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': 'Min PIN code length', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'min_pin_code_length', - 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-MinPINCodeLength-257-24', - 'unit_of_measurement': None, - }) -# --- -# name: test_sensors[door_lock_with_unbolt][sensor.mock_door_lock_min_pin_code_length-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Min PIN code length', - }), - 'context': , - 'entity_id': 'sensor.mock_door_lock_min_pin_code_length', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '6', - }) -# --- # name: test_sensors[eve_contact_sensor][sensor.eve_door_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 389becc4f6397886c7a552243bbceac3205e36c0 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 27 May 2025 17:46:21 +0200 Subject: [PATCH 0014/1664] Disable advanced window cover position Matter sensor by default (#145713) * Disable advanced window cover position Matter sensor by default * Enanble disabled sensors in snapshot test --- homeassistant/components/matter/sensor.py | 1 + .../matter/snapshots/test_sensor.ambr | 306 ++++++++++++++++++ tests/components/matter/test_sensor.py | 2 +- 3 files changed, 308 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index e1fbf1c5a82..70e4cb238f5 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -972,6 +972,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterSensorEntityDescription( key="TargetPositionLiftPercent100ths", entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, translation_key="window_covering_target_position", measurement_to_ha=lambda x: round((10000 - x) / 100), native_unit_of_measurement=PERCENTAGE, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 685d3c0022d..3a5a937b4a4 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -2425,6 +2425,159 @@ 'state': '0.0', }) # --- +# name: test_sensors[generic_switch][sensor.mock_generic_switch_current_switch_position-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.mock_generic_switch_current_switch_position', + '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': 'Current switch position', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[generic_switch][sensor.mock_generic_switch_current_switch_position-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Generic Switch Current switch position', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_generic_switch_current_switch_position', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_current_switch_position_1-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.mock_generic_switch_current_switch_position_1', + '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': 'Current switch position (1)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_current_switch_position_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Generic Switch Current switch position (1)', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_generic_switch_current_switch_position_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_fancy_button-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.mock_generic_switch_fancy_button', + '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': 'Fancy Button', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-2-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[generic_switch_multi][sensor.mock_generic_switch_fancy_button-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Generic Switch Fancy Button', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.mock_generic_switch_fancy_button', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[humidity_sensor][sensor.mock_humidity_sensor_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2715,6 +2868,159 @@ 'state': 'stopped', }) # --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_config-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.inovelli_config', + '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': 'Config', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-5-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_config-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli Config', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.inovelli_config', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_down-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.inovelli_down', + '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': 'Down', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-4-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_down-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli Down', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.inovelli_down', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_up-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.inovelli_up', + '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': 'Up', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_current_position', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-3-SwitchCurrentPosition-59-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[multi_endpoint_light][sensor.inovelli_up-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli Up', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.inovelli_up', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_sensors[oven][sensor.mock_oven_current_phase-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 19697efab71..e15e3f9f53e 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -17,7 +17,7 @@ from .common import ( ) -@pytest.mark.usefixtures("matter_devices") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "matter_devices") async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 8880ab64987920a77cad1bdf1c6008c4de74f2d4 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 27 May 2025 18:45:49 +0200 Subject: [PATCH 0015/1664] Catch PermissionDenied(Route API disabled) in google_travel_time (#145722) Catch PermissionDenied(Route API disabled) --- .../google_travel_time/config_flow.py | 9 ++++- .../components/google_travel_time/helpers.py | 39 +++++++++++++++++++ .../components/google_travel_time/sensor.py | 13 ++++++- .../google_travel_time/strings.json | 7 ++++ .../google_travel_time/test_config_flow.py | 13 ++++++- .../google_travel_time/test_sensor.py | 26 ++++++++++++- 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 24ea29aef03..9e07fdefe9d 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -50,7 +50,12 @@ from .const import ( UNITS_IMPERIAL, UNITS_METRIC, ) -from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry +from .helpers import ( + InvalidApiKeyException, + PermissionDeniedException, + UnknownException, + validate_config_entry, +) RECONFIGURE_SCHEMA = vol.Schema( { @@ -188,6 +193,8 @@ async def validate_input( user_input[CONF_ORIGIN], user_input[CONF_DESTINATION], ) + except PermissionDeniedException: + return {"base": "permission_denied"} except InvalidApiKeyException: return {"base": "invalid_auth"} except TimeoutError: diff --git a/homeassistant/components/google_travel_time/helpers.py b/homeassistant/components/google_travel_time/helpers.py index 2e36abd62b1..70f9300c92f 100644 --- a/homeassistant/components/google_travel_time/helpers.py +++ b/homeassistant/components/google_travel_time/helpers.py @@ -7,6 +7,7 @@ from google.api_core.exceptions import ( Forbidden, GatewayTimeout, GoogleAPIError, + PermissionDenied, Unauthorized, ) from google.maps.routing_v2 import ( @@ -19,10 +20,18 @@ from google.maps.routing_v2 import ( from google.type import latlng_pb2 import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.location import find_coordinates +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) @@ -67,6 +76,9 @@ async def validate_config_entry( await client.compute_routes( request, metadata=[("x-goog-fieldmask", field_mask)] ) + except PermissionDenied as permission_error: + _LOGGER.error("Permission denied: %s", permission_error.message) + raise PermissionDeniedException from permission_error except (Unauthorized, Forbidden) as unauthorized_error: _LOGGER.error("Request denied: %s", unauthorized_error.message) raise InvalidApiKeyException from unauthorized_error @@ -84,3 +96,30 @@ class InvalidApiKeyException(Exception): class UnknownException(Exception): """Unknown API Error.""" + + +class PermissionDeniedException(Exception): + """Permission Denied Error.""" + + +def create_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Create an issue for the Routes API being disabled.""" + async_create_issue( + hass, + DOMAIN, + f"routes_api_disabled_{entry.entry_id}", + learn_more_url="https://www.home-assistant.io/integrations/google_travel_time#setup", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="routes_api_disabled", + translation_placeholders={ + "entry_title": entry.title, + "enable_api_url": "https://cloud.google.com/endpoints/docs/openapi/enable-api", + "api_key_restrictions_url": "https://cloud.google.com/docs/authentication/api-keys#adding-api-restrictions", + }, + ) + + +def delete_routes_api_disabled_issue(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Delete the issue for the Routes API being disabled.""" + async_delete_issue(hass, DOMAIN, f"routes_api_disabled_{entry.entry_id}") diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 7448fc1cb09..6323813b759 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -7,7 +7,7 @@ import logging from typing import TYPE_CHECKING, Any from google.api_core.client_options import ClientOptions -from google.api_core.exceptions import GoogleAPIError +from google.api_core.exceptions import GoogleAPIError, PermissionDenied from google.maps.routing_v2 import ( ComputeRoutesRequest, Route, @@ -58,7 +58,11 @@ from .const import ( TRAVEL_MODES_TO_GOOGLE_SDK_ENUM, UNITS_TO_GOOGLE_SDK_ENUM, ) -from .helpers import convert_to_waypoint +from .helpers import ( + convert_to_waypoint, + create_routes_api_disabled_issue, + delete_routes_api_disabled_issue, +) _LOGGER = logging.getLogger(__name__) @@ -273,6 +277,11 @@ class GoogleTravelTimeSensor(SensorEntity): ) if response is not None and len(response.routes) > 0: self._route = response.routes[0] + delete_routes_api_disabled_issue(self.hass, self._config_entry) + except PermissionDenied: + _LOGGER.error("Routes API is disabled for this API key") + create_routes_api_disabled_issue(self.hass, self._config_entry) + self._route = None except GoogleAPIError as ex: _LOGGER.error("Error getting travel time: %s", ex) self._route = None diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 87bc09eb456..f46d33fda09 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -21,6 +21,7 @@ } }, "error": { + "permission_denied": "The Routes API is not enabled for this API key. Please see the setup instructions for detailed information.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]" @@ -100,5 +101,11 @@ "fewer_transfers": "Fewer transfers" } } + }, + "issues": { + "routes_api_disabled": { + "title": "The Routes API must be enabled", + "description": "Your Google Travel Time integration `{entry_title}` uses an API key which does not have the Routes API enabled.\n\n Please follow the instructions to [enable the API for your project]({enable_api_url}) and make sure your [API key restrictions]({api_key_restrictions_url}) allow access to the Routes API.\n\n After enabling the API this issue will be resolved automatically." + } } } diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 8cdb3c270d0..562ca152ce8 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock, patch -from google.api_core.exceptions import GatewayTimeout, GoogleAPIError, Unauthorized +from google.api_core.exceptions import ( + GatewayTimeout, + GoogleAPIError, + PermissionDenied, + Unauthorized, +) import pytest from homeassistant.components.google_travel_time.const import ( @@ -98,6 +103,12 @@ async def test_minimum_fields(hass: HomeAssistant) -> None: (GoogleAPIError("test"), "cannot_connect"), (GatewayTimeout("Timeout error."), "timeout_connect"), (Unauthorized("Invalid API key."), "invalid_auth"), + ( + PermissionDenied( + "Requests to this API routes.googleapis.com method google.maps.routing.v2.Routes.ComputeRoutes are blocked." + ), + "permission_denied", + ), ], ) async def test_errors( diff --git a/tests/components/google_travel_time/test_sensor.py b/tests/components/google_travel_time/test_sensor.py index 58843d8275c..0ab5e38a644 100644 --- a/tests/components/google_travel_time/test_sensor.py +++ b/tests/components/google_travel_time/test_sensor.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory -from google.api_core.exceptions import GoogleAPIError +from google.api_core.exceptions import GoogleAPIError, PermissionDenied from google.maps.routing_v2 import Units import pytest @@ -20,6 +20,7 @@ from homeassistant.components.google_travel_time.const import ( from homeassistant.components.google_travel_time.sensor import SCAN_INTERVAL from homeassistant.const import CONF_MODE, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, @@ -170,3 +171,26 @@ async def test_sensor_exception( await hass.async_block_till_done() assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN assert "Error getting travel time" in caplog.text + + +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +async def test_sensor_routes_api_disabled( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + routes_mock: AsyncMock, + mock_config: MockConfigEntry, + freezer: FrozenDateTimeFactory, + issue_registry: ir.IssueRegistry, +) -> None: + """Test that exception gets caught and issue created.""" + routes_mock.compute_routes.side_effect = PermissionDenied("Errormessage") + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("sensor.google_travel_time").state == STATE_UNKNOWN + assert "Routes API is disabled for this API key" in caplog.text + + assert len(issue_registry.issues) == 1 From 41a140d16c6b78d1c204f6adeb1277315e1b4426 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Tue, 27 May 2025 17:44:48 +0200 Subject: [PATCH 0016/1664] Debug log the update response in google_travel_time (#145725) Debug log the update response --- homeassistant/components/google_travel_time/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 6323813b759..1a9b361bd33 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -275,6 +275,7 @@ class GoogleTravelTimeSensor(SensorEntity): response = await self._client.compute_routes( request, metadata=[("x-goog-fieldmask", FIELD_MASK)] ) + _LOGGER.debug("Received response: %s", response) if response is not None and len(response.routes) > 0: self._route = response.routes[0] delete_routes_api_disabled_issue(self.hass, self._config_entry) From 6e6aae2ea3594743d451e63679325cdc89f2669a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 27 May 2025 19:29:04 +0200 Subject: [PATCH 0017/1664] Fix unbound local variable in Acmeda config flow (#145729) --- homeassistant/components/acmeda/config_flow.py | 3 ++- tests/components/acmeda/test_config_flow.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/acmeda/config_flow.py b/homeassistant/components/acmeda/config_flow.py index 5024507a7d3..785906ebf2a 100644 --- a/homeassistant/components/acmeda/config_flow.py +++ b/homeassistant/components/acmeda/config_flow.py @@ -40,9 +40,10 @@ class AcmedaFlowHandler(ConfigFlow, domain=DOMAIN): entry.unique_id for entry in self._async_current_entries() } + hubs: list[aiopulse.Hub] = [] with suppress(TimeoutError): async with timeout(5): - hubs: list[aiopulse.Hub] = [ + hubs = [ hub async for hub in aiopulse.Hub.discover() if hub.id not in already_configured diff --git a/tests/components/acmeda/test_config_flow.py b/tests/components/acmeda/test_config_flow.py index 7b92c1aac3b..6589013d432 100644 --- a/tests/components/acmeda/test_config_flow.py +++ b/tests/components/acmeda/test_config_flow.py @@ -49,6 +49,21 @@ async def test_show_form_no_hubs(hass: HomeAssistant, mock_hub_discover) -> None assert len(mock_hub_discover.mock_calls) == 1 +async def test_timeout_fetching_hub(hass: HomeAssistant, mock_hub_discover) -> None: + """Test that flow aborts if no hubs are discovered.""" + mock_hub_discover.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + # Check we performed the discovery + assert len(mock_hub_discover.mock_calls) == 1 + + @pytest.mark.usefixtures("mock_hub_run") async def test_show_form_one_hub(hass: HomeAssistant, mock_hub_discover) -> None: """Test that a config is created when one hub discovered.""" From 6adb27d17326147ac56e1a54d9a00651655f41fa Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 27 May 2025 21:27:52 +0200 Subject: [PATCH 0018/1664] Tado update mobile devices interval (#145738) Update the mobile devices interval to five minutes --- homeassistant/components/tado/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 5f3aa1de1e4..09c6ec40208 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=4) SCAN_INTERVAL = timedelta(minutes=5) -SCAN_MOBILE_DEVICE_INTERVAL = timedelta(seconds=30) +SCAN_MOBILE_DEVICE_INTERVAL = timedelta(minutes=5) class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): From 3160fe9abcf1961408894cb0bb3a50f077727c97 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 27 May 2025 22:12:07 +0200 Subject: [PATCH 0019/1664] Update frontend to 20250527.0 (#145741) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index fe445ae6b28..32d243b3431 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250526.0"] + "requirements": ["home-assistant-frontend==20250527.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae59ce94200..378e9fdce83 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.48.2 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250526.0 +home-assistant-frontend==20250527.0 home-assistant-intents==2025.5.7 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 414153e193e..a54a12ee9c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.73 # homeassistant.components.frontend -home-assistant-frontend==20250526.0 +home-assistant-frontend==20250527.0 # homeassistant.components.conversation home-assistant-intents==2025.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f858d8e4315..f381de177dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -994,7 +994,7 @@ hole==0.8.0 holidays==0.73 # homeassistant.components.frontend -home-assistant-frontend==20250526.0 +home-assistant-frontend==20250527.0 # homeassistant.components.conversation home-assistant-intents==2025.5.7 From 10adb57b837906d529b826e2740a741bd4d52db2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 27 May 2025 22:16:13 +0200 Subject: [PATCH 0020/1664] Bump version to 2025.6.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a36aa88ca84..6b3cbb4f27c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 7df9230b7ee..7e1e98bdb24 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b0" +version = "2025.6.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From adddf330fd835fcef52e90ade508feb79b646963 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 28 May 2025 10:16:40 +0200 Subject: [PATCH 0021/1664] Ensure mqtt sensor unit of measurement validation for state class `measurement_angle` (#145648) --- homeassistant/components/mqtt/config_flow.py | 24 +++++++++++++++--- homeassistant/components/mqtt/sensor.py | 12 +++++++++ homeassistant/components/mqtt/strings.json | 1 + tests/components/mqtt/test_config_flow.py | 10 +++++++- tests/components/mqtt/test_sensor.py | 26 ++++++++++++++++++++ 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index bb884d6392f..b41e549093d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -39,6 +39,7 @@ from homeassistant.components.light import ( from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, + STATE_CLASS_UNITS, SensorDeviceClass, SensorStateClass, ) @@ -640,6 +641,13 @@ def validate_sensor_platform_config( ): errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + if ( + (state_class := config.get(CONF_STATE_CLASS)) is not None + and state_class in STATE_CLASS_UNITS + and config.get(CONF_UNIT_OF_MEASUREMENT) not in STATE_CLASS_UNITS[state_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom_for_state_class" + return errors @@ -676,11 +684,19 @@ class PlatformField: @callback def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: """Return a context based unit of measurement selector.""" + + if (state_class := user_data.get(CONF_STATE_CLASS)) in STATE_CLASS_UNITS: + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in STATE_CLASS_UNITS[state_class]], + sort=True, + custom_value=True, + ) + ) + if ( - user_data is None - or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None - or device_class not in DEVICE_CLASS_UNITS - ): + device_class := user_data.get(CONF_DEVICE_CLASS) + ) is None or device_class not in DEVICE_CLASS_UNITS: return TEXT_SELECTOR return SelectSelector( SelectSelectorConfig( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index b27ef68368a..46d475fcee8 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, + STATE_CLASS_UNITS, STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, @@ -117,6 +118,17 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"got `{CONF_DEVICE_CLASS}` '{device_class}'" ) + if ( + (state_class := config.get(CONF_STATE_CLASS)) is not None + and state_class in STATE_CLASS_UNITS + and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) + not in STATE_CLASS_UNITS[state_class] + ): + raise vol.Invalid( + f"The unit of measurement '{unit_of_measurement}' is not valid " + f"together with state class '{state_class}'" + ) + if (device_class := config.get(CONF_DEVICE_CLASS)) is None or ( unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) ) is None: diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 8fc97362857..9bc6df1b633 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -644,6 +644,7 @@ "invalid_template": "Invalid template", "invalid_supported_color_modes": "Invalid supported color modes selection", "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", + "invalid_uom_for_state_class": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected state class, please either remove the state class, select a state class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", "invalid_url": "Invalid URL", "last_reset_not_with_state_class_total": "The last reset value template option should be used with state class 'Total' only", "max_below_min_kelvin": "Max Kelvin value should be greater than min Kelvin value", diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index a43617badb0..e30aa5d50d6 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3038,7 +3038,15 @@ async def test_migrate_of_incompatible_config_entry( { "state_class": "measurement", }, - (), + ( + ( + { + "state_class": "measurement_angle", + "unit_of_measurement": "deg", + }, + {"unit_of_measurement": "invalid_uom_for_state_class"}, + ), + ), { "state_topic": "test-topic", }, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 0bafacfed26..ea1b7e186e2 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -995,6 +995,32 @@ async def test_invalid_state_class( assert "expected SensorStateClass or one of" in caplog.text +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + sensor.DOMAIN: { + "name": "test", + "state_topic": "test-topic", + "state_class": "measurement_angle", + "unit_of_measurement": "deg", + } + } + } + ], +) +async def test_invalid_state_class_with_unit_of_measurement( + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture +) -> None: + """Test state_class option with invalid unit of measurement.""" + assert await mqtt_mock_entry() + assert ( + "The unit of measurement 'deg' is not valid together with state class 'measurement_angle'" + in caplog.text + ) + + @pytest.mark.parametrize( ("hass_config", "error_logged"), [ From f86bf69ebcd5a13d142b0371469eb857c92b3a60 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 28 May 2025 08:13:28 +0200 Subject: [PATCH 0022/1664] Update otp description for amazon_devices (#145701) * Update otp description from amazon_devices * separate * Update strings.json --- homeassistant/components/amazon_devices/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/amazon_devices/strings.json b/homeassistant/components/amazon_devices/strings.json index 8db249b44ed..47e6234cd9c 100644 --- a/homeassistant/components/amazon_devices/strings.json +++ b/homeassistant/components/amazon_devices/strings.json @@ -5,7 +5,7 @@ "data_description_country": "The country of your Amazon account.", "data_description_username": "The email address of your Amazon account.", "data_description_password": "The password of your Amazon account.", - "data_description_code": "The one-time password sent to your email address." + "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." }, "config": { "flow_title": "{username}", From 0e7a1bb76cf1890c9bcfb6ad36039ec5e7a47345 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 27 May 2025 23:00:52 +0200 Subject: [PATCH 0023/1664] Make async_remove_stale_devices_links_keep_entity_device move entities (#145719) Co-authored-by: Jan Bouwhuis Co-authored-by: Joost Lekkerkerker --- homeassistant/helpers/device.py | 20 ++++++----- tests/helpers/test_device.py | 60 ++++++++++++++++++++------------- 2 files changed, 49 insertions(+), 31 deletions(-) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index 16212422236..a7d888900b1 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -64,10 +64,10 @@ def async_remove_stale_devices_links_keep_entity_device( entry_id: str, source_entity_id_or_uuid: str, ) -> None: - """Remove the link between stale devices and a configuration entry. + """Remove entry_id from all devices except that of source_entity_id_or_uuid. - Only the device passed in the source_entity_id_or_uuid parameter - linked to the configuration entry will be maintained. + Also moves all entities linked to the entry_id to the device of + source_entity_id_or_uuid. """ async_remove_stale_devices_links_keep_current_device( @@ -83,13 +83,17 @@ def async_remove_stale_devices_links_keep_current_device( entry_id: str, current_device_id: str | None, ) -> None: - """Remove the link between stale devices and a configuration entry. - - Only the device passed in the current_device_id parameter linked to - the configuration entry will be maintained. - """ + """Remove entry_id from all devices except current_device_id.""" dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) + + # Make sure all entities are linked to the correct device + for entity in ent_reg.entities.get_entries_for_config_entry_id(entry_id): + if entity.device_id == current_device_id: + continue + ent_reg.async_update_entity(entity.entity_id, device_id=current_device_id) + # Removes all devices from the config entry that are not the same as the current device for device in dev_reg.devices.get_devices_for_config_entry_id(entry_id): if device.id == current_device_id: diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 852d418da23..266435ef05d 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -118,61 +118,75 @@ async def test_remove_stale_device_links_keep_entity_device( entity_registry: er.EntityRegistry, ) -> None: """Test cleaning works for entity.""" - config_entry = MockConfigEntry(domain="hue") - config_entry.add_to_hass(hass) + helper_config_entry = MockConfigEntry(domain="helper_integration") + helper_config_entry.add_to_hass(hass) + host_config_entry = MockConfigEntry(domain="host_integration") + host_config_entry.add_to_hass(hass) current_device = device_registry.async_get_or_create( identifiers={("test", "current_device")}, connections={("mac", "30:31:32:33:34:00")}, - config_entry_id=config_entry.entry_id, + config_entry_id=helper_config_entry.entry_id, ) - assert current_device is not None - device_registry.async_get_or_create( + stale_device_1 = device_registry.async_get_or_create( identifiers={("test", "stale_device_1")}, connections={("mac", "30:31:32:33:34:01")}, - config_entry_id=config_entry.entry_id, + config_entry_id=helper_config_entry.entry_id, ) device_registry.async_get_or_create( identifiers={("test", "stale_device_2")}, connections={("mac", "30:31:32:33:34:02")}, - config_entry_id=config_entry.entry_id, + config_entry_id=helper_config_entry.entry_id, ) - # Source entity registry + # Source entity source_entity = entity_registry.async_get_or_create( "sensor", - "test", + "host_integration", "source", - config_entry=config_entry, + config_entry=host_config_entry, device_id=current_device.id, ) - await hass.async_block_till_done() - assert entity_registry.async_get("sensor.test_source") is not None + assert entity_registry.async_get(source_entity.entity_id) is not None - devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( - config_entry.entry_id + # Helper entity connected to a stale device + helper_entity = entity_registry.async_get_or_create( + "sensor", + "helper_integration", + "helper", + config_entry=helper_config_entry, + device_id=stale_device_1.id, + ) + assert entity_registry.async_get(helper_entity.entity_id) is not None + + devices_helper_entry = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id ) # 3 devices linked to the config entry are expected (1 current device + 2 stales) - assert len(devices_config_entry) == 3 + assert len(devices_helper_entry) == 3 - # Manual cleanup should unlink stales devices from the config entry + # Manual cleanup should unlink stale devices from the config entry async_remove_stale_devices_links_keep_entity_device( hass, - entry_id=config_entry.entry_id, + entry_id=helper_config_entry.entry_id, source_entity_id_or_uuid=source_entity.entity_id, ) - devices_config_entry = device_registry.devices.get_devices_for_config_entry_id( - config_entry.entry_id + await hass.async_block_till_done() + + devices_helper_entry = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id ) - # After cleanup, only one device is expected to be linked to the config entry - assert len(devices_config_entry) == 1 - - assert current_device in devices_config_entry + # After cleanup, only one device is expected to be linked to the config entry, and + # the entities should exist and be linked to the current device + assert len(devices_helper_entry) == 1 + assert current_device in devices_helper_entry + assert entity_registry.async_get(source_entity.entity_id) is not None + assert entity_registry.async_get(helper_entity.entity_id) is not None async def test_remove_stale_devices_links_keep_current_device( From cd133cbbe38f4042e99e9c49e4c4d8853b382a67 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 28 May 2025 20:51:27 +0200 Subject: [PATCH 0024/1664] Add level of collections in Immich media source tree (#145734) * add layer for collections in media source tree * re-arange tests, add test for collection layer * fix --- .../components/immich/media_source.py | 46 ++++-- tests/components/immich/test_media_source.py | 149 ++++++++++-------- 2 files changed, 116 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 201076f1295..0d0616875c6 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -43,11 +43,12 @@ class ImmichMediaSourceIdentifier: def __init__(self, identifier: str) -> None: """Split identifier into parts.""" parts = identifier.split("/") - # coonfig_entry.unique_id/album_id/asset_it/filename + # config_entry.unique_id/collection/collection_id/asset_id/file_name self.unique_id = parts[0] - self.album_id = parts[1] if len(parts) > 1 else None - self.asset_id = parts[2] if len(parts) > 2 else None - self.file_name = parts[3] if len(parts) > 2 else None + self.collection = parts[1] if len(parts) > 1 else None + self.collection_id = parts[2] if len(parts) > 2 else None + self.asset_id = parts[3] if len(parts) > 3 else None + self.file_name = parts[4] if len(parts) > 3 else None class ImmichMediaSource(MediaSource): @@ -87,6 +88,7 @@ class ImmichMediaSource(MediaSource): ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" if not item.identifier: + LOGGER.debug("Render all Immich instances") return [ BrowseMediaSource( domain=DOMAIN, @@ -108,8 +110,22 @@ class ImmichMediaSource(MediaSource): assert entry immich_api = entry.runtime_data.api - if identifier.album_id is None: - # Get Albums + if identifier.collection is None: + LOGGER.debug("Render all collections for %s", entry.title) + return [ + BrowseMediaSource( + domain=DOMAIN, + identifier=f"{identifier.unique_id}/albums", + media_class=MediaClass.DIRECTORY, + media_content_type=MediaClass.IMAGE, + title="albums", + can_play=False, + can_expand=True, + ) + ] + + if identifier.collection_id is None: + LOGGER.debug("Render all albums for %s", entry.title) try: albums = await immich_api.albums.async_get_all_albums() except ImmichError: @@ -118,7 +134,7 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{item.identifier}/{album.album_id}", + identifier=f"{identifier.unique_id}/albums/{album.album_id}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, title=album.name, @@ -129,10 +145,14 @@ class ImmichMediaSource(MediaSource): for album in albums ] - # Request items of album + 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.album_id + identifier.collection_id ) except ImmichError: return [] @@ -141,8 +161,8 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}/" - f"{identifier.album_id}/" + f"{identifier.unique_id}/albums/" + f"{identifier.collection_id}/" f"{asset.asset_id}/" f"{asset.file_name}" ), @@ -161,8 +181,8 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}/" - f"{identifier.album_id}/" + f"{identifier.unique_id}/albums/" + f"{identifier.collection_id}/" f"{asset.asset_id}/" f"{asset.file_name}" ), diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index c8da8d94eeb..0f448fbf23d 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -44,8 +44,8 @@ async def test_get_media_source(hass: HomeAssistant) -> None: ("identifier", "exception_msg"), [ ("unique_id", "No file name"), - ("unique_id/album_id", "No file name"), - ("unique_id/album_id/asset_id/filename", "No file extension"), + ("unique_id/albums/album_id", "No file name"), + ("unique_id/albums/album_id/asset_id/filename", "No file extension"), ], ) async def test_resolve_media_bad_identifier( @@ -64,12 +64,12 @@ async def test_resolve_media_bad_identifier( ("identifier", "url", "mime_type"), [ ( - "unique_id/album_id/asset_id/filename.jpg", + "unique_id/albums/album_id/asset_id/filename.jpg", "/immich/unique_id/asset_id/filename.jpg/fullsize", "image/jpeg", ), ( - "unique_id/album_id/asset_id/filename.png", + "unique_id/albums/album_id/asset_id/filename.png", "/immich/unique_id/asset_id/filename.png/fullsize", "image/png", ), @@ -95,13 +95,82 @@ async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: source = await async_get_media_source(hass) item = MediaSourceItem( - hass, DOMAIN, "unique_id/album_id/asset_id/filename.png", None + hass, DOMAIN, "unique_id/albums/album_id/asset_id/filename.png", None ) with pytest.raises(BrowseError, match="Immich is not configured"): await source.async_browse_media(item) -async def test_browse_media_album_error( +async def test_browse_media_get_root( + hass: HomeAssistant, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test browse_media returning root media sources.""" + 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) + + # get root + item = MediaSourceItem(hass, DOMAIN, "", 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 == "Someone" + assert media_file.media_content_id == ( + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e" + ) + + # get collections + item = MediaSourceItem(hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", 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 == "albums" + assert media_file.media_content_id == ( + "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, @@ -124,7 +193,7 @@ async def test_browse_media_album_error( source = await async_get_media_source(hass) - item = MediaSourceItem(hass, DOMAIN, mock_config_entry.unique_id, None) + item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}/albums", None) result = await source.async_browse_media(item) assert result @@ -132,59 +201,7 @@ async def test_browse_media_album_error( assert len(result.children) == 0 -async def test_browse_media_get_root( - hass: HomeAssistant, - mock_immich: Mock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test browse_media returning root media sources.""" - 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, "", 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 == "Someone" - assert media_file.media_content_id == ( - "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e" - ) - - -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", 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/" - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" - ) - - -async def test_browse_media_get_items_error( +async def test_browse_media_get_album_items_error( hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry, @@ -202,7 +219,7 @@ async def test_browse_media_get_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -223,7 +240,7 @@ async def test_browse_media_get_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -233,7 +250,7 @@ async def test_browse_media_get_items_error( assert len(result.children) == 0 -async def test_browse_media_get_items( +async def test_browse_media_get_album_items( hass: HomeAssistant, mock_immich: Mock, mock_config_entry: MockConfigEntry, @@ -249,7 +266,7 @@ async def test_browse_media_get_items( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -259,7 +276,7 @@ async def test_browse_media_get_items( media_file = result.children[0] assert isinstance(media_file, BrowseMedia) assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/" "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6/" "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg" ) @@ -276,7 +293,7 @@ async def test_browse_media_get_items( media_file = result.children[1] assert isinstance(media_file, BrowseMedia) assert media_file.identifier == ( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/" "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6/" "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4" ) From 61823ec7e2f9eb840eadbbe4f7d7a6a9ca85d1df Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 27 May 2025 22:17:34 +0200 Subject: [PATCH 0025/1664] Fix dns resolver error in dnsip config flow validation (#145735) Fix dns resolver error in dnsip --- homeassistant/components/dnsip/config_flow.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index e7b60d5bd6f..6b86f1627bc 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import contextlib -from typing import Any +from typing import Any, Literal import aiodns from aiodns.error import DNSError @@ -62,16 +62,16 @@ async def async_validate_hostname( """Validate hostname.""" async def async_check( - hostname: str, resolver: str, qtype: str, port: int = 53 + hostname: str, resolver: str, qtype: Literal["A", "AAAA"], port: int = 53 ) -> bool: """Return if able to resolve hostname.""" - result = False + result: bool = False with contextlib.suppress(DNSError): - result = bool( - await aiodns.DNSResolver( # type: ignore[call-overload] - nameservers=[resolver], udp_port=port, tcp_port=port - ).query(hostname, qtype) + _resolver = aiodns.DNSResolver( + nameservers=[resolver], udp_port=port, tcp_port=port ) + result = bool(await _resolver.query(hostname, qtype)) + return result result: dict[str, bool] = {} From e825bd0bdbbd060192fe6211eda515f5d05fb8f6 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 27 May 2025 22:58:46 +0200 Subject: [PATCH 0026/1664] Bump uiprotect to version 7.10.1 (#145737) Co-authored-by: Jan Bouwhuis --- 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 f825e0a5eaf..1cf2e4391e2 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.10.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.10.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index a54a12ee9c4..c885e71a969 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.10.0 +uiprotect==7.10.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f381de177dd..0ea08f302a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2422,7 +2422,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.10.0 +uiprotect==7.10.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From fb833965221e625f16a383fdc29e69f31c57887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 28 May 2025 14:56:47 +0100 Subject: [PATCH 0027/1664] Add Shelly zwave virtual integration (#145749) --- homeassistant/brands/shelly.json | 6 ++++++ homeassistant/generated/integrations.json | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 homeassistant/brands/shelly.json diff --git a/homeassistant/brands/shelly.json b/homeassistant/brands/shelly.json new file mode 100644 index 00000000000..94d683157ee --- /dev/null +++ b/homeassistant/brands/shelly.json @@ -0,0 +1,6 @@ +{ + "domain": "shelly", + "name": "shelly", + "integrations": ["shelly"], + "iot_standards": ["zwave"] +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 4ae336f3c61..775272f77c4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5867,10 +5867,18 @@ "iot_class": "local_push" }, "shelly": { - "name": "Shelly", - "integration_type": "device", - "config_flow": true, - "iot_class": "local_push" + "name": "shelly", + "integrations": { + "shelly": { + "integration_type": "device", + "config_flow": true, + "iot_class": "local_push", + "name": "Shelly" + } + }, + "iot_standards": [ + "zwave" + ] }, "shodan": { "name": "Shodan", From 644a6f5569b8c8feddce1578f0fcff90bb8c0548 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 28 May 2025 08:43:59 +0200 Subject: [PATCH 0028/1664] Add more Amazon Devices DHCP matches (#145754) --- homeassistant/components/amazon_devices/manifest.json | 4 +++- homeassistant/generated/dhcp.py | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json index 606dec83150..7593fbd4943 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/amazon_devices/manifest.json @@ -13,6 +13,7 @@ { "macaddress": "50D45C*" }, { "macaddress": "50DCE7*" }, { "macaddress": "68F63B*" }, + { "macaddress": "6C0C9A*" }, { "macaddress": "74D637*" }, { "macaddress": "7C6166*" }, { "macaddress": "901195*" }, @@ -22,7 +23,8 @@ { "macaddress": "A8E621*" }, { "macaddress": "C095CF*" }, { "macaddress": "D8BE65*" }, - { "macaddress": "EC2BEB*" } + { "macaddress": "EC2BEB*" }, + { "macaddress": "F02F9E*" } ], "documentation": "https://www.home-assistant.io/integrations/amazon_devices", "integration_type": "hub", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 19fa6cc706a..6ef3051a953 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -62,6 +62,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "amazon_devices", "macaddress": "68F63B*", }, + { + "domain": "amazon_devices", + "macaddress": "6C0C9A*", + }, { "domain": "amazon_devices", "macaddress": "74D637*", @@ -102,6 +106,10 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "amazon_devices", "macaddress": "EC2BEB*", }, + { + "domain": "amazon_devices", + "macaddress": "F02F9E*", + }, { "domain": "august", "hostname": "connect", From 6d44daf599c7e969314506e84f1f0e2f62735a01 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 28 May 2025 09:39:33 +0200 Subject: [PATCH 0029/1664] Bump pylamarzocco to 2.0.7 (#145763) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 6118e364c15..36a0a489e30 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.6"] + "requirements": ["pylamarzocco==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index c885e71a969..d8ffc983546 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2096,7 +2096,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.6 +pylamarzocco==2.0.7 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ea08f302a1..8cbe9918eab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1714,7 +1714,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.6 +pylamarzocco==2.0.7 # homeassistant.components.lastfm pylast==5.1.0 From f1ec0b2c596f25b3e84f0876d20669c9a7689886 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 28 May 2025 12:26:28 +0200 Subject: [PATCH 0030/1664] Handle late abort when creating subentry (#145765) * Handle late abort when creating subentry * Move error handling to the base class * Narrow down expected error in test --- homeassistant/data_entry_flow.py | 13 ++- .../components/config/test_config_entries.py | 82 +++++++++++++++++++ tests/test_config_entries.py | 2 +- tests/test_data_entry_flow.py | 31 ++++++- 4 files changed, 123 insertions(+), 5 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 9286f9c78f5..ce1c0806b14 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -543,8 +543,17 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): flow.cur_step = result return result - # We pass a copy of the result because we're mutating our version - result = await self.async_finish_flow(flow, result.copy()) + try: + # We pass a copy of the result because we're mutating our version + result = await self.async_finish_flow(flow, result.copy()) + except AbortFlow as err: + result = self._flow_result( + type=FlowResultType.ABORT, + flow_id=flow.flow_id, + handler=flow.handler, + reason=err.reason, + description_placeholders=err.description_placeholders, + ) # _async_finish_flow may change result type, check it again if result["type"] == FlowResultType.FORM: diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 6784866ea4b..c6e82976bf1 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1526,6 +1526,88 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant, client) -> None: } +async def test_subentry_flow_abort_duplicate(hass: HomeAssistant, client) -> None: + """Test we can handle a subentry flow raising due to unique_id collision.""" + + class TestFlow(core_ce.ConfigFlow): + class SubentryFlowHandler(core_ce.ConfigSubentryFlow): + async def async_step_user(self, user_input=None): + return await self.async_step_finish() + + async def async_step_finish(self, user_input=None): + if user_input: + return self.async_create_entry( + title="Mock title", data=user_input, unique_id="test" + ) + + return self.async_show_form( + step_id="finish", data_schema=vol.Schema({"enabled": bool}) + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: core_ce.ConfigEntry + ) -> dict[str, type[core_ce.ConfigSubentryFlow]]: + return {"test": TestFlow.SubentryFlowHandler} + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + MockConfigEntry( + domain="test", + entry_id="test1", + source="bla", + subentries_data=[ + core_ce.ConfigSubentryData( + data={}, + subentry_id="mock_id", + subentry_type="test", + title="Title", + unique_id="test", + ) + ], + ).add_to_hass(hass) + entry = hass.config_entries.async_entries()[0] + + with mock_config_flow("test", TestFlow): + url = "/api/config/config_entries/subentries/flow" + resp = await client.post(url, json={"handler": [entry.entry_id, "test"]}) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data.pop("flow_id") + assert data == { + "type": "form", + "handler": ["test1", "test"], + "step_id": "finish", + "data_schema": [{"name": "enabled", "type": "boolean"}], + "description_placeholders": None, + "errors": None, + "last_step": None, + "preview": None, + } + + with mock_config_flow("test", TestFlow): + resp = await client.post( + f"/api/config/config_entries/subentries/flow/{flow_id}", + json={"enabled": True}, + ) + assert resp.status == HTTPStatus.OK + + entries = hass.config_entries.async_entries("test") + assert len(entries) == 1 + + data = await resp.json() + data.pop("flow_id") + assert data == { + "handler": ["test1", "test"], + "reason": "already_configured", + "type": "abort", + "description_placeholders": None, + } + + async def test_subentry_does_not_support_reconfigure( hass: HomeAssistant, client: TestClient ) -> None: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index ffff19f2c46..55b8434160e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2226,7 +2226,7 @@ async def test_entry_subentry_no_context( @pytest.mark.parametrize( ("unique_id", "expected_result"), - [(None, does_not_raise()), ("test", pytest.raises(HomeAssistantError))], + [(None, does_not_raise()), ("test", pytest.raises(data_entry_flow.AbortFlow))], ) async def test_entry_subentry_duplicate( hass: HomeAssistant, diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 961afd69c2d..a5908f0feab 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -886,8 +886,8 @@ async def test_show_progress_fires_only_when_changed( ) # change (description placeholder) -async def test_abort_flow_exception(manager: MockFlowManager) -> None: - """Test that the AbortFlow exception works.""" +async def test_abort_flow_exception_step(manager: MockFlowManager) -> None: + """Test that the AbortFlow exception works in a step.""" @manager.mock_reg_handler("test") class TestFlow(data_entry_flow.FlowHandler): @@ -900,6 +900,33 @@ async def test_abort_flow_exception(manager: MockFlowManager) -> None: assert form["description_placeholders"] == {"placeholder": "yo"} +async def test_abort_flow_exception_finish_flow(hass: HomeAssistant) -> None: + """Test that the AbortFlow exception works when finishing a flow.""" + + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, input): + """Return init form with one input field 'count'.""" + return self.async_create_entry(title="init", data=input) + + class FlowManager(data_entry_flow.FlowManager): + async def async_create_flow(self, handler_key, *, context, data): + """Create a test flow.""" + return TestFlow() + + async def async_finish_flow(self, flow, result): + """Raise AbortFlow.""" + raise data_entry_flow.AbortFlow("mock-reason", {"placeholder": "yo"}) + + manager = FlowManager(hass) + + form = await manager.async_init("test") + assert form["type"] == data_entry_flow.FlowResultType.ABORT + assert form["reason"] == "mock-reason" + assert form["description_placeholders"] == {"placeholder": "yo"} + + async def test_init_unknown_flow(manager: MockFlowManager) -> None: """Test that UnknownFlow is raised when async_create_flow returns None.""" From 13b487972307331a389b12d3b5af64a63f304e00 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 May 2025 09:58:44 +0200 Subject: [PATCH 0031/1664] Deprecate dlib image processing integrations (#145767) --- .../components/dlib_face_detect/__init__.py | 2 + .../dlib_face_detect/image_processing.py | 23 ++++++++++- .../components/dlib_face_identify/__init__.py | 3 ++ .../dlib_face_identify/image_processing.py | 25 ++++++++++- requirements_test_all.txt | 4 ++ tests/components/dlib_face_detect/__init__.py | 1 + .../dlib_face_detect/test_image_processing.py | 37 +++++++++++++++++ .../components/dlib_face_identify/__init__.py | 1 + .../test_image_processing.py | 41 +++++++++++++++++++ 9 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tests/components/dlib_face_detect/__init__.py create mode 100644 tests/components/dlib_face_detect/test_image_processing.py create mode 100644 tests/components/dlib_face_identify/__init__.py create mode 100644 tests/components/dlib_face_identify/test_image_processing.py diff --git a/homeassistant/components/dlib_face_detect/__init__.py b/homeassistant/components/dlib_face_detect/__init__.py index a732132955f..0de082595ea 100644 --- a/homeassistant/components/dlib_face_detect/__init__.py +++ b/homeassistant/components/dlib_face_detect/__init__.py @@ -1 +1,3 @@ """The dlib_face_detect component.""" + +DOMAIN = "dlib_face_detect" diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index 79f03ab3af7..9bd78f89653 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -11,10 +11,17 @@ from homeassistant.components.image_processing import ( ImageProcessingFaceEntity, ) from homeassistant.const import ATTR_LOCATION, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + split_entity_id, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA @@ -25,6 +32,20 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Dlib Face detection platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Dlib Face Detect", + }, + ) source: list[dict[str, str]] = config[CONF_SOURCE] add_entities( DlibFaceDetectEntity(camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) diff --git a/homeassistant/components/dlib_face_identify/__init__.py b/homeassistant/components/dlib_face_identify/__init__.py index 79b9e4ec4bc..0e682d6b839 100644 --- a/homeassistant/components/dlib_face_identify/__init__.py +++ b/homeassistant/components/dlib_face_identify/__init__.py @@ -1 +1,4 @@ """The dlib_face_identify component.""" + +CONF_FACES = "faces" +DOMAIN = "dlib_face_identify" diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index c41dad863d4..c7c512c16d9 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -15,14 +15,20 @@ from homeassistant.components.image_processing import ( ImageProcessingFaceEntity, ) from homeassistant.const import ATTR_NAME, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + split_entity_id, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import CONF_FACES, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_FACES = "faces" PLATFORM_SCHEMA = IMAGE_PROCESSING_PLATFORM_SCHEMA.extend( { @@ -39,6 +45,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Dlib Face detection platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Dlib Face Identify", + }, + ) + confidence: float = config[CONF_CONFIDENCE] faces: dict[str, str] = config[CONF_FACES] source: list[dict[str, str]] = config[CONF_SOURCE] diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cbe9918eab..24bb23a331a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -783,6 +783,10 @@ evolutionhttp==0.0.18 # homeassistant.components.faa_delays faadelays==2023.9.1 +# homeassistant.components.dlib_face_detect +# homeassistant.components.dlib_face_identify +# face-recognition==1.2.3 + # homeassistant.components.fastdotcom fastdotcom==0.0.3 diff --git a/tests/components/dlib_face_detect/__init__.py b/tests/components/dlib_face_detect/__init__.py new file mode 100644 index 00000000000..a732132955f --- /dev/null +++ b/tests/components/dlib_face_detect/__init__.py @@ -0,0 +1 @@ +"""The dlib_face_detect component.""" diff --git a/tests/components/dlib_face_detect/test_image_processing.py b/tests/components/dlib_face_detect/test_image_processing.py new file mode 100644 index 00000000000..e3b82a4cedf --- /dev/null +++ b/tests/components/dlib_face_detect/test_image_processing.py @@ -0,0 +1,37 @@ +"""Dlib Face Identity Image Processing Tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.dlib_face_detect import DOMAIN as DLIB_DOMAIN +from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_SOURCE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", face_recognition=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + IMAGE_PROCESSING_DOMAIN, + { + IMAGE_PROCESSING_DOMAIN: [ + { + CONF_PLATFORM: DLIB_DOMAIN, + CONF_SOURCE: [ + {CONF_ENTITY_ID: "camera.test_camera"}, + ], + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DLIB_DOMAIN}", + ) in issue_registry.issues diff --git a/tests/components/dlib_face_identify/__init__.py b/tests/components/dlib_face_identify/__init__.py new file mode 100644 index 00000000000..79b9e4ec4bc --- /dev/null +++ b/tests/components/dlib_face_identify/__init__.py @@ -0,0 +1 @@ +"""The dlib_face_identify component.""" diff --git a/tests/components/dlib_face_identify/test_image_processing.py b/tests/components/dlib_face_identify/test_image_processing.py new file mode 100644 index 00000000000..f914baeffb9 --- /dev/null +++ b/tests/components/dlib_face_identify/test_image_processing.py @@ -0,0 +1,41 @@ +"""Dlib Face Identity Image Processing Tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.dlib_face_identify import ( + CONF_FACES, + DOMAIN as DLIB_DOMAIN, +) +from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_PLATFORM, CONF_SOURCE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", face_recognition=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + IMAGE_PROCESSING_DOMAIN, + { + IMAGE_PROCESSING_DOMAIN: [ + { + CONF_PLATFORM: DLIB_DOMAIN, + CONF_SOURCE: [ + {CONF_ENTITY_ID: "camera.test_camera"}, + ], + CONF_FACES: {"person1": __file__}, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DLIB_DOMAIN}", + ) in issue_registry.issues From 74104cf1073bec7ee37791c7140aa33957d144e1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 May 2025 11:16:08 +0200 Subject: [PATCH 0032/1664] Deprecate GStreamer integration (#145768) --- .../components/gstreamer/__init__.py | 2 ++ .../components/gstreamer/media_player.py | 20 +++++++++-- requirements_test_all.txt | 3 ++ tests/components/gstreamer/__init__.py | 1 + .../components/gstreamer/test_media_player.py | 34 +++++++++++++++++++ 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/components/gstreamer/__init__.py create mode 100644 tests/components/gstreamer/test_media_player.py diff --git a/homeassistant/components/gstreamer/__init__.py b/homeassistant/components/gstreamer/__init__.py index 9fb97d25744..d24ac28f25f 100644 --- a/homeassistant/components/gstreamer/__init__.py +++ b/homeassistant/components/gstreamer/__init__.py @@ -1 +1,3 @@ """The gstreamer component.""" + +DOMAIN = "gstreamer" diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index bb78aff8faf..7d830377f1b 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -19,16 +19,18 @@ from homeassistant.components.media_player import ( async_process_play_media_url, ) from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) CONF_PIPELINE = "pipeline" -DOMAIN = "gstreamer" PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PIPELINE): cv.string} @@ -48,6 +50,20 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Gstreamer platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "GStreamer", + }, + ) name = config.get(CONF_NAME) pipeline = config.get(CONF_PIPELINE) diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24bb23a331a..e14b9109ab7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -945,6 +945,9 @@ growattServer==1.6.0 # homeassistant.components.google_sheets gspread==5.5.0 +# homeassistant.components.gstreamer +gstreamer-player==1.1.2 + # homeassistant.components.profiler guppy3==3.1.5 diff --git a/tests/components/gstreamer/__init__.py b/tests/components/gstreamer/__init__.py new file mode 100644 index 00000000000..56369257098 --- /dev/null +++ b/tests/components/gstreamer/__init__.py @@ -0,0 +1 @@ +"""Gstreamer tests.""" diff --git a/tests/components/gstreamer/test_media_player.py b/tests/components/gstreamer/test_media_player.py new file mode 100644 index 00000000000..9fcf8eb7cfc --- /dev/null +++ b/tests/components/gstreamer/test_media_player.py @@ -0,0 +1,34 @@ +"""Tests for the Gstreamer platform.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.gstreamer import DOMAIN as GSTREAMER_DOMAIN +from homeassistant.components.media_player import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", gsp=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: GSTREAMER_DOMAIN, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{GSTREAMER_DOMAIN}", + ) in issue_registry.issues From 3f172233871327fc80e446ca5d15b80a21f6fcbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 28 May 2025 10:57:01 +0200 Subject: [PATCH 0033/1664] Add more information about possible hostnames at Home Connect (#145770) --- homeassistant/components/home_connect/manifest.json | 4 ++-- homeassistant/generated/dhcp.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e550d22e0ca..e8a36cd60d9 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -10,11 +10,11 @@ "macaddress": "C8D778*" }, { - "hostname": "(bosch|siemens)-*", + "hostname": "(balay|bosch|neff|siemens)-*", "macaddress": "68A40E*" }, { - "hostname": "siemens-*", + "hostname": "(siemens|neff)-*", "macaddress": "38B4D3*" } ], diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6ef3051a953..0fb8b48b01c 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -367,12 +367,12 @@ DHCP: Final[list[dict[str, str | bool]]] = [ }, { "domain": "home_connect", - "hostname": "(bosch|siemens)-*", + "hostname": "(balay|bosch|neff|siemens)-*", "macaddress": "68A40E*", }, { "domain": "home_connect", - "hostname": "siemens-*", + "hostname": "(siemens|neff)-*", "macaddress": "38B4D3*", }, { From eb2728e5b981888c2dcefd628160ea7f2903cf66 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 28 May 2025 10:15:24 +0200 Subject: [PATCH 0034/1664] Fix uom for prebrew numbers in lamarzocco (#145772) --- homeassistant/components/lamarzocco/number.py | 4 ++-- tests/components/lamarzocco/snapshots/test_number.ambr | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 7c4fe33a041..980a08c09ae 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -119,7 +119,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( key="prebrew_on", translation_key="prebrew_time_on", device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, + native_unit_of_measurement=UnitOfTime.SECONDS, native_step=PRECISION_TENTHS, native_min_value=0, native_max_value=10, @@ -158,7 +158,7 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( key="prebrew_off", translation_key="prebrew_time_off", device_class=NumberDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, + native_unit_of_measurement=UnitOfTime.SECONDS, native_step=PRECISION_TENTHS, native_min_value=0, native_max_value=10, diff --git a/tests/components/lamarzocco/snapshots/test_number.ambr b/tests/components/lamarzocco/snapshots/test_number.ambr index 85892521456..5f451695443 100644 --- a/tests/components/lamarzocco/snapshots/test_number.ambr +++ b/tests/components/lamarzocco/snapshots/test_number.ambr @@ -126,7 +126,7 @@ 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_off_time', @@ -173,7 +173,7 @@ 'supported_features': 0, 'translation_key': 'prebrew_time_off', 'unique_id': 'MR012345_prebrew_off', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_prebrew_on[Linea Micra] @@ -185,7 +185,7 @@ 'min': 0, 'mode': , 'step': 0.1, - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'number.mr012345_prebrew_on_time', @@ -232,7 +232,7 @@ 'supported_features': 0, 'translation_key': 'prebrew_time_on', 'unique_id': 'MR012345_prebrew_on', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_preinfusion[Linea Micra] From a53c786fe07ecd8a50c1bc627140d5a0f880ca59 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 May 2025 13:12:55 +0200 Subject: [PATCH 0035/1664] Deprecate pandora integration (#145785) --- homeassistant/components/pandora/__init__.py | 2 ++ .../components/pandora/media_player.py | 20 +++++++++++- requirements_test_all.txt | 5 +++ tests/components/pandora/__init__.py | 1 + tests/components/pandora/test_media_player.py | 31 +++++++++++++++++++ 5 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/components/pandora/__init__.py create mode 100644 tests/components/pandora/test_media_player.py diff --git a/homeassistant/components/pandora/__init__.py b/homeassistant/components/pandora/__init__.py index 9664730bdab..0850b00553e 100644 --- a/homeassistant/components/pandora/__init__.py +++ b/homeassistant/components/pandora/__init__.py @@ -1 +1,3 @@ """The pandora component.""" + +DOMAIN = "pandora" diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 064b2930971..77564245522 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -27,10 +27,13 @@ from homeassistant.const import ( SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) @@ -53,6 +56,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Pandora media player platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Pandora", + }, + ) + if not _pianobar_exists(): return pandora = PandoraMediaPlayer("Pandora") diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e14b9109ab7..f03e3c6bc6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1393,6 +1393,11 @@ peco==0.1.2 # homeassistant.components.escea pescea==1.0.12 +# homeassistant.components.aruba +# homeassistant.components.cisco_ios +# homeassistant.components.pandora +pexpect==4.9.0 + # homeassistant.components.modem_callerid phone-modem==0.1.1 diff --git a/tests/components/pandora/__init__.py b/tests/components/pandora/__init__.py new file mode 100644 index 00000000000..6fccecfd679 --- /dev/null +++ b/tests/components/pandora/__init__.py @@ -0,0 +1 @@ +"""Padora component tests.""" diff --git a/tests/components/pandora/test_media_player.py b/tests/components/pandora/test_media_player.py new file mode 100644 index 00000000000..2af72ba2224 --- /dev/null +++ b/tests/components/pandora/test_media_player.py @@ -0,0 +1,31 @@ +"""Pandora media player tests.""" + +from homeassistant.components.media_player import DOMAIN as PLATFORM_DOMAIN +from homeassistant.components.pandora import DOMAIN as PANDORA_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: PANDORA_DOMAIN, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{PANDORA_DOMAIN}", + ) in issue_registry.issues From fbd05a0fcf3e833c5410a75eaa0cf6cd9030a9f8 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 May 2025 18:28:37 +0200 Subject: [PATCH 0036/1664] Deprecate lirc integration (#145797) --- homeassistant/components/lirc/__init__.py | 17 ++++++++++++- requirements_test_all.txt | 3 +++ tests/components/lirc/__init__.py | 1 + tests/components/lirc/test_init.py | 31 +++++++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/components/lirc/__init__.py create mode 100644 tests/components/lirc/test_init.py diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index f5b26743a03..6b8e0d08d52 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -7,8 +7,9 @@ import time import lirc from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -26,6 +27,20 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIRC capability.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "LIRC", + }, + ) # blocking=True gives unexpected behavior (multiple responses for 1 press) # also by not blocking, we allow hass to shut down the thread gracefully # on exit. diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f03e3c6bc6a..a67f27dc6b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2012,6 +2012,9 @@ python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay python-linkplay==0.2.8 +# homeassistant.components.lirc +# python-lirc==1.2.3 + # homeassistant.components.matter python-matter-server==7.0.0 diff --git a/tests/components/lirc/__init__.py b/tests/components/lirc/__init__.py new file mode 100644 index 00000000000..f8e11b194a6 --- /dev/null +++ b/tests/components/lirc/__init__.py @@ -0,0 +1 @@ +"""LIRC tests.""" diff --git a/tests/components/lirc/test_init.py b/tests/components/lirc/test_init.py new file mode 100644 index 00000000000..d6fd7975c77 --- /dev/null +++ b/tests/components/lirc/test_init.py @@ -0,0 +1,31 @@ +"""Tests for the LIRC.""" + +from unittest.mock import Mock, patch + +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", lirc=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + from homeassistant.components.lirc import ( # pylint: disable=import-outside-toplevel + DOMAIN as LIRC_DOMAIN, + ) + + assert await async_setup_component( + hass, + LIRC_DOMAIN, + { + LIRC_DOMAIN: {}, + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{LIRC_DOMAIN}", + ) in issue_registry.issues From 74102d03197f32e40773872fc4628c77c65a6ab1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 28 May 2025 16:33:20 +0200 Subject: [PATCH 0037/1664] Bump reolink-aio to 0.13.4 (#145799) --- 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 a6f0b59426a..694dd43a532 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.13.3"] + "requirements": ["reolink-aio==0.13.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8ffc983546..da5d520c1a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2652,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.3 +reolink-aio==0.13.4 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a67f27dc6b5..9cb25847a65 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2180,7 +2180,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.3 +reolink-aio==0.13.4 # homeassistant.components.rflink rflink==0.0.66 From 83af5ec36bed410748e6574cb0f14c692c0f941f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 May 2025 17:22:18 +0200 Subject: [PATCH 0038/1664] Deprecate keyboard integration (#145805) --- homeassistant/components/keyboard/__init__.py | 17 ++++++++++- requirements_test_all.txt | 3 ++ tests/components/keyboard/__init__.py | 1 + tests/components/keyboard/test_init.py | 29 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/components/keyboard/__init__.py create mode 100644 tests/components/keyboard/test_init.py diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py index bf935f119d0..227472ff553 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -11,8 +11,9 @@ from homeassistant.const import ( SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType DOMAIN = "keyboard" @@ -24,6 +25,20 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Listen for keyboard events.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Keyboard", + }, + ) keyboard = PyKeyboard() keyboard.special_key_assignment() diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cb25847a65..58b27544486 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2098,6 +2098,9 @@ pytrydan==0.8.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 +# homeassistant.components.keyboard +# pyuserinput==0.1.11 + # homeassistant.components.vera pyvera==0.3.15 diff --git a/tests/components/keyboard/__init__.py b/tests/components/keyboard/__init__.py new file mode 100644 index 00000000000..7bc8a91511f --- /dev/null +++ b/tests/components/keyboard/__init__.py @@ -0,0 +1 @@ +"""Keyboard tests.""" diff --git a/tests/components/keyboard/test_init.py b/tests/components/keyboard/test_init.py new file mode 100644 index 00000000000..42a700a3d07 --- /dev/null +++ b/tests/components/keyboard/test_init.py @@ -0,0 +1,29 @@ +"""Keyboard tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", pykeyboard=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + from homeassistant.components.keyboard import ( # pylint:disable=import-outside-toplevel + DOMAIN as KEYBOARD_DOMAIN, + ) + + assert await async_setup_component( + hass, + KEYBOARD_DOMAIN, + {KEYBOARD_DOMAIN: {}}, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{KEYBOARD_DOMAIN}", + ) in issue_registry.issues From 612861061cba5302aa08173a8b025756cfce2bc1 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Wed, 28 May 2025 17:52:51 +0100 Subject: [PATCH 0039/1664] Fix HOMEASSISTANT_STOP unsubscribe in data update coordinator (#145809) * initial commit * a better approach * Add comment --- homeassistant/helpers/update_coordinator.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 7130264eb0d..bd85391f98f 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -138,6 +138,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): async def _on_hass_stop(_: Event) -> None: """Shutdown coordinator on HomeAssistant stop.""" + # Already cleared on EVENT_HOMEASSISTANT_STOP, via async_fire_internal + self._unsub_shutdown = None await self.async_shutdown() self._unsub_shutdown = self.hass.bus.async_listen_once( From 12f8ebb3eae35ab945b82442461d4baedec2d5ae Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 28 May 2025 13:14:13 -0500 Subject: [PATCH 0040/1664] Bump intents to 2025.5.28 (#145816) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2955bb96833..6078d73e99b 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.7"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.28"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 378e9fdce83..c2c4a20b947 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250527.0 -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.5.28 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/pyproject.toml b/pyproject.toml index 7e1e98bdb24..78d09a61477 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its # dependencies to stage 0. - "home-assistant-intents==2025.5.7", + "home-assistant-intents==2025.5.28", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", diff --git a/requirements.txt b/requirements.txt index e4d1cc5ba30..403948d1445 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.5.28 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index da5d520c1a3..2716c6992cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1167,7 +1167,7 @@ holidays==0.73 home-assistant-frontend==20250527.0 # homeassistant.components.conversation -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.5.28 # homeassistant.components.homematicip_cloud homematicip==2.0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58b27544486..ea2df4c3fe0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1004,7 +1004,7 @@ holidays==0.73 home-assistant-frontend==20250527.0 # homeassistant.components.conversation -home-assistant-intents==2025.5.7 +home-assistant-intents==2025.5.28 # homeassistant.components.homematicip_cloud homematicip==2.0.1.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 5ca638ef487..647755d8237 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.26.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.3b0 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.7 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.1.3b0 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 309acb961b4b1b223bf3822c20fdaf9db3739f6e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 28 May 2025 20:49:20 +0200 Subject: [PATCH 0041/1664] Fix Immich media source browsing with multiple config entries (#145823) fix media source browsing with multiple config entries --- homeassistant/components/immich/media_source.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 0d0616875c6..9304039f297 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -30,11 +30,8 @@ LOGGER = getLogger(__name__) async def async_get_media_source(hass: HomeAssistant) -> MediaSource: """Set up Immich media source.""" - entries = hass.config_entries.async_entries( - DOMAIN, include_disabled=False, include_ignore=False - ) hass.http.register_view(ImmichMediaView(hass)) - return ImmichMediaSource(hass, entries) + return ImmichMediaSource(hass) class ImmichMediaSourceIdentifier: @@ -56,18 +53,17 @@ class ImmichMediaSource(MediaSource): name = "Immich" - def __init__(self, hass: HomeAssistant, entries: list[ConfigEntry]) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize Immich media source.""" super().__init__(DOMAIN) self.hass = hass - self.entries = entries async def async_browse_media( self, item: MediaSourceItem, ) -> BrowseMediaSource: """Return media.""" - if not self.hass.config_entries.async_loaded_entries(DOMAIN): + if not (entries := self.hass.config_entries.async_loaded_entries(DOMAIN)): raise BrowseError("Immich is not configured") return BrowseMediaSource( domain=DOMAIN, @@ -79,12 +75,12 @@ class ImmichMediaSource(MediaSource): can_expand=True, children_media_class=MediaClass.DIRECTORY, children=[ - *await self._async_build_immich(item), + *await self._async_build_immich(item, entries), ], ) async def _async_build_immich( - self, item: MediaSourceItem + self, item: MediaSourceItem, entries: list[ConfigEntry] ) -> list[BrowseMediaSource]: """Handle browsing different immich instances.""" if not item.identifier: @@ -99,7 +95,7 @@ class ImmichMediaSource(MediaSource): can_play=False, can_expand=True, ) - for entry in self.entries + for entry in entries ] identifier = ImmichMediaSourceIdentifier(item.identifier) entry: ImmichConfigEntry | None = ( From d0d228d9f4dac8cb1535b45a5580921f6ea85e9f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 28 May 2025 23:17:14 +0200 Subject: [PATCH 0042/1664] Update frontend to 20250528.0 (#145828) Co-authored-by: Robert Resch --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 32d243b3431..3ee40e1ce60 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250527.0"] + "requirements": ["home-assistant-frontend==20250528.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c2c4a20b947..33766ec52c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.48.2 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250527.0 +home-assistant-frontend==20250528.0 home-assistant-intents==2025.5.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2716c6992cb..2677eedbb39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.73 # homeassistant.components.frontend -home-assistant-frontend==20250527.0 +home-assistant-frontend==20250528.0 # homeassistant.components.conversation home-assistant-intents==2025.5.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea2df4c3fe0..185e51d8a48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1001,7 +1001,7 @@ hole==0.8.0 holidays==0.73 # homeassistant.components.frontend -home-assistant-frontend==20250527.0 +home-assistant-frontend==20250528.0 # homeassistant.components.conversation home-assistant-intents==2025.5.28 From 17a0b4f3d0942f5bd6f60f8da2d87db05579f79e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 28 May 2025 23:18:38 +0200 Subject: [PATCH 0043/1664] Bump version to 2025.6.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b3cbb4f27c..5a6ac701771 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 78d09a61477..04dcb3e3057 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b1" +version = "2025.6.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From e0d3b819e5153073e69321318e52a6e6301f5d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Lersveen?= <7195448+lersveen@users.noreply.github.com> Date: Thu, 29 May 2025 03:52:05 +0200 Subject: [PATCH 0044/1664] Set correct nobo_hub max temperature (#145751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max temperature 30°C is implemented upstream in pynobo and the Nobø Energy Hub app also stops at 30°C. --- homeassistant/components/nobo_hub/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 771da420213..018f3e2b06a 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -40,7 +40,7 @@ SUPPORT_FLAGS = ( PRESET_MODES = [PRESET_NONE, PRESET_COMFORT, PRESET_ECO, PRESET_AWAY] MIN_TEMPERATURE = 7 -MAX_TEMPERATURE = 40 +MAX_TEMPERATURE = 30 async def async_setup_entry( From fa66ea31d398af81f14f2a12aa31a49a5550e603 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 29 May 2025 15:35:35 +0200 Subject: [PATCH 0045/1664] Deprecate tensorflow (#145806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/tensorflow/__init__.py | 3 ++ .../components/tensorflow/image_processing.py | 26 ++++++++++-- requirements_test_all.txt | 9 +++++ tests/components/tensorflow/__init__.py | 1 + .../tensorflow/test_image_processing.py | 40 +++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 tests/components/tensorflow/__init__.py create mode 100644 tests/components/tensorflow/test_image_processing.py diff --git a/homeassistant/components/tensorflow/__init__.py b/homeassistant/components/tensorflow/__init__.py index 00a695d6aa8..7ed20cbe4b6 100644 --- a/homeassistant/components/tensorflow/__init__.py +++ b/homeassistant/components/tensorflow/__init__.py @@ -1 +1,4 @@ """The tensorflow component.""" + +DOMAIN = "tensorflow" +CONF_GRAPH = "graph" diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index 0fb069e8da8..05be56d444d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -26,15 +26,21 @@ from homeassistant.const import ( CONF_SOURCE, EVENT_HOMEASSISTANT_START, ) -from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + split_entity_id, +) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.pil import draw_box +from . import CONF_GRAPH, DOMAIN + os.environ["TF_CPP_MIN_LOG_LEVEL"] = "2" -DOMAIN = "tensorflow" _LOGGER = logging.getLogger(__name__) ATTR_MATCHES = "matches" @@ -47,7 +53,6 @@ CONF_BOTTOM = "bottom" CONF_CATEGORIES = "categories" CONF_CATEGORY = "category" CONF_FILE_OUT = "file_out" -CONF_GRAPH = "graph" CONF_LABELS = "labels" CONF_LABEL_OFFSET = "label_offset" CONF_LEFT = "left" @@ -110,6 +115,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the TensorFlow image processing platform.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Tensorflow", + }, + ) + model_config = config[CONF_MODEL] model_dir = model_config.get(CONF_MODEL_DIR) or hass.config.path("tensorflow") labels = model_config.get(CONF_LABELS) or hass.config.path( diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 185e51d8a48..a5a927e94bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1560,6 +1560,9 @@ pybravia==0.3.4 # homeassistant.components.cloudflare pycfdns==3.0.0 +# homeassistant.components.tensorflow +# pycocotools==2.0.6 + # homeassistant.components.comfoconnect pycomfoconnect==0.5.1 @@ -2365,6 +2368,9 @@ temescal==0.5 # homeassistant.components.temper temperusb==1.6.1 +# homeassistant.components.tensorflow +# tensorflow==2.5.0 + # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie @@ -2382,6 +2388,9 @@ teslemetry-stream==0.7.9 # homeassistant.components.tessie tessie-api==0.1.1 +# homeassistant.components.tensorflow +# tf-models-official==2.5.0 + # homeassistant.components.thermobeacon thermobeacon-ble==0.10.0 diff --git a/tests/components/tensorflow/__init__.py b/tests/components/tensorflow/__init__.py new file mode 100644 index 00000000000..458de30c9fa --- /dev/null +++ b/tests/components/tensorflow/__init__.py @@ -0,0 +1 @@ +"""TensorFlow component tests.""" diff --git a/tests/components/tensorflow/test_image_processing.py b/tests/components/tensorflow/test_image_processing.py new file mode 100644 index 00000000000..06199b9c60c --- /dev/null +++ b/tests/components/tensorflow/test_image_processing.py @@ -0,0 +1,40 @@ +"""Tensorflow test.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.image_processing import DOMAIN as IMAGE_PROCESSING_DOMAINN +from homeassistant.components.tensorflow import CONF_GRAPH, DOMAIN as TENSORFLOW_DOMAIN +from homeassistant.const import CONF_ENTITY_ID, CONF_MODEL, CONF_PLATFORM, CONF_SOURCE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", tensorflow=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + IMAGE_PROCESSING_DOMAINN, + { + IMAGE_PROCESSING_DOMAINN: [ + { + CONF_PLATFORM: TENSORFLOW_DOMAIN, + CONF_SOURCE: [ + {CONF_ENTITY_ID: "camera.test_camera"}, + ], + CONF_MODEL: { + CONF_GRAPH: ".", + }, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{TENSORFLOW_DOMAIN}", + ) in issue_registry.issues From 95fb2a7d7f466e15a7916114aa31d9f16ca677d5 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 28 May 2025 23:54:48 +0200 Subject: [PATCH 0046/1664] Deprecate decora integration (#145807) --- homeassistant/components/decora/__init__.py | 2 ++ homeassistant/components/decora/light.py | 19 ++++++++++++ requirements_test_all.txt | 6 ++++ tests/components/decora/__init__.py | 1 + tests/components/decora/test_light.py | 34 +++++++++++++++++++++ 5 files changed, 62 insertions(+) create mode 100644 tests/components/decora/__init__.py create mode 100644 tests/components/decora/test_light.py diff --git a/homeassistant/components/decora/__init__.py b/homeassistant/components/decora/__init__.py index 694ff77fdb3..4ba4fb4dee0 100644 --- a/homeassistant/components/decora/__init__.py +++ b/homeassistant/components/decora/__init__.py @@ -1 +1,3 @@ """The decora component.""" + +DOMAIN = "decora" diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py index a7d14b83aca..d0226a24dcc 100644 --- a/homeassistant/components/decora/light.py +++ b/homeassistant/components/decora/light.py @@ -21,7 +21,11 @@ from homeassistant.components.light import ( LightEntity, ) from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue + +from . import DOMAIN if TYPE_CHECKING: from homeassistant.core import HomeAssistant @@ -90,6 +94,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an Decora switch.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Leviton Decora", + }, + ) + lights = [] for address, device_config in config[CONF_DEVICES].items(): device = {} diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5a927e94bd..263832d2bc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -561,6 +561,9 @@ bluecurrent-api==1.2.3 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 +# homeassistant.components.decora +# bluepy==1.3.0 + # homeassistant.components.bluetooth bluetooth-adapters==0.21.4 @@ -655,6 +658,9 @@ dbus-fast==2.43.0 # homeassistant.components.debugpy debugpy==1.8.14 +# homeassistant.components.decora +# decora==0.6 + # homeassistant.components.ecovacs deebot-client==13.2.1 diff --git a/tests/components/decora/__init__.py b/tests/components/decora/__init__.py new file mode 100644 index 00000000000..399b353aa0c --- /dev/null +++ b/tests/components/decora/__init__.py @@ -0,0 +1 @@ +"""Decora component tests.""" diff --git a/tests/components/decora/test_light.py b/tests/components/decora/test_light.py new file mode 100644 index 00000000000..6315d6c3986 --- /dev/null +++ b/tests/components/decora/test_light.py @@ -0,0 +1,34 @@ +"""Decora component tests.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.decora import DOMAIN as DECORA_DOMAIN +from homeassistant.components.light import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", {"bluepy": Mock(), "bluepy.btle": Mock(), "decora": Mock()}) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: DECORA_DOMAIN, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DECORA_DOMAIN}", + ) in issue_registry.issues From 26586b451435e02785e94cc8a3fff764e2bd6eef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 29 May 2025 15:28:54 +0200 Subject: [PATCH 0047/1664] Fix language selections in workday (#145813) --- .../components/workday/binary_sensor.py | 44 ++++++++++++++++-- .../components/workday/config_flow.py | 17 +------ .../components/workday/test_binary_sensor.py | 46 +++++++++++++++++++ 3 files changed, 89 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6b878db8159..a48e19e59b2 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -94,21 +94,59 @@ def _get_obj_holidays( language=language, categories=set_categories, ) + + supported_languages = obj_holidays.supported_languages + default_language = obj_holidays.default_language + + if default_language and not language: + # If no language is set, use the default language + LOGGER.debug("Changing language from None to %s", default_language) + return country_holidays( # Return default if no language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + if ( - (supported_languages := obj_holidays.supported_languages) + default_language and language + and language not in supported_languages and language.startswith("en") ): + # If language does not match supported languages, use the first English variant + if default_language.startswith("en"): + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) for lang in supported_languages: if lang.startswith("en"): - obj_holidays = country_holidays( + LOGGER.debug("Changing language from %s to %s", language, lang) + return country_holidays( country, subdiv=province, years=year, language=lang, categories=set_categories, ) - LOGGER.debug("Changing language from %s to %s", language, lang) + + if default_language and language and language not in supported_languages: + # If language does not match supported languages, use the default language + LOGGER.debug("Changing language from %s to %s", language, default_language) + return country_holidays( # Return default English if default language + country, + subdiv=province, + years=year, + language=default_language, + categories=set_categories, + ) + return obj_holidays diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index b0b1e9fcc02..7a8a8181a9f 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -67,8 +67,7 @@ def add_province_and_language_to_schema( _country = country_holidays(country=country) if country_default_language := (_country.default_language): - selectable_languages = _country.supported_languages - new_selectable_languages = list(selectable_languages) + new_selectable_languages = list(_country.supported_languages) language_schema = { vol.Optional( CONF_LANGUAGE, default=country_default_language @@ -154,19 +153,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: years=year, language=language, ) - if ( - (supported_languages := obj_holidays.supported_languages) - and language - and language.startswith("en") - ): - for lang in supported_languages: - if lang.startswith("en"): - obj_holidays = country_holidays( - country, - subdiv=province, - years=year, - language=lang, - ) + else: obj_holidays = HolidayBase(years=year) diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 212c3e9d305..8f8894e3536 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -461,3 +461,49 @@ async def test_only_repairs_for_current_next_year( assert len(issue_registry.issues) == 2 assert issue_registry.issues == snapshot + + +async def test_missing_language( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when language exist but is empty.""" + config = { + "add_holidays": [], + "country": "AU", + "days_offset": 0, + "excludes": ["sat", "sun", "holiday"], + "language": None, + "name": "Workday Sensor", + "platform": "workday", + "province": "QLD", + "remove_holidays": [ + "Labour Day", + ], + "workdays": ["mon", "tue", "wed", "thu", "fri"], + } + await init_integration(hass, config) + assert "Changing language from None to en_AU" in caplog.text + + +async def test_incorrect_english_variant( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test when language exist but is empty.""" + config = { + "add_holidays": [], + "country": "AU", + "days_offset": 0, + "excludes": ["sat", "sun", "holiday"], + "language": "en_UK", # Incorrect variant + "name": "Workday Sensor", + "platform": "workday", + "province": "QLD", + "remove_holidays": [ + "Labour Day", + ], + "workdays": ["mon", "tue", "wed", "thu", "fri"], + } + await init_integration(hass, config) + assert "Changing language from en_UK to en_AU" in caplog.text From 4d22b35a9f109f914a2210617b32dd24643b9ec3 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Thu, 29 May 2025 09:35:05 +0200 Subject: [PATCH 0048/1664] Bump aiotedee to 0.2.23 (#145822) * Bump aiotedee to 0.2.23 * update snapshot --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tedee/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index bca51f08f93..012e82318ed 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["aiotedee"], "quality_scale": "platinum", - "requirements": ["aiotedee==0.2.20"] + "requirements": ["aiotedee==0.2.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2677eedbb39..0aad41a4bcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -405,7 +405,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.20 +aiotedee==0.2.23 # homeassistant.components.tractive aiotractive==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 263832d2bc1..d358800b7f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -387,7 +387,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.20 +aiotedee==0.2.23 # homeassistant.components.tractive aiotractive==0.6.0 diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr index 401c519c215..046a8fd210a 100644 --- a/tests/components/tedee/snapshots/test_diagnostics.ambr +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -6,6 +6,7 @@ 'duration_pullspring': 2, 'is_charging': False, 'is_connected': True, + 'is_enabled_auto_pullspring': False, 'is_enabled_pullspring': 1, 'lock_id': '**REDACTED**', 'lock_name': 'Lock-1A2B', @@ -18,6 +19,7 @@ 'duration_pullspring': 0, 'is_charging': False, 'is_connected': True, + 'is_enabled_auto_pullspring': False, 'is_enabled_pullspring': 0, 'lock_id': '**REDACTED**', 'lock_name': 'Lock-2C3D', From 0e87d14ca8890c7427432e37914e8588b2fb45d3 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 29 May 2025 10:28:02 +0200 Subject: [PATCH 0049/1664] Use mime type provided by Immich (#145830) use mime type from immich instead of guessing it --- .../components/immich/media_source.py | 68 ++++++++++-------- tests/components/immich/test_media_source.py | 69 +++++++++++-------- 2 files changed, 78 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index 9304039f297..a7c55f9c572 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -3,7 +3,6 @@ from __future__ import annotations from logging import getLogger -import mimetypes from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse from aioimmich.exceptions import ImmichError @@ -39,13 +38,14 @@ class ImmichMediaSourceIdentifier: def __init__(self, identifier: str) -> None: """Split identifier into parts.""" - parts = identifier.split("/") - # config_entry.unique_id/collection/collection_id/asset_id/file_name + parts = identifier.split("|") + # config_entry.unique_id|collection|collection_id|asset_id|file_name|mime_type self.unique_id = parts[0] self.collection = parts[1] if len(parts) > 1 else None self.collection_id = parts[2] if len(parts) > 2 else None self.asset_id = parts[3] if len(parts) > 3 else None self.file_name = parts[4] if len(parts) > 3 else None + self.mime_type = parts[5] if len(parts) > 3 else None class ImmichMediaSource(MediaSource): @@ -111,7 +111,7 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}/albums", + identifier=f"{identifier.unique_id}|albums", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, title="albums", @@ -130,13 +130,13 @@ class ImmichMediaSource(MediaSource): return [ BrowseMediaSource( domain=DOMAIN, - identifier=f"{identifier.unique_id}/albums/{album.album_id}", + identifier=f"{identifier.unique_id}|albums|{album.album_id}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, title=album.name, can_play=False, can_expand=True, - thumbnail=f"/immich/{identifier.unique_id}/{album.thumbnail_asset_id}/thumb.jpg/thumbnail", + thumbnail=f"/immich/{identifier.unique_id}/{album.thumbnail_asset_id}/thumbnail/image/jpg", ) for album in albums ] @@ -157,17 +157,18 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}/albums/" - f"{identifier.collection_id}/" - f"{asset.asset_id}/" - f"{asset.file_name}" + f"{identifier.unique_id}|albums|" + f"{identifier.collection_id}|" + f"{asset.asset_id}|" + f"{asset.file_name}|" + f"{asset.mime_type}" ), media_class=MediaClass.IMAGE, media_content_type=asset.mime_type, title=asset.file_name, can_play=False, can_expand=False, - thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/{asset.file_name}/thumbnail", + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{asset.mime_type}", ) for asset in album_info.assets if asset.mime_type.startswith("image/") @@ -177,17 +178,18 @@ class ImmichMediaSource(MediaSource): BrowseMediaSource( domain=DOMAIN, identifier=( - f"{identifier.unique_id}/albums/" - f"{identifier.collection_id}/" - f"{asset.asset_id}/" - f"{asset.file_name}" + f"{identifier.unique_id}|albums|" + f"{identifier.collection_id}|" + f"{asset.asset_id}|" + f"{asset.file_name}|" + f"{asset.mime_type}" ), media_class=MediaClass.VIDEO, media_content_type=asset.mime_type, title=asset.file_name, can_play=True, can_expand=False, - thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail.jpg/thumbnail", + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/image/jpeg", ) for asset in album_info.assets if asset.mime_type.startswith("video/") @@ -197,17 +199,23 @@ class ImmichMediaSource(MediaSource): async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: """Resolve media to a url.""" - identifier = ImmichMediaSourceIdentifier(item.identifier) - if identifier.file_name is None: - raise Unresolvable("No file name") - mime_type, _ = mimetypes.guess_type(identifier.file_name) - if not isinstance(mime_type, str): - raise Unresolvable("No file extension") + try: + identifier = ImmichMediaSourceIdentifier(item.identifier) + except IndexError as err: + raise Unresolvable( + f"Could not parse identifier: {item.identifier}" + ) from err + + if identifier.mime_type is None: + raise Unresolvable( + f"Could not resolve identifier that has no mime-type: {item.identifier}" + ) + return PlayMedia( ( - f"/immich/{identifier.unique_id}/{identifier.asset_id}/{identifier.file_name}/fullsize" + f"/immich/{identifier.unique_id}/{identifier.asset_id}/fullsize/{identifier.mime_type}" ), - mime_type, + identifier.mime_type, ) @@ -228,10 +236,10 @@ class ImmichMediaView(HomeAssistantView): if not self.hass.config_entries.async_loaded_entries(DOMAIN): raise HTTPNotFound - asset_id, file_name, size = location.split("/") - mime_type, _ = mimetypes.guess_type(file_name) - if not isinstance(mime_type, str): - raise HTTPNotFound + try: + asset_id, size, mime_type_base, mime_type_format = location.split("/") + except ValueError as err: + raise HTTPNotFound from err entry: ImmichConfigEntry | None = ( self.hass.config_entries.async_entry_for_domain_unique_id( @@ -242,7 +250,7 @@ class ImmichMediaView(HomeAssistantView): immich_api = entry.runtime_data.api # stream response for videos - if mime_type.startswith("video/"): + if mime_type_base == "video": try: resp = await immich_api.assets.async_play_video_stream(asset_id) except ImmichError as exc: @@ -259,4 +267,4 @@ class ImmichMediaView(HomeAssistantView): 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=mime_type) + return Response(body=image, content_type=f"{mime_type_base}/{mime_type_format}") diff --git a/tests/components/immich/test_media_source.py b/tests/components/immich/test_media_source.py index 0f448fbf23d..5b396a780cc 100644 --- a/tests/components/immich/test_media_source.py +++ b/tests/components/immich/test_media_source.py @@ -43,9 +43,15 @@ async def test_get_media_source(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("identifier", "exception_msg"), [ - ("unique_id", "No file name"), - ("unique_id/albums/album_id", "No file name"), - ("unique_id/albums/album_id/asset_id/filename", "No file extension"), + ("unique_id", "Could not resolve identifier that has no mime-type"), + ( + "unique_id|albums|album_id", + "Could not resolve identifier that has no mime-type", + ), + ( + "unique_id|albums|album_id|asset_id|filename", + "Could not parse identifier", + ), ], ) async def test_resolve_media_bad_identifier( @@ -64,15 +70,20 @@ async def test_resolve_media_bad_identifier( ("identifier", "url", "mime_type"), [ ( - "unique_id/albums/album_id/asset_id/filename.jpg", - "/immich/unique_id/asset_id/filename.jpg/fullsize", + "unique_id|albums|album_id|asset_id|filename.jpg|image/jpeg", + "/immich/unique_id/asset_id/fullsize/image/jpeg", "image/jpeg", ), ( - "unique_id/albums/album_id/asset_id/filename.png", - "/immich/unique_id/asset_id/filename.png/fullsize", + "unique_id|albums|album_id|asset_id|filename.png|image/png", + "/immich/unique_id/asset_id/fullsize/image/png", "image/png", ), + ( + "unique_id|albums|album_id|asset_id|filename.mp4|video/mp4", + "/immich/unique_id/asset_id/fullsize/video/mp4", + "video/mp4", + ), ], ) async def test_resolve_media_success( @@ -137,7 +148,7 @@ async def test_browse_media_get_root( assert isinstance(media_file, BrowseMedia) assert media_file.title == "albums" assert media_file.media_content_id == ( - "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums" + "media-source://immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums" ) @@ -154,7 +165,7 @@ async def test_browse_media_get_albums( source = await async_get_media_source(hass) item = MediaSourceItem( - hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums", None + hass, DOMAIN, "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums", None ) result = await source.async_browse_media(item) @@ -165,7 +176,7 @@ async def test_browse_media_get_albums( assert media_file.title == "My Album" assert media_file.media_content_id == ( "media-source://immich/" - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6" ) @@ -193,7 +204,7 @@ async def test_browse_media_get_albums_error( source = await async_get_media_source(hass) - item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}/albums", None) + item = MediaSourceItem(hass, DOMAIN, f"{mock_config_entry.unique_id}|albums", None) result = await source.async_browse_media(item) assert result @@ -219,7 +230,7 @@ async def test_browse_media_get_album_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -240,7 +251,7 @@ async def test_browse_media_get_album_items_error( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -266,7 +277,7 @@ async def test_browse_media_get_album_items( item = MediaSourceItem( hass, DOMAIN, - "e7ef5713-9dab-4bd4-b899-715b0ca4379e/albums/721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", None, ) result = await source.async_browse_media(item) @@ -276,9 +287,9 @@ async def test_browse_media_get_album_items( 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" + "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 @@ -287,15 +298,15 @@ async def test_browse_media_get_album_items( assert not media_file.can_expand assert media_file.thumbnail == ( "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail" + "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" + "e7ef5713-9dab-4bd4-b899-715b0ca4379e|albums|" + "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6|" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b|filename.mp4|video/mp4" ) assert media_file.title == "filename.mp4" assert media_file.media_class == MediaClass.VIDEO @@ -304,7 +315,7 @@ async def test_browse_media_get_album_items( assert not media_file.can_expand assert media_file.thumbnail == ( "/immich/e7ef5713-9dab-4bd4-b899-715b0ca4379e/" - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail.jpg/thumbnail" + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/thumbnail/image/jpeg" ) @@ -327,12 +338,12 @@ async def test_media_view( with patch("homeassistant.components.immich.PLATFORMS", []): await setup_integration(hass, mock_config_entry) - # wrong url (without file extension) + # wrong url (without mime type) with pytest.raises(web.HTTPNotFound): await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename/thumbnail", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail", ) # exception in async_view_asset() @@ -348,7 +359,7 @@ async def test_media_view( await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/image/jpeg", ) # exception in async_play_video_stream() @@ -364,7 +375,7 @@ async def test_media_view( await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4/fullsize", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/fullsize/video/mp4", ) # success @@ -374,14 +385,14 @@ async def test_media_view( result = await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/filename.jpg/thumbnail", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/thumbnail/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/filename.jpg/fullsize", + "2e94c203-50aa-4ad2-8e29-56dd74e0eff4/fullsize/image/jpeg", ) assert isinstance(result, web.Response) @@ -393,6 +404,6 @@ async def test_media_view( result = await view.get( request, "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/filename.mp4/fullsize", + "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b/fullsize/video/mp4", ) assert isinstance(result, web.StreamResponse) From 64b4642c496caf1043a7d24808429242994d8730 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Thu, 29 May 2025 11:58:29 +1000 Subject: [PATCH 0050/1664] Fix Tessie volume max and step (#145835) * Use fixed volume max and step * Update snapshot --- homeassistant/components/tessie/media_player.py | 9 ++++++--- tests/components/tessie/snapshots/test_media_player.ambr | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py index 139ee07ca5b..ecac11587c1 100644 --- a/homeassistant/components/tessie/media_player.py +++ b/homeassistant/components/tessie/media_player.py @@ -20,6 +20,10 @@ STATES = { "Stopped": MediaPlayerState.IDLE, } +# Tesla uses 31 steps, in 0.333 increments up to 10.333 +VOLUME_STEP = 1 / 31 +VOLUME_FACTOR = 31 / 3 # 10.333 + PARALLEL_UPDATES = 0 @@ -38,6 +42,7 @@ class TessieMediaEntity(TessieEntity, MediaPlayerEntity): """Vehicle Location Media Class.""" _attr_device_class = MediaPlayerDeviceClass.SPEAKER + _attr_volume_step = VOLUME_STEP def __init__( self, @@ -57,9 +62,7 @@ class TessieMediaEntity(TessieEntity, MediaPlayerEntity): @property def volume_level(self) -> float: """Volume level of the media player (0..1).""" - return self.get("vehicle_state_media_info_audio_volume", 0) / self.get( - "vehicle_state_media_info_audio_volume_max", 10.333333 - ) + return self.get("vehicle_state_media_info_audio_volume", 0) / VOLUME_FACTOR @property def media_duration(self) -> int | None: diff --git a/tests/components/tessie/snapshots/test_media_player.ambr b/tests/components/tessie/snapshots/test_media_player.ambr index ff0f6c794a7..69a5ca4b86b 100644 --- a/tests/components/tessie/snapshots/test_media_player.ambr +++ b/tests/components/tessie/snapshots/test_media_player.ambr @@ -41,7 +41,7 @@ 'device_class': 'speaker', 'friendly_name': 'Test Media player', 'supported_features': , - 'volume_level': 0.22580323309042688, + 'volume_level': 0.2258032258064516, }), 'context': , 'entity_id': 'media_player.test_media_player', @@ -64,7 +64,7 @@ 'media_title': 'Song', 'source': 'Spotify', 'supported_features': , - 'volume_level': 0.22580323309042688, + 'volume_level': 0.2258032258064516, }), 'context': , 'entity_id': 'media_player.test_media_player', From 097eecd78a28b3f2183de940d2155edfc930c965 Mon Sep 17 00:00:00 2001 From: Matthew FitzGerald-Chamberlain Date: Wed, 28 May 2025 20:43:28 -0500 Subject: [PATCH 0051/1664] Bump pyaprilaire to 0.9.1 (#145836) --- homeassistant/components/aprilaire/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json index 6fe3beae3bc..fa30882f669 100644 --- a/homeassistant/components/aprilaire/manifest.json +++ b/homeassistant/components/aprilaire/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["pyaprilaire"], - "requirements": ["pyaprilaire==0.9.0"] + "requirements": ["pyaprilaire==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0aad41a4bcc..8a9ce0e353a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1832,7 +1832,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.9.0 +pyaprilaire==0.9.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d358800b7f4..142ba2e57e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1531,7 +1531,7 @@ pyairnow==1.2.1 pyairvisual==2023.08.1 # homeassistant.components.aprilaire -pyaprilaire==0.9.0 +pyaprilaire==0.9.1 # homeassistant.components.asuswrt pyasuswrt==0.1.21 From 5cfccb7e1d7dddccc41619bb5cc10462f65ffaea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 May 2025 19:38:06 -0500 Subject: [PATCH 0052/1664] Bump aiohttp to 3.12.3 (#145837) --- 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 33766ec52c4..89c2d39edd8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.12.2 +aiohttp==3.12.3 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 04dcb3e3057..4a7022b7b67 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.2", + "aiohttp==3.12.3", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 403948d1445..bc044454da4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.2 +aiohttp==3.12.3 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 4317fad79858f23c89c8ef2d74663d4e8936527a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 28 May 2025 21:08:01 -0500 Subject: [PATCH 0053/1664] Bump aiohttp to 3.12.4 (#145838) --- 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 89c2d39edd8..8dde4a6a654 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.12.3 +aiohttp==3.12.4 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 4a7022b7b67..331381def64 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.3", + "aiohttp==3.12.4", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index bc044454da4..f1ced51637e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.3 +aiohttp==3.12.4 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 0f7379c941feba20e4930b4e1d838fff3d6dfeb3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 29 May 2025 15:31:50 +0200 Subject: [PATCH 0054/1664] Reolink fallback to download command for playback (#145842) --- homeassistant/components/reolink/views.py | 27 ++++++++++++++++++++++- tests/components/reolink/test_views.py | 7 +++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 44265244b18..7f062055f7e 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -52,6 +52,7 @@ class PlaybackProxyView(HomeAssistantView): verify_ssl=False, ssl_cipher=SSLCipherList.INSECURE, ) + self._vod_type: str | None = None async def get( self, @@ -68,6 +69,8 @@ class PlaybackProxyView(HomeAssistantView): filename_decoded = urlsafe_b64decode(filename.encode("utf-8")).decode("utf-8") ch = int(channel) + if self._vod_type is not None: + vod_type = self._vod_type try: host = get_host(self.hass, config_entry_id) except Unresolvable: @@ -127,6 +130,25 @@ class PlaybackProxyView(HomeAssistantView): "apolication/octet-stream", ]: err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}" + if ( + reolink_response.content_type == "video/x-flv" + and vod_type == VodRequestType.PLAYBACK.value + ): + # next time use DOWNLOAD immediately + self._vod_type = VodRequestType.DOWNLOAD.value + _LOGGER.debug( + "%s, retrying using download instead of playback cmd", err_str + ) + return await self.get( + request, + config_entry_id, + channel, + stream_res, + self._vod_type, + filename, + retry, + ) + _LOGGER.error(err_str) if reolink_response.content_type == "text/html": text = await reolink_response.text() @@ -140,7 +162,10 @@ class PlaybackProxyView(HomeAssistantView): reolink_response.reason, response_headers, ) - response_headers["Content-Type"] = "video/mp4" + if "Content-Type" not in response_headers: + response_headers["Content-Type"] = reolink_response.content_type + if response_headers["Content-Type"] == "apolication/octet-stream": + response_headers["Content-Type"] = "application/octet-stream" response = web.StreamResponse( status=reolink_response.status, diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 3521de072b6..992e47f0575 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -58,17 +58,22 @@ def get_mock_session( return mock_session +@pytest.mark.parametrize( + ("content_type"), + [("video/mp4"), ("application/octet-stream"), ("apolication/octet-stream")], +) async def test_playback_proxy( hass: HomeAssistant, reolink_connect: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, + content_type: str, ) -> None: """Test successful playback proxy URL.""" reolink_connect.get_vod_source.return_value = (TEST_MIME_TYPE_MP4, TEST_URL) - mock_session = get_mock_session() + mock_session = get_mock_session(content_type=content_type) with patch( "homeassistant.components.reolink.views.async_get_clientsession", From d46f28792c3f1739d87568a77bb220131b2c1800 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 29 May 2025 15:30:02 +0200 Subject: [PATCH 0055/1664] Bump aioimmich to 0.7.0 (#145845) --- 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 454adae5501..5b56a7e3e2d 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.6.0"] + "requirements": ["aioimmich==0.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a9ce0e353a..6aeaf8b077f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.6.0 +aioimmich==0.7.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 142ba2e57e9..ae0b8ee7cd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -265,7 +265,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.6.0 +aioimmich==0.7.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 600ac17a5f459e922b0d89c41aee4a4f5f672c81 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 29 May 2025 14:12:51 +0200 Subject: [PATCH 0056/1664] Deprecate sms integration (#145847) --- .../components/homeassistant/strings.json | 4 ++ homeassistant/components/sms/__init__.py | 24 +++++++- tests/components/sms/test_init.py | 59 +++++++++++++++++++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 tests/components/sms/test_init.py diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index e4c3e19cf7c..123e625d0fc 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -18,6 +18,10 @@ "title": "The {integration_title} YAML configuration is being removed", "description": "Configuring {integration_title} using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." }, + "deprecated_system_packages_config_flow_integration": { + "title": "The {integration_title} integration is being removed", + "description": "The {integration_title} integration is being removed as it requires additional system packages, which can't be installed on supported Home Assistant installations. Remove all \"{integration_title}\" config entries to fix this issue." + }, "deprecated_system_packages_yaml_integration": { "title": "The {integration_title} integration is being removed", "description": "The {integration_title} integration is being removed as it requires additional system packages, which can't be installed on supported Home Assistant installations. Remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 2d18d44de3a..6c7c5374f7d 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -6,9 +6,14 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, CONF_NAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -41,6 +46,7 @@ CONFIG_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) +DEPRECATED_ISSUE_ID = f"deprecated_system_packages_config_flow_integration_{DOMAIN}" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -52,6 +58,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Configure Gammu state machine.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + DEPRECATED_ISSUE_ID, + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_config_flow_integration", + translation_placeholders={ + "integration_title": "SMS notifications via GSM-modem", + }, + ) device = entry.data[CONF_DEVICE] connection_mode = "at" @@ -101,4 +120,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway = hass.data[DOMAIN].pop(SMS_GATEWAY)[GATEWAY] await gateway.terminate_async() + if not hass.config_entries.async_loaded_entries(DOMAIN): + async_delete_issue(hass, HOMEASSISTANT_DOMAIN, DEPRECATED_ISSUE_ID) + return unload_ok diff --git a/tests/components/sms/test_init.py b/tests/components/sms/test_init.py new file mode 100644 index 00000000000..03cebfe9b52 --- /dev/null +++ b/tests/components/sms/test_init.py @@ -0,0 +1,59 @@ +"""Test init.""" + +from unittest.mock import Mock, patch + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +@patch.dict( + "sys.modules", + { + "gammu": Mock(), + "gammu.asyncworker": Mock(), + }, +) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + from homeassistant.components.sms import ( # pylint: disable=import-outside-toplevel + DEPRECATED_ISSUE_ID, + DOMAIN as SMS_DOMAIN, + ) + + with ( + patch("homeassistant.components.sms.create_sms_gateway", autospec=True), + patch("homeassistant.components.sms.PLATFORMS", []), + ): + config_entry = MockConfigEntry( + title="test", + domain=SMS_DOMAIN, + data={ + CONF_DEVICE: "/dev/ttyUSB0", + }, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert ( + HOMEASSISTANT_DOMAIN, + DEPRECATED_ISSUE_ID, + ) in issue_registry.issues + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert ( + HOMEASSISTANT_DOMAIN, + DEPRECATED_ISSUE_ID, + ) not in issue_registry.issues From 48103bd2446df6793377a07332f28e9efea644ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 30 May 2025 01:40:11 +0200 Subject: [PATCH 0057/1664] Bump aiohomeconnect to 0.17.1 (#145873) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index e8a36cd60d9..d4b37552fb7 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -21,6 +21,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.17.0"], + "requirements": ["aiohomeconnect==0.17.1"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 6aeaf8b077f..68ec08b1c90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.0 +aiohomeconnect==0.17.1 # homeassistant.components.homekit_controller aiohomekit==3.2.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae0b8ee7cd2..77661e0f624 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.0 +aiohomeconnect==0.17.1 # homeassistant.components.homekit_controller aiohomekit==3.2.14 From aa8a6058b5b2d5edf758fa2ebab508cedb1934b6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 30 May 2025 12:56:51 +0200 Subject: [PATCH 0058/1664] Bump version to 2025.6.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5a6ac701771..29095678e48 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 331381def64..dc185344ca8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b2" +version = "2025.6.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From f0fcef574432f45a971785dce9f3c9cecd9d9ea3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 31 May 2025 20:25:24 +0200 Subject: [PATCH 0059/1664] Add more Amazon Devices DHCP matches (#145776) --- .../components/amazon_devices/manifest.json | 89 ++++- homeassistant/generated/dhcp.py | 348 ++++++++++++++++++ 2 files changed, 436 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json index 7593fbd4943..eb9fae6ddbe 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/amazon_devices/manifest.json @@ -4,27 +4,114 @@ "codeowners": ["@chemelli74"], "config_flow": true, "dhcp": [ + { "macaddress": "007147*" }, + { "macaddress": "00FC8B*" }, + { "macaddress": "0812A5*" }, + { "macaddress": "086AE5*" }, + { "macaddress": "08849D*" }, + { "macaddress": "089115*" }, { "macaddress": "08A6BC*" }, + { "macaddress": "08C224*" }, + { "macaddress": "0CDC91*" }, + { "macaddress": "0CEE99*" }, + { "macaddress": "1009F9*" }, + { "macaddress": "109693*" }, { "macaddress": "10BF67*" }, + { "macaddress": "10CE02*" }, + { "macaddress": "140AC5*" }, + { "macaddress": "149138*" }, + { "macaddress": "1848BE*" }, + { "macaddress": "1C12B0*" }, + { "macaddress": "1C4D66*" }, + { "macaddress": "1C93C4*" }, + { "macaddress": "1CFE2B*" }, + { "macaddress": "244CE3*" }, + { "macaddress": "24CE33*" }, + { "macaddress": "2873F6*" }, + { "macaddress": "2C71FF*" }, + { "macaddress": "34AFB3*" }, + { "macaddress": "34D270*" }, + { "macaddress": "38F73D*" }, + { "macaddress": "3C5CC4*" }, + { "macaddress": "3CE441*" }, { "macaddress": "440049*" }, + { "macaddress": "40A2DB*" }, + { "macaddress": "40A9CF*" }, + { "macaddress": "40B4CD*" }, { "macaddress": "443D54*" }, + { "macaddress": "44650D*" }, + { "macaddress": "485F2D*" }, + { "macaddress": "48785E*" }, { "macaddress": "48B423*" }, { "macaddress": "4C1744*" }, + { "macaddress": "4CEFC0*" }, + { "macaddress": "5007C3*" }, { "macaddress": "50D45C*" }, { "macaddress": "50DCE7*" }, + { "macaddress": "50F5DA*" }, + { "macaddress": "5C415A*" }, + { "macaddress": "6837E9*" }, + { "macaddress": "6854FD*" }, + { "macaddress": "689A87*" }, + { "macaddress": "68B691*" }, + { "macaddress": "68DBF5*" }, { "macaddress": "68F63B*" }, { "macaddress": "6C0C9A*" }, + { "macaddress": "6C5697*" }, + { "macaddress": "7458F3*" }, + { "macaddress": "74C246*" }, { "macaddress": "74D637*" }, + { "macaddress": "74E20C*" }, + { "macaddress": "74ECB2*" }, + { "macaddress": "786C84*" }, + { "macaddress": "78A03F*" }, { "macaddress": "7C6166*" }, + { "macaddress": "7C6305*" }, + { "macaddress": "7CD566*" }, + { "macaddress": "8871E5*" }, { "macaddress": "901195*" }, + { "macaddress": "90235B*" }, + { "macaddress": "90A822*" }, + { "macaddress": "90F82E*" }, { "macaddress": "943A91*" }, { "macaddress": "98226E*" }, + { "macaddress": "98CCF3*" }, { "macaddress": "9CC8E9*" }, + { "macaddress": "A002DC*" }, + { "macaddress": "A0D2B1*" }, + { "macaddress": "A40801*" }, { "macaddress": "A8E621*" }, + { "macaddress": "AC416A*" }, + { "macaddress": "AC63BE*" }, + { "macaddress": "ACCCFC*" }, + { "macaddress": "B0739C*" }, + { "macaddress": "B0CFCB*" }, + { "macaddress": "B0F7C4*" }, + { "macaddress": "B85F98*" }, + { "macaddress": "C091B9*" }, { "macaddress": "C095CF*" }, + { "macaddress": "C49500*" }, + { "macaddress": "C86C3D*" }, + { "macaddress": "CC9EA2*" }, + { "macaddress": "CCF735*" }, + { "macaddress": "DC54D7*" }, { "macaddress": "D8BE65*" }, + { "macaddress": "D8FBD6*" }, + { "macaddress": "DC91BF*" }, + { "macaddress": "DCA0D0*" }, + { "macaddress": "E0F728*" }, { "macaddress": "EC2BEB*" }, - { "macaddress": "F02F9E*" } + { "macaddress": "EC8AC4*" }, + { "macaddress": "ECA138*" }, + { "macaddress": "F02F9E*" }, + { "macaddress": "F0272D*" }, + { "macaddress": "F0F0A4*" }, + { "macaddress": "F4032A*" }, + { "macaddress": "F854B8*" }, + { "macaddress": "FC492D*" }, + { "macaddress": "FC65DE*" }, + { "macaddress": "FCA183*" }, + { "macaddress": "FCE9D8*" } ], "documentation": "https://www.home-assistant.io/integrations/amazon_devices", "integration_type": "hub", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 0fb8b48b01c..5285ab7a1db 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -26,22 +26,158 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "airzone", "macaddress": "E84F25*", }, + { + "domain": "amazon_devices", + "macaddress": "007147*", + }, + { + "domain": "amazon_devices", + "macaddress": "00FC8B*", + }, + { + "domain": "amazon_devices", + "macaddress": "0812A5*", + }, + { + "domain": "amazon_devices", + "macaddress": "086AE5*", + }, + { + "domain": "amazon_devices", + "macaddress": "08849D*", + }, + { + "domain": "amazon_devices", + "macaddress": "089115*", + }, { "domain": "amazon_devices", "macaddress": "08A6BC*", }, + { + "domain": "amazon_devices", + "macaddress": "08C224*", + }, + { + "domain": "amazon_devices", + "macaddress": "0CDC91*", + }, + { + "domain": "amazon_devices", + "macaddress": "0CEE99*", + }, + { + "domain": "amazon_devices", + "macaddress": "1009F9*", + }, + { + "domain": "amazon_devices", + "macaddress": "109693*", + }, { "domain": "amazon_devices", "macaddress": "10BF67*", }, + { + "domain": "amazon_devices", + "macaddress": "10CE02*", + }, + { + "domain": "amazon_devices", + "macaddress": "140AC5*", + }, + { + "domain": "amazon_devices", + "macaddress": "149138*", + }, + { + "domain": "amazon_devices", + "macaddress": "1848BE*", + }, + { + "domain": "amazon_devices", + "macaddress": "1C12B0*", + }, + { + "domain": "amazon_devices", + "macaddress": "1C4D66*", + }, + { + "domain": "amazon_devices", + "macaddress": "1C93C4*", + }, + { + "domain": "amazon_devices", + "macaddress": "1CFE2B*", + }, + { + "domain": "amazon_devices", + "macaddress": "244CE3*", + }, + { + "domain": "amazon_devices", + "macaddress": "24CE33*", + }, + { + "domain": "amazon_devices", + "macaddress": "2873F6*", + }, + { + "domain": "amazon_devices", + "macaddress": "2C71FF*", + }, + { + "domain": "amazon_devices", + "macaddress": "34AFB3*", + }, + { + "domain": "amazon_devices", + "macaddress": "34D270*", + }, + { + "domain": "amazon_devices", + "macaddress": "38F73D*", + }, + { + "domain": "amazon_devices", + "macaddress": "3C5CC4*", + }, + { + "domain": "amazon_devices", + "macaddress": "3CE441*", + }, { "domain": "amazon_devices", "macaddress": "440049*", }, + { + "domain": "amazon_devices", + "macaddress": "40A2DB*", + }, + { + "domain": "amazon_devices", + "macaddress": "40A9CF*", + }, + { + "domain": "amazon_devices", + "macaddress": "40B4CD*", + }, { "domain": "amazon_devices", "macaddress": "443D54*", }, + { + "domain": "amazon_devices", + "macaddress": "44650D*", + }, + { + "domain": "amazon_devices", + "macaddress": "485F2D*", + }, + { + "domain": "amazon_devices", + "macaddress": "48785E*", + }, { "domain": "amazon_devices", "macaddress": "48B423*", @@ -50,6 +186,14 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "amazon_devices", "macaddress": "4C1744*", }, + { + "domain": "amazon_devices", + "macaddress": "4CEFC0*", + }, + { + "domain": "amazon_devices", + "macaddress": "5007C3*", + }, { "domain": "amazon_devices", "macaddress": "50D45C*", @@ -58,6 +202,34 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "amazon_devices", "macaddress": "50DCE7*", }, + { + "domain": "amazon_devices", + "macaddress": "50F5DA*", + }, + { + "domain": "amazon_devices", + "macaddress": "5C415A*", + }, + { + "domain": "amazon_devices", + "macaddress": "6837E9*", + }, + { + "domain": "amazon_devices", + "macaddress": "6854FD*", + }, + { + "domain": "amazon_devices", + "macaddress": "689A87*", + }, + { + "domain": "amazon_devices", + "macaddress": "68B691*", + }, + { + "domain": "amazon_devices", + "macaddress": "68DBF5*", + }, { "domain": "amazon_devices", "macaddress": "68F63B*", @@ -66,18 +238,70 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "amazon_devices", "macaddress": "6C0C9A*", }, + { + "domain": "amazon_devices", + "macaddress": "6C5697*", + }, + { + "domain": "amazon_devices", + "macaddress": "7458F3*", + }, + { + "domain": "amazon_devices", + "macaddress": "74C246*", + }, { "domain": "amazon_devices", "macaddress": "74D637*", }, + { + "domain": "amazon_devices", + "macaddress": "74E20C*", + }, + { + "domain": "amazon_devices", + "macaddress": "74ECB2*", + }, + { + "domain": "amazon_devices", + "macaddress": "786C84*", + }, + { + "domain": "amazon_devices", + "macaddress": "78A03F*", + }, { "domain": "amazon_devices", "macaddress": "7C6166*", }, + { + "domain": "amazon_devices", + "macaddress": "7C6305*", + }, + { + "domain": "amazon_devices", + "macaddress": "7CD566*", + }, + { + "domain": "amazon_devices", + "macaddress": "8871E5*", + }, { "domain": "amazon_devices", "macaddress": "901195*", }, + { + "domain": "amazon_devices", + "macaddress": "90235B*", + }, + { + "domain": "amazon_devices", + "macaddress": "90A822*", + }, + { + "domain": "amazon_devices", + "macaddress": "90F82E*", + }, { "domain": "amazon_devices", "macaddress": "943A91*", @@ -86,30 +310,154 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "amazon_devices", "macaddress": "98226E*", }, + { + "domain": "amazon_devices", + "macaddress": "98CCF3*", + }, { "domain": "amazon_devices", "macaddress": "9CC8E9*", }, + { + "domain": "amazon_devices", + "macaddress": "A002DC*", + }, + { + "domain": "amazon_devices", + "macaddress": "A0D2B1*", + }, + { + "domain": "amazon_devices", + "macaddress": "A40801*", + }, { "domain": "amazon_devices", "macaddress": "A8E621*", }, + { + "domain": "amazon_devices", + "macaddress": "AC416A*", + }, + { + "domain": "amazon_devices", + "macaddress": "AC63BE*", + }, + { + "domain": "amazon_devices", + "macaddress": "ACCCFC*", + }, + { + "domain": "amazon_devices", + "macaddress": "B0739C*", + }, + { + "domain": "amazon_devices", + "macaddress": "B0CFCB*", + }, + { + "domain": "amazon_devices", + "macaddress": "B0F7C4*", + }, + { + "domain": "amazon_devices", + "macaddress": "B85F98*", + }, + { + "domain": "amazon_devices", + "macaddress": "C091B9*", + }, { "domain": "amazon_devices", "macaddress": "C095CF*", }, + { + "domain": "amazon_devices", + "macaddress": "C49500*", + }, + { + "domain": "amazon_devices", + "macaddress": "C86C3D*", + }, + { + "domain": "amazon_devices", + "macaddress": "CC9EA2*", + }, + { + "domain": "amazon_devices", + "macaddress": "CCF735*", + }, + { + "domain": "amazon_devices", + "macaddress": "DC54D7*", + }, { "domain": "amazon_devices", "macaddress": "D8BE65*", }, + { + "domain": "amazon_devices", + "macaddress": "D8FBD6*", + }, + { + "domain": "amazon_devices", + "macaddress": "DC91BF*", + }, + { + "domain": "amazon_devices", + "macaddress": "DCA0D0*", + }, + { + "domain": "amazon_devices", + "macaddress": "E0F728*", + }, { "domain": "amazon_devices", "macaddress": "EC2BEB*", }, + { + "domain": "amazon_devices", + "macaddress": "EC8AC4*", + }, + { + "domain": "amazon_devices", + "macaddress": "ECA138*", + }, { "domain": "amazon_devices", "macaddress": "F02F9E*", }, + { + "domain": "amazon_devices", + "macaddress": "F0272D*", + }, + { + "domain": "amazon_devices", + "macaddress": "F0F0A4*", + }, + { + "domain": "amazon_devices", + "macaddress": "F4032A*", + }, + { + "domain": "amazon_devices", + "macaddress": "F854B8*", + }, + { + "domain": "amazon_devices", + "macaddress": "FC492D*", + }, + { + "domain": "amazon_devices", + "macaddress": "FC65DE*", + }, + { + "domain": "amazon_devices", + "macaddress": "FCA183*", + }, + { + "domain": "amazon_devices", + "macaddress": "FCE9D8*", + }, { "domain": "august", "hostname": "connect", From 9879ecad85565b2fd5d14813d3b522c110c28f01 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Sat, 31 May 2025 20:00:34 +0200 Subject: [PATCH 0060/1664] Deprecate snips integration (#145784) --- homeassistant/components/snips/__init__.py | 21 ++++++++++++++++++++- tests/components/snips/test_init.py | 16 ++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 70837b95ec5..293caeaedac 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -7,8 +7,13 @@ import logging import voluptuous as vol from homeassistant.components import mqtt -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + HomeAssistant, + ServiceCall, +) from homeassistant.helpers import config_validation as cv, intent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType DOMAIN = "snips" @@ -91,6 +96,20 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate Snips component.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Snips", + }, + ) # Make sure MQTT integration is enabled and the client is available if not await mqtt.async_wait_for_mqtt_client(hass): diff --git a/tests/components/snips/test_init.py b/tests/components/snips/test_init.py index 82dbf1cd281..2be6d769f08 100644 --- a/tests/components/snips/test_init.py +++ b/tests/components/snips/test_init.py @@ -7,7 +7,8 @@ import pytest import voluptuous as vol from homeassistant.components import snips -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.intent import ServiceIntentHandler, async_register from homeassistant.setup import async_setup_component @@ -15,9 +16,13 @@ from tests.common import async_fire_mqtt_message, async_mock_intent, async_mock_ from tests.typing import MqttMockHAClient -async def test_snips_config(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: +async def test_snips_config( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + issue_registry: ir.IssueRegistry, +) -> None: """Test Snips Config.""" - result = await async_setup_component( + assert await async_setup_component( hass, "snips", { @@ -28,7 +33,10 @@ async def test_snips_config(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> } }, ) - assert result + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{snips.DOMAIN}", + ) in issue_registry.issues async def test_snips_no_mqtt( From 306bbdc697f1a4a238e4efa42011677f398b3f3b Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sat, 31 May 2025 02:22:40 +0800 Subject: [PATCH 0061/1664] Bump switchbot-api to 2.4.0 (#145786) * update switchbot-api version to 2.4.0 * debug for test code --- .../components/switchbot_cloud/__init__.py | 12 +++++++++--- .../components/switchbot_cloud/config_flow.py | 10 +++++++--- .../components/switchbot_cloud/coordinator.py | 4 ++-- .../components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/switchbot_cloud/test_config_flow.py | 8 ++++---- tests/components/switchbot_cloud/test_init.py | 14 ++++++++++---- 8 files changed, 35 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index c7bf66a5803..7b7f60589f0 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -7,7 +7,13 @@ from dataclasses import dataclass, field from logging import getLogger from aiohttp import web -from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI +from switchbot_api import ( + Device, + Remote, + SwitchBotAPI, + SwitchBotAuthenticationError, + SwitchBotConnectionError, +) from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry @@ -175,12 +181,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = SwitchBotAPI(token=token, secret=secret) try: devices = await api.list_devices() - except InvalidAuth as ex: + except SwitchBotAuthenticationError as ex: _LOGGER.error( "Invalid authentication while connecting to SwitchBot API: %s", ex ) return False - except CannotConnect as ex: + except SwitchBotConnectionError as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) coordinators_by_id: dict[str, SwitchBotCoordinator] = {} diff --git a/homeassistant/components/switchbot_cloud/config_flow.py b/homeassistant/components/switchbot_cloud/config_flow.py index eafe823bc0b..0ba1e0295e0 100644 --- a/homeassistant/components/switchbot_cloud/config_flow.py +++ b/homeassistant/components/switchbot_cloud/config_flow.py @@ -3,7 +3,11 @@ from logging import getLogger from typing import Any -from switchbot_api import CannotConnect, InvalidAuth, SwitchBotAPI +from switchbot_api import ( + SwitchBotAPI, + SwitchBotAuthenticationError, + SwitchBotConnectionError, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -36,9 +40,9 @@ class SwitchBotCloudConfigFlow(ConfigFlow, domain=DOMAIN): await SwitchBotAPI( token=user_input[CONF_API_TOKEN], secret=user_input[CONF_API_KEY] ).list_devices() - except CannotConnect: + except SwitchBotConnectionError: errors["base"] = "cannot_connect" - except InvalidAuth: + except SwitchBotAuthenticationError: errors["base"] = "invalid_auth" except Exception: _LOGGER.exception("Unexpected exception") diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py index 4f047145b47..9fc8f64aa68 100644 --- a/homeassistant/components/switchbot_cloud/coordinator.py +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -4,7 +4,7 @@ from asyncio import timeout from logging import getLogger from typing import Any -from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI +from switchbot_api import Device, Remote, SwitchBotAPI, SwitchBotConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -70,5 +70,5 @@ class SwitchBotCoordinator(DataUpdateCoordinator[Status]): status: Status = await self._api.get_status(self._device_id) _LOGGER.debug("Refreshing %s with %s", self._device_id, status) return status - except CannotConnect as err: + except SwitchBotConnectionError as err: raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 83404aac2ba..e0c49d9e739 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot_api"], - "requirements": ["switchbot-api==2.3.1"] + "requirements": ["switchbot-api==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68ec08b1c90..eae64f1225a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2859,7 +2859,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.3.1 +switchbot-api==2.4.0 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77661e0f624..002189807fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2354,7 +2354,7 @@ subarulink==0.7.13 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.3.1 +switchbot-api==2.4.0 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 diff --git a/tests/components/switchbot_cloud/test_config_flow.py b/tests/components/switchbot_cloud/test_config_flow.py index 1d49b503ef2..5eef1805a5a 100644 --- a/tests/components/switchbot_cloud/test_config_flow.py +++ b/tests/components/switchbot_cloud/test_config_flow.py @@ -6,8 +6,8 @@ import pytest from homeassistant import config_entries from homeassistant.components.switchbot_cloud.config_flow import ( - CannotConnect, - InvalidAuth, + SwitchBotAuthenticationError, + SwitchBotConnectionError, ) from homeassistant.components.switchbot_cloud.const import DOMAIN, ENTRY_TITLE from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN @@ -57,8 +57,8 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: @pytest.mark.parametrize( ("error", "message"), [ - (InvalidAuth, "invalid_auth"), - (CannotConnect, "cannot_connect"), + (SwitchBotAuthenticationError, "invalid_auth"), + (SwitchBotConnectionError, "cannot_connect"), (Exception, "unknown"), ], ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index bab9200e7c9..b55106e90d9 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -3,7 +3,13 @@ from unittest.mock import patch import pytest -from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState, Remote +from switchbot_api import ( + Device, + PowerState, + Remote, + SwitchBotAuthenticationError, + SwitchBotConnectionError, +) from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState @@ -127,8 +133,8 @@ async def test_setup_entry_success( @pytest.mark.parametrize( ("error", "state"), [ - (InvalidAuth, ConfigEntryState.SETUP_ERROR), - (CannotConnect, ConfigEntryState.SETUP_RETRY), + (SwitchBotAuthenticationError, ConfigEntryState.SETUP_ERROR), + (SwitchBotConnectionError, ConfigEntryState.SETUP_RETRY), ], ) async def test_setup_entry_fails_when_listing_devices( @@ -162,7 +168,7 @@ async def test_setup_entry_fails_when_refreshing( hubDeviceId="test-hub-id", ) ] - mock_get_status.side_effect = CannotConnect + mock_get_status.side_effect = SwitchBotConnectionError entry = await configure_integration(hass) assert entry.state is ConfigEntryState.SETUP_RETRY From c84ffb54d2fe5b0c9aca802ab269d50dbbd76b8c Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 31 May 2025 04:21:51 +1000 Subject: [PATCH 0062/1664] Bump tesla-fleet-api to 1.1.1. (#145869) bump --- 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 53c8e7d554c..8f5ba1468a5 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.0.17"] + "requirements": ["tesla-fleet-api==1.1.1"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 855cdc9f364..7fc621eeeae 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.0.17", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.1.1", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 3f71bcb95e3..9ad87e9dbbe 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.0.17"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index eae64f1225a..997184849ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2900,7 +2900,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.17 +tesla-fleet-api==1.1.1 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 002189807fa..66637db0537 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2380,7 +2380,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.0.17 +tesla-fleet-api==1.1.1 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From fb2d8c640643120bc228951c07ba15b81e601848 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 1 Jun 2025 04:01:10 +1000 Subject: [PATCH 0063/1664] Add streaming to charge cable connected in Teslemetry (#145880) --- homeassistant/components/teslemetry/binary_sensor.py | 3 +++ tests/components/teslemetry/snapshots/test_binary_sensor.ambr | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 99c21cbe03e..a32c5fea40e 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -125,6 +125,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="charge_state_conn_charge_cable", polling=True, polling_value_fn=lambda x: x != "", + streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( + lambda value: callback(value != "Unknown") + ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, ), diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 8bcd837d06f..06ec0a60434 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -673,7 +673,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor[binary_sensor.test_charge_enable_request-entry] @@ -3374,7 +3374,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'on', + 'state': 'unknown', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] From a6608bd7ea8145fe1f81b353a65a4deb5264cc1b Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Fri, 30 May 2025 20:21:14 +0200 Subject: [PATCH 0064/1664] Bump pyiskra to 0.1.19 (#145889) --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index caa176ab6b6..3f7c805a917 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.15"] + "requirements": ["pyiskra==0.1.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 997184849ae..9735bbc48bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2051,7 +2051,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.15 +pyiskra==0.1.19 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66637db0537..18d2be2057e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1699,7 +1699,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.15 +pyiskra==0.1.19 # homeassistant.components.iss pyiss==1.0.1 From 6015f60db4d9554a82169082aa95d3fa052f019f Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Fri, 30 May 2025 19:35:08 +0200 Subject: [PATCH 0065/1664] Bump python-linkplay to v0.2.9 (#145892) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index fafc9e66514..eb9b5a87c75 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.8"], + "requirements": ["python-linkplay==0.2.9"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9735bbc48bb..84739a2500c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.8 +python-linkplay==0.2.9 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18d2be2057e..70b1190a7eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2019,7 +2019,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.8 +python-linkplay==0.2.9 # homeassistant.components.lirc # python-lirc==1.2.3 From ddc79a631d2ba39ce786658e514de42a109da5c6 Mon Sep 17 00:00:00 2001 From: Jordan Harvey Date: Fri, 30 May 2025 18:33:03 +0100 Subject: [PATCH 0066/1664] Bump pyprobeplus to 1.0.1 (#145897) --- homeassistant/components/probe_plus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/probe_plus/manifest.json b/homeassistant/components/probe_plus/manifest.json index cf61e394a83..e7db39b8ae4 100644 --- a/homeassistant/components/probe_plus/manifest.json +++ b/homeassistant/components/probe_plus/manifest.json @@ -15,5 +15,5 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "bronze", - "requirements": ["pyprobeplus==1.0.0"] + "requirements": ["pyprobeplus==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 84739a2500c..e4b150bbf89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2251,7 +2251,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.probe_plus -pyprobeplus==1.0.0 +pyprobeplus==1.0.1 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70b1190a7eb..59d4d9a2db3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1869,7 +1869,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.probe_plus -pyprobeplus==1.0.0 +pyprobeplus==1.0.1 # homeassistant.components.profiler pyprof2calltree==1.4.5 From d0bf9d9bfb610c84b467167c2bf8fb9626e8b887 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 31 May 2025 11:19:32 +0200 Subject: [PATCH 0067/1664] Move server device creation to init in jellyfin (#145910) * Move server device creation to init in jellyfin * move device creation to after coordinator refresh --- homeassistant/components/jellyfin/__init__.py | 13 +++++++++++-- homeassistant/components/jellyfin/entity.py | 8 ++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index 1cb6219ada0..d22594070ff 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -7,7 +7,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS +from .const import CONF_CLIENT_DEVICE_ID, DEFAULT_NAME, DOMAIN, PLATFORMS from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -35,9 +35,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: JellyfinConfigEntry) -> coordinator = JellyfinDataUpdateCoordinator( hass, entry, client, server_info, user_id ) - await coordinator.async_config_entry_first_refresh() + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, coordinator.server_id)}, + manufacturer=DEFAULT_NAME, + name=coordinator.server_name, + sw_version=coordinator.server_version, + ) + entry.runtime_data = coordinator entry.async_on_unload(client.stop) diff --git a/homeassistant/components/jellyfin/entity.py b/homeassistant/components/jellyfin/entity.py index 4a3b2b77bb1..107a67d6a89 100644 --- a/homeassistant/components/jellyfin/entity.py +++ b/homeassistant/components/jellyfin/entity.py @@ -4,10 +4,10 @@ from __future__ import annotations from typing import Any -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN from .coordinator import JellyfinDataUpdateCoordinator @@ -24,11 +24,7 @@ class JellyfinServerEntity(JellyfinEntity): """Initialize the Jellyfin entity.""" super().__init__(coordinator) self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.server_id)}, - manufacturer=DEFAULT_NAME, - name=coordinator.server_name, - sw_version=coordinator.server_version, ) From cd905a65934b649d33ff75e10c54b9255a7ef49d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 31 May 2025 02:22:49 -0700 Subject: [PATCH 0068/1664] Bump opower to 0.12.3 (#145918) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 7ac9f4cc943..0aa26dbb4b1 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.12.2"] + "requirements": ["opower==0.12.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index e4b150bbf89..a1ad1673b67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,7 +1617,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.2 +opower==0.12.3 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59d4d9a2db3..3a989d3c2b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.2 +opower==0.12.3 # homeassistant.components.oralb oralb-ble==0.17.6 From 532c077ddf985f72c1ef8dfff34c7b5f2353954e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 31 May 2025 04:12:00 -0500 Subject: [PATCH 0069/1664] Bump aiohttp to 3.12.6 (#145919) * Bump aiohttp to 3.12.5 changelog: https://github.com/aio-libs/aiohttp/compare/v3.12.4...v3.12.5 * .6 * fix mock --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/hassio/conftest.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8dde4a6a654..3a70b1ff8e8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.12.4 +aiohttp==3.12.6 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index dc185344ca8..791b616b0c3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.4", + "aiohttp==3.12.6", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index f1ced51637e..a9a3e105f33 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.4 +aiohttp==3.12.6 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index ea38865ac5a..a71ee370b32 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -63,7 +63,7 @@ async def hassio_client_supervisor( @pytest.fixture -def hassio_handler( +async def hassio_handler( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> Generator[HassIO]: """Create mock hassio handler.""" From ef0b3c9f9c482df884e2660b962a4f64d4113c99 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 31 May 2025 17:54:36 +0200 Subject: [PATCH 0070/1664] Update frontend to 20250531.0 (#145933) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 3ee40e1ce60..7282482f329 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250528.0"] + "requirements": ["home-assistant-frontend==20250531.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3a70b1ff8e8..5aa0d8ae82b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.48.2 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250528.0 +home-assistant-frontend==20250531.0 home-assistant-intents==2025.5.28 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index a1ad1673b67..fc61430c302 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.73 # homeassistant.components.frontend -home-assistant-frontend==20250528.0 +home-assistant-frontend==20250531.0 # homeassistant.components.conversation home-assistant-intents==2025.5.28 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a989d3c2b7..81cb7278ec1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.73 # homeassistant.components.frontend -home-assistant-frontend==20250528.0 +home-assistant-frontend==20250531.0 # homeassistant.components.conversation home-assistant-intents==2025.5.28 From 745902bc7e92a2075f46e6958265502fee297c48 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 31 May 2025 20:25:47 +0200 Subject: [PATCH 0071/1664] Bump pylamarzocco to 2.0.8 (#145938) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 36a0a489e30..46a29427264 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.7"] + "requirements": ["pylamarzocco==2.0.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index fc61430c302..63ddad956e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2096,7 +2096,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.7 +pylamarzocco==2.0.8 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81cb7278ec1..c7c1f182cd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1735,7 +1735,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.7 +pylamarzocco==2.0.8 # homeassistant.components.lastfm pylast==5.1.0 From 907cebdd6d57107e1c8ee2597d2de03662124198 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 31 May 2025 20:25:57 +0200 Subject: [PATCH 0072/1664] Increase update intervals in lamarzocco (#145939) --- homeassistant/components/lamarzocco/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py index f0f64e02c28..b6379f237ae 100644 --- a/homeassistant/components/lamarzocco/coordinator.py +++ b/homeassistant/components/lamarzocco/coordinator.py @@ -20,8 +20,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN SCAN_INTERVAL = timedelta(seconds=15) -SETTINGS_UPDATE_INTERVAL = timedelta(hours=1) -SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5) +SETTINGS_UPDATE_INTERVAL = timedelta(hours=8) +SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=30) STATISTICS_UPDATE_INTERVAL = timedelta(minutes=15) _LOGGER = logging.getLogger(__name__) From 06d869aaa5efdfddd2a10648ac9c94172871cb37 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 31 May 2025 21:25:06 +0200 Subject: [PATCH 0073/1664] Bump version to 2025.6.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 29095678e48..edbdba419f3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 791b616b0c3..aa9de97d73b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b3" +version = "2025.6.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From ea6b9e5260c74aa00ad172fb65e54a67ce1f74fc Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 3 Jun 2025 12:12:56 +0200 Subject: [PATCH 0074/1664] SMA add missing strings for DHCP (#145782) --- homeassistant/components/sma/config_flow.py | 1 + homeassistant/components/sma/strings.json | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index c920b4b0a3a..f43c851d04a 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -218,5 +218,6 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): vol.Required(CONF_PASSWORD): cv.string, } ), + description_placeholders={CONF_HOST: self._data[CONF_HOST]}, errors=errors, ) diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index 3a7c87acfcc..e19acf20cf8 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -32,6 +32,16 @@ }, "description": "Enter your SMA device information.", "title": "Set up SMA Solar" + }, + "discovery_confirm": { + "title": "[%key:component::sma::config::step::user::title]", + "description": "Do you want to setup the discovered SMA ({host})?", + "data": { + "group": "[%key:component::sma::config::step::user::data::group]", + "password": "[%key:common::config_flow::data::password%]", + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" + } } } } From b1d35de8e4612266d722de36b23492f0be1b3df9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 4 Jun 2025 10:00:50 +0200 Subject: [PATCH 0075/1664] Deprecate eddystone temperature integration (#145833) --- .../eddystone_temperature/__init__.py | 5 +++ .../eddystone_temperature/sensor.py | 24 ++++++++-- requirements_test_all.txt | 3 ++ .../eddystone_temperature/__init__.py | 1 + .../eddystone_temperature/test_sensor.py | 45 +++++++++++++++++++ 5 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 tests/components/eddystone_temperature/__init__.py create mode 100644 tests/components/eddystone_temperature/test_sensor.py diff --git a/homeassistant/components/eddystone_temperature/__init__.py b/homeassistant/components/eddystone_temperature/__init__.py index 2d6f92498bd..af37eb629b5 100644 --- a/homeassistant/components/eddystone_temperature/__init__.py +++ b/homeassistant/components/eddystone_temperature/__init__.py @@ -1 +1,6 @@ """The eddystone_temperature component.""" + +DOMAIN = "eddystone_temperature" +CONF_BEACONS = "beacons" +CONF_INSTANCE = "instance" +CONF_NAMESPACE = "namespace" diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 1047c52e111..7b8e726cf45 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -23,17 +23,18 @@ from homeassistant.const import ( STATE_UNKNOWN, UnitOfTemperature, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, Event, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import CONF_BEACONS, CONF_INSTANCE, CONF_NAMESPACE, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_BEACONS = "beacons" CONF_BT_DEVICE_ID = "bt_device_id" -CONF_INSTANCE = "instance" -CONF_NAMESPACE = "namespace" + BEACON_SCHEMA = vol.Schema( { @@ -58,6 +59,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Validate configuration, create devices and start monitoring thread.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Eddystone", + }, + ) + bt_device_id: int = config[CONF_BT_DEVICE_ID] beacons: dict[str, dict[str, str]] = config[CONF_BEACONS] diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7c1f182cd2..e3f5788e5d8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -533,6 +533,9 @@ babel==2.15.0 # homeassistant.components.homekit base36==0.1.1 +# homeassistant.components.eddystone_temperature +# beacontools[scan]==2.1.0 + # homeassistant.components.scrape beautifulsoup4==4.13.3 diff --git a/tests/components/eddystone_temperature/__init__.py b/tests/components/eddystone_temperature/__init__.py new file mode 100644 index 00000000000..af67530c946 --- /dev/null +++ b/tests/components/eddystone_temperature/__init__.py @@ -0,0 +1 @@ +"""Tests for eddystone temperature.""" diff --git a/tests/components/eddystone_temperature/test_sensor.py b/tests/components/eddystone_temperature/test_sensor.py new file mode 100644 index 00000000000..056681fdb90 --- /dev/null +++ b/tests/components/eddystone_temperature/test_sensor.py @@ -0,0 +1,45 @@ +"""Tests for eddystone temperature.""" + +from unittest.mock import Mock, patch + +from homeassistant.components.eddystone_temperature import ( + CONF_BEACONS, + CONF_INSTANCE, + CONF_NAMESPACE, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +@patch.dict("sys.modules", beacontools=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component( + hass, + PLATFORM_DOMAIN, + { + PLATFORM_DOMAIN: [ + { + CONF_PLATFORM: DOMAIN, + CONF_BEACONS: { + "living_room": { + CONF_NAMESPACE: "112233445566778899AA", + CONF_INSTANCE: "000000000001", + } + }, + } + ], + }, + ) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + ) in issue_registry.issues From 03f028b7e290c8ed5e1449573b77491fc5ede746 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 2 Jun 2025 09:45:14 +0200 Subject: [PATCH 0076/1664] Deprecate hddtemp (#145850) --- homeassistant/components/hddtemp/__init__.py | 2 ++ homeassistant/components/hddtemp/sensor.py | 20 ++++++++++++++++++- tests/components/hddtemp/test_sensor.py | 21 ++++++++++++++++++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py index 121238df9fe..66a819f1e8d 100644 --- a/homeassistant/components/hddtemp/__init__.py +++ b/homeassistant/components/hddtemp/__init__.py @@ -1 +1,3 @@ """The hddtemp component.""" + +DOMAIN = "hddtemp" diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 4d9bbeb9516..192ddffd330 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -22,11 +22,14 @@ from homeassistant.const import ( CONF_PORT, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" @@ -56,6 +59,21 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the HDDTemp sensor.""" + create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + breaks_in_ha_version="2025.12.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_system_packages_yaml_integration", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "hddtemp", + }, + ) + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index 56ad9fdcb0e..62882c7df8b 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -1,12 +1,15 @@ """The tests for the hddtemp platform.""" import socket -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest +from homeassistant.components.hddtemp import DOMAIN +from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component VALID_CONFIG_MINIMAL = {"sensor": {"platform": "hddtemp"}} @@ -192,3 +195,17 @@ async def test_hddtemp_host_unreachable(hass: HomeAssistant, telnetmock) -> None assert await async_setup_component(hass, "sensor", VALID_CONFIG_HOST_UNREACHABLE) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + + +@patch.dict("sys.modules", gsp=Mock()) +async def test_repair_issue_is_created( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issue is created.""" + assert await async_setup_component(hass, PLATFORM_DOMAIN, VALID_CONFIG_MINIMAL) + await hass.async_block_till_done() + assert ( + HOMEASSISTANT_DOMAIN, + f"deprecated_system_packages_yaml_integration_{DOMAIN}", + ) in issue_registry.issues From 1e1b0424d74c8168226b38d1c3da20975b2f1c9c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 2 Jun 2025 09:52:02 +0200 Subject: [PATCH 0077/1664] Fix removal of devices during Z-Wave migration (#145867) --- homeassistant/components/zwave_js/__init__.py | 8 +- .../components/zwave_js/config_flow.py | 21 +++ homeassistant/components/zwave_js/const.py | 1 + tests/components/zwave_js/test_config_flow.py | 130 +++++++++++++++--- 4 files changed, 135 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 6e76b2f89cf..abbf10fb494 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -94,6 +94,7 @@ from .const import ( CONF_DATA_COLLECTION_OPTED_IN, CONF_INSTALLER_MODE, CONF_INTEGRATION_CREATED_ADDON, + CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, CONF_NETWORK_KEY, @@ -405,9 +406,10 @@ class DriverEvents: # Devices that are in the device registry that are not known by the controller # can be removed - 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) + 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: diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index e2941b52522..08c9ec2e2b2 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -56,6 +56,7 @@ from .const import ( CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, CONF_INTEGRATION_CREATED_ADDON, + CONF_KEEP_OLD_DEVICES, CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_LR_S2_AUTHENTICATED_KEY, CONF_S0_LEGACY_KEY, @@ -1383,9 +1384,20 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): config_entry = self._reconfigure_config_entry assert config_entry is not None + # Make sure we keep the old devices + # so that user customizations are not lost, + # when loading the config entry. + self.hass.config_entries.async_update_entry( + config_entry, data=config_entry.data | {CONF_KEEP_OLD_DEVICES: True} + ) + # Reload the config entry to reconnect the client after the addon restart await self.hass.config_entries.async_reload(config_entry.entry_id) + data = config_entry.data.copy() + data.pop(CONF_KEEP_OLD_DEVICES, None) + self.hass.config_entries.async_update_entry(config_entry, data=data) + @callback def forward_progress(event: dict) -> None: """Forward progress events to frontend.""" @@ -1436,6 +1448,15 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): config_entry, unique_id=str(version_info.home_id) ) await self.hass.config_entries.async_reload(config_entry.entry_id) + + # Reload the config entry two times to clean up + # the stale device entry. + # Since both the old and the new controller have the same node id, + # but different hardware identifiers, the integration + # will create a new device for the new controller, on the first reload, + # but not immediately remove the old device. + await self.hass.config_entries.async_reload(config_entry.entry_id) + finally: for unsub in unsubs: unsub() diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 31cfb144e2a..6d5cbb98902 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -27,6 +27,7 @@ CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY = "lr_s2_access_control_key" CONF_ADDON_LR_S2_AUTHENTICATED_KEY = "lr_s2_authenticated_key" CONF_INSTALLER_MODE = "installer_mode" CONF_INTEGRATION_CREATED_ADDON = "integration_created_addon" +CONF_KEEP_OLD_DEVICES = "keep_old_devices" CONF_NETWORK_KEY = "network_key" CONF_S0_LEGACY_KEY = "s0_legacy_key" CONF_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index c9929759a49..fc01c9b29b1 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -15,6 +15,7 @@ import pytest from serial.tools.list_ports_common import ListPortInfo from voluptuous import InInvalid from zwave_js_server.exceptions import FailedCommand +from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo from homeassistant import config_entries, data_entry_flow @@ -40,6 +41,7 @@ from homeassistant.components.zwave_js.const import ( from homeassistant.components.zwave_js.helpers import SERVER_VERSION_TIMEOUT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -969,7 +971,7 @@ async def test_usb_discovery_migration( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -983,6 +985,7 @@ async def test_usb_discovery_migration( 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 == "5678" @@ -1097,7 +1100,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -1108,9 +1111,10 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_successful" - assert integration.data["url"] == "ws://host1:3001" - assert integration.data["usb_path"] == USB_DISCOVERY_INFO.device - assert integration.data["use_addon"] is True + 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 @pytest.mark.usefixtures("supervisor", "addon_installed") @@ -3422,6 +3426,7 @@ async def test_reconfigure_migrate_no_addon( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_required" + assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("mock_sdk_version") @@ -3446,6 +3451,7 @@ async def test_reconfigure_migrate_low_sdk_version( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_low_sdk_version" + assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("supervisor", "addon_running") @@ -3457,15 +3463,22 @@ async def test_reconfigure_migrate_low_sdk_version( "final_unique_id", ), [ - (None, "4321", None, "8765"), - (aiohttp.ClientError("Boom"), "1234", None, "8765"), + (None, "4321", None, "3245146787"), + (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), (None, "4321", aiohttp.ClientError("Boom"), "5678"), - (aiohttp.ClientError("Boom"), "1234", aiohttp.ClientError("Boom"), "5678"), + ( + aiohttp.ClientError("Boom"), + "3245146787", + aiohttp.ClientError("Boom"), + "5678", + ), ], ) async def test_reconfigure_migrate_with_addon( hass: HomeAssistant, client: MagicMock, + device_registry: dr.DeviceRegistry, + multisensor_6: Node, integration: MockConfigEntry, restart_addon: AsyncMock, addon_options: dict[str, Any], @@ -3482,9 +3495,9 @@ async def test_reconfigure_migrate_with_addon( version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 + assert client.driver.controller.home_id == 3245146787 hass.config_entries.async_update_entry( entry, - unique_id="1234", data={ "url": "ws://localhost:3000", "use_addon": True, @@ -3493,6 +3506,39 @@ async def test_reconfigure_migrate_with_addon( ) addon_options["device"] = "/dev/ttyUSB0" + controller_node = client.driver.controller.own_node + controller_device_id = ( + f"{client.driver.controller.home_id}-{controller_node.node_id}" + ) + controller_device_id_ext = ( + f"{controller_device_id}-{controller_node.manufacturer_id}:" + f"{controller_node.product_type}:{controller_node.product_id}" + ) + + assert len(device_registry.devices) == 2 + # Verify there's a device entry for the controller. + device = device_registry.async_get_device( + identifiers={(DOMAIN, controller_device_id)} + ) + assert device + assert device == device_registry.async_get_device( + identifiers={(DOMAIN, controller_device_id_ext)} + ) + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW090" + assert device.name == "Z‐Stick Gen5 USB Controller" + # Verify there's a device entry for the multisensor. + sensor_device_id = f"{client.driver.controller.home_id}-{multisensor_6.node_id}" + device = device_registry.async_get_device(identifiers={(DOMAIN, sensor_device_id)}) + assert device + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + assert device.name == "Multisensor 6" + # Customize the sensor device name. + device_registry.async_update_device( + device.id, name_by_user="Custom Sensor Device Name" + ) + async def mock_backup_nvm_raw(): await asyncio.sleep(0) client.driver.controller.emit( @@ -3521,6 +3567,7 @@ async def test_reconfigure_migrate_with_addon( "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"} ) @@ -3591,6 +3638,17 @@ async def test_reconfigure_migrate_with_addon( "core_zwave_js", AddonsOptions(config={"device": "/test"}) ) + # Simulate the new connected controller hardware labels. + # This will cause a new device entry to be created + # when the config entry is loaded before restoring NVM. + controller_node = client.driver.controller.own_node + controller_node.data["manufacturerId"] = 999 + controller_node.data["productId"] = 999 + controller_node.device_config.data["description"] = "New Device Name" + controller_node.device_config.data["label"] = "New Device Model" + controller_node.device_config.data["manufacturer"] = "New Device Manufacturer" + client.driver.controller.data["homeId"] = 5678 + await hass.async_block_till_done() assert restart_addon.call_args == call("core_zwave_js") @@ -3599,14 +3657,14 @@ async def test_reconfigure_migrate_with_addon( assert entry.unique_id == "5678" get_server_version.side_effect = restore_server_version_side_effect - version_info.home_id = 8765 + version_info.home_id = 3245146787 assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "restore_nvm" assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3620,8 +3678,29 @@ 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 entry.unique_id == final_unique_id + assert len(device_registry.devices) == 2 + controller_device_id_ext = ( + f"{controller_device_id}-{controller_node.manufacturer_id}:" + f"{controller_node.product_type}:{controller_node.product_id}" + ) + device = device_registry.async_get_device( + identifiers={(DOMAIN, controller_device_id_ext)} + ) + assert device + assert device.manufacturer == "New Device Manufacturer" + assert device.model == "New Device Model" + assert device.name == "New Device Name" + device = device_registry.async_get_device(identifiers={(DOMAIN, sensor_device_id)}) + assert device + assert device.manufacturer == "AEON Labs" + assert device.model == "ZW100" + assert device.name == "Multisensor 6" + assert device.name_by_user == "Custom Sensor Device Name" + assert client.driver.controller.home_id == 3245146787 + @pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_reset_driver_ready_timeout( @@ -3755,7 +3834,7 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3770,6 +3849,7 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( assert entry.data["usb_path"] == "/test" assert entry.data["use_addon"] is True assert entry.unique_id == "5678" + assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("supervisor", "addon_running") @@ -3895,7 +3975,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert client.connect.call_count == 2 await hass.async_block_till_done() - assert client.connect.call_count == 3 + assert client.connect.call_count == 4 assert entry.state is config_entries.ConfigEntryState.LOADED assert client.driver.controller.async_restore_nvm.call_count == 1 assert len(events) == 2 @@ -3906,9 +3986,10 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "migration_successful" - assert integration.data["url"] == "ws://host1:3001" - assert integration.data["usb_path"] == "/test" - assert integration.data["use_addon"] is True + 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 async def test_reconfigure_migrate_backup_failure( @@ -3942,6 +4023,7 @@ async def test_reconfigure_migrate_backup_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" + assert "keep_old_devices" not in entry.data async def test_reconfigure_migrate_backup_file_failure( @@ -3988,6 +4070,7 @@ async def test_reconfigure_migrate_backup_file_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" + assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("supervisor", "addon_running") @@ -4073,6 +4156,7 @@ async def test_reconfigure_migrate_start_addon_failure( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "addon_start_failed" + assert "keep_old_devices" not in entry.data @pytest.mark.usefixtures("supervisor", "addon_running", "restart_addon") @@ -4187,6 +4271,7 @@ async def test_reconfigure_migrate_restore_failure( hass.config_entries.flow.async_abort(result["flow_id"]) assert len(hass.config_entries.flow.async_progress()) == 0 + assert "keep_old_devices" not in entry.data async def test_get_driver_failure_intent_migrate( @@ -4196,13 +4281,13 @@ async def test_get_driver_failure_intent_migrate( """Test get driver failure in intent migrate step.""" entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "reconfigure" - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(entry.entry_id) result = await hass.config_entries.flow.async_configure( result["flow_id"], {"next_step_id": "intent_migrate"} @@ -4210,6 +4295,7 @@ async def test_get_driver_failure_intent_migrate( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "config_entry_not_loaded" + assert "keep_old_devices" not in entry.data async def test_get_driver_failure_instruct_unplug( @@ -4231,7 +4317,7 @@ async def test_get_driver_failure_instruct_unplug( ) entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4254,7 +4340,7 @@ async def test_get_driver_failure_instruct_unplug( assert client.driver.controller.async_backup_nvm_raw.call_count == 1 assert mock_file.call_count == 1 - await hass.config_entries.async_unload(integration.entry_id) + await hass.config_entries.async_unload(entry.entry_id) result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -4270,7 +4356,7 @@ async def test_hard_reset_failure( """Test hard reset failure.""" entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) async def mock_backup_nvm_raw(): @@ -4320,7 +4406,7 @@ async def test_choose_serial_port_usb_ports_failure( """Test choose serial port usb ports failure.""" entry = integration hass.config_entries.async_update_entry( - integration, unique_id="1234", data={**integration.data, "use_addon": True} + entry, unique_id="1234", data={**entry.data, "use_addon": True} ) async def mock_backup_nvm_raw(): From d302e817c821bf241e5778cacad1979876274808 Mon Sep 17 00:00:00 2001 From: Ian Date: Tue, 3 Jun 2025 04:20:14 -0700 Subject: [PATCH 0078/1664] NextBus: Bump py_nextbusnext to 2.2.0 (#145904) --- homeassistant/components/nextbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index a4f6d54f58c..4b7057f7142 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.1.2"] + "requirements": ["py-nextbusnext==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63ddad956e9..4408673f575 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1762,7 +1762,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.1.2 +py-nextbusnext==2.2.0 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3f5788e5d8..6f527a9c393 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1485,7 +1485,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.1.2 +py-nextbusnext==2.2.0 # homeassistant.components.nightscout py-nightscout==1.2.2 From 1a21e01f851631ecaff2c4852e644baccadf2c64 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 1 Jun 2025 21:14:19 +0200 Subject: [PATCH 0079/1664] Bump aioimmich to 0.8.0 (#145908) --- homeassistant/components/immich/manifest.json | 2 +- .../components/immich/media_source.py | 28 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/immich/conftest.py | 118 +++++++++++------- tests/components/immich/const.py | 109 ++++++++++++---- .../immich/snapshots/test_diagnostics.ambr | 46 ++++--- .../immich/snapshots/test_sensor.ambr | 4 +- 8 files changed, 206 insertions(+), 105 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 5b56a7e3e2d..b7c8176356f 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.7.0"] + "requirements": ["aioimmich==0.8.0"] } diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index a7c55f9c572..c636fda879a 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -133,10 +133,10 @@ class ImmichMediaSource(MediaSource): identifier=f"{identifier.unique_id}|albums|{album.album_id}", media_class=MediaClass.DIRECTORY, media_content_type=MediaClass.IMAGE, - title=album.name, + title=album.album_name, can_play=False, can_expand=True, - thumbnail=f"/immich/{identifier.unique_id}/{album.thumbnail_asset_id}/thumbnail/image/jpg", + thumbnail=f"/immich/{identifier.unique_id}/{album.album_thumbnail_asset_id}/thumbnail/image/jpg", ) for album in albums ] @@ -160,18 +160,19 @@ class ImmichMediaSource(MediaSource): f"{identifier.unique_id}|albums|" f"{identifier.collection_id}|" f"{asset.asset_id}|" - f"{asset.file_name}|" - f"{asset.mime_type}" + f"{asset.original_file_name}|" + f"{mime_type}" ), media_class=MediaClass.IMAGE, - media_content_type=asset.mime_type, - title=asset.file_name, + media_content_type=mime_type, + title=asset.original_file_name, can_play=False, can_expand=False, - thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{asset.mime_type}", + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{mime_type}", ) for asset in album_info.assets - if asset.mime_type.startswith("image/") + if (mime_type := asset.original_mime_type) + and mime_type.startswith("image/") ] ret.extend( @@ -181,18 +182,19 @@ class ImmichMediaSource(MediaSource): f"{identifier.unique_id}|albums|" f"{identifier.collection_id}|" f"{asset.asset_id}|" - f"{asset.file_name}|" - f"{asset.mime_type}" + f"{asset.original_file_name}|" + f"{mime_type}" ), media_class=MediaClass.VIDEO, - media_content_type=asset.mime_type, - title=asset.file_name, + media_content_type=mime_type, + title=asset.original_file_name, can_play=True, can_expand=False, thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/image/jpeg", ) for asset in album_info.assets - if asset.mime_type.startswith("video/") + if (mime_type := asset.original_mime_type) + and mime_type.startswith("video/") ) return ret diff --git a/requirements_all.txt b/requirements_all.txt index 4408673f575..a7aeda1007b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.7.0 +aioimmich==0.8.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f527a9c393..10121e31eb9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -265,7 +265,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.7.0 +aioimmich==0.8.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index 1b9a7df8df7..f8f959e0b0a 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -1,7 +1,6 @@ """Common fixtures for the Immich tests.""" from collections.abc import AsyncGenerator, Generator -from datetime import datetime from unittest.mock import AsyncMock, patch from aioimmich import ImmichAlbums, ImmichAssests, ImmichServer, ImmichUsers @@ -10,7 +9,7 @@ from aioimmich.server.models import ( ImmichServerStatistics, ImmichServerStorage, ) -from aioimmich.users.models import AvatarColor, ImmichUser, UserStatus +from aioimmich.users.models import ImmichUserObject import pytest from homeassistant.components.immich.const import DOMAIN @@ -78,36 +77,58 @@ def mock_immich_assets() -> AsyncMock: def mock_immich_server() -> AsyncMock: """Mock the Immich server.""" mock = AsyncMock(spec=ImmichServer) - mock.async_get_about_info.return_value = ImmichServerAbout( - "v1.132.3", - "some_url", - False, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, - None, + mock.async_get_about_info.return_value = ImmichServerAbout.from_dict( + { + "version": "v1.132.3", + "versionUrl": "https://github.com/immich-app/immich/releases/tag/v1.132.3", + "licensed": False, + "build": "14709928600", + "buildUrl": "https://github.com/immich-app/immich/actions/runs/14709928600", + "buildImage": "v1.132.3", + "buildImageUrl": "https://github.com/immich-app/immich/pkgs/container/immich-server", + "repository": "immich-app/immich", + "repositoryUrl": "https://github.com/immich-app/immich", + "sourceRef": "v1.132.3", + "sourceCommit": "02994883fe3f3972323bb6759d0170a4062f5236", + "sourceUrl": "https://github.com/immich-app/immich/commit/02994883fe3f3972323bb6759d0170a4062f5236", + "nodejs": "v22.14.0", + "exiftool": "13.00", + "ffmpeg": "7.0.2-7", + "libvips": "8.16.1", + "imagemagick": "7.1.1-47", + } ) - mock.async_get_storage_info.return_value = ImmichServerStorage( - "294.2 GiB", - "142.9 GiB", - "136.3 GiB", - 315926315008, - 153400434688, - 146402975744, - 48.56, + mock.async_get_storage_info.return_value = ImmichServerStorage.from_dict( + { + "diskSize": "294.2 GiB", + "diskUse": "142.9 GiB", + "diskAvailable": "136.3 GiB", + "diskSizeRaw": 315926315008, + "diskUseRaw": 153400406016, + "diskAvailableRaw": 146403004416, + "diskUsagePercentage": 48.56, + } ) - mock.async_get_server_statistics.return_value = ImmichServerStatistics( - 27038, 1836, 119525451912, 54291170551, 65234281361 + mock.async_get_server_statistics.return_value = ImmichServerStatistics.from_dict( + { + "photos": 27038, + "videos": 1836, + "usage": 119525451912, + "usagePhotos": 54291170551, + "usageVideos": 65234281361, + "usageByUser": [ + { + "userId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "userName": "admin", + "photos": 27038, + "videos": 1836, + "usage": 119525451912, + "usagePhotos": 54291170551, + "usageVideos": 65234281361, + "quotaSizeInBytes": None, + } + ], + } ) return mock @@ -116,23 +137,26 @@ def mock_immich_server() -> AsyncMock: def mock_immich_user() -> AsyncMock: """Mock the Immich server.""" mock = AsyncMock(spec=ImmichUsers) - mock.async_get_my_user.return_value = ImmichUser( - "e7ef5713-9dab-4bd4-b899-715b0ca4379e", - "user@immich.local", - "user", - "", - AvatarColor.PRIMARY, - datetime.fromisoformat("2025-05-11T10:07:46.866Z"), - "user", - False, - True, - datetime.fromisoformat("2025-05-11T10:07:46.866Z"), - None, - None, - "", - None, - None, - UserStatus.ACTIVE, + mock.async_get_my_user.return_value = ImmichUserObject.from_dict( + { + "id": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "email": "user@immich.local", + "name": "user", + "profileImagePath": "", + "avatarColor": "primary", + "profileChangedAt": "2025-05-11T10:07:46.866Z", + "storageLabel": "user", + "shouldChangePassword": True, + "isAdmin": True, + "createdAt": "2025-05-11T10:07:46.866Z", + "deletedAt": None, + "updatedAt": "2025-05-18T00:59:55.547Z", + "oauthId": "", + "quotaSizeInBytes": None, + "quotaUsageInBytes": 119526467534, + "status": "active", + "license": None, + } ) return mock diff --git a/tests/components/immich/const.py b/tests/components/immich/const.py index ac0b221f721..97721bc7dbc 100644 --- a/tests/components/immich/const.py +++ b/tests/components/immich/const.py @@ -1,7 +1,6 @@ """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, @@ -26,27 +25,91 @@ MOCK_CONFIG_ENTRY_DATA = { CONF_VERIFY_SSL: False, } -MOCK_ALBUM_WITHOUT_ASSETS = ImmichAlbum( - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - "My Album", - "This is my first great album", - "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", - 1, - [], -) +ALBUM_DATA = { + "id": "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", + "albumName": "My Album", + "albumThumbnailAssetId": "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", + "albumUsers": [], + "assetCount": 1, + "assets": [], + "createdAt": "2025-05-11T10:13:22.799Z", + "hasSharedLink": False, + "isActivityEnabled": False, + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "owner": { + "id": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "email": "admin@immich.local", + "name": "admin", + "profileImagePath": "", + "avatarColor": "primary", + "profileChangedAt": "2025-05-11T10:07:46.866Z", + }, + "shared": False, + "updatedAt": "2025-05-17T11:26:03.696Z", +} -MOCK_ALBUM_WITH_ASSETS = ImmichAlbum( - "721e1a4b-aa12-441e-8d3b-5ac7ab283bb6", - "My Album", - "This is my first great album", - "0d03a7ad-ddc7-45a6-adee-68d322a6d2f5", - 1, - [ - ImmichAsset( - "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", "filename.jpg", "image/jpeg" - ), - ImmichAsset( - "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", "filename.mp4", "video/mp4" - ), - ], +MOCK_ALBUM_WITHOUT_ASSETS = ImmichAlbum.from_dict(ALBUM_DATA) + +MOCK_ALBUM_WITH_ASSETS = ImmichAlbum.from_dict( + { + **ALBUM_DATA, + "assets": [ + { + "id": "2e94c203-50aa-4ad2-8e29-56dd74e0eff4", + "deviceAssetId": "web-filename.jpg-1675185639000", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "WEB", + "libraryId": None, + "type": "IMAGE", + "originalPath": "upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/b4/b8/b4b8ef00-8a6d-4056-91ff-7f86dc66e427.jpg", + "originalFileName": "filename.jpg", + "originalMimeType": "image/jpeg", + "thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==", + "fileCreatedAt": "2023-01-31T17:20:37.085+00:00", + "fileModifiedAt": "2023-01-31T17:20:39+00:00", + "localDateTime": "2023-01-31T18:20:37.085+00:00", + "updatedAt": "2025-05-11T10:13:49.590401+00:00", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "duration": "0:00:00.00000", + "exifInfo": {}, + "livePhotoVideoId": None, + "people": [], + "checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + { + "id": "2e65a5f2-db83-44c4-81ab-f5ff20c9bd7b", + "deviceAssetId": "web-filename.mp4-1675185639000", + "ownerId": "e7ef5713-9dab-4bd4-b899-715b0ca4379e", + "deviceId": "WEB", + "libraryId": None, + "type": "IMAGE", + "originalPath": "upload/upload/e7ef5713-9dab-4bd4-b899-715b0ca4379e/b4/b8/b4b8ef00-8a6d-4056-eeff-7f86dc66e427.mp4", + "originalFileName": "filename.mp4", + "originalMimeType": "video/mp4", + "thumbhash": "1igGFALX8mVGdHc5aChJf5nxNg==", + "fileCreatedAt": "2023-01-31T17:20:37.085+00:00", + "fileModifiedAt": "2023-01-31T17:20:39+00:00", + "localDateTime": "2023-01-31T18:20:37.085+00:00", + "updatedAt": "2025-05-11T10:13:49.590401+00:00", + "isFavorite": False, + "isArchived": False, + "isTrashed": False, + "duration": "0:00:00.00000", + "exifInfo": {}, + "livePhotoVideoId": None, + "people": [], + "checksum": "HJm7TVOP80S+eiYZnAhWyRaB/Yc=", + "isOffline": False, + "hasMetadata": True, + "duplicateId": None, + "resized": True, + }, + ], + } ) diff --git a/tests/components/immich/snapshots/test_diagnostics.ambr b/tests/components/immich/snapshots/test_diagnostics.ambr index 3216de2fabd..b3dd3c47db6 100644 --- a/tests/components/immich/snapshots/test_diagnostics.ambr +++ b/tests/components/immich/snapshots/test_diagnostics.ambr @@ -3,36 +3,48 @@ dict({ 'data': dict({ 'server_about': dict({ - 'build': None, - 'build_image': None, - 'build_image_url': None, - 'build_url': None, - 'exiftool': None, - 'ffmpeg': None, - 'imagemagick': None, - 'libvips': None, + 'build': '14709928600', + 'build_image': 'v1.132.3', + 'build_image_url': 'https://github.com/immich-app/immich/pkgs/container/immich-server', + 'build_url': 'https://github.com/immich-app/immich/actions/runs/14709928600', + 'exiftool': '13.00', + 'ffmpeg': '7.0.2-7', + 'imagemagick': '7.1.1-47', + 'libvips': '8.16.1', 'licensed': False, - 'nodejs': None, - 'repository': None, - 'repository_url': None, - 'source_commit': None, - 'source_ref': None, - 'source_url': None, + 'nodejs': 'v22.14.0', + 'repository': 'immich-app/immich', + 'repository_url': 'https://github.com/immich-app/immich', + 'source_commit': '02994883fe3f3972323bb6759d0170a4062f5236', + 'source_ref': 'v1.132.3', + 'source_url': 'https://github.com/immich-app/immich/commit/02994883fe3f3972323bb6759d0170a4062f5236', 'version': 'v1.132.3', - 'version_url': 'some_url', + 'version_url': 'https://github.com/immich-app/immich/releases/tag/v1.132.3', }), 'server_storage': dict({ 'disk_available': '136.3 GiB', - 'disk_available_raw': 146402975744, + 'disk_available_raw': 146403004416, 'disk_size': '294.2 GiB', 'disk_size_raw': 315926315008, 'disk_usage_percentage': 48.56, 'disk_use': '142.9 GiB', - 'disk_use_raw': 153400434688, + 'disk_use_raw': 153400406016, }), 'server_usage': dict({ 'photos': 27038, 'usage': 119525451912, + 'usage_by_user': list([ + dict({ + 'photos': 27038, + 'quota_size_in_bytes': None, + 'usage': 119525451912, + 'usage_photos': 54291170551, + 'usage_videos': 65234281361, + 'user_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e', + 'user_name': 'admin', + 'videos': 1836, + }), + ]), 'usage_photos': 54291170551, 'usage_videos': 65234281361, 'videos': 1836, diff --git a/tests/components/immich/snapshots/test_sensor.ambr b/tests/components/immich/snapshots/test_sensor.ambr index d1ae9a8be8d..590e7d9ad5c 100644 --- a/tests/components/immich/snapshots/test_sensor.ambr +++ b/tests/components/immich/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '136.34839630127', + 'state': '136.34842300415', }) # --- # name: test_sensors[sensor.someone_disk_size-entry] @@ -225,7 +225,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '142.865287780762', + 'state': '142.865261077881', }) # --- # name: test_sensors[sensor.someone_disk_used_by_photos-entry] From 88f2c3abd3909f23d00c168de622c805997f1da5 Mon Sep 17 00:00:00 2001 From: TimL Date: Sun, 1 Jun 2025 11:14:08 +1000 Subject: [PATCH 0080/1664] Bump pysmlight to v0.2.5 (#145949) --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index b2a03a737fc..f47960a65bd 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.4"], + "requirements": ["pysmlight==0.2.5"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index a7aeda1007b..bcb2237124b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2353,7 +2353,7 @@ pysmhi==1.0.2 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.4 +pysmlight==0.2.5 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 10121e31eb9..cb0e7035770 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1953,7 +1953,7 @@ pysmhi==1.0.2 pysml==0.0.12 # homeassistant.components.smlight -pysmlight==0.2.4 +pysmlight==0.2.5 # homeassistant.components.snmp pysnmp==6.2.6 From 7e851370126fcf64fd47a66ecf4cf6b08e7a0abd Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 2 Jun 2025 00:47:05 -0700 Subject: [PATCH 0081/1664] Bump ical to 10.0.0 (#145954) --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index c5a9d4784bc..fecd245869a 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==9.2.5"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.0"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index fc636d75482..e0b08313d63 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==9.2.5"] + "requirements": ["ical==10.0.0"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index cd19090f400..c8e80e4f91b 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==9.2.5"] + "requirements": ["ical==10.0.0"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 60b5e15e8fb..7bdc5362ae7 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==9.2.5"] + "requirements": ["ical==10.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bcb2237124b..68241b5d5ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1203,7 +1203,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.2.5 +ical==10.0.0 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb0e7035770..e8176cbd41f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==9.2.5 +ical==10.0.0 # homeassistant.components.caldav icalendar==6.1.0 From f280032dcf8deffcc152359721e82b7c97b5999b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noah=20Gro=C3=9F?= Date: Tue, 3 Jun 2025 10:57:59 +0200 Subject: [PATCH 0082/1664] Bump python-picnic-api2 to 1.3.1 (#145962) --- homeassistant/components/picnic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json index 251964c15d0..e7623c5eb03 100644 --- a/homeassistant/components/picnic/manifest.json +++ b/homeassistant/components/picnic/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/picnic", "iot_class": "cloud_polling", "loggers": ["python_picnic_api2"], - "requirements": ["python-picnic-api2==1.2.4"] + "requirements": ["python-picnic-api2==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68241b5d5ff..ca3ef670783 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2486,7 +2486,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.4 +python-picnic-api2==1.3.1 # homeassistant.components.rabbitair python-rabbitair==0.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8176cbd41f..297e3ff5958 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2056,7 +2056,7 @@ python-otbr-api==2.7.0 python-overseerr==0.7.1 # homeassistant.components.picnic -python-picnic-api2==1.2.4 +python-picnic-api2==1.3.1 # homeassistant.components.rabbitair python-rabbitair==0.0.8 From d729eed7c2f3b31a557601ea2aca38e7de368504 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 2 Jun 2025 10:48:42 +0300 Subject: [PATCH 0083/1664] Add diagnostics to Amazon devices (#145964) --- .../components/amazon_devices/diagnostics.py | 66 +++++++++++++++++ .../snapshots/test_diagnostics.ambr | 74 +++++++++++++++++++ .../amazon_devices/test_diagnostics.py | 70 ++++++++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 homeassistant/components/amazon_devices/diagnostics.py create mode 100644 tests/components/amazon_devices/snapshots/test_diagnostics.ambr create mode 100644 tests/components/amazon_devices/test_diagnostics.py diff --git a/homeassistant/components/amazon_devices/diagnostics.py b/homeassistant/components/amazon_devices/diagnostics.py new file mode 100644 index 00000000000..e9a0773cd3f --- /dev/null +++ b/homeassistant/components/amazon_devices/diagnostics.py @@ -0,0 +1,66 @@ +"""Diagnostics support for Amazon Devices integration.""" + +from __future__ import annotations + +from typing import Any + +from aioamazondevices.api import AmazonDevice + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntry + +from .coordinator import AmazonConfigEntry + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME, CONF_NAME, "title"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: AmazonConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + coordinator = entry.runtime_data + + devices: list[dict[str, dict[str, Any]]] = [ + build_device_data(device) for device in coordinator.data.values() + ] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "device_info": { + "last_update success": coordinator.last_update_success, + "last_exception": repr(coordinator.last_exception), + "devices": devices, + }, + } + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: AmazonConfigEntry, device_entry: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device.""" + + coordinator = entry.runtime_data + + assert device_entry.serial_number + + return build_device_data(coordinator.data[device_entry.serial_number]) + + +def build_device_data(device: AmazonDevice) -> dict[str, Any]: + """Build device data for diagnostics.""" + return { + "account name": device.account_name, + "capabilities": device.capabilities, + "device family": device.device_family, + "device type": device.device_type, + "device cluster members": device.device_cluster_members, + "online": device.online, + "serial number": device.serial_number, + "software version": device.software_version, + "do not disturb": device.do_not_disturb, + "response style": device.response_style, + "bluetooth state": device.bluetooth_state, + } diff --git a/tests/components/amazon_devices/snapshots/test_diagnostics.ambr b/tests/components/amazon_devices/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..0b5164418aa --- /dev/null +++ b/tests/components/amazon_devices/snapshots/test_diagnostics.ambr @@ -0,0 +1,74 @@ +# serializer version: 1 +# name: test_device_diagnostics + dict({ + 'account name': 'Echo Test', + 'bluetooth state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device cluster members': list([ + 'echo_test_serial_number', + ]), + 'device family': 'mine', + 'device type': 'echo', + 'do not disturb': False, + 'online': True, + 'response style': None, + 'serial number': 'echo_test_serial_number', + 'software version': 'echo_test_software_version', + }) +# --- +# name: test_entry_diagnostics + dict({ + 'device_info': dict({ + 'devices': list([ + dict({ + 'account name': 'Echo Test', + 'bluetooth state': True, + 'capabilities': list([ + 'AUDIO_PLAYER', + 'MICROPHONE', + ]), + 'device cluster members': list([ + 'echo_test_serial_number', + ]), + 'device family': 'mine', + 'device type': 'echo', + 'do not disturb': False, + 'online': True, + 'response style': None, + 'serial number': 'echo_test_serial_number', + 'software version': 'echo_test_software_version', + }), + ]), + 'last_exception': 'None', + 'last_update success': True, + }), + 'entry': dict({ + 'data': dict({ + 'country': 'IT', + 'login_data': dict({ + 'session': 'test-session', + }), + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'discovery_keys': dict({ + }), + 'domain': 'amazon_devices', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'subentries': list([ + ]), + 'title': '**REDACTED**', + 'unique_id': 'fake_email@gmail.com', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/amazon_devices/test_diagnostics.py b/tests/components/amazon_devices/test_diagnostics.py new file mode 100644 index 00000000000..e548702650b --- /dev/null +++ b/tests/components/amazon_devices/test_diagnostics.py @@ -0,0 +1,70 @@ +"""Tests for Amazon Devices diagnostics platform.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.components.amazon_devices.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test Amazon config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + + assert await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) == snapshot( + exclude=props( + "entry_id", + "created_at", + "modified_at", + ) + ) + + +async def test_device_diagnostics( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test Amazon device diagnostics.""" + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, TEST_SERIAL_NUMBER)} + ) + assert device, repr(device_registry.devices) + + assert await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) == snapshot( + exclude=props( + "entry_id", + "created_at", + "modified_at", + ) + ) From 6defed2915ce53f08d3b111608fb6d8ff86071b9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 1 Jun 2025 22:15:18 +0300 Subject: [PATCH 0084/1664] Bump aioamazondevices to 3.0.4 (#145971) --- homeassistant/components/amazon_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/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json index eb9fae6ddbe..a24671298d9 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/amazon_devices/manifest.json @@ -118,5 +118,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==2.1.1"] + "requirements": ["aioamazondevices==3.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca3ef670783..a5eada9ccc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.amazon_devices -aioamazondevices==2.1.1 +aioamazondevices==3.0.4 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 297e3ff5958..3611eaac1f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.amazon_devices -aioamazondevices==2.1.1 +aioamazondevices==3.0.4 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 9e1d8c2fc6f58d8d909356ec17be3c8913828981 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 2 Jun 2025 15:11:56 +0200 Subject: [PATCH 0085/1664] Bump reolink-aio to 0.13.5 (#145974) * Add debug logging * Bump reolink-aio to 0.13.5 * Revert "Add debug logging" This reverts commit f96030a6c8dccca7888b6d1274d5ed3a251ac03c. --- 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 694dd43a532..5ae8b0305e4 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.13.4"] + "requirements": ["reolink-aio==0.13.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index a5eada9ccc2..28188b36675 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2652,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.4 +reolink-aio==0.13.5 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3611eaac1f7..9c895522d12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.4 +reolink-aio==0.13.5 # homeassistant.components.rflink rflink==0.0.66 From 76269333528940ccae6a37c41a57f781578cb59a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 2 Jun 2025 20:48:40 +0200 Subject: [PATCH 0086/1664] Bump go2rtc-client to 0.2.1 (#146019) * Bump go2rtc-client to 0.2.0 * Bump go2rtc-client to 0.2.1 * Clean up hassfest exception --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/go2rtc/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/go2rtc/manifest.json b/homeassistant/components/go2rtc/manifest.json index 09f7b3fd74c..dd50b4ba076 100644 --- a/homeassistant/components/go2rtc/manifest.json +++ b/homeassistant/components/go2rtc/manifest.json @@ -8,6 +8,6 @@ "integration_type": "system", "iot_class": "local_polling", "quality_scale": "internal", - "requirements": ["go2rtc-client==0.1.3b0"], + "requirements": ["go2rtc-client==0.2.1"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5aa0d8ae82b..21b9d5e064a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ cronsim==2.6 cryptography==45.0.1 dbus-fast==2.43.0 fnv-hash-fast==1.5.0 -go2rtc-client==0.1.3b0 +go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.48.2 hass-nabucasa==0.101.0 diff --git a/requirements_all.txt b/requirements_all.txt index 28188b36675..4af2e16aa4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1026,7 +1026,7 @@ gitterpy==0.1.7 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.3b0 +go2rtc-client==0.2.1 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/requirements_test.txt b/requirements_test.txt index 40349402c4d..e97b71fa7dc 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,7 +10,7 @@ astroid==3.3.10 coverage==7.6.12 freezegun==1.5.1 -go2rtc-client==0.1.3b0 +go2rtc-client==0.2.1 license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.16.0a8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c895522d12..2f51d93402b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ gios==6.0.0 glances-api==0.8.0 # homeassistant.components.go2rtc -go2rtc-client==0.1.3b0 +go2rtc-client==0.2.1 # homeassistant.components.goalzero goalzero==0.2.2 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 647755d8237..981dc3344c0 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -25,7 +25,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ stdlib-list==0.10.0 pipdeptree==2.26.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.1.3b0 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + PyTurboJPEG==1.7.5 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From 6f0947419359cec70eb09e86d988cd7ed6ea7f29 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Jun 2025 14:04:02 +0100 Subject: [PATCH 0087/1664] Bump grpcio to 1.72.1 (#146029) --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 21b9d5e064a..598e2834e86 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -88,9 +88,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.72.0 -grpcio-status==1.72.0 -grpcio-reflection==1.72.0 +grpcio==1.72.1 +grpcio-status==1.72.1 +grpcio-reflection==1.72.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 082062c53a0..606141e4c65 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -115,9 +115,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.72.0 -grpcio-status==1.72.0 -grpcio-reflection==1.72.0 +grpcio==1.72.1 +grpcio-status==1.72.1 +grpcio-reflection==1.72.1 # This is a old unmaintained library and is replaced with pycryptodome pycrypto==1000000000.0.0 From cf521d4c7c6f17014ddcc7e203eb95d7322dea25 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 2 Jun 2025 15:12:13 +0200 Subject: [PATCH 0088/1664] Improve debug logging Reolink (#146033) Add debug logging --- homeassistant/components/reolink/__init__.py | 4 ++++ homeassistant/components/reolink/config_flow.py | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 57d41c20521..38f8e709b5c 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -150,6 +150,10 @@ async def async_setup_entry( if host.api.new_devices and config_entry.state == ConfigEntryState.LOADED: # Their are new cameras/chimes connected, reload to add them. + _LOGGER.debug( + "Reloading Reolink %s to add new device (capabilities)", + host.api.nvr_name, + ) hass.async_create_task( hass.config_entries.async_reload(config_entry.entry_id) ) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 12ccd455be3..659169c3618 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -194,6 +194,13 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): ) raise AbortFlow("already_configured") + if existing_entry and existing_entry.data[CONF_HOST] != discovery_info.ip: + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', updating from old IP '%s'", + discovery_info.ip, + existing_entry.data[CONF_HOST], + ) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self.context["title_placeholders"] = { From e5cb77d1681bbe8afa545d4c3f7566cbd97b8bb3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:05:33 +0200 Subject: [PATCH 0089/1664] Adjust ConnectionFailure logging in SamsungTV (#146044) --- homeassistant/components/samsungtv/bridge.py | 21 +++++++---- .../components/samsungtv/test_media_player.py | 35 +++++++++++++++++-- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 11da83219c7..d8682856752 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -636,14 +636,21 @@ class SamsungTVWSBridge( ) self._remote = None except ConnectionFailure as err: - LOGGER.warning( - ( + error_details = err.args[0] + if "ms.channel.timeOut" in (error_details := repr(err)): + # The websocket was connected, but the TV is probably asleep + LOGGER.debug( + "Channel timeout occurred trying to get remote for %s: %s", + self.host, + error_details, + ) + else: + LOGGER.warning( "Unexpected ConnectionFailure trying to get remote for %s, " - "please report this issue: %s" - ), - self.host, - repr(err), - ) + "please report this issue: %s", + self.host, + error_details, + ) self._remote = None except (WebSocketException, AsyncioTimeoutError, OSError) as err: LOGGER.debug("Failed to get remote for %s: %s", self.host, repr(err)) diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index 58797b67423..1bf3c953fc6 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -409,7 +409,7 @@ async def test_update_ws_connection_failure( patch.object( remote_websocket, "start_listening", - side_effect=ConnectionFailure('{"event": "ms.voiceApp.hide"}'), + side_effect=ConnectionFailure({"event": "ms.voiceApp.hide"}), ), patch.object(remote_websocket, "is_alive", return_value=False), ): @@ -419,7 +419,7 @@ async def test_update_ws_connection_failure( assert ( "Unexpected ConnectionFailure trying to get remote for fake_host, please " - 'report this issue: ConnectionFailure(\'{"event": "ms.voiceApp.hide"}\')' + "report this issue: ConnectionFailure({'event': 'ms.voiceApp.hide'})" in caplog.text ) @@ -427,6 +427,37 @@ async def test_update_ws_connection_failure( assert state.state == STATE_OFF +@pytest.mark.usefixtures("rest_api") +async def test_update_ws_connection_failure_channel_timeout( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + remote_websocket: Mock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Testing update tv connection failure exception.""" + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) + + with ( + patch.object( + remote_websocket, + "start_listening", + side_effect=ConnectionFailure({"event": "ms.channel.timeOut"}), + ), + patch.object(remote_websocket, "is_alive", return_value=False), + ): + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert ( + "Channel timeout occurred trying to get remote for fake_host: " + "ConnectionFailure({'event': 'ms.channel.timeOut'})" in caplog.text + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + @pytest.mark.usefixtures("rest_api") async def test_update_ws_connection_closed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, remote_websocket: Mock From e15edbd54b3350df4b5ebc483d2a06e5c66d980a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 3 Jun 2025 12:30:18 +0200 Subject: [PATCH 0090/1664] Adjust SamsungTV on/off logging (#146045) * Adjust SamsungTV on/off logging * Update coordinator.py --- homeassistant/components/samsungtv/coordinator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/coordinator.py b/homeassistant/components/samsungtv/coordinator.py index ed3c24946ab..9b09436be88 100644 --- a/homeassistant/components/samsungtv/coordinator.py +++ b/homeassistant/components/samsungtv/coordinator.py @@ -39,7 +39,7 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): ) self.bridge = bridge - self.is_on: bool | None = False + self.is_on: bool | None = None self.async_extra_update: Callable[[], Coroutine[Any, Any, None]] | None = None async def _async_update_data(self) -> None: @@ -52,7 +52,12 @@ class SamsungTVDataUpdateCoordinator(DataUpdateCoordinator[None]): else: self.is_on = await self.bridge.async_is_on() if self.is_on != old_state: - LOGGER.debug("TV %s state updated to %s", self.bridge.host, self.is_on) + LOGGER.debug( + "TV %s state updated from %s to %s", + self.bridge.host, + old_state, + self.is_on, + ) if self.async_extra_update: await self.async_extra_update() From 999c9b3dc53b625b5c2521ba8c0637fccc1da1bd Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 2 Jun 2025 19:58:54 +0200 Subject: [PATCH 0091/1664] Don't use multi-line conditionals in immich (#146062) --- .../components/immich/media_source.py | 73 ++++++++----------- 1 file changed, 32 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/immich/media_source.py b/homeassistant/components/immich/media_source.py index c636fda879a..caf8264895b 100644 --- a/homeassistant/components/immich/media_source.py +++ b/homeassistant/components/immich/media_source.py @@ -153,49 +153,40 @@ class ImmichMediaSource(MediaSource): except ImmichError: return [] - ret = [ - BrowseMediaSource( - domain=DOMAIN, - identifier=( - f"{identifier.unique_id}|albums|" - f"{identifier.collection_id}|" - f"{asset.asset_id}|" - f"{asset.original_file_name}|" - f"{mime_type}" - ), - media_class=MediaClass.IMAGE, - media_content_type=mime_type, - title=asset.original_file_name, - can_play=False, - can_expand=False, - thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{mime_type}", - ) - for asset in album_info.assets - if (mime_type := asset.original_mime_type) - and mime_type.startswith("image/") - ] + ret: list[BrowseMediaSource] = [] + for asset in album_info.assets: + if not (mime_type := asset.original_mime_type) or not mime_type.startswith( + ("image/", "video/") + ): + continue - ret.extend( - BrowseMediaSource( - domain=DOMAIN, - identifier=( - f"{identifier.unique_id}|albums|" - f"{identifier.collection_id}|" - f"{asset.asset_id}|" - f"{asset.original_file_name}|" - f"{mime_type}" - ), - media_class=MediaClass.VIDEO, - media_content_type=mime_type, - title=asset.original_file_name, - can_play=True, - can_expand=False, - thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/image/jpeg", + if mime_type.startswith("image/"): + media_class = MediaClass.IMAGE + can_play = False + thumb_mime_type = mime_type + else: + media_class = MediaClass.VIDEO + can_play = True + thumb_mime_type = "image/jpeg" + + ret.append( + BrowseMediaSource( + domain=DOMAIN, + identifier=( + f"{identifier.unique_id}|albums|" + f"{identifier.collection_id}|" + f"{asset.asset_id}|" + f"{asset.original_file_name}|" + f"{mime_type}" + ), + media_class=media_class, + media_content_type=mime_type, + title=asset.original_file_name, + can_play=can_play, + can_expand=False, + thumbnail=f"/immich/{identifier.unique_id}/{asset.asset_id}/thumbnail/{thumb_mime_type}", + ) ) - for asset in album_info.assets - if (mime_type := asset.original_mime_type) - and mime_type.startswith("video/") - ) return ret From 1e304fad653529d018906d639718416929f3be41 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 2 Jun 2025 22:38:17 +0300 Subject: [PATCH 0092/1664] Fix Shelly BLU TRV calibrate button (#146066) --- homeassistant/components/shelly/button.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index 44f81cc8b36..eab7514514d 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final -from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS +from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY_G3, RPC_GENERATIONS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError from homeassistant.components.button import ( @@ -62,7 +62,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ translation_key="self_test", entity_category=EntityCategory.DIAGNOSTIC, press_action="trigger_shelly_gas_self_test", - supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, + supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( key="mute", @@ -70,7 +70,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ translation_key="mute", entity_category=EntityCategory.CONFIG, press_action="trigger_shelly_gas_mute", - supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, + supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS, ), ShellyButtonDescription[ShellyBlockCoordinator]( key="unmute", @@ -78,7 +78,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [ translation_key="unmute", entity_category=EntityCategory.CONFIG, press_action="trigger_shelly_gas_unmute", - supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS, + supported=lambda coordinator: coordinator.model in SHELLY_GAS_MODELS, ), ] @@ -89,7 +89,7 @@ BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [ translation_key="calibrate", entity_category=EntityCategory.CONFIG, press_action="trigger_blu_trv_calibration", - supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY, + supported=lambda coordinator: coordinator.model == MODEL_BLU_GATEWAY_G3, ), ] @@ -160,6 +160,7 @@ async def async_setup_entry( ShellyBluTrvButton(coordinator, button, id_) for id_ in blutrv_key_ids for button in BLU_TRV_BUTTONS + if button.supported(coordinator) ) async_add_entities(entities) From 1838a731d60b05e9f8c9b1f1db36dbb5ee71b297 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 3 Jun 2025 01:18:49 +0300 Subject: [PATCH 0093/1664] Bump aioamazondevices to 3.0.5 (#146073) --- homeassistant/components/amazon_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/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json index a24671298d9..bd9bc701d3e 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/amazon_devices/manifest.json @@ -118,5 +118,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.0.4"] + "requirements": ["aioamazondevices==3.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4af2e16aa4a..890dc1cb1cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.amazon_devices -aioamazondevices==3.0.4 +aioamazondevices==3.0.5 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2f51d93402b..5e472b51a61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.amazon_devices -aioamazondevices==3.0.4 +aioamazondevices==3.0.5 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 415858119a48dd3b282f7a650b0d726042816f5e Mon Sep 17 00:00:00 2001 From: SNoof85 Date: Tue, 3 Jun 2025 11:23:52 +0200 Subject: [PATCH 0094/1664] Add state class measurement to Freebox temperature sensors (#146074) --- homeassistant/components/freebox/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 33af56a1f9e..45fe18db95a 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -84,6 +84,7 @@ async def async_setup_entry( name=f"Freebox {sensor_name}", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), ) for sensor_name in router.sensors_temperature From 010c5cab8783a8151d70722591737245cdf93f06 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Tue, 3 Jun 2025 18:11:02 +0800 Subject: [PATCH 0095/1664] Fix nightlatch option for all switchbot locks (#146090) --- homeassistant/components/switchbot/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 04b4e20b7ce..82e6e43130b 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -367,7 +367,9 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): ), ): int } - if self.config_entry.data.get(CONF_SENSOR_TYPE) == SupportedModels.LOCK_PRO: + if self.config_entry.data.get(CONF_SENSOR_TYPE, "").startswith( + SupportedModels.LOCK + ): options.update( { vol.Optional( From 81cbb6e5cfcea2dc170c691c67587228c7fb0a65 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 3 Jun 2025 18:40:20 +1000 Subject: [PATCH 0096/1664] Fix BMS and Charge states in Teslemetry (#146091) Fix BMS and Charge states --- homeassistant/components/teslemetry/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index ab075d18132..8ddd7e186cb 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -205,7 +205,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( key="charge_state_charging_state", polling=True, streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( - lambda value: None if value is None else callback(value.lower()) + lambda value: callback(None if value is None else CHARGE_STATES.get(value)) ), polling_value_fn=lambda value: CHARGE_STATES.get(str(value)), options=list(CHARGE_STATES.values()), @@ -533,7 +533,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = ( TeslemetryVehicleSensorEntityDescription( key="bms_state", streaming_listener=lambda vehicle, callback: vehicle.listen_BMSState( - lambda value: None if value is None else callback(BMS_STATES.get(value)) + lambda value: callback(None if value is None else BMS_STATES.get(value)) ), device_class=SensorDeviceClass.ENUM, options=list(BMS_STATES.values()), From abfd443541a7c6f7a712e2b6b9ae5ce2cb907a7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Jun 2025 11:57:58 +0100 Subject: [PATCH 0097/1664] Bump bleak-esphome to 2.16.0 (#146110) --- homeassistant/components/eq3btsmart/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 1f619b2017c..889401ffc3e 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.15.1"] + "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d5faacfd1b0..d0bed1fdb4e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -19,7 +19,7 @@ "requirements": [ "aioesphomeapi==31.1.0", "esphome-dashboard-api==1.3.0", - "bleak-esphome==2.15.1" + "bleak-esphome==2.16.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 890dc1cb1cb..4021c23edf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -610,7 +610,7 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.15.1 +bleak-esphome==2.16.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e472b51a61..dca4fc91144 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -544,7 +544,7 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.15.1 +bleak-esphome==2.16.0 # homeassistant.components.bluetooth bleak-retry-connector==3.9.0 From 1d578d856343739f67769ee63a02612c937c8b4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Jun 2025 15:56:20 +0100 Subject: [PATCH 0098/1664] Bump habluetooth to 3.49.0 (#146111) * Bump habluetooth to 3.49.0 changelog: https://github.com/Bluetooth-Devices/habluetooth/compare/v3.48.2...v3.49.0 * update diag * diag --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bluetooth/test_diagnostics.py | 1 + tests/components/esphome/test_diagnostics.py | 1 + tests/components/shelly/test_diagnostics.py | 7 ++++++- 7 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4fc835e4532..f212f4bdc17 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -21,6 +21,6 @@ "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.1", "dbus-fast==2.43.0", - "habluetooth==3.48.2" + "habluetooth==3.49.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 598e2834e86..9cc65c62c7e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ dbus-fast==2.43.0 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==3.48.2 +habluetooth==3.49.0 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 4021c23edf4..5de5b884d5b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1121,7 +1121,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.48.2 +habluetooth==3.49.0 # homeassistant.components.cloud hass-nabucasa==0.101.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dca4fc91144..de8f6f70f0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -979,7 +979,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.48.2 +habluetooth==3.49.0 # homeassistant.components.cloud hass-nabucasa==0.101.0 diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 80fca88b2de..540bf1bfbd1 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -655,6 +655,7 @@ async def test_diagnostics_remote_adapter( "source": "esp32", "start_time": ANY, "time_since_last_device_detection": {"44:44:33:11:23:45": ANY}, + "raw_advertisement_data": {"44:44:33:11:23:45": None}, "type": "FakeScanner", }, ], diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 84f2243a844..70acf327788 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -95,6 +95,7 @@ async def test_diagnostics_with_bluetooth( "scanning": True, "source": "AA:BB:CC:DD:EE:FC", "start_time": ANY, + "raw_advertisement_data": {}, "time_since_last_device_detection": {}, "type": "ESPHomeScanner", }, diff --git a/tests/components/shelly/test_diagnostics.py b/tests/components/shelly/test_diagnostics.py index 300b67abe75..6bd44fa036a 100644 --- a/tests/components/shelly/test_diagnostics.py +++ b/tests/components/shelly/test_diagnostics.py @@ -103,7 +103,6 @@ async def test_rpc_config_entry_diagnostics( ) result = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert result == { "entry": entry_dict | {"discovery_keys": {}}, "bluetooth": { @@ -152,6 +151,12 @@ async def test_rpc_config_entry_diagnostics( "start_time": ANY, "source": "12:34:56:78:9A:BE", "time_since_last_device_detection": {"AA:BB:CC:DD:EE:FF": ANY}, + "raw_advertisement_data": { + "AA:BB:CC:DD:EE:FF": { + "__type": "", + "repr": "b'\\x02\\x01\\x06\\t\\xffY\\x00\\xd1\\xfb;t\\xc8\\x90\\x11\\x07\\x1b\\xc5\\xd5\\xa5\\x02\\x00\\xb8\\x9f\\xe6\\x11M\"\\x00\\r\\xa2\\xcb\\x06\\x16\\x00\\rH\\x10a'", + } + }, "type": "ShellyBLEScanner", } }, From e8aab396204d4b446e62dee7ef24b77557082d23 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Tue, 3 Jun 2025 21:54:44 +0200 Subject: [PATCH 0099/1664] SMA fix strings (#146112) * Fix * Feedback --- homeassistant/components/sma/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/strings.json b/homeassistant/components/sma/strings.json index e19acf20cf8..8253d94a749 100644 --- a/homeassistant/components/sma/strings.json +++ b/homeassistant/components/sma/strings.json @@ -34,10 +34,10 @@ "title": "Set up SMA Solar" }, "discovery_confirm": { - "title": "[%key:component::sma::config::step::user::title]", - "description": "Do you want to setup the discovered SMA ({host})?", + "title": "[%key:component::sma::config::step::user::title%]", + "description": "Do you want to set up the discovered SMA device ({host})?", "data": { - "group": "[%key:component::sma::config::step::user::data::group]", + "group": "[%key:component::sma::config::step::user::data::group%]", "password": "[%key:common::config_flow::data::password%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" From f71a1a7a899e48202974789ad35a6f551f800377 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Jun 2025 07:40:10 +0100 Subject: [PATCH 0100/1664] Bump protobuf to 6.31.1 (#146128) changelog: https://github.com/protocolbuffers/protobuf/compare/v30.2...v31.1 --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9cc65c62c7e..6df7d7d1802 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -145,7 +145,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.30.2 +protobuf==6.31.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 606141e4c65..0ea69b365a2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -172,7 +172,7 @@ iso4217!=1.10.20220401 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==6.30.2 +protobuf==6.31.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From bfb140d2e94e25be4662d22ced33a0a3d5c0b635 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Jun 2025 09:34:04 +0100 Subject: [PATCH 0101/1664] Bump aioesphomeapi to 32.0.0 (#146135) --- 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 d0bed1fdb4e..eea0ed060f9 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==31.1.0", + "aioesphomeapi==32.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 5de5b884d5b..ee5f2f3d8d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==31.1.0 +aioesphomeapi==32.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de8f6f70f0c..f544b68f573 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==31.1.0 +aioesphomeapi==32.0.0 # homeassistant.components.flo aioflo==2021.11.0 From 6c098c3e0a7b6ca2017fe9fb63fbac8d8255f5a5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Jun 2025 09:02:53 +0000 Subject: [PATCH 0102/1664] Bump version to 2025.6.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index edbdba419f3..25d722ea685 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index aa9de97d73b..7abfb13f248 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b4" +version = "2025.6.0b5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 78d2bf736c07c37759b5d8583a0e202e412f3ec2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 10 Jun 2025 18:05:55 +0200 Subject: [PATCH 0103/1664] Reolink conserve battery (#145452) --- homeassistant/components/reolink/__init__.py | 59 ++++++++++++--- homeassistant/components/reolink/const.py | 6 ++ homeassistant/components/reolink/entity.py | 4 +- homeassistant/components/reolink/host.py | 46 +++++++++--- tests/components/reolink/test_init.py | 77 +++++++++++++++++++- 5 files changed, 170 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 38f8e709b5c..aa4ee36fc67 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta import logging +from time import time from typing import Any from reolink_aio.api import RETRY_ATTEMPTS @@ -28,7 +30,13 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import ( + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_PORT, + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin from .host import ReolinkHost from .services import async_setup_services @@ -220,6 +228,24 @@ async def async_setup_entry( hass.http.register_view(PlaybackProxyView(hass)) + await register_callbacks(host, device_coordinator, hass) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + config_entry.async_on_unload( + config_entry.add_update_listener(entry_update_listener) + ) + + return True + + +async def register_callbacks( + host: ReolinkHost, + device_coordinator: DataUpdateCoordinator[None], + hass: HomeAssistant, +) -> None: + """Register update callbacks.""" + async def refresh(*args: Any) -> None: """Request refresh of coordinator.""" await device_coordinator.async_request_refresh() @@ -233,17 +259,29 @@ async def async_setup_entry( host.cancel_refresh_privacy_mode = async_call_later(hass, 2, refresh) host.privacy_mode = host.api.baichuan.privacy_mode() + def generate_async_camera_wake(channel: int) -> Callable[[], None]: + def async_camera_wake() -> None: + """Request update when a battery camera wakes up.""" + if ( + not host.api.sleeping(channel) + and time() - host.last_wake[channel] + > BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + ): + hass.loop.create_task(device_coordinator.async_request_refresh()) + + return async_camera_wake + host.api.baichuan.register_callback( "privacy_mode_change", async_privacy_mode_change, 623 ) - - await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - - config_entry.async_on_unload( - config_entry.add_update_listener(entry_update_listener) - ) - - return True + for channel in host.api.channels: + if host.api.supported(channel, "battery"): + host.api.baichuan.register_callback( + f"camera_{channel}_wake", + generate_async_camera_wake(channel), + 145, + channel, + ) async def entry_update_listener( @@ -262,6 +300,9 @@ async def async_unload_entry( await host.stop() host.api.baichuan.unregister_callback("privacy_mode_change") + for channel in host.api.channels: + if host.api.supported(channel, "battery"): + host.api.baichuan.unregister_callback(f"camera_{channel}_wake") if host.cancel_refresh_privacy_mode is not None: host.cancel_refresh_privacy_mode() diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index 026d1219881..bd9c4bb84a2 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -5,3 +5,9 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" CONF_BC_PORT = "baichuan_port" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" + +# Conserve battery by not waking the battery cameras each minute during normal update +# Most props are cached in the Home Hub and updated, but some are skipped +BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL = 3600 # seconds +BATTERY_WAKE_UPDATE_INTERVAL = 6 * BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL +BATTERY_ALL_WAKE_UPDATE_INTERVAL = 2 * BATTERY_WAKE_UPDATE_INTERVAL diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index f2a0b20994a..d7e8817b1b7 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -142,7 +142,9 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] async def async_update(self) -> None: """Force full update from the generic entity update service.""" - self._host.last_wake = 0 + for channel in self._host.api.channels: + if self._host.api.supported(channel, "battery"): + self._host.last_wake[channel] = 0 await super().async_update() diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index c3a8d340501..39b58c92ac3 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -34,7 +34,15 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.storage import Store from homeassistant.util.ssl import SSLCipherList -from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import ( + BATTERY_ALL_WAKE_UPDATE_INTERVAL, + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + BATTERY_WAKE_UPDATE_INTERVAL, + CONF_BC_PORT, + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from .exceptions import ( PasswordIncompatible, ReolinkSetupException, @@ -52,10 +60,6 @@ POLL_INTERVAL_NO_PUSH = 5 LONG_POLL_COOLDOWN = 0.75 LONG_POLL_ERROR_COOLDOWN = 30 -# Conserve battery by not waking the battery cameras each minute during normal update -# Most props are cached in the Home Hub and updated, but some are skipped -BATTERY_WAKE_UPDATE_INTERVAL = 3600 # seconds - _LOGGER = logging.getLogger(__name__) @@ -95,7 +99,8 @@ class ReolinkHost: bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), ) - self.last_wake: float = 0 + self.last_wake: defaultdict[int, float] = defaultdict(float) + self.last_all_wake: float = 0 self.update_cmd: defaultdict[str, defaultdict[int | None, int]] = defaultdict( lambda: defaultdict(int) ) @@ -459,15 +464,34 @@ class ReolinkHost: async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" - wake = False - if time() - self.last_wake > BATTERY_WAKE_UPDATE_INTERVAL: + wake: dict[int, bool] = {} + now = time() + for channel in self._api.stream_channels: # wake the battery cameras for a complete update - wake = True - self.last_wake = time() + if not self._api.supported(channel, "battery"): + wake[channel] = True + elif ( + ( + not self._api.sleeping(channel) + and now - self.last_wake[channel] + > BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + ) + or (now - self.last_wake[channel] > BATTERY_WAKE_UPDATE_INTERVAL) + or (now - self.last_all_wake > BATTERY_ALL_WAKE_UPDATE_INTERVAL) + ): + # let a waking update coincide with the camera waking up by itself unless it did not wake for BATTERY_WAKE_UPDATE_INTERVAL + wake[channel] = True + self.last_wake[channel] = now + else: + wake[channel] = False - for channel in self._api.channels: + # check privacy mode if enabled if self._api.baichuan.privacy_mode(channel): await self._api.baichuan.get_privacy_mode(channel) + + if all(wake.values()): + self.last_all_wake = now + if self._api.baichuan.privacy_mode(): return # API is shutdown, no need to check states diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 3551632903f..86c4ed861a1 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -19,7 +19,12 @@ from homeassistant.components.reolink import ( FIRMWARE_UPDATE_INTERVAL, NUM_CRED_ERRORS, ) -from homeassistant.components.reolink.const import CONF_BC_PORT, DOMAIN +from homeassistant.components.reolink.const import ( + BATTERY_ALL_WAKE_UPDATE_INTERVAL, + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_PORT, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_HOST, @@ -1111,6 +1116,76 @@ async def test_privacy_mode_change_callback( assert config_entry.state is ConfigEntryState.NOT_LOADED +async def test_camera_wake_callback( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + config_entry: MockConfigEntry, + reolink_connect: MagicMock, +) -> None: + """Test camera wake callback.""" + + class callback_mock_class: + callback_func = None + + def register_callback( + self, callback_id: str, callback: Callable[[], None], *args, **key_args + ) -> None: + if callback_id == "camera_0_wake": + self.callback_func = callback + + callback_mock = callback_mock_class() + + reolink_connect.model = TEST_HOST_MODEL + reolink_connect.baichuan.events_active = True + reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_connect.baichuan.register_callback = callback_mock.register_callback + reolink_connect.sleeping.return_value = True + reolink_connect.audio_record.return_value = True + reolink_connect.get_states = AsyncMock() + + with ( + patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]), + patch( + "homeassistant.components.reolink.host.time", + return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL, + ), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" + assert hass.states.get(entity_id).state == STATE_ON + + reolink_connect.sleeping.return_value = False + reolink_connect.get_states.reset_mock() + assert reolink_connect.get_states.call_count == 0 + + # simulate a TCP push callback signaling the battery camera woke up + reolink_connect.audio_record.return_value = False + assert callback_mock.callback_func is not None + with ( + patch( + "homeassistant.components.reolink.host.time", + return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL + + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + + 5, + ), + patch( + "homeassistant.components.reolink.time", + return_value=BATTERY_ALL_WAKE_UPDATE_INTERVAL + + BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL + + 5, + ), + ): + callback_mock.callback_func() + await hass.async_block_till_done() + + # check that a coordinator update was scheduled. + assert reolink_connect.get_states.call_count >= 1 + assert hass.states.get(entity_id).state == STATE_OFF + + async def test_remove( hass: HomeAssistant, reolink_connect: MagicMock, From 5821b2f03ccd3519b137a77c677592dca26d6f81 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:25:26 +0200 Subject: [PATCH 0104/1664] fix possible mac collision in enphase_envoy (#145549) * fix possible mac collision in enphase_envoy * remove redundant device registry async_get --- .../components/enphase_envoy/coordinator.py | 12 +++- tests/components/enphase_envoy/test_init.py | 58 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index 40c690b29ec..cfff0777af5 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -180,9 +180,15 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) return - device_registry.async_update_device( - device_id=envoy_device.id, - new_connections={connection}, + device_registry.async_get_or_create( + config_entry_id=self.config_entry.entry_id, + identifiers={ + ( + DOMAIN, + self.envoy_serial_number, + ) + }, + connections={connection}, ) _LOGGER.debug("added connection: %s to %s", connection, self.name) diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index ef071b421fe..560d0719424 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -510,7 +510,6 @@ async def test_coordinator_interface_information_no_device( ) # update device to force no device found in mac verification - device_registry = dr.async_get(hass) envoy_device = device_registry.async_get_device( identifiers={ ( @@ -531,3 +530,60 @@ async def test_coordinator_interface_information_no_device( # verify no device found message in log assert "No envoy device found in device registry" in caplog.text + + +@respx.mock +async def test_coordinator_interface_information_mac_also_in_other_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_envoy: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, +) -> None: + """Test coordinator interface mac verification with MAC also in other existing device.""" + await setup_integration(hass, config_entry) + + caplog.set_level(logging.DEBUG) + logging.getLogger("homeassistant.components.enphase_envoy.coordinator").setLevel( + logging.DEBUG + ) + + # add existing device with MAC and sparsely populated i.e. unifi that found envoy + other_config_entry = MockConfigEntry(domain="test", data={}) + other_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=other_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:55")}, + manufacturer="Enphase Energy", + ) + + envoy_device = device_registry.async_get_device( + identifiers={ + ( + DOMAIN, + mock_envoy.serial_number, + ) + } + ) + assert envoy_device + + # move time forward so interface information is fetched + freezer.tick(MAC_VERIFICATION_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # verify mac was added + assert "added connection: ('mac', '00:11:22:33:44:55') to Envoy 1234" in caplog.text + + # verify connection is now in envoy device + envoy_device_refetched = device_registry.async_get(envoy_device.id) + assert envoy_device_refetched + assert envoy_device_refetched.name == "Envoy 1234" + assert envoy_device_refetched.serial_number == "1234" + assert envoy_device_refetched.connections == { + ( + dr.CONNECTION_NETWORK_MAC, + "00:11:22:33:44:55", + ) + } From 41431282ee938d2dcc410dd744625801b7b88699 Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Tue, 10 Jun 2025 16:23:55 +0200 Subject: [PATCH 0105/1664] Add evaporate water program id for Miele oven (#145996) --- homeassistant/components/miele/const.py | 1 + homeassistant/components/miele/strings.json | 1 + 2 files changed, 2 insertions(+) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index 0d11cbdd0a5..bda276c6d8a 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -527,6 +527,7 @@ OVEN_PROGRAM_ID: dict[int, str] = { 116: "custom_program_20", 323: "pyrolytic", 326: "descale", + 327: "evaporate_water", 335: "shabbat_program", 336: "yom_tov", 356: "defrost", diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 6774d813e44..cf01d01e476 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -542,6 +542,7 @@ "endive_strips": "Endive (strips)", "espresso": "Espresso", "espresso_macchiato": "Espresso macchiato", + "evaporate_water": "Evaporate water", "express": "Express", "express_20": "Express 20'", "extra_quiet": "Extra quiet", From dfc4889d456409becb7d9015f8554f052de6c319 Mon Sep 17 00:00:00 2001 From: Ian Date: Tue, 10 Jun 2025 06:03:20 -0700 Subject: [PATCH 0106/1664] Throttle Nextbus if we are reaching the rate limit (#146064) Co-authored-by: Josef Zweck Co-authored-by: Robert Resch --- .../components/nextbus/coordinator.py | 35 ++++++++++--- tests/components/nextbus/conftest.py | 7 +++ tests/components/nextbus/test_sensor.py | 52 +++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/nextbus/coordinator.py b/homeassistant/components/nextbus/coordinator.py index 617669adf2f..e8d7ab06915 100644 --- a/homeassistant/components/nextbus/coordinator.py +++ b/homeassistant/components/nextbus/coordinator.py @@ -1,8 +1,8 @@ """NextBus data update coordinator.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging -from typing import Any +from typing import Any, override from py_nextbus import NextBusClient from py_nextbus.client import NextBusFormatError, NextBusHTTPError @@ -15,8 +15,14 @@ from .util import RouteStop _LOGGER = logging.getLogger(__name__) +# At what percentage of the request limit should the coordinator pause making requests +UPDATE_INTERVAL_SECONDS = 30 +THROTTLE_PRECENTAGE = 80 -class NextBusDataUpdateCoordinator(DataUpdateCoordinator): + +class NextBusDataUpdateCoordinator( + DataUpdateCoordinator[dict[RouteStop, dict[str, Any]]] +): """Class to manage fetching NextBus data.""" def __init__(self, hass: HomeAssistant, agency: str) -> None: @@ -26,7 +32,7 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, config_entry=None, # It is shared between multiple entries name=DOMAIN, - update_interval=timedelta(seconds=30), + update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), ) self.client = NextBusClient(agency_id=agency) self._agency = agency @@ -49,9 +55,26 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): """Check if this coordinator is tracking any routes.""" return len(self._route_stops) > 0 - async def _async_update_data(self) -> dict[str, Any]: + @override + async def _async_update_data(self) -> dict[RouteStop, dict[str, Any]]: """Fetch data from NextBus.""" + if ( + # If we have predictions, check the rate limit + self._predictions + # If are over our rate limit percentage, we should throttle + and self.client.rate_limit_percent >= THROTTLE_PRECENTAGE + # But only if we have a reset time to unthrottle + and self.client.rate_limit_reset is not None + # Unless we are after the reset time + and datetime.now() < self.client.rate_limit_reset + ): + self.logger.debug( + "Rate limit threshold reached. Skipping updates for. Routes: %s", + str(self._route_stops), + ) + return self._predictions + _stops_to_route_stops: dict[str, set[RouteStop]] = {} for route_stop in self._route_stops: _stops_to_route_stops.setdefault(route_stop.stop_id, set()).add(route_stop) @@ -60,7 +83,7 @@ class NextBusDataUpdateCoordinator(DataUpdateCoordinator): "Updating data from API. Routes: %s", str(_stops_to_route_stops) ) - def _update_data() -> dict: + def _update_data() -> dict[RouteStop, dict[str, Any]]: """Fetch data from NextBus.""" self.logger.debug("Updating data from API (executor)") predictions: dict[RouteStop, dict[str, Any]] = {} diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py index 3f687989313..9891f6ffa49 100644 --- a/tests/components/nextbus/conftest.py +++ b/tests/components/nextbus/conftest.py @@ -137,6 +137,13 @@ def mock_nextbus_lists( def mock_nextbus() -> Generator[MagicMock]: """Create a mock py_nextbus module.""" with patch("homeassistant.components.nextbus.coordinator.NextBusClient") as client: + instance = client.return_value + + # Set some mocked rate limit values + instance.rate_limit = 450 + instance.rate_limit_remaining = 225 + instance.rate_limit_percent = 50.0 + yield client diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 04140a17c4f..eacab5cd5c4 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the nexbus sensor component.""" from copy import deepcopy +from datetime import datetime, timedelta from unittest.mock import MagicMock from urllib.error import HTTPError @@ -122,6 +123,57 @@ async def test_verify_no_upcoming( assert state.state == "unknown" +async def test_verify_throttle( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Verify that the sensor coordinator is throttled correctly.""" + + # Set rate limit past threshold, should be ignored for first request + mock_client = mock_nextbus.return_value + mock_client.rate_limit_percent = 99.0 + mock_client.rate_limit_reset = datetime.now() + timedelta(seconds=30) + + # Do a request with the initial config and get predictions + await assert_setup_sensor(hass, CONFIG_BASIC) + + # Validate the predictions are present + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.state == "2019-03-28T21:09:31+00:00" + assert state.attributes["agency"] == VALID_AGENCY + assert state.attributes["route"] == VALID_ROUTE_TITLE + assert state.attributes["stop"] == VALID_STOP_TITLE + assert state.attributes["upcoming"] == "1, 2, 3, 10" + + # Update the predictions mock to return a different result + mock_nextbus_predictions.return_value = NO_UPCOMING + + # Move time forward and bump the rate limit reset time + mock_client.rate_limit_reset = freezer.tick(31) + timedelta(seconds=30) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify that the sensor state is unchanged + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.state == "2019-03-28T21:09:31+00:00" + + # Move time forward past the rate limit reset time + freezer.tick(31) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # Verify that the sensor state is updated with the new predictions + state = hass.states.get(SENSOR_ID) + assert state is not None + assert state.attributes["upcoming"] == "No upcoming predictions" + assert state.state == "unknown" + + async def test_unload_entry( hass: HomeAssistant, mock_nextbus: MagicMock, From ce76b5db1657bcb2677a90e5311ea1cf93a5237c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Jun 2025 12:57:40 +0100 Subject: [PATCH 0107/1664] Bump aiohttp to 3.12.8 (#146153) --- 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 6df7d7d1802..3cfcc2e0a9e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.12.6 +aiohttp==3.12.8 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 7abfb13f248..1921b1550b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.6", + "aiohttp==3.12.8", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index a9a3e105f33..17b01971087 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.6 +aiohttp==3.12.8 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From 38c92a2338f2b2f354459e1e19a6504fd32aa06f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:35:16 +0200 Subject: [PATCH 0108/1664] Bump aioimmich to 0.9.0 (#146154) bump aioimmich to 0.9.0 --- 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 b7c8176356f..1a4ccc8580c 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.8.0"] + "requirements": ["aioimmich==0.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee5f2f3d8d3..173f7f94c2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.8.0 +aioimmich==0.9.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f544b68f573..fcca7761e2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -265,7 +265,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.8.0 +aioimmich==0.9.0 # homeassistant.components.apache_kafka aiokafka==0.10.0 From d8759898662e47c4b51b50f0be934e07204f98f7 Mon Sep 17 00:00:00 2001 From: Iskra kranj <162285659+iskrakranj@users.noreply.github.com> Date: Wed, 4 Jun 2025 14:51:40 +0200 Subject: [PATCH 0109/1664] Bump pyiskra to 0.1.21 (#146156) --- homeassistant/components/iskra/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iskra/manifest.json b/homeassistant/components/iskra/manifest.json index 3f7c805a917..da983db9969 100644 --- a/homeassistant/components/iskra/manifest.json +++ b/homeassistant/components/iskra/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyiskra"], - "requirements": ["pyiskra==0.1.19"] + "requirements": ["pyiskra==0.1.21"] } diff --git a/requirements_all.txt b/requirements_all.txt index 173f7f94c2a..217d94bf1a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2051,7 +2051,7 @@ pyiqvia==2022.04.0 pyirishrail==0.0.2 # homeassistant.components.iskra -pyiskra==0.1.19 +pyiskra==0.1.21 # homeassistant.components.iss pyiss==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcca7761e2e..e6b68640306 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1702,7 +1702,7 @@ pyipp==0.17.0 pyiqvia==2022.04.0 # homeassistant.components.iskra -pyiskra==0.1.19 +pyiskra==0.1.21 # homeassistant.components.iss pyiss==1.0.1 From 5accc3dec2ae521bfe21451e4261849097287adf Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 4 Jun 2025 22:32:44 +0200 Subject: [PATCH 0110/1664] Bump uiprotect to 7.11.0 (#146171) Bump uiprotect to version 7.11.0 --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 1cf2e4391e2..64bb278a8e2 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.10.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.11.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 217d94bf1a6..af40384991e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.10.1 +uiprotect==7.11.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6b68640306..b5c4c9e7760 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.10.1 +uiprotect==7.11.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 8312780c477edd2508fbc2468d808d49a1643bd7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Jun 2025 19:12:19 +0100 Subject: [PATCH 0111/1664] Bump aiohttp to 3.12.9 (#146178) --- 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 3cfcc2e0a9e..9b5a389e05b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.2.3 -aiohttp==3.12.8 +aiohttp==3.12.9 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 1921b1550b6..2e0d06707dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.8", + "aiohttp==3.12.9", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.2.3", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 17b01971087..8c65e9a0b5a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.8 +aiohttp==3.12.9 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.2.3 aiohttp-asyncmdnsresolver==0.1.1 From e4140d71abe646e7b58275aaec4a373a2607c1bf Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 10 Jun 2025 23:00:02 +1000 Subject: [PATCH 0112/1664] Prevent energy history returning zero in Teslemetry (#146202) --- homeassistant/components/teslemetry/coordinator.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index 406b9cb2d84..c31bdc2a34e 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -195,9 +195,13 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): raise UpdateFailed(e.message) from e # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) for period in data.get("time_series", []): for key in ENERGY_HISTORY_FIELDS: - output[key] += period.get(key, 0) + if key in period: + if output[key] is None: + output[key] = period[key] + else: + output[key] += period[key] return output From e5dd15da82234c346c709e0983e6e126070233a9 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 6 Jun 2025 00:39:55 +1000 Subject: [PATCH 0113/1664] Fix Export Rule Select Entity in Tessie (#146203) Fix TessieExportRuleSelectEntity --- homeassistant/components/tessie/select.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py index 471372a68bd..ce907deb9c8 100644 --- a/homeassistant/components/tessie/select.py +++ b/homeassistant/components/tessie/select.py @@ -168,6 +168,8 @@ class TessieExportRuleSelectEntity(TessieEnergyEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" - await handle_command(self.api.grid_import_export(option)) + await handle_command( + self.api.grid_import_export(customer_preferred_export_rule=option) + ) self._attr_current_option = option self.async_write_ha_state() From fc8b5129314ac4c7fda5970439c6c3bfbb9d1983 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 5 Jun 2025 18:02:11 +0200 Subject: [PATCH 0114/1664] Remove zeroconf discovery from Spotify (#146213) --- .../components/spotify/manifest.json | 3 +- homeassistant/components/spotify/strings.json | 3 -- homeassistant/generated/zeroconf.py | 5 --- tests/components/spotify/test_config_flow.py | 41 +------------------ 4 files changed, 2 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 27b8da7cecf..80fcc777e73 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -8,6 +8,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["spotifyaio"], - "requirements": ["spotifyaio==0.8.11"], - "zeroconf": ["_spotify-connect._tcp.local."] + "requirements": ["spotifyaio==0.8.11"] } diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 303942803be..66d837c503f 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -7,9 +7,6 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The Spotify integration needs to re-authenticate with Spotify for account: {account}" - }, - "oauth_discovery": { - "description": "Home Assistant has found Spotify on your network. Press **Submit** to continue setting up Spotify." } }, "abort": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index ed5ac37c0cd..e675a0bb237 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -865,11 +865,6 @@ ZEROCONF = { "domain": "soundtouch", }, ], - "_spotify-connect._tcp.local.": [ - { - "domain": "spotify", - }, - ], "_ssh._tcp.local.": [ { "domain": "smappee", diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 0f48002e5db..31842253c0c 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -1,33 +1,21 @@ """Tests for the Spotify config flow.""" from http import HTTPStatus -from ipaddress import ip_address from unittest.mock import MagicMock, patch import pytest from spotifyaio import SpotifyConnectionError from homeassistant.components.spotify.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -BLANK_ZEROCONF_INFO = ZeroconfServiceInfo( - ip_address=ip_address("1.2.3.4"), - ip_addresses=[ip_address("1.2.3.4")], - hostname="mock_hostname", - name="mock_name", - port=None, - properties={}, - type="mock_type", -) - async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: """Check flow aborts when no configuration is present.""" @@ -39,18 +27,6 @@ async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: assert result["reason"] == "missing_credentials" -async def test_zeroconf_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check zeroconf flow aborts when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF}, data=BLANK_ZEROCONF_INFO - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - @pytest.mark.usefixtures("current_request_with_host") @pytest.mark.usefixtures("setup_credentials") async def test_full_flow( @@ -258,18 +234,3 @@ async def test_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_account_mismatch" - - -async def test_zeroconf(hass: HomeAssistant) -> None: - """Check zeroconf flow aborts when an entry already exist.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_ZEROCONF}, data=BLANK_ZEROCONF_INFO - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "oauth_discovery" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "missing_credentials" From f6a4486c6567fece2091016aaade569e58f47142 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Jun 2025 14:10:35 +0200 Subject: [PATCH 0115/1664] Explain Withings setup (#146216) --- .../components/withings/application_credentials.py | 8 ++++++++ homeassistant/components/withings/strings.json | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index ce96ed782dd..0939f9c5b82 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -75,3 +75,11 @@ class WithingsLocalOAuth2Implementation(AuthImplementation): } ) return {**token, **new_token} + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "developer_dashboard_url": "https://developer.withings.com/dashboard/welcome", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 8eb4293c637..14c7bf640e9 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "To be able to login to Withings we require a client ID and secret. To acquire them, please follow the following steps.\n\n1. Go to the [Withings Developer Dashboard]({developer_dashboard_url}) and be sure to select the Public Cloud.\n1. Log in with your Withings account.\n1. Select **Create an application**.\n1. Select the checkbox for **Public API integration**.\n1. Select **Development** as target environment.\n1. Fill in an application name and description of your choice.\n1. Fill in `{redirect_url}` for the registered URL. Make sure that you don't press the button to test it.\n1. Fill in the client ID and secret that are now available." + }, "config": { "step": { "pick_implementation": { @@ -9,7 +12,7 @@ "description": "The Withings integration needs to re-authenticate your account" }, "oauth_discovery": { - "description": "Home Assistant has found a Withings device on your network. Press **Submit** to continue setting up Withings." + "description": "Home Assistant has found a Withings device on your network. Be aware that the setup of Withings is more complicated than many other integrations. Press **Submit** to continue setting up Withings." } }, "error": { From 91e29a3bf125029f215fd51a7a87d5ce3a80e3ef Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 5 Jun 2025 21:50:19 +0200 Subject: [PATCH 0116/1664] Bump aioimmich to 0.9.1 (#146222) bump aioimmich to 0.9.1 --- 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 1a4ccc8580c..36c993e9c8f 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.9.0"] + "requirements": ["aioimmich==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index af40384991e..a4b6a12111e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.9.0 +aioimmich==0.9.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5c4c9e7760..1def9ca9e8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -265,7 +265,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.9.0 +aioimmich==0.9.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 4d3145e559aa307e4126de2b16d73aeeec88c1a2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 7 Jun 2025 12:43:16 +1000 Subject: [PATCH 0117/1664] Add missing write state to Teslemetry (#146267) --- homeassistant/components/teslemetry/cover.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index c58559ab308..f6ff71ab0cc 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -441,6 +441,7 @@ class TeslemetryStreamingRearTrunkEntity( """Update the entity attributes.""" self._attr_is_closed = None if value is None else not value + self.async_write_ha_state() class TeslemetrySunroofEntity(TeslemetryVehiclePollingEntity, CoverEntity): From 761c2578fba0022af5626fb26c009308c5d97836 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 09:30:43 -0500 Subject: [PATCH 0118/1664] Bump aiohttp-fast-zlib to 0.3.0 (#146285) changelog: https://github.com/Bluetooth-Devices/aiohttp-fast-zlib/compare/v0.2.3...v0.3.0 proper aiohttp 3.12 support --- 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 9b5a389e05b..eaa3d025a5b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -5,7 +5,7 @@ aiodiscover==2.7.0 aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 -aiohttp-fast-zlib==0.2.3 +aiohttp-fast-zlib==0.3.0 aiohttp==3.12.9 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 diff --git a/pyproject.toml b/pyproject.toml index 2e0d06707dc..48d8265abab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "aiohasupervisor==0.3.1", "aiohttp==3.12.9", "aiohttp_cors==0.7.0", - "aiohttp-fast-zlib==0.2.3", + "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", "aiozoneinfo==0.2.3", "annotatedyaml==0.4.5", diff --git a/requirements.txt b/requirements.txt index 8c65e9a0b5a..c8c65b783ad 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,7 +7,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp==3.12.9 aiohttp_cors==0.7.0 -aiohttp-fast-zlib==0.2.3 +aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 aiozoneinfo==0.2.3 annotatedyaml==0.4.5 From 79daeb23a99f8b8def5a1fb942b9ac7f9ac509af Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 7 Jun 2025 19:18:24 +0200 Subject: [PATCH 0119/1664] Bump holidays to 0.74 (#146290) --- 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 bd6fd51e726..5a5f1daf967 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.73", "babel==2.15.0"] + "requirements": ["holidays==0.74", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 7a03133dd86..9091dd131dd 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.73"] + "requirements": ["holidays==0.74"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4b6a12111e..2742d46bd4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.73 +holidays==0.74 # homeassistant.components.frontend home-assistant-frontend==20250531.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1def9ca9e8b..1d12f4f7993 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.73 +holidays==0.74 # homeassistant.components.frontend home-assistant-frontend==20250531.0 From 21833e7c3128abd134a07b37889e7294d57320c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 12:08:32 -0500 Subject: [PATCH 0120/1664] Bump aiohttp to 3.12.11 (#146298) --- 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 eaa3d025a5b..a994e1d6333 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.9 +aiohttp==3.12.11 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 48d8265abab..7f458bebf39 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.9", + "aiohttp==3.12.11", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index c8c65b783ad..88576cd0c4c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.9 +aiohttp==3.12.11 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From 1fc05d1a307c0a67ba48a45a391d59be5e51394c Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 8 Jun 2025 19:47:00 +0200 Subject: [PATCH 0121/1664] Do not probe linkplay device if another config entry already contains the host (#146305) * Do not probe if config entry already contains the host * Add unit test * Use common fixture --- .../components/linkplay/config_flow.py | 3 +++ tests/components/linkplay/test_config_flow.py | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/linkplay/config_flow.py b/homeassistant/components/linkplay/config_flow.py index 11e4aabf257..266d2fef857 100644 --- a/homeassistant/components/linkplay/config_flow.py +++ b/homeassistant/components/linkplay/config_flow.py @@ -31,6 +31,9 @@ class LinkPlayConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle Zeroconf discovery.""" + # Do not probe the device if the host is already configured + self._async_abort_entries_match({CONF_HOST: discovery_info.host}) + session: ClientSession = await async_get_client_session(self.hass) bridge: LinkPlayBridge | None = None diff --git a/tests/components/linkplay/test_config_flow.py b/tests/components/linkplay/test_config_flow.py index adf6aa601ae..8c0dd4af88b 100644 --- a/tests/components/linkplay/test_config_flow.py +++ b/tests/components/linkplay/test_config_flow.py @@ -220,3 +220,28 @@ async def test_user_flow_errors( CONF_HOST: HOST, } assert result["result"].unique_id == UUID + + +@pytest.mark.usefixtures("mock_linkplay_factory_bridge") +async def test_zeroconf_no_probe_existing_device( + hass: HomeAssistant, mock_linkplay_factory_bridge: AsyncMock +) -> None: + """Test we do not probe the device is the host is already configured.""" + entry = MockConfigEntry( + data={CONF_HOST: HOST}, + domain=DOMAIN, + title=NAME, + unique_id=UUID, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert len(mock_linkplay_factory_bridge.mock_calls) == 0 From 5e5431c9f9a70dc1e3a44dc24fffb8440538e1fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Jun 2025 04:48:11 -0500 Subject: [PATCH 0122/1664] Use entity unique id for ESPHome media player formats (#146318) --- homeassistant/components/esphome/entity.py | 1 + .../components/esphome/media_player.py | 7 +- tests/components/esphome/test_media_player.py | 102 ++++++++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 15ea54422d4..37f8e738aee 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -226,6 +226,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): _static_info: _InfoT _state: _StateT _has_state: bool + unique_id: str def __init__( self, diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 3af6c0b2049..f18b5e7bf5c 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -78,7 +78,7 @@ class EsphomeMediaPlayer( if self._static_info.supports_pause: flags |= MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.PLAY self._attr_supported_features = flags - self._entry_data.media_player_formats[static_info.unique_id] = cast( + self._entry_data.media_player_formats[self.unique_id] = cast( MediaPlayerInfo, static_info ).supported_formats @@ -114,9 +114,8 @@ class EsphomeMediaPlayer( media_id = async_process_play_media_url(self.hass, media_id) announcement = kwargs.get(ATTR_MEDIA_ANNOUNCE) bypass_proxy = kwargs.get(ATTR_MEDIA_EXTRA, {}).get(ATTR_BYPASS_PROXY) - supported_formats: list[MediaPlayerSupportedFormat] | None = ( - self._entry_data.media_player_formats.get(self._static_info.unique_id) + self._entry_data.media_player_formats.get(self.unique_id) ) if ( @@ -139,7 +138,7 @@ class EsphomeMediaPlayer( async def async_will_remove_from_hass(self) -> None: """Handle entity being removed.""" await super().async_will_remove_from_hass() - self._entry_data.media_player_formats.pop(self.entity_id, None) + self._entry_data.media_player_formats.pop(self.unique_id, None) def _get_proxy_url( self, diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 18a997dc09a..3f1e5e99c34 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -430,3 +430,105 @@ async def test_media_player_proxy( mock_async_create_proxy_url.assert_not_called() media_args = mock_client.media_player_command.call_args.kwargs assert media_args["media_url"] == media_url + + +async def test_media_player_formats_reload_preserves_data( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that media player formats are properly managed on reload.""" + # Create a media player with supported formats + supported_formats = [ + MediaPlayerSupportedFormat( + format="mp3", + sample_rate=48000, + num_channels=2, + purpose=MediaPlayerFormatPurpose.DEFAULT, + ), + MediaPlayerSupportedFormat( + format="wav", + sample_rate=16000, + num_channels=1, + purpose=MediaPlayerFormatPurpose.ANNOUNCEMENT, + sample_bytes=2, + ), + ] + + mock_device = await mock_esphome_device( + mock_client=mock_client, + entity_info=[ + MediaPlayerInfo( + object_id="test_media_player", + key=1, + name="Test Media Player", + unique_id="test_unique_id", + supports_pause=True, + supported_formats=supported_formats, + ) + ], + states=[ + MediaPlayerEntityState( + key=1, volume=50, muted=False, state=MediaPlayerState.IDLE + ) + ], + ) + await hass.async_block_till_done() + + # Verify entity was created + state = hass.states.get("media_player.test_test_media_player") + assert state is not None + assert state.state == "idle" + + # Test that play_media works with proxy URL (which requires formats to be stored) + media_url = "http://127.0.0.1/test.mp3" + + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + }, + blocking=True, + ) + + # Verify the API was called with a proxy URL (contains /api/esphome/ffmpeg_proxy/) + mock_client.media_player_command.assert_called_once() + call_args = mock_client.media_player_command.call_args + assert "/api/esphome/ffmpeg_proxy/" in call_args.kwargs["media_url"] + assert ".mp3" in call_args.kwargs["media_url"] # Should use mp3 format for default + assert call_args.kwargs["announcement"] is None + + mock_client.media_player_command.reset_mock() + + # Reload the integration + await hass.config_entries.async_reload(mock_device.entry.entry_id) + await hass.async_block_till_done() + + # Verify entity still exists after reload + state = hass.states.get("media_player.test_test_media_player") + assert state is not None + + # Test that play_media still works after reload with announcement + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: media_url, + ATTR_MEDIA_ANNOUNCE: True, + }, + blocking=True, + ) + + # Verify the API was called with a proxy URL using wav format for announcements + mock_client.media_player_command.assert_called_once() + call_args = mock_client.media_player_command.call_args + assert "/api/esphome/ffmpeg_proxy/" in call_args.kwargs["media_url"] + assert ( + ".wav" in call_args.kwargs["media_url"] + ) # Should use wav format for announcement + assert call_args.kwargs["announcement"] is True From 79919774437dacbfb84d86de1a21831c5b264390 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Mon, 9 Jun 2025 00:35:54 +1200 Subject: [PATCH 0123/1664] Fix bosch alarm areas not correctly subscribing to alarms (#146322) * Fix bosch alarm areas not correctly subscribing to alarms * add test --- .../components/bosch_alarm/alarm_control_panel.py | 2 +- .../components/bosch_alarm/test_alarm_control_panel.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py index 60365070587..b502ee32fca 100644 --- a/homeassistant/components/bosch_alarm/alarm_control_panel.py +++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py @@ -50,7 +50,7 @@ class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity): def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None: """Initialise a Bosch Alarm control panel entity.""" - super().__init__(panel, area_id, unique_id, False, False, True) + super().__init__(panel, area_id, unique_id, True, False, True) self._attr_unique_id = self._area_unique_id @property diff --git a/tests/components/bosch_alarm/test_alarm_control_panel.py b/tests/components/bosch_alarm/test_alarm_control_panel.py index 31d2f928ec5..51767396880 100644 --- a/tests/components/bosch_alarm/test_alarm_control_panel.py +++ b/tests/components/bosch_alarm/test_alarm_control_panel.py @@ -66,6 +66,16 @@ async def test_update_alarm_device( assert hass.states.get(entity_id).state == AlarmControlPanelState.ARMED_AWAY + area.is_triggered.return_value = True + + await call_observable(hass, area.alarm_observer) + + assert hass.states.get(entity_id).state == AlarmControlPanelState.TRIGGERED + + area.is_triggered.return_value = False + + await call_observable(hass, area.alarm_observer) + await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, From 0eb3714abcdd4bd9c2b80960885318625da256bf Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 8 Jun 2025 11:47:46 -0700 Subject: [PATCH 0124/1664] Allow different manufacturer than Amazon in Amazon Devices (#146333) --- homeassistant/components/amazon_devices/entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/amazon_devices/entity.py b/homeassistant/components/amazon_devices/entity.py index bab8009ceb0..962e2f55ae6 100644 --- a/homeassistant/components/amazon_devices/entity.py +++ b/homeassistant/components/amazon_devices/entity.py @@ -25,15 +25,15 @@ class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]): """Initialize the entity.""" super().__init__(coordinator) self._serial_num = serial_num - model_details = coordinator.api.get_model_details(self.device) - model = model_details["model"] if model_details else None + model_details = coordinator.api.get_model_details(self.device) or {} + model = model_details.get("model") self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, serial_num)}, name=self.device.account_name, model=model, model_id=self.device.device_type, - manufacturer="Amazon", - hw_version=model_details["hw_version"] if model_details else None, + manufacturer=model_details.get("manufacturer", "Amazon"), + hw_version=model_details.get("hw_version"), sw_version=( self.device.software_version if model != SPEAKER_GROUP_MODEL else None ), From 80b09e3212034d9e923014da2a1738fe2af82ac2 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 8 Jun 2025 18:02:06 +0200 Subject: [PATCH 0125/1664] Bump py-synologydsm-api to 2.7.3 (#146338) bump py-synologydsm-api to 2.7.3 --- homeassistant/components/synology_dsm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json index cd054c7eb74..3022b4c2af9 100644 --- a/homeassistant/components/synology_dsm/manifest.json +++ b/homeassistant/components/synology_dsm/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/synology_dsm", "iot_class": "local_polling", "loggers": ["synology_dsm"], - "requirements": ["py-synologydsm-api==2.7.2"], + "requirements": ["py-synologydsm-api==2.7.3"], "ssdp": [ { "manufacturer": "Synology", diff --git a/requirements_all.txt b/requirements_all.txt index 2742d46bd4d..fb2a9172301 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1774,7 +1774,7 @@ py-schluter==0.1.7 py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.2 +py-synologydsm-api==2.7.3 # homeassistant.components.atome pyAtome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d12f4f7993..e056dfe6700 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1494,7 +1494,7 @@ py-nightscout==1.2.2 py-sucks==0.9.11 # homeassistant.components.synology_dsm -py-synologydsm-api==2.7.2 +py-synologydsm-api==2.7.3 # homeassistant.components.hdmi_cec pyCEC==0.5.2 From b3ee2a8885069da550dbe4e677afb6d063f2e1dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Jun 2025 11:15:00 -0500 Subject: [PATCH 0126/1664] Bump aioesphomeapi to 32.2.0 (#146344) --- 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 eea0ed060f9..3ae66838823 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==32.0.0", + "aioesphomeapi==32.2.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index fb2a9172301..fbcffd47dee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.0.0 +aioesphomeapi==32.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e056dfe6700..df13b85db26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.0.0 +aioesphomeapi==32.2.0 # homeassistant.components.flo aioflo==2021.11.0 From e97ab1fe3cc4b5e32652be88f9c77bf68ae2cea2 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 10 Jun 2025 14:38:52 +0200 Subject: [PATCH 0127/1664] Change interval for Powerfox integration (#146348) --- homeassistant/components/powerfox/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/powerfox/const.py b/homeassistant/components/powerfox/const.py index 0970e8a1b66..790f241ae8e 100644 --- a/homeassistant/components/powerfox/const.py +++ b/homeassistant/components/powerfox/const.py @@ -8,4 +8,4 @@ from typing import Final DOMAIN: Final = "powerfox" LOGGER = logging.getLogger(__package__) -SCAN_INTERVAL = timedelta(minutes=1) +SCAN_INTERVAL = timedelta(seconds=10) From bfe2eeb8332e7b1da5ed7922f6fd88e7dd2a9ee2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Jun 2025 08:30:19 -0500 Subject: [PATCH 0128/1664] Shift ESPHome log parsing to the library (#146349) --- homeassistant/components/esphome/manager.py | 23 +++++++++------------ 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 1b0e4fc8986..b4af39586d4 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio from functools import partial import logging -import re from typing import TYPE_CHECKING, Any, NamedTuple from aioesphomeapi import ( @@ -23,6 +22,7 @@ from aioesphomeapi import ( RequiresEncryptionAPIError, UserService, UserServiceArgType, + parse_log_message, ) from awesomeversion import AwesomeVersion import voluptuous as vol @@ -110,11 +110,6 @@ LOGGER_TO_LOG_LEVEL = { logging.ERROR: LogLevel.LOG_LEVEL_ERROR, logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR, } -# 7-bit and 8-bit C1 ANSI sequences -# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python -ANSI_ESCAPE_78BIT = re.compile( - rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])" -) @callback @@ -387,13 +382,15 @@ class ESPHomeManager: def _async_on_log(self, msg: SubscribeLogsResponse) -> None: """Handle a log message from the API.""" - log: bytes = msg.message - _LOGGER.log( - LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), - "%s: %s", - self.entry.title, - ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"), - ) + for line in parse_log_message( + msg.message.decode("utf-8", "backslashreplace"), "", strip_ansi_escapes=True + ): + _LOGGER.log( + LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG), + "%s: %s", + self.entry.title, + line, + ) @callback def _async_get_equivalent_log_level(self) -> LogLevel: From 7bd6ec68a888f60d95f2fb3c7c93e79a343c553e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 10 Jun 2025 11:41:36 +0200 Subject: [PATCH 0129/1664] Explain Home Connect setup (#146356) * Explain Home Connect setup * Avoid using "we" * Fix login spelling * Fix signup spelling --- .../components/home_connect/application_credentials.py | 10 ++++++++++ homeassistant/components/home_connect/strings.json | 5 ++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/application_credentials.py b/homeassistant/components/home_connect/application_credentials.py index d66255e6810..20a3a211b6a 100644 --- a/homeassistant/components/home_connect/application_credentials.py +++ b/homeassistant/components/home_connect/application_credentials.py @@ -12,3 +12,13 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe authorize_url=OAUTH2_AUTHORIZE, token_url=OAUTH2_TOKEN, ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "developer_dashboard_url": "https://developer.home-connect.com/", + "applications_url": "https://developer.home-connect.com/applications", + "register_application_url": "https://developer.home-connect.com/application/add", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 9d33f1d3ffd..71a1f1918f6 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Login to Home Connect requires a client ID and secret. To acquire them, please follow the following steps.\n\n1. Visit the [Home Connect Developer Program website]({developer_dashboard_url}) and signup for a development account.\n1. Enter the email of your login for the original Home Connect app under **Default Home Connect User Account for Testing** in the sign up process.\n1. Go to the [Applications]({applications_url}) page and select [Register Application]({register_application_url}) and set the fields to the following values: \n\t* **Application ID**: Home Assistant (or any other name that makes sense)\n\t* **OAuth Flow**: Authorization Code Grant Flow\n\t* **Redirect URI**: `{redirect_url}`\n\nIn the newly created application's details, you will find the **Client ID** and the **Client Secret**." + }, "common": { "confirmed": "Confirmed", "present": "Present" @@ -13,7 +16,7 @@ "description": "The Home Connect integration needs to re-authenticate your account" }, "oauth_discovery": { - "description": "Home Assistant has found a Home Connect device on your network. Press **Submit** to continue setting up Home Connect." + "description": "Home Assistant has found a Home Connect device on your network. Be aware that the setup of Home Connect is more complicated than many other integrations. Press **Submit** to continue setting up Home Connect." } }, "abort": { From d89b99f42b08966512b2c8b9912f7d1477f77ae0 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 10 Jun 2025 14:10:49 +0200 Subject: [PATCH 0130/1664] Improve error logging in trend binary sensor (#146358) --- .../components/trend/binary_sensor.py | 9 +++- tests/components/trend/test_binary_sensor.py | 47 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 4261f96bbe6..2bc5949b970 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -239,7 +239,14 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self.async_schedule_update_ha_state(True) except (ValueError, TypeError) as ex: - _LOGGER.error(ex) + _LOGGER.error( + "Error processing sensor state change for " + "entity_id=%s, attribute=%s, state=%s: %s", + self._entity_id, + self._attribute, + new_state.state, + ex, + ) self.async_on_remove( async_track_state_change_event( diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 4a829bb86d2..4f19c7e3427 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -437,3 +437,50 @@ async def test_unavailable_source( await hass.async_block_till_done() assert hass.states.get("binary_sensor.test_trend_sensor").state == "on" + + +async def test_invalid_state_handling( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_component: ComponentSetup, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of invalid states in trend sensor.""" + await setup_component( + { + "sample_duration": 10000, + "min_gradient": 1, + "max_samples": 25, + "min_samples": 5, + }, + ) + + for val in (10, 20, 30, 40, 50, 60): + freezer.tick(timedelta(seconds=2)) + hass.states.async_set("sensor.test_state", val) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == STATE_ON + + # Set an invalid state + hass.states.async_set("sensor.test_state", "invalid") + await hass.async_block_till_done() + + # The trend sensor should handle the invalid state gracefully + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == STATE_ON + + # Check if a warning is logged + assert ( + "Error processing sensor state change for entity_id=sensor.test_state, " + "attribute=None, state=invalid: could not convert string to float: 'invalid'" + ) in caplog.text + + # Set a valid state again + hass.states.async_set("sensor.test_state", 50) + await hass.async_block_till_done() + + # The trend sensor should return to a valid state + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == "on" From 0874f1c350741becb68e7acee552f33e29b0ee19 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 8 Jun 2025 23:43:20 +0200 Subject: [PATCH 0131/1664] Bump python-linkplay to v0.2.10 (#146359) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index eb9b5a87c75..1bbf70ed3ac 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.9"], + "requirements": ["python-linkplay==0.2.10"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fbcffd47dee..19a71af3641 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.9 +python-linkplay==0.2.10 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df13b85db26..befaeeebbe3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.9 +python-linkplay==0.2.10 # homeassistant.components.lirc # python-lirc==1.2.3 From ca77b5210f5b88a3e56c41de4fcaac895bebe6a8 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 9 Jun 2025 14:00:37 -0400 Subject: [PATCH 0132/1664] Bump pydrawise to 2025.6.0 (#146369) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 0c355c34a71..03b9dc68a79 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.3.0"] + "requirements": ["pydrawise==2025.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 19a71af3641..f2778052b4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.3.0 +pydrawise==2025.6.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index befaeeebbe3..8ea955cb20e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1603,7 +1603,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.3.0 +pydrawise==2025.6.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 From 0b24a9abc30c39128d88ac57c325c61ea1014884 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Mon, 9 Jun 2025 13:53:44 -0400 Subject: [PATCH 0133/1664] Bump env-canada to v0.11.2 (#146371) --- homeassistant/components/environment_canada/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index da0be245fcd..a6a6e447426 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.10.2"] + "requirements": ["env-canada==0.11.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f2778052b4e..ef173c1eb9d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -878,7 +878,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.10.2 +env-canada==0.11.2 # homeassistant.components.season ephem==4.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ea955cb20e..9bb4a42d641 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -757,7 +757,7 @@ energyzero==2.1.1 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.10.2 +env-canada==0.11.2 # homeassistant.components.season ephem==4.1.6 From e7a7b2417b874d206cf0fd5cdd7924a35cc9907c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Jun 2025 19:25:29 -0500 Subject: [PATCH 0134/1664] Bump aioesphomeapi to 32.2.1 (#146375) --- 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 3ae66838823..9b70aba4de1 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==32.2.0", + "aioesphomeapi==32.2.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index ef173c1eb9d..88e5ab99bc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.0 +aioesphomeapi==32.2.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bb4a42d641..c1af6b4f120 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.0 +aioesphomeapi==32.2.1 # homeassistant.components.flo aioflo==2021.11.0 From f629731930598fd336e3a8cf24b95430fc2b1952 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 9 Jun 2025 20:59:02 +0300 Subject: [PATCH 0135/1664] Bump aioamazondevices to 3.0.6 (#146385) --- homeassistant/components/amazon_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/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json index bd9bc701d3e..37a56486a08 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/amazon_devices/manifest.json @@ -118,5 +118,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "bronze", - "requirements": ["aioamazondevices==3.0.5"] + "requirements": ["aioamazondevices==3.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 88e5ab99bc4..946f5bcfcc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.amazon_devices -aioamazondevices==3.0.5 +aioamazondevices==3.0.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c1af6b4f120..ff1899391c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.amazon_devices -aioamazondevices==3.0.5 +aioamazondevices==3.0.6 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 3d0d70ece65e537be359ce8a2243164721c998d0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Jun 2025 13:24:40 +0200 Subject: [PATCH 0136/1664] Fix switch_as_x entity_id tracking (#146386) --- .../components/switch_as_x/__init__.py | 21 +++++-- tests/components/switch_as_x/test_init.py | 63 +++++++++++++------ 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 71cb9e9c225..b07bf0fdaec 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -44,10 +44,12 @@ def async_add_to_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - registry = er.async_get(hass) + entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) try: - entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID]) + entity_id = er.async_validate_entity_id( + entity_registry, entry.options[CONF_ENTITY_ID] + ) except vol.Invalid: # The entity is identified by an unknown entity registry ID _LOGGER.error( @@ -68,14 +70,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return if "entity_id" in data["changes"]: - # Entity_id changed, reload the config entry - await hass.config_entries.async_reload(entry.entry_id) + # Entity_id changed, update or reload the config entry + if valid_entity_id(entry.options[CONF_ENTITY_ID]): + # If the entity is pointed to by an entity ID, update the entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: data["entity_id"]}, + ) + else: + await hass.config_entries.async_reload(entry.entry_id) if device_id and "device_id" in data["changes"]: # If the tracked switch is no longer in the device, remove our config entry # from the device if ( - not (entity_entry := registry.async_get(data[CONF_ENTITY_ID])) + not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID])) or not device_registry.async_get(device_id) or entity_entry.device_id == device_id ): diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index cd80fab69bc..0b965fc2ad1 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -39,6 +39,44 @@ EXPOSE_SETTINGS = { } +@pytest.fixture +def switch_entity_registry_entry( + entity_registry: er.EntityRegistry, +) -> er.RegistryEntry: + """Fixture to create a switch entity entry.""" + return entity_registry.async_get_or_create( + "switch", "test", "unique", original_name="ABC" + ) + + +@pytest.fixture +def switch_as_x_config_entry( + hass: HomeAssistant, + switch_entity_registry_entry: er.RegistryEntry, + target_domain: str, + use_entity_registry_id: bool, +) -> MockConfigEntry: + """Fixture to create a switch_as_x config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_registry_entry.id + if use_entity_registry_id + else switch_entity_registry_entry.entity_id, + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_unregistered_uuid( hass: HomeAssistant, target_domain: str @@ -67,6 +105,7 @@ async def test_config_entry_unregistered_uuid( assert len(hass.states.async_all()) == 0 +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.parametrize( ("target_domain", "state_on", "state_off"), [ @@ -81,33 +120,17 @@ async def test_config_entry_unregistered_uuid( async def test_entity_registry_events( hass: HomeAssistant, entity_registry: er.EntityRegistry, + switch_entity_registry_entry: er.RegistryEntry, + switch_as_x_config_entry: MockConfigEntry, target_domain: str, state_on: str, state_off: str, ) -> None: """Test entity registry events are tracked.""" - registry_entry = entity_registry.async_get_or_create( - "switch", "test", "unique", original_name="ABC" - ) - switch_entity_id = registry_entry.entity_id + switch_entity_id = switch_entity_registry_entry.entity_id hass.states.async_set(switch_entity_id, STATE_ON) - config_entry = MockConfigEntry( - data={}, - domain=DOMAIN, - options={ - CONF_ENTITY_ID: registry_entry.id, - CONF_INVERT: False, - CONF_TARGET_DOMAIN: target_domain, - }, - title="ABC", - version=SwitchAsXConfigFlowHandler.VERSION, - minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, - ) - - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) await hass.async_block_till_done() assert hass.states.get(f"{target_domain}.abc").state == state_on From 218864d08c06844f64a2281662fdbea28fb048cc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Jun 2025 17:04:55 +0200 Subject: [PATCH 0137/1664] Update switch_as_x to handle wrapped switch moved to another device (#146387) * Update switch_as_x to handle wrapped switch moved to another device * Reload switch_as_x config entry after updating device * Make sure the switch_as_x entity is not removed --- .../components/switch_as_x/__init__.py | 24 ++- tests/components/switch_as_x/test_init.py | 151 ++++++++++++++++-- 2 files changed, 164 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index b07bf0fdaec..9e40a99299a 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event -from .const import CONF_INVERT, CONF_TARGET_DOMAIN +from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN from .light import LightSwitch __all__ = ["LightSwitch"] @@ -81,7 +81,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_reload(entry.entry_id) if device_id and "device_id" in data["changes"]: - # If the tracked switch is no longer in the device, remove our config entry + # Handle the wrapped switch being moved to a different device or removed # from the device if ( not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID])) @@ -91,10 +91,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # No need to do any cleanup return + # The wrapped switch has been moved to a different device, update the + # switch_as_x entity and the device entry to include our config entry + switch_as_x_entity_id = entity_registry.async_get_entity_id( + entry.options[CONF_TARGET_DOMAIN], DOMAIN, entry.entry_id + ) + if switch_as_x_entity_id: + # Update the switch_as_x entity to point to the new device (or no device) + entity_registry.async_update_entity( + switch_as_x_entity_id, device_id=entity_entry.device_id + ) + + if entity_entry.device_id is not None: + device_registry.async_update_device( + entity_entry.device_id, add_config_entry_id=entry.entry_id + ) + device_registry.async_update_device( device_id, remove_config_entry_id=entry.entry_id ) + # Reload the config entry so the switch_as_x entity is recreated with + # correct device info + await hass.config_entries.async_reload(entry.entry_id) + entry.async_on_unload( async_track_entity_registry_updated_event( hass, entity_id, async_registry_updated diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 0b965fc2ad1..2c87b0e3a92 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest +from homeassistant.components import switch_as_x from homeassistant.components.homeassistant import exposed_entities from homeassistant.components.lock import LockState from homeassistant.components.switch_as_x.config_flow import SwitchAsXConfigFlowHandler @@ -24,8 +25,9 @@ from homeassistant.const import ( EntityCategory, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component from . import PLATFORMS_TO_TEST @@ -222,16 +224,39 @@ async def test_device_registry_config_entry_1( device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id in device_entry.config_entries - # Remove the wrapped switch's config entry from the device - device_registry.async_update_device( - device_entry.id, remove_config_entry_id=switch_config_entry.entry_id - ) - await hass.async_block_till_done() - await hass.async_block_till_done() + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) + + # Remove the wrapped switch's config entry from the device, this removes the + # wrapped switch + with patch( + "homeassistant.components.switch_as_x.async_unload_entry", + wraps=switch_as_x.async_unload_entry, + ) as mock_setup_entry: + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=switch_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + # Check that the switch_as_x config entry is removed from the device device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id not in device_entry.config_entries + # Check that the switch_as_x config entry is removed + assert ( + switch_as_x_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_device_registry_config_entry_2( @@ -281,13 +306,121 @@ async def test_device_registry_config_entry_2( device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id in device_entry.config_entries + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) + # Remove the wrapped switch from the device - entity_registry.async_update_entity(switch_entity_entry.entity_id, device_id=None) - await hass.async_block_till_done() + with patch( + "homeassistant.components.switch_as_x.async_unload_entry", + wraps=switch_as_x.async_unload_entry, + ) as mock_setup_entry: + entity_registry.async_update_entity( + switch_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + # Check that the switch_as_x config entry is removed from the device device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id not in device_entry.config_entries + # Check that the switch_as_x config entry is not removed + assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) +async def test_device_registry_config_entry_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + target_domain: str, +) -> None: + """Test we add our config entry to the tracked switch's device.""" + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + device_entry_2 = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + switch_entity_entry = entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=device_entry.id, + original_name="ABC", + ) + + switch_as_x_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_ENTITY_ID: switch_entity_entry.id, + CONF_INVERT: False, + CONF_TARGET_DOMAIN: target_domain, + }, + title="ABC", + version=SwitchAsXConfigFlowHandler.VERSION, + minor_version=SwitchAsXConfigFlowHandler.MINOR_VERSION, + ) + + switch_as_x_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(switch_as_x_config_entry.entry_id) + await hass.async_block_till_done() + + entity_entry = entity_registry.async_get(f"{target_domain}.abc") + assert entity_entry.device_id == switch_entity_entry.device_id + + device_entry = device_registry.async_get(device_entry.id) + assert switch_as_x_config_entry.entry_id in device_entry.config_entries + device_entry_2 = device_registry.async_get(device_entry_2.id) + assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries + + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_entry.entity_id, add_event) + + # Move the wrapped switch to another device + with patch( + "homeassistant.components.switch_as_x.async_unload_entry", + wraps=switch_as_x.async_unload_entry, + ) as mock_setup_entry: + entity_registry.async_update_entity( + switch_entity_entry.entity_id, device_id=device_entry_2.id + ) + await hass.async_block_till_done() + mock_setup_entry.assert_called_once() + + # Check that the switch_as_x config entry is moved to the other device + device_entry = device_registry.async_get(device_entry.id) + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries + device_entry_2 = device_registry.async_get(device_entry_2.id) + assert switch_as_x_config_entry.entry_id in device_entry_2.config_entries + + # Check that the switch_as_x config entry is not removed + assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_entity_id( From a3220ecae6c33c1175d401a0e669cd680dad1346 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 9 Jun 2025 19:51:46 +0200 Subject: [PATCH 0138/1664] Bump pynordpool to 0.3.0 (#146396) --- homeassistant/components/nordpool/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nordpool/manifest.json b/homeassistant/components/nordpool/manifest.json index b096d2bd506..ca299b470ea 100644 --- a/homeassistant/components/nordpool/manifest.json +++ b/homeassistant/components/nordpool/manifest.json @@ -8,6 +8,6 @@ "iot_class": "cloud_polling", "loggers": ["pynordpool"], "quality_scale": "platinum", - "requirements": ["pynordpool==0.2.4"], + "requirements": ["pynordpool==0.3.0"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 946f5bcfcc2..dd84a358fd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2174,7 +2174,7 @@ pynina==0.3.6 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.4 +pynordpool==0.3.0 # homeassistant.components.nuki pynuki==1.6.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff1899391c1..d0c4252f456 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1804,7 +1804,7 @@ pynina==0.3.6 pynobo==1.8.1 # homeassistant.components.nordpool -pynordpool==0.2.4 +pynordpool==0.3.0 # homeassistant.components.nuki pynuki==1.6.3 From c6ff0e64927d429c7f56e2e7b460442eebfbbe9a Mon Sep 17 00:00:00 2001 From: wittypluck Date: Mon, 9 Jun 2025 19:55:09 +0200 Subject: [PATCH 0139/1664] Fix CO concentration unit in OpenWeatherMap (#146403) --- homeassistant/components/openweathermap/sensor.py | 3 +-- tests/components/openweathermap/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 789e9647f77..87b7860afb5 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_MILLION, DEGREE, PERCENTAGE, UV_INDEX, @@ -170,7 +169,7 @@ AIRPOLLUTION_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key=ATTR_API_AIRPOLLUTION_CO, - native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, ), diff --git a/tests/components/openweathermap/snapshots/test_sensor.ambr b/tests/components/openweathermap/snapshots/test_sensor.ambr index cbd86f14676..11a1feb721f 100644 --- a/tests/components/openweathermap/snapshots/test_sensor.ambr +++ b/tests/components/openweathermap/snapshots/test_sensor.ambr @@ -86,7 +86,7 @@ 'supported_features': 0, 'translation_key': None, 'unique_id': '12.34-56.78-co', - 'unit_of_measurement': 'ppm', + 'unit_of_measurement': 'µg/m³', }) # --- # name: test_sensor_states[air_pollution][sensor.openweathermap_carbon_monoxide-state] @@ -96,7 +96,7 @@ 'device_class': 'carbon_monoxide', 'friendly_name': 'openweathermap Carbon monoxide', 'state_class': , - 'unit_of_measurement': 'ppm', + 'unit_of_measurement': 'µg/m³', }), 'context': , 'entity_id': 'sensor.openweathermap_carbon_monoxide', From 9997fc11b186e8f406d8d314037bf80ae82f7e73 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Jun 2025 14:31:18 +0200 Subject: [PATCH 0140/1664] Handle changes to source entity in derivative helper (#146407) * Handle changes to source entity in derivative helper * Rename helper function, improve docstring * Add tests * Improve derivative tests * Deduplicate tests * Rename helpers/helper_entity.py to helpers/helper_integration.py * Rename tests --- .../components/derivative/__init__.py | 27 ++ .../components/switch_as_x/__init__.py | 79 +--- homeassistant/helpers/helper_integration.py | 105 +++++ tests/components/derivative/test_init.py | 279 +++++++++++- tests/helpers/test_helper_integration.py | 424 ++++++++++++++++++ 5 files changed, 847 insertions(+), 67 deletions(-) create mode 100644 homeassistant/helpers/helper_integration.py create mode 100644 tests/helpers/test_helper_integration.py diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 5117663f3c5..5eb499b0efd 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -2,12 +2,18 @@ from __future__ import annotations +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes + +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,6 +23,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry.entry_id, entry.options[CONF_SOURCE] ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SOURCE: source_entity_id}, + ) + + entity_registry = er.async_get(hass) + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + get_helper_entity_id=lambda: entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, entry.entry_id + ), + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_SOURCE] + ), + source_entity_id_or_uuid=entry.options[CONF_SOURCE], + ) + ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 9e40a99299a..6e9e3a93b45 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -9,9 +9,9 @@ import voluptuous as vol from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID -from homeassistant.core import Event, HomeAssistant, callback, valid_entity_id +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN from .light import LightSwitch @@ -45,7 +45,6 @@ def async_add_to_device( async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" entity_registry = er.async_get(hass) - device_registry = dr.async_get(hass) try: entity_id = er.async_validate_entity_id( entity_registry, entry.options[CONF_ENTITY_ID] @@ -58,72 +57,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - async def async_registry_updated( - event: Event[er.EventEntityRegistryUpdatedData], - ) -> None: - """Handle entity registry update.""" - data = event.data - if data["action"] == "remove": - await hass.config_entries.async_remove(entry.entry_id) - - if data["action"] != "update": - return - - if "entity_id" in data["changes"]: - # Entity_id changed, update or reload the config entry - if valid_entity_id(entry.options[CONF_ENTITY_ID]): - # If the entity is pointed to by an entity ID, update the entry - hass.config_entries.async_update_entry( - entry, - options={**entry.options, CONF_ENTITY_ID: data["entity_id"]}, - ) - else: - await hass.config_entries.async_reload(entry.entry_id) - - if device_id and "device_id" in data["changes"]: - # Handle the wrapped switch being moved to a different device or removed - # from the device - if ( - not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID])) - or not device_registry.async_get(device_id) - or entity_entry.device_id == device_id - ): - # No need to do any cleanup - return - - # The wrapped switch has been moved to a different device, update the - # switch_as_x entity and the device entry to include our config entry - switch_as_x_entity_id = entity_registry.async_get_entity_id( - entry.options[CONF_TARGET_DOMAIN], DOMAIN, entry.entry_id - ) - if switch_as_x_entity_id: - # Update the switch_as_x entity to point to the new device (or no device) - entity_registry.async_update_entity( - switch_as_x_entity_id, device_id=entity_entry.device_id - ) - - if entity_entry.device_id is not None: - device_registry.async_update_device( - entity_entry.device_id, add_config_entry_id=entry.entry_id - ) - - device_registry.async_update_device( - device_id, remove_config_entry_id=entry.entry_id - ) - - # Reload the config entry so the switch_as_x entity is recreated with - # correct device info - await hass.config_entries.async_reload(entry.entry_id) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) entry.async_on_unload( - async_track_entity_registry_updated_event( - hass, entity_id, async_registry_updated + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + get_helper_entity_id=lambda: entity_registry.async_get_entity_id( + entry.options[CONF_TARGET_DOMAIN], DOMAIN, entry.entry_id + ), + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_add_to_device(hass, entry, entity_id), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], ) ) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) - device_id = async_add_to_device(hass, entry, entity_id) - await hass.config_entries.async_forward_entry_setups( entry, (entry.options[CONF_TARGET_DOMAIN],) ) diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py new file mode 100644 index 00000000000..4f39ef4c843 --- /dev/null +++ b/homeassistant/helpers/helper_integration.py @@ -0,0 +1,105 @@ +"""Helpers for helper integrations.""" + +from __future__ import annotations + +from collections.abc import Callable + +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity_id + +from . import device_registry as dr, entity_registry as er +from .event import async_track_entity_registry_updated_event + + +def async_handle_source_entity_changes( + hass: HomeAssistant, + *, + helper_config_entry_id: str, + get_helper_entity_id: Callable[[], str | None], + set_source_entity_id_or_uuid: Callable[[str], None], + source_device_id: str | None, + source_entity_id_or_uuid: str, +) -> CALLBACK_TYPE: + """Handle changes to a helper entity's source entity. + + The following changes are handled: + - Entity removal: If the source entity is removed, the helper config entry + is removed, and the helper entity is cleaned up. + - Entity ID changed: If the source entity's entity ID changes and the source + entity is identified by an entity ID, the set_source_entity_id_or_uuid is + called. If the source entity is identified by a UUID, the helper config entry + is reloaded. + - Source entity moved to another device: The helper entity is updated to link + to the new device, and the helper config entry removed from the old device + and added to the new device. Then the helper config entry is reloaded. + - Source entity removed from the device: The helper entity is updated to link + to no device, and the helper config entry removed from the old device. Then + the helper config entry is reloaded. + """ + + async def async_registry_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + nonlocal source_device_id + + data = event.data + if data["action"] == "remove": + await hass.config_entries.async_remove(helper_config_entry_id) + + if data["action"] != "update": + return + + if "entity_id" in data["changes"]: + # Entity_id changed, update or reload the config entry + if valid_entity_id(source_entity_id_or_uuid): + # If the entity is pointed to by an entity ID, update the entry + set_source_entity_id_or_uuid(data["entity_id"]) + else: + await hass.config_entries.async_reload(helper_config_entry_id) + + if not source_device_id or "device_id" not in data["changes"]: + return + + # Handle the source entity being moved to a different device or removed + # from the device + if ( + not (source_entity_entry := entity_registry.async_get(data["entity_id"])) + or not device_registry.async_get(source_device_id) + or source_entity_entry.device_id == source_device_id + ): + # No need to do any cleanup + return + + # The source entity has been moved to a different device, update the helper + # helper entity to link to the new device and the helper device to include + # the helper config entry + helper_entity_id = get_helper_entity_id() + if helper_entity_id: + # Update the helper entity to link to the new device (or no device) + entity_registry.async_update_entity( + helper_entity_id, device_id=source_entity_entry.device_id + ) + + if source_entity_entry.device_id is not None: + device_registry.async_update_device( + source_entity_entry.device_id, + add_config_entry_id=helper_config_entry_id, + ) + + device_registry.async_update_device( + source_device_id, remove_config_entry_id=helper_config_entry_id + ) + source_device_id = source_entity_entry.device_id + + # Reload the config entry so the helper entity is recreated with + # correct device info + await hass.config_entries.async_reload(helper_config_entry_id) + + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + source_entity_id = er.async_validate_entity_id( + entity_registry, source_entity_id_or_uuid + ) + return async_track_entity_registry_updated_event( + hass, source_entity_id, async_registry_updated + ) diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 32802080e39..f75d5940da7 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -1,23 +1,103 @@ """Test the Derivative integration.""" +from unittest.mock import patch + import pytest +from homeassistant.components import derivative +from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry -@pytest.mark.parametrize("platform", ["sensor"]) +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def derivative_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a derivative config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_setup_and_remove_config_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, - platform: str, ) -> None: """Test setting up and removing a config entry.""" input_sensor_entity_id = "sensor.input" - derivative_entity_id = f"{platform}.my_derivative" + derivative_entity_id = "sensor.my_derivative" # Setup the config entry config_entry = MockConfigEntry( @@ -147,3 +227,194 @@ async def test_device_cleaning( derivative_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the derivative config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + # Check that the derivative config entry is removed + assert derivative_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert derivative_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert derivative_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the derivative config entry is updated with the new entity ID + assert derivative_config_entry.options["source"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py new file mode 100644 index 00000000000..25d490c27bb --- /dev/null +++ b/tests/helpers/test_helper_integration.py @@ -0,0 +1,424 @@ +"""Tests for the helper entity helpers.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock + +import pytest + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, +) + +HELPER_DOMAIN = "helper" +SOURCE_DOMAIN = "test" + + +@pytest.fixture +def source_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a source config entry.""" + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + return source_config_entry + + +@pytest.fixture +def source_device( + device_registry: dr.DeviceRegistry, + source_config_entry: ConfigEntry, +) -> dr.DeviceEntry: + """Fixture to create a source device.""" + return device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def source_entity_entry( + entity_registry: er.EntityRegistry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a source entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + SOURCE_DOMAIN, + "unique", + config_entry=source_config_entry, + device_id=source_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def helper_config_entry( + hass: HomeAssistant, + source_entity_entry: er.RegistryEntry, + use_entity_registry_id: bool, +) -> MockConfigEntry: + """Fixture to create a helper config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=HELPER_DOMAIN, + options={ + "name": "My helper", + "round": 1.0, + "source": source_entity_entry.id + if use_entity_registry_id + else source_entity_entry.entity_id, + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My helper", + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def mock_helper_flow() -> Generator[None]: + """Mock helper config flow.""" + + class MockConfigFlow: + """Mock the helper config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + with mock_config_flow(HELPER_DOMAIN, MockConfigFlow): + yield + + +@pytest.fixture +def helper_entity_entry( + entity_registry: er.EntityRegistry, + helper_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a helper entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + helper_config_entry.entry_id, + config_entry=helper_config_entry, + device_id=source_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def async_remove_entry() -> AsyncMock: + """Fixture to mock async_remove_entry.""" + return AsyncMock(return_value=True) + + +@pytest.fixture +def async_unload_entry() -> AsyncMock: + """Fixture to mock async_unload_entry.""" + return AsyncMock(return_value=True) + + +@pytest.fixture +def set_source_entity_id_or_uuid() -> AsyncMock: + """Fixture to mock async_unload_entry.""" + return Mock() + + +@pytest.fixture +def mock_helper_integration( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, +) -> None: + """Mock the helper integration.""" + + def get_helper_entity_id() -> str | None: + """Get the helper entity ID.""" + return entity_registry.async_get_entity_id( + "sensor", HELPER_DOMAIN, helper_config_entry.entry_id + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Mock setup entry.""" + async_handle_source_entity_changes( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + get_helper_entity_id=get_helper_entity_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=source_entity_entry.device_id, + source_entity_id_or_uuid=helper_config_entry.options["source"], + ) + return True + + mock_integration( + hass, + MockModule( + HELPER_DOMAIN, + async_remove_entry=async_remove_entry, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry, + ), + ) + mock_platform(hass, f"{HELPER_DOMAIN}.config_flow", None) + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the helper config entry is removed when the source entity is removed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entitys's config entry from the device, this removes the + # source entity + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Check that the helper config entry is unloaded and removed + async_unload_entry.assert_called_once() + async_remove_entry.assert_called_once() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id not in source_device.config_entries + + # Check that the helper config entry is removed + assert helper_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the source entity removed from the source device.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entity from the device + entity_registry.async_update_entity(source_entity_entry.entity_id, device_id=None) + await hass.async_block_till_done() + async_remove_entry.assert_not_called() + async_unload_entry.assert_called_once() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id not in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the source entity is moved to another device.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + # Create another device to move the source entity to + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert helper_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Move the source entity to another device + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + async_remove_entry.assert_not_called() + async_unload_entry.assert_called_once() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is moved to the other device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert helper_config_entry.entry_id in source_device_2.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize( + ("use_entity_registry_id", "unload_calls", "set_source_entity_id_calls"), + [(True, 1, 0), (False, 0, 1)], +) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, + unload_calls: int, + set_source_entity_id_calls: int, +) -> None: + """Test the source entity's entity ID is changed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Change the source entity's entity ID + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + async_remove_entry.assert_not_called() + assert len(async_unload_entry.mock_calls) == unload_calls + assert len(set_source_entity_id_or_uuid.mock_calls) == set_source_entity_id_calls + + # Check that the helper config is still in the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] From ec30b12fd1e668049299eeba47caa0caa9ebc79e Mon Sep 17 00:00:00 2001 From: Whitney Young Date: Tue, 10 Jun 2025 08:35:40 -0700 Subject: [PATCH 0141/1664] Fix initial state of UV protection window (#146408) The `binary_sensor` is created when the config entry is loaded after the `async_config_entry_first_refresh` has completed (during the forward of setup to platforms). Therefore, the update coordinator will already have data and will not trigger the invocation of `_handle_coordinator_update`. Fixing this just means performing the same update at initialization. --- homeassistant/components/openuv/binary_sensor.py | 6 ++++-- homeassistant/components/openuv/entity.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index f45404ce38e..09c9ab75192 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -49,6 +49,10 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): @callback def _handle_coordinator_update(self) -> None: """Update the entity from the latest data.""" + self._update_attrs() + super()._handle_coordinator_update() + + def _update_attrs(self) -> None: data = self.coordinator.data for key in ("from_time", "to_time", "from_uv", "to_uv"): @@ -78,5 +82,3 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt), } ) - - super()._handle_coordinator_update() diff --git a/homeassistant/components/openuv/entity.py b/homeassistant/components/openuv/entity.py index f3015815bf1..2303f21f2b8 100644 --- a/homeassistant/components/openuv/entity.py +++ b/homeassistant/components/openuv/entity.py @@ -31,3 +31,8 @@ class OpenUvEntity(CoordinatorEntity): name="OpenUV", entry_type=DeviceEntryType.SERVICE, ) + + self._update_attrs() + + def _update_attrs(self) -> None: + """Override point for updating attributes during init.""" From 97d91ddddba41ed0457aa4df6e30287924aac07d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Jun 2025 04:44:34 -0500 Subject: [PATCH 0142/1664] Bump propcache to 0.3.2 (#146418) --- 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 a994e1d6333..b34d994b8a5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.2.1 -propcache==0.3.1 +propcache==0.3.2 psutil-home-assistant==0.0.1 PyJWT==2.10.1 pymicro-vad==1.0.1 diff --git a/pyproject.toml b/pyproject.toml index 7f458bebf39..65157f8452a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,7 @@ dependencies = [ # PyJWT has loose dependency. We want the latest one. "cryptography==45.0.1", "Pillow==11.2.1", - "propcache==0.3.1", + "propcache==0.3.2", "pyOpenSSL==25.1.0", "orjson==3.10.18", "packaging>=23.1", diff --git a/requirements.txt b/requirements.txt index 88576cd0c4c..2ded4aeca71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -36,7 +36,7 @@ numpy==2.2.2 PyJWT==2.10.1 cryptography==45.0.1 Pillow==11.2.1 -propcache==0.3.1 +propcache==0.3.2 pyOpenSSL==25.1.0 orjson==3.10.18 packaging>=23.1 From 2b08c4c344583996ad6905eafcdf790a9712c893 Mon Sep 17 00:00:00 2001 From: Jamin Date: Tue, 10 Jun 2025 09:22:53 -0500 Subject: [PATCH 0143/1664] Check hangup error in voip (#146423) Check hangup error Prevent an error where the call end future may have already been set when a hangup is detected. --- homeassistant/components/voip/assist_satellite.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py index 7b34d7a11ba..ac8065cabf7 100644 --- a/homeassistant/components/voip/assist_satellite.py +++ b/homeassistant/components/voip/assist_satellite.py @@ -336,7 +336,8 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol if self._run_pipeline_task is not None: _LOGGER.debug("Cancelling running pipeline") self._run_pipeline_task.cancel() - self._call_end_future.set_result(None) + if not self._call_end_future.done(): + self._call_end_future.set_result(None) self.disconnect() break From 41abc8404de78145b45a08e3bcbbd6712eee1182 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Jun 2025 04:26:29 -0500 Subject: [PATCH 0144/1664] Bump yarl to 1.20.1 (#146424) --- 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 b34d994b8a5..ae8b93788b7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -74,7 +74,7 @@ voluptuous-openapi==0.1.0 voluptuous-serialize==2.6.0 voluptuous==0.15.2 webrtc-models==0.3.0 -yarl==1.20.0 +yarl==1.20.1 zeroconf==0.147.0 # Constrain pycryptodome to avoid vulnerability diff --git a/pyproject.toml b/pyproject.toml index 65157f8452a..51f89f932bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ dependencies = [ "voluptuous==0.15.2", "voluptuous-serialize==2.6.0", "voluptuous-openapi==0.1.0", - "yarl==1.20.0", + "yarl==1.20.1", "webrtc-models==0.3.0", "zeroconf==0.147.0", ] diff --git a/requirements.txt b/requirements.txt index 2ded4aeca71..0c5927dd9e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -58,6 +58,6 @@ uv==0.7.1 voluptuous==0.15.2 voluptuous-serialize==2.6.0 voluptuous-openapi==0.1.0 -yarl==1.20.0 +yarl==1.20.1 webrtc-models==0.3.0 zeroconf==0.147.0 From 4f0e4bc1ca89516e54a433364813ed08af076171 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Jun 2025 02:39:53 -0500 Subject: [PATCH 0145/1664] Bump aiohttp to 3.12.12 (#146426) --- 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 ae8b93788b7..f62c1c899ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.4.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.11 +aiohttp==3.12.12 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 51f89f932bc..c31deb67dcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.11", + "aiohttp==3.12.12", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 0c5927dd9e5..c07d0e282a0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.4.0 aiohasupervisor==0.3.1 -aiohttp==3.12.11 +aiohttp==3.12.12 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From f945defa2b4e601e389cdba1a0b0d44e45dfd467 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 10 Jun 2025 11:14:31 +0200 Subject: [PATCH 0146/1664] Reformat Dockerfile to reduce merge conflicts (#146435) --- script/hassfest/docker.py | 7 +++++-- script/hassfest/docker/Dockerfile | 14 ++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py index 4bf6c3bb0a6..1f112c11b94 100644 --- a/script/hassfest/docker.py +++ b/script/hassfest/docker.py @@ -103,7 +103,10 @@ RUN --mount=from=ghcr.io/astral-sh/uv:{uv},source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree=={pipdeptree} tqdm=={tqdm} ruff=={ruff} \ + stdlib-list==0.10.0 \ + pipdeptree=={pipdeptree} \ + tqdm=={tqdm} \ + ruff=={ruff} \ {required_components_packages} LABEL "name"="hassfest" @@ -169,7 +172,7 @@ def _generate_hassfest_dockerimage( return File( _HASSFEST_TEMPLATE.format( timeout=timeout, - required_components_packages=" ".join(sorted(packages)), + required_components_packages=" \\\n ".join(sorted(packages)), **package_versions, ), config.root / "script/hassfest/docker/Dockerfile", diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 981dc3344c0..830bdc4445e 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -24,8 +24,18 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ --no-cache \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -r /usr/src/homeassistant/requirements.txt \ - stdlib-list==0.10.0 pipdeptree==2.26.1 tqdm==4.67.1 ruff==0.11.0 \ - PyTurboJPEG==1.7.5 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.5.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 + stdlib-list==0.10.0 \ + pipdeptree==2.26.1 \ + tqdm==4.67.1 \ + ruff==0.11.0 \ + PyTurboJPEG==1.7.5 \ + go2rtc-client==0.2.1 \ + ha-ffmpeg==3.2.2 \ + hassil==2.2.3 \ + home-assistant-intents==2025.5.28 \ + mutagen==1.47.0 \ + pymicro-vad==1.0.1 \ + pyspeex-noise==1.0.2 LABEL "name"="hassfest" LABEL "maintainer"="Home Assistant " From b222fe5afaa9f46e696b1ab708a293e66c1aaff7 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 10 Jun 2025 06:31:32 -0700 Subject: [PATCH 0147/1664] Handle grpc errors in Google Assistant SDK (#146438) --- .../google_assistant_sdk/helpers.py | 14 +++++++- .../google_assistant_sdk/strings.json | 5 +++ .../google_assistant_sdk/test_init.py | 27 ++++++++++++++- .../google_assistant_sdk/test_notify.py | 34 ++++++++++++++++--- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index ca774bed77e..b319e1e432c 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -12,6 +12,7 @@ import aiohttp from aiohttp import web from gassist_text import TextAssistant from google.oauth2.credentials import Credentials +from grpc import RpcError from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player import ( @@ -25,6 +26,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session from homeassistant.helpers.event import async_call_later @@ -83,7 +85,17 @@ async def async_send_text_commands( ) as assistant: command_response_list = [] for command in commands: - resp = await hass.async_add_executor_job(assistant.assist, command) + try: + resp = await hass.async_add_executor_job(assistant.assist, command) + except RpcError as err: + _LOGGER.error( + "Failed to send command '%s' to Google Assistant: %s", + command, + err, + ) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="grpc_error" + ) from err text_response = resp[0] _LOGGER.debug("command: %s\nresponse: %s", command, text_response) audio_response = resp[2] diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 87c93023900..885ff0aad71 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -57,5 +57,10 @@ } } } + }, + "exceptions": { + "grpc_error": { + "message": "Failed to communicate with Google Assistant" + } } } diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index f986497ed29..9bb08c802c2 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -6,6 +6,7 @@ import time from unittest.mock import call, patch import aiohttp +from grpc import RpcError import pytest from homeassistant.components import conversation @@ -13,6 +14,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES from homeassistant.config_entries import ConfigEntryState from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -231,11 +233,34 @@ async def test_send_text_command_expired_token_refresh_failure( {"command": "turn on tv"}, blocking=True, ) - await hass.async_block_till_done() assert any(entry.async_get_active_flows(hass, {"reauth"})) == requires_reauth +async def test_send_text_command_grpc_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test service call send_text_command when RpcError is raised.""" + await setup_integration() + + command = "turn on home assistant unsupported device" + with ( + patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist", + side_effect=RpcError(), + ) as mock_assist_call, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + DOMAIN, + "send_text_command", + {"command": command}, + blocking=True, + ) + mock_assist_call.assert_called_once_with(command) + + async def test_send_text_command_media_player( hass: HomeAssistant, setup_integration: ComponentSetup, diff --git a/tests/components/google_assistant_sdk/test_notify.py b/tests/components/google_assistant_sdk/test_notify.py index 266846b17e1..ca4162c9e7a 100644 --- a/tests/components/google_assistant_sdk/test_notify.py +++ b/tests/components/google_assistant_sdk/test_notify.py @@ -2,6 +2,7 @@ from unittest.mock import call, patch +from grpc import RpcError import pytest from homeassistant.components import notify @@ -9,6 +10,7 @@ from homeassistant.components.google_assistant_sdk import DOMAIN from homeassistant.components.google_assistant_sdk.const import SUPPORTED_LANGUAGE_CODES from homeassistant.components.google_assistant_sdk.notify import broadcast_commands from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import ComponentSetup, ExpectedCredentials @@ -45,8 +47,8 @@ async def test_broadcast_no_targets( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: message}, + blocking=True, ) - await hass.async_block_till_done() mock_text_assistant.assert_called_once_with( ExpectedCredentials(), language_code, audio_out=False ) @@ -54,6 +56,30 @@ async def test_broadcast_no_targets( mock_text_assistant.assert_has_calls([call().__enter__().assist(expected_command)]) +async def test_broadcast_grpc_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test broadcast handling when RpcError is raised.""" + await setup_integration() + + with ( + patch( + "homeassistant.components.google_assistant_sdk.helpers.TextAssistant.assist", + side_effect=RpcError(), + ) as mock_assist_call, + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + notify.DOMAIN, + DOMAIN, + {notify.ATTR_MESSAGE: "Dinner is served"}, + blocking=True, + ) + + mock_assist_call.assert_called_once_with("broadcast Dinner is served") + + @pytest.mark.parametrize( ("language_code", "message", "target", "expected_command"), [ @@ -103,8 +129,8 @@ async def test_broadcast_one_target( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: message, notify.ATTR_TARGET: [target]}, + blocking=True, ) - await hass.async_block_till_done() mock_assist_call.assert_called_once_with(expected_command) @@ -127,8 +153,8 @@ async def test_broadcast_two_targets( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: message, notify.ATTR_TARGET: [target1, target2]}, + blocking=True, ) - await hass.async_block_till_done() mock_assist_call.assert_has_calls( [call(expected_command1), call(expected_command2)] ) @@ -148,8 +174,8 @@ async def test_broadcast_empty_message( notify.DOMAIN, DOMAIN, {notify.ATTR_MESSAGE: ""}, + blocking=True, ) - await hass.async_block_till_done() mock_assist_call.assert_not_called() From ba19d4f0431ead5b421d8c96c1714029fd900f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 10 Jun 2025 11:56:24 +0200 Subject: [PATCH 0148/1664] Fix typo at application credentials string at Home Connect integration (#146442) Fix typos --- homeassistant/components/home_connect/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 71a1f1918f6..7aadf6b0dde 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Login to Home Connect requires a client ID and secret. To acquire them, please follow the following steps.\n\n1. Visit the [Home Connect Developer Program website]({developer_dashboard_url}) and signup for a development account.\n1. Enter the email of your login for the original Home Connect app under **Default Home Connect User Account for Testing** in the sign up process.\n1. Go to the [Applications]({applications_url}) page and select [Register Application]({register_application_url}) and set the fields to the following values: \n\t* **Application ID**: Home Assistant (or any other name that makes sense)\n\t* **OAuth Flow**: Authorization Code Grant Flow\n\t* **Redirect URI**: `{redirect_url}`\n\nIn the newly created application's details, you will find the **Client ID** and the **Client Secret**." + "description": "Login to Home Connect requires a client ID and secret. To acquire them, please follow the following steps.\n\n1. Visit the [Home Connect Developer Program website]({developer_dashboard_url}) and sign up for a development account.\n1. Enter the email of your login for the original Home Connect app under **Default Home Connect User Account for Testing** in the signup process.\n1. Go to the [Applications]({applications_url}) page and select [Register Application]({register_application_url}) and set the fields to the following values: \n\t* **Application ID**: Home Assistant (or any other name that makes sense)\n\t* **OAuth Flow**: Authorization Code Grant Flow\n\t* **Redirect URI**: {redirect_url}\n\nIn the newly created application's details, you will find the **Client ID** and the **Client Secret**." }, "common": { "confirmed": "Confirmed", From b2d25b1883a8ca65218430394f83fcde40fce248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 10 Jun 2025 14:11:07 +0200 Subject: [PATCH 0149/1664] Improvements for Home Connect application credentials string (#146443) --- homeassistant/components/home_connect/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 7aadf6b0dde..1445a8eae08 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Login to Home Connect requires a client ID and secret. To acquire them, please follow the following steps.\n\n1. Visit the [Home Connect Developer Program website]({developer_dashboard_url}) and sign up for a development account.\n1. Enter the email of your login for the original Home Connect app under **Default Home Connect User Account for Testing** in the signup process.\n1. Go to the [Applications]({applications_url}) page and select [Register Application]({register_application_url}) and set the fields to the following values: \n\t* **Application ID**: Home Assistant (or any other name that makes sense)\n\t* **OAuth Flow**: Authorization Code Grant Flow\n\t* **Redirect URI**: {redirect_url}\n\nIn the newly created application's details, you will find the **Client ID** and the **Client Secret**." + "description": "Login to Home Connect requires a client ID and secret. To acquire them, please follow the following steps.\n\n1. Visit the [Home Connect Developer Program website]({developer_dashboard_url}) and sign up for a development account.\n1. Enter the email of your login for the original Home Connect app under **Default Home Connect User Account for Testing** in the signup process.\n1. Go to the [Applications]({applications_url}) page and select [Register Application]({register_application_url}) and set the fields to the following values:\n * **Application ID**: Home Assistant (or any other name that makes sense)\n * **OAuth Flow**: Authorization Code Grant Flow\n * **Redirect URI**: `{redirect_url}`\n\nIn the newly created application's details, you will find the **Client ID** and the **Client Secret**." }, "common": { "confirmed": "Confirmed", From 6f4029983acdbbcdae1b4fdd937f9b788f3c4fc6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:00:41 +0200 Subject: [PATCH 0150/1664] Update requests to 2.32.4 (#146445) --- 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 f62c1c899ba..af9b0472bb1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -61,7 +61,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.4 securetar==2025.2.1 SQLAlchemy==2.0.40 standard-aifc==3.13.0 diff --git a/pyproject.toml b/pyproject.toml index c31deb67dcf..52910c7f319 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,7 +106,7 @@ dependencies = [ # dependencies to stage 0. "PyTurboJPEG==1.7.5", "PyYAML==6.0.2", - "requests==2.32.3", + "requests==2.32.4", "securetar==2025.2.1", "SQLAlchemy==2.0.40", "standard-aifc==3.13.0", diff --git a/requirements.txt b/requirements.txt index c07d0e282a0..bff95490470 100644 --- a/requirements.txt +++ b/requirements.txt @@ -46,7 +46,7 @@ pyspeex-noise==1.0.2 python-slugify==8.0.4 PyTurboJPEG==1.7.5 PyYAML==6.0.2 -requests==2.32.3 +requests==2.32.4 securetar==2025.2.1 SQLAlchemy==2.0.40 standard-aifc==3.13.0 From bdbb74aff120b9f6efc234626ca4c564efd0fa28 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Jun 2025 14:52:24 +0200 Subject: [PATCH 0151/1664] Return expected state in SmartThings water heater (#146449) --- .../components/smartthings/strings.json | 9 ----- .../components/smartthings/water_heater.py | 9 +++-- .../snapshots/test_water_heater.ambr | 36 +++++++++---------- .../smartthings/test_water_heater.py | 27 +++++++------- 4 files changed, 39 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 7b5edde2d10..8e972ac8aea 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -619,15 +619,6 @@ "keep_fresh_mode": { "name": "Keep fresh mode" } - }, - "water_heater": { - "water_heater": { - "state": { - "standard": "Standard", - "force": "Forced", - "power": "Power" - } - } } }, "issues": { diff --git a/homeassistant/components/smartthings/water_heater.py b/homeassistant/components/smartthings/water_heater.py index addbfed2ec4..4b1aaaa5549 100644 --- a/homeassistant/components/smartthings/water_heater.py +++ b/homeassistant/components/smartthings/water_heater.py @@ -10,6 +10,9 @@ from homeassistant.components.water_heater import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, STATE_ECO, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, WaterHeaterEntity, WaterHeaterEntityFeature, ) @@ -24,9 +27,9 @@ from .entity import SmartThingsEntity OPERATION_MAP_TO_HA: dict[str, str] = { "eco": STATE_ECO, - "std": "standard", - "force": "force", - "power": "power", + "std": STATE_HEAT_PUMP, + "force": STATE_HIGH_DEMAND, + "power": STATE_PERFORMANCE, } HA_TO_OPERATION_MAP = {v: k for k, v in OPERATION_MAP_TO_HA.items()} diff --git a/tests/components/smartthings/snapshots/test_water_heater.ambr b/tests/components/smartthings/snapshots/test_water_heater.ambr index 3e5afed3b86..d52400b9de2 100644 --- a/tests/components/smartthings/snapshots/test_water_heater.ambr +++ b/tests/components/smartthings/snapshots/test_water_heater.ambr @@ -10,9 +10,9 @@ 'operation_list': list([ 'off', 'eco', - 'standard', - 'power', - 'force', + 'heat_pump', + 'performance', + 'high_demand', ]), }), 'config_entry_id': , @@ -55,9 +55,9 @@ 'operation_list': list([ 'off', 'eco', - 'standard', - 'power', - 'force', + 'heat_pump', + 'performance', + 'high_demand', ]), 'operation_mode': 'off', 'supported_features': , @@ -84,8 +84,8 @@ 'operation_list': list([ 'off', 'eco', - 'standard', - 'force', + 'heat_pump', + 'high_demand', ]), }), 'config_entry_id': , @@ -128,8 +128,8 @@ 'operation_list': list([ 'off', 'eco', - 'standard', - 'force', + 'heat_pump', + 'high_demand', ]), 'operation_mode': 'off', 'supported_features': , @@ -156,9 +156,9 @@ 'operation_list': list([ 'off', 'eco', - 'standard', - 'power', - 'force', + 'heat_pump', + 'performance', + 'high_demand', ]), }), 'config_entry_id': , @@ -201,11 +201,11 @@ 'operation_list': list([ 'off', 'eco', - 'standard', - 'power', - 'force', + 'heat_pump', + 'performance', + 'high_demand', ]), - 'operation_mode': 'standard', + 'operation_mode': 'heat_pump', 'supported_features': , 'target_temp_high': 57, 'target_temp_low': 40, @@ -216,6 +216,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'standard', + 'state': 'heat_pump', }) # --- diff --git a/tests/components/smartthings/test_water_heater.py b/tests/components/smartthings/test_water_heater.py index a12280e5c92..30c85539d3a 100644 --- a/tests/components/smartthings/test_water_heater.py +++ b/tests/components/smartthings/test_water_heater.py @@ -20,6 +20,9 @@ from homeassistant.components.water_heater import ( SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE, STATE_ECO, + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, WaterHeaterEntityFeature, ) from homeassistant.const import ( @@ -66,9 +69,9 @@ async def test_all_entities( ("operation_mode", "argument"), [ (STATE_ECO, "eco"), - ("standard", "std"), - ("force", "force"), - ("power", "power"), + (STATE_HEAT_PUMP, "std"), + (STATE_HIGH_DEMAND, "force"), + (STATE_PERFORMANCE, "power"), ], ) async def test_set_operation_mode( @@ -299,9 +302,9 @@ async def test_operation_list_update( ] == [ STATE_OFF, STATE_ECO, - "standard", - "power", - "force", + STATE_HEAT_PUMP, + STATE_PERFORMANCE, + STATE_HIGH_DEMAND, ] await trigger_update( @@ -318,8 +321,8 @@ async def test_operation_list_update( ] == [ STATE_OFF, STATE_ECO, - "force", - "power", + STATE_HIGH_DEMAND, + STATE_PERFORMANCE, ] @@ -332,7 +335,7 @@ async def test_current_operation_update( """Test state update.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("water_heater.warmepumpe").state == "standard" + assert hass.states.get("water_heater.warmepumpe").state == STATE_HEAT_PUMP await trigger_update( hass, @@ -356,7 +359,7 @@ async def test_switch_update( await setup_integration(hass, mock_config_entry) state = hass.states.get("water_heater.warmepumpe") - assert state.state == "standard" + assert state.state == STATE_HEAT_PUMP assert ( state.attributes[ATTR_SUPPORTED_FEATURES] == WaterHeaterEntityFeature.ON_OFF @@ -516,7 +519,7 @@ async def test_availability( """Test availability.""" await setup_integration(hass, mock_config_entry) - assert hass.states.get("water_heater.warmepumpe").state == "standard" + assert hass.states.get("water_heater.warmepumpe").state == STATE_HEAT_PUMP await trigger_health_update( hass, devices, "3810e5ad-5351-d9f9-12ff-000001200000", HealthStatus.OFFLINE @@ -528,7 +531,7 @@ async def test_availability( hass, devices, "3810e5ad-5351-d9f9-12ff-000001200000", HealthStatus.ONLINE ) - assert hass.states.get("water_heater.warmepumpe").state == "standard" + assert hass.states.get("water_heater.warmepumpe").state == STATE_HEAT_PUMP @pytest.mark.parametrize("device_fixture", ["da_sac_ehs_000002_sub"]) From fcd71931e75907740743fa532c744e9dec2dcc59 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 10 Jun 2025 17:04:22 +0100 Subject: [PATCH 0152/1664] Update wording deprecated system package integration repair (#146450) Co-authored-by: Martin Hjelmare --- homeassistant/components/homeassistant/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 123e625d0fc..93b4105c702 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -20,11 +20,11 @@ }, "deprecated_system_packages_config_flow_integration": { "title": "The {integration_title} integration is being removed", - "description": "The {integration_title} integration is being removed as it requires additional system packages, which can't be installed on supported Home Assistant installations. Remove all \"{integration_title}\" config entries to fix this issue." + "description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove all \"{integration_title}\" config entries." }, "deprecated_system_packages_yaml_integration": { "title": "The {integration_title} integration is being removed", - "description": "The {integration_title} integration is being removed as it requires additional system packages, which can't be installed on supported Home Assistant installations. Remove the `{domain}` configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + "description": "The {integration_title} integration is being removed as it depends on system packages that can only be installed on systems running a deprecated architecture. To resolve this, remove the {domain} entry from your configuration.yaml file and restart Home Assistant." }, "historic_currency": { "title": "The configured currency is no longer in use", From 1040646610732cc38a4bc3d87fd9eecbc4aec6ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20Schr=C3=B6der?= Date: Tue, 10 Jun 2025 16:20:35 +0200 Subject: [PATCH 0153/1664] Update caldav to 1.6.0 (#146456) Fixes #140798 --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index 5c1334c8029..d0e0bd0b1d0 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/caldav", "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"], - "requirements": ["caldav==1.3.9", "icalendar==6.1.0"] + "requirements": ["caldav==1.6.0", "icalendar==6.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd84a358fd4..a459bafdbf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -698,7 +698,7 @@ buienradar==1.0.6 cached-ipaddress==0.10.0 # homeassistant.components.caldav -caldav==1.3.9 +caldav==1.6.0 # homeassistant.components.cisco_mobility_express ciscomobilityexpress==0.3.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0c4252f456..f0b1e22519b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -616,7 +616,7 @@ buienradar==1.0.6 cached-ipaddress==0.10.0 # homeassistant.components.caldav -caldav==1.3.9 +caldav==1.6.0 # homeassistant.components.coinbase coinbase-advanced-py==1.2.2 From 1d91ca5716854b2e28dfc46e9a138fb792be8627 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Jun 2025 17:37:21 +0200 Subject: [PATCH 0154/1664] Bump pySmartThings to 3.2.4 (#146459) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 180d4eebed1..481048c3bdb 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.3"] + "requirements": ["pysmartthings==3.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index a459bafdbf3..67b7173fe98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2341,7 +2341,7 @@ pysmappee==0.2.29 pysmarlaapi==0.8.2 # homeassistant.components.smartthings -pysmartthings==3.2.3 +pysmartthings==3.2.4 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0b1e22519b..42d106778b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1941,7 +1941,7 @@ pysmappee==0.2.29 pysmarlaapi==0.8.2 # homeassistant.components.smartthings -pysmartthings==3.2.3 +pysmartthings==3.2.4 # homeassistant.components.smarty pysmarty2==0.10.2 From 18e1a26da1987c57c56d4b96398cb43761562970 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Jun 2025 17:31:30 +0200 Subject: [PATCH 0155/1664] Catch exception before retrying in AirGradient (#146460) --- .../components/airgradient/coordinator.py | 13 ++++++++++--- tests/components/airgradient/test_init.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airgradient/coordinator.py b/homeassistant/components/airgradient/coordinator.py index 7484c7e85a9..9ee103b3a90 100644 --- a/homeassistant/components/airgradient/coordinator.py +++ b/homeassistant/components/airgradient/coordinator.py @@ -51,9 +51,16 @@ class AirGradientCoordinator(DataUpdateCoordinator[AirGradientData]): async def _async_setup(self) -> None: """Set up the coordinator.""" - self._current_version = ( - await self.client.get_current_measures() - ).firmware_version + try: + self._current_version = ( + await self.client.get_current_measures() + ).firmware_version + except AirGradientError as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_error", + translation_placeholders={"error": str(error)}, + ) from error async def _async_update_data(self) -> AirGradientData: try: diff --git a/tests/components/airgradient/test_init.py b/tests/components/airgradient/test_init.py index a253cb2888a..5732cd526f6 100644 --- a/tests/components/airgradient/test_init.py +++ b/tests/components/airgradient/test_init.py @@ -3,10 +3,12 @@ from datetime import timedelta from unittest.mock import AsyncMock +from airgradient import AirGradientError from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.components.airgradient.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -54,3 +56,16 @@ async def test_new_firmware_version( ) assert device_entry is not None assert device_entry.sw_version == "3.1.2" + + +async def test_setup_retry( + hass: HomeAssistant, + mock_airgradient_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test retrying setup.""" + mock_airgradient_client.get_current_measures.side_effect = AirGradientError() + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY From 49646210144aba77abc71f5c86c0d4707a4c8ad1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 10 Jun 2025 19:28:48 +0200 Subject: [PATCH 0156/1664] Fix incorrect categories handling in holiday (#146470) --- homeassistant/components/holiday/calendar.py | 16 ++-- tests/components/holiday/test_calendar.py | 80 +++++++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py index 1c01319129b..c5b67b7d555 100644 --- a/homeassistant/components/holiday/calendar.py +++ b/homeassistant/components/holiday/calendar.py @@ -25,17 +25,12 @@ def _get_obj_holidays_and_language( selected_categories: list[str] | None, ) -> tuple[HolidayBase, str]: """Get the object for the requested country and year.""" - if selected_categories is None: - categories = [PUBLIC] - else: - categories = [PUBLIC, *selected_categories] - obj_holidays = country_holidays( country, subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=language, - categories=categories, + categories=selected_categories, ) if language == "en": for lang in obj_holidays.supported_languages: @@ -45,7 +40,7 @@ def _get_obj_holidays_and_language( subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=lang, - categories=categories, + categories=selected_categories, ) language = lang break @@ -59,7 +54,7 @@ def _get_obj_holidays_and_language( subdiv=province, years={dt_util.now().year, dt_util.now().year + 1}, language=default_language, - categories=categories, + categories=selected_categories, ) language = default_language @@ -77,6 +72,11 @@ async def async_setup_entry( categories: list[str] | None = config_entry.options.get(CONF_CATEGORIES) language = hass.config.language + if categories is None: + categories = [PUBLIC] + else: + categories = [PUBLIC, *categories] + obj_holidays, language = await hass.async_add_executor_job( _get_obj_holidays_and_language, country, province, language, categories ) diff --git a/tests/components/holiday/test_calendar.py b/tests/components/holiday/test_calendar.py index 6733d38442b..463f8645647 100644 --- a/tests/components/holiday/test_calendar.py +++ b/tests/components/holiday/test_calendar.py @@ -3,13 +3,18 @@ from datetime import datetime, timedelta from freezegun.api import FrozenDateTimeFactory +from holidays import CATHOLIC import pytest from homeassistant.components.calendar import ( DOMAIN as CALENDAR_DOMAIN, SERVICE_GET_EVENTS, ) -from homeassistant.components.holiday.const import CONF_PROVINCE, DOMAIN +from homeassistant.components.holiday.const import ( + CONF_CATEGORIES, + CONF_PROVINCE, + DOMAIN, +) from homeassistant.const import CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -353,3 +358,76 @@ async def test_language_not_exist( ] } } + + +async def test_categories( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test if there is no next event.""" + await hass.config.async_set_time_zone("Europe/Berlin") + zone = await dt_util.async_get_time_zone("Europe/Berlin") + freezer.move_to(datetime(2025, 8, 14, 12, tzinfo=zone)) + + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_COUNTRY: "DE", + CONF_PROVINCE: "BY", + }, + options={ + CONF_CATEGORIES: [CATHOLIC], + }, + title="Germany", + ) + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.germany", + "end_date_time": dt_util.now() + timedelta(days=2), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.germany": { + "events": [ + { + "start": "2025-08-15", + "end": "2025-08-16", + "summary": "Assumption Day", + "location": "Germany", + } + ] + } + } + + freezer.move_to(datetime(2025, 12, 23, 12, tzinfo=zone)) + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + "entity_id": "calendar.germany", + "end_date_time": dt_util.now() + timedelta(days=2), + }, + blocking=True, + return_response=True, + ) + assert response == { + "calendar.germany": { + "events": [ + { + "start": "2025-12-25", + "end": "2025-12-26", + "summary": "Christmas Day", + "location": "Germany", + } + ] + } + } From 39962a3f48d4d2b5b3f852355044a5b9908b86a5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 10 Jun 2025 20:18:19 +0300 Subject: [PATCH 0157/1664] Avoid closing shared aiohttp session in Vodafone Station (#146471) --- .../components/vodafone_station/__init__.py | 1 - .../vodafone_station/config_flow.py | 1 - .../vodafone_station/coordinator.py | 47 +++++++++---------- 3 files changed, 22 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 5efc33ca882..17b0fe6e501 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -37,7 +37,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator = entry.runtime_data await coordinator.api.logout() - await coordinator.api.close() return unload_ok diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index b69078b8ce6..c330a93a1a8 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -48,7 +48,6 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, await api.login() finally: await api.logout() - await api.close() return {"title": data[CONF_HOST]} diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 846d4b042c0..57d39151160 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -117,32 +117,29 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): async def _async_update_data(self) -> UpdateCoordinatorDataType: """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) + try: - try: - await self.api.login() - raw_data_devices = await self.api.get_devices_data() - data_sensors = await self.api.get_sensor_data() - await self.api.logout() - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="cannot_authenticate", - translation_placeholders={"error": repr(err)}, - ) from err - except ( - exceptions.CannotConnect, - exceptions.AlreadyLogged, - exceptions.GenericLoginError, - JSONDecodeError, - ) as err: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - translation_placeholders={"error": repr(err)}, - ) from err - except (ConfigEntryAuthFailed, UpdateFailed): - await self.api.close() - raise + await self.api.login() + raw_data_devices = await self.api.get_devices_data() + data_sensors = await self.api.get_sensor_data() + await self.api.logout() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="cannot_authenticate", + translation_placeholders={"error": repr(err)}, + ) from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + JSONDecodeError, + ) as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"error": repr(err)}, + ) from err utc_point_in_time = dt_util.utcnow() data_devices = { From bf8ef0a767de8ee0696eb1ad18d4dd0b93a3eda8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 10 Jun 2025 20:28:37 +0300 Subject: [PATCH 0158/1664] Fix EntityCategory for binary_sensor platform in Amazon Devices (#146472) * Fix EntityCategory for binary_sensor platform in Amazon Devices * update snapshots --- homeassistant/components/amazon_devices/binary_sensor.py | 3 +++ .../amazon_devices/snapshots/test_binary_sensor.ambr | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amazon_devices/binary_sensor.py b/homeassistant/components/amazon_devices/binary_sensor.py index 2e41983dda4..ab1fadc7548 100644 --- a/homeassistant/components/amazon_devices/binary_sensor.py +++ b/homeassistant/components/amazon_devices/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -34,10 +35,12 @@ BINARY_SENSORS: Final = ( AmazonBinarySensorEntityDescription( key="online", device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, is_on_fn=lambda _device: _device.online, ), AmazonBinarySensorEntityDescription( key="bluetooth", + entity_category=EntityCategory.DIAGNOSTIC, translation_key="bluetooth", is_on_fn=lambda _device: _device.bluetooth_state, ), diff --git a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr b/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr index 0d3a5252a73..e914541d19c 100644 --- a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr +++ b/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.echo_test_bluetooth', 'has_entity_name': True, 'hidden_by': None, @@ -59,7 +59,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'binary_sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'binary_sensor.echo_test_connectivity', 'has_entity_name': True, 'hidden_by': None, From 8949a595fe026cd54321b499df982e966ea84248 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 10 Jun 2025 17:45:26 +0000 Subject: [PATCH 0159/1664] Bump version to 2025.6.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 25d722ea685..b07549d387f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 52910c7f319..58ad46b63e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b5" +version = "2025.6.0b6" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 35580c0849e09ba874c2e792d3feb58e7632b33e Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:44:06 +0200 Subject: [PATCH 0160/1664] Bump homematicip to 2.0.4 (#144096) * Bump to 2.0.2 with all necessary changes * bump to prerelease * add addiional tests * Bump to homematicip 2.0.3 * do not delete device * Setup BRAND_SWITCH_MEASURING as light * bump to 2.0.4 * refactor test_remove_obsolete_entities * move test * use const from homematicip lib --- .../components/homematicip_cloud/hap.py | 16 +++++++++++++++ .../components/homematicip_cloud/light.py | 11 ++++++---- .../homematicip_cloud/manifest.json | 2 +- .../components/homematicip_cloud/sensor.py | 13 ++---------- .../components/homematicip_cloud/switch.py | 15 ++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/homematicip_cloud/test_hap.py | 20 +++++++++++++++++++ 8 files changed, 54 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 86630c2896c..f3681a89110 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -112,6 +112,7 @@ class HomematicipHAP: self.config_entry = config_entry self._ws_close_requested = False + self._ws_connection_closed = asyncio.Event() self._retry_task: asyncio.Task | None = None self._tries = 0 self._accesspoint_connected = True @@ -218,6 +219,8 @@ class HomematicipHAP: try: await self.home.get_current_state_async() hmip_events = self.home.enable_events() + self.home.set_on_connected_handler(self.ws_connected_handler) + self.home.set_on_disconnected_handler(self.ws_disconnected_handler) tries = 0 await hmip_events except HmipConnectionError: @@ -267,6 +270,18 @@ class HomematicipHAP: "Reset connection to access point id %s", self.config_entry.unique_id ) + async def ws_connected_handler(self) -> None: + """Handle websocket connected.""" + _LOGGER.debug("WebSocket connection to HomematicIP established") + if self._ws_connection_closed.is_set(): + await self.get_state() + self._ws_connection_closed.clear() + + async def ws_disconnected_handler(self) -> None: + """Handle websocket disconnection.""" + _LOGGER.warning("WebSocket connection to HomematicIP closed") + self._ws_connection_closed.set() + async def get_hap( self, hass: HomeAssistant, @@ -290,6 +305,7 @@ class HomematicipHAP: raise HmipcConnectionError from err home.on_update(self.async_update) home.on_create(self.async_create_entity) + hass.loop.create_task(self.async_connect()) return home diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 855f5851d73..d5175e6e647 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -4,16 +4,16 @@ from __future__ import annotations from typing import Any -from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState +from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState from homematicip.base.functionalChannels import NotificationLightChannel from homematicip.device import ( BrandDimmer, - BrandSwitchMeasuring, BrandSwitchNotificationLight, Dimmer, DinRailDimmer3, FullFlushDimmer, PluggableDimmer, + SwitchMeasuring, WiredDimmer3, ) from packaging.version import Version @@ -44,9 +44,12 @@ async def async_setup_entry( hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] for device in hap.home.devices: - if isinstance(device, BrandSwitchMeasuring): + if ( + isinstance(device, SwitchMeasuring) + and getattr(device, "deviceType", None) == DeviceType.BRAND_SWITCH_MEASURING + ): entities.append(HomematicipLightMeasuring(hap, device)) - elif isinstance(device, BrandSwitchNotificationLight): + if isinstance(device, BrandSwitchNotificationLight): device_version = Version(device.firmwareVersion) entities.append(HomematicipLight(hap, device)) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 15bc24c110f..fc4a1cb831f 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.1.1"] + "requirements": ["homematicip==2.0.4"] } diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 4f43e6d6ca7..13f3694de7a 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -11,12 +11,10 @@ from homematicip.base.functionalChannels import ( FunctionalChannel, ) from homematicip.device import ( - BrandSwitchMeasuring, EnergySensorsInterface, FloorTerminalBlock6, FloorTerminalBlock10, FloorTerminalBlock12, - FullFlushSwitchMeasuring, HeatingThermostat, HeatingThermostatCompact, HeatingThermostatEvo, @@ -26,9 +24,9 @@ from homematicip.device import ( MotionDetectorOutdoor, MotionDetectorPushButton, PassageDetector, - PlugableSwitchMeasuring, PresenceDetectorIndoor, RoomControlDeviceAnalog, + SwitchMeasuring, TemperatureDifferenceSensor2, TemperatureHumiditySensorDisplay, TemperatureHumiditySensorOutdoor, @@ -143,14 +141,7 @@ async def async_setup_entry( ), ): entities.append(HomematicipIlluminanceSensor(hap, device)) - if isinstance( - device, - ( - PlugableSwitchMeasuring, - BrandSwitchMeasuring, - FullFlushSwitchMeasuring, - ), - ): + if isinstance(device, SwitchMeasuring): entities.append(HomematicipPowerSensor(hap, device)) entities.append(HomematicipEnergySensor(hap, device)) if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 4927d9a32df..66a40229c7e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,20 +4,19 @@ from __future__ import annotations from typing import Any +from homematicip.base.enums import DeviceType from homematicip.device import ( BrandSwitch2, - BrandSwitchMeasuring, DinRailSwitch, DinRailSwitch4, FullFlushInputSwitch, - FullFlushSwitchMeasuring, HeatingSwitch2, MultiIOBox, OpenCollector8Module, PlugableSwitch, - PlugableSwitchMeasuring, PrintedCircuitBoardSwitch2, PrintedCircuitBoardSwitchBattery, + SwitchMeasuring, WiredSwitch8, ) from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup @@ -43,12 +42,10 @@ async def async_setup_entry( if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup)) ] for device in hap.home.devices: - if isinstance(device, BrandSwitchMeasuring): - # BrandSwitchMeasuring inherits PlugableSwitchMeasuring - # This entity is implemented in the light platform and will - # not be added in the switch platform - pass - elif isinstance(device, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)): + if ( + isinstance(device, SwitchMeasuring) + and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING + ): entities.append(HomematicipSwitchMeasuring(hap, device)) elif isinstance(device, WiredSwitch8): entities.extend( diff --git a/requirements_all.txt b/requirements_all.txt index 67b7173fe98..98b43cb4635 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,7 +1170,7 @@ home-assistant-frontend==20250531.0 home-assistant-intents==2025.5.28 # homeassistant.components.homematicip_cloud -homematicip==2.0.1.1 +homematicip==2.0.4 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42d106778b0..23ace366641 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ home-assistant-frontend==20250531.0 home-assistant-intents==2025.5.28 # homeassistant.components.homematicip_cloud -homematicip==2.0.1.1 +homematicip==2.0.4 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 13aaa4d83ba..94d6f9d5dd6 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -231,3 +231,23 @@ async def test_auth_create_exception(hass: HomeAssistant, simple_mock_auth) -> N ), ): assert not await hmip_auth.get_auth(hass, HAPID, HAPPIN) + + +async def test_get_state_after_disconnect( + hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home +) -> None: + """Test get state after disconnect.""" + hass.config.components.add(DOMAIN) + hap = HomematicipHAP(hass, hmip_config_entry) + assert hap + + with patch.object(hap, "get_state") as mock_get_state: + assert not hap._ws_connection_closed.is_set() + + await hap.ws_connected_handler() + mock_get_state.assert_not_called() + + await hap.ws_disconnected_handler() + assert hap._ws_connection_closed.is_set() + await hap.ws_connected_handler() + mock_get_state.assert_called_once() From 63e49c5d3c70bab6e98a3bb444857bd056f39c08 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Jun 2025 10:31:08 +0200 Subject: [PATCH 0161/1664] Explain Nest setup (#146217) --- homeassistant/components/nest/config_flow.py | 8 -------- homeassistant/components/nest/strings.json | 3 +++ tests/components/nest/test_config_flow.py | 8 ++++++++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 1513a039407..0b249db7a4b 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -31,7 +31,6 @@ from homeassistant.helpers.selector import ( SelectSelectorConfig, SelectSelectorMode, ) -from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.util import get_random_string from . import api @@ -441,10 +440,3 @@ class NestFlowHandler( if self._structure_config_title: title = self._structure_config_title return self.async_create_entry(title=title, data=self._data) - - async def async_step_dhcp( - self, discovery_info: DhcpServiceInfo - ) -> ConfigFlowResult: - """Handle a flow initialized by discovery.""" - await self._async_handle_discovery_without_unique_id() - return await self.async_step_user() diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 4a8689ff04c..5146d04af0b 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -47,6 +47,9 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "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." } }, "error": { diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py index 3f369f3e127..67364aff412 100644 --- a/tests/components/nest/test_config_flow.py +++ b/tests/components/nest/test_config_flow.py @@ -995,6 +995,10 @@ async def test_dhcp_discovery( ) await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + + result = await oauth.async_configure(result, {}) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "create_cloud_project" result = await oauth.async_configure(result, {}) @@ -1033,6 +1037,10 @@ async def test_dhcp_discovery_with_creds( ) await hass.async_block_till_done() assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "oauth_discovery" + + result = await oauth.async_configure(result, {}) + assert result.get("type") is FlowResultType.FORM assert result.get("step_id") == "cloud_project" result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) From 4147211f948195fb78fbd3d804a70839a9a3b42c Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 11 Jun 2025 05:58:07 -0400 Subject: [PATCH 0162/1664] Add color_temp_kelvin to set_temperature action variables (#146448) --- homeassistant/components/template/light.py | 4 +++- tests/components/template/test_light.py | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 9fc935bf0ee..c852ee1808d 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -524,8 +524,10 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): ATTR_COLOR_TEMP_KELVIN in kwargs and (script := CONF_TEMPERATURE_ACTION) in self._action_scripts ): + kelvin = kwargs[ATTR_COLOR_TEMP_KELVIN] + common_params[ATTR_COLOR_TEMP_KELVIN] = kelvin common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired( - kwargs[ATTR_COLOR_TEMP_KELVIN] + kelvin ) return (script, common_params) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index f240c2412e0..eaa1708aea7 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -79,6 +79,7 @@ OPTIMISTIC_COLOR_TEMP_LIGHT_CONFIG = { "action": "set_temperature", "caller": "{{ this.entity_id }}", "color_temp": "{{color_temp}}", + "color_temp_kelvin": "{{color_temp_kelvin}}", }, }, } @@ -1535,6 +1536,7 @@ async def test_temperature_action_no_template( assert calls[-1].data["action"] == "set_temperature" assert calls[-1].data["caller"] == "light.test_template_light" assert calls[-1].data["color_temp"] == 345 + assert calls[-1].data["color_temp_kelvin"] == 2898 state = hass.states.get("light.test_template_light") assert state is not None From 43e16bb913e51927e869290becf06ae0219a79d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Jun 2025 11:35:14 +0200 Subject: [PATCH 0163/1664] Split deprecated system issue in 2 places (#146453) --- homeassistant/components/hassio/__init__.py | 67 +++- .../components/homeassistant/__init__.py | 95 ++---- tests/components/hassio/test_init.py | 290 +++++++++++++++++- tests/components/homeassistant/test_init.py | 80 +---- 4 files changed, 391 insertions(+), 141 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index eeeedff00bb..041877e3944 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, discovery_flow, + issue_registry as ir, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.deprecation import ( @@ -51,9 +52,11 @@ from homeassistant.helpers.hassio import ( get_supervisor_ip as _get_supervisor_ip, is_hassio as _is_hassio, ) +from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) +from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -109,7 +112,7 @@ from .coordinator import ( get_core_info, # noqa: F401 get_core_stats, # noqa: F401 get_host_info, # noqa: F401 - get_info, # noqa: F401 + get_info, get_issues_info, # noqa: F401 get_os_info, get_supervisor_info, # noqa: F401 @@ -168,6 +171,11 @@ SERVICE_RESTORE_PARTIAL = "restore_partial" VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) +DEPRECATION_URL = ( + "https://www.home-assistant.io/blog/2025/05/22/" + "deprecating-core-and-supervised-installation-methods-and-32-bit-systems/" +) + def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" @@ -546,6 +554,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator + system_info = await async_get_system_info(hass) + + def deprecated_setup_issue() -> None: + os_info = get_os_info(hass) + info = get_info(hass) + if os_info is None or info is None: + return + is_haos = info.get("hassos") is not None + arch = system_info["arch"] + board = os_info.get("board") + supported_board = board in {"rpi3", "rpi4", "tinker", "odroid-xu4", "rpi2"} + if is_haos and arch == "armv7" and supported_board: + issue_id = "deprecated_os_" + if board in {"rpi3", "rpi4"}: + issue_id += "aarch64" + elif board in {"tinker", "odroid-xu4", "rpi2"}: + issue_id += "armv7" + ir.async_create_issue( + hass, + "homeassistant", + issue_id, + breaks_in_ha_version="2025.12.0", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_guide": "https://www.home-assistant.io/installation/", + }, + ) + deprecated_architecture = False + if arch in {"i386", "armhf"} or (arch == "armv7" and not supported_board): + deprecated_architecture = True + if not is_haos or deprecated_architecture: + issue_id = "deprecated" + if not is_haos: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + "homeassistant", + issue_id, + breaks_in_ha_version="2025.12.0", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": "OS" if is_haos else "Supervised", + "arch": arch, + }, + ) + listener() + + listener = coordinator.async_add_listener(deprecated_setup_issue) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5f012c6a054..1433358b568 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -4,7 +4,7 @@ import asyncio from collections.abc import Callable, Coroutine import itertools as it import logging -from typing import TYPE_CHECKING, Any +from typing import Any import voluptuous as vol @@ -38,7 +38,6 @@ from homeassistant.helpers import ( restore_state, ) from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.helpers.importlib import async_import_module from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service import ( async_extract_config_entry_ids, @@ -402,46 +401,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: info = await async_get_system_info(hass) installation_type = info["installation_type"][15:] - deprecated_method = installation_type in { - "Core", - "Supervised", - } - arch = info["arch"] - if arch == "armv7": - if installation_type == "OS": - # Local import to avoid circular dependencies - # We use the import helper because hassio - # may not be loaded yet and we don't want to - # do blocking I/O in the event loop to import it. - if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio - else: - hassio = await async_import_module( - hass, "homeassistant.components.hassio" - ) - os_info = hassio.get_os_info(hass) - assert os_info is not None - issue_id = "deprecated_os_" - board = os_info.get("board") - if board in {"rpi3", "rpi4"}: - issue_id += "aarch64" - elif board in {"tinker", "odroid-xu4", "rpi2"}: - issue_id += "armv7" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2025.12.0", - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "installation_guide": "https://www.home-assistant.io/installation/", - }, - ) - elif installation_type == "Container": + if installation_type in {"Core", "Container"}: + deprecated_method = installation_type == "Core" + arch = info["arch"] + if arch == "armv7" and installation_type == "Container": ir.async_create_issue( hass, DOMAIN, @@ -452,29 +415,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: severity=IssueSeverity.WARNING, translation_key="deprecated_container_armv7", ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or (arch == "armv7" and deprecated_method): - deprecated_architecture = True - if deprecated_method or deprecated_architecture: - issue_id = "deprecated" - if deprecated_method: - issue_id += "_method" - if deprecated_architecture: - issue_id += "_architecture" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2025.12.0", - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "installation_type": installation_type, - "arch": arch, - }, - ) + deprecated_architecture = False + if arch in {"i386", "armhf"} or ( + arch == "armv7" and installation_type != "Container" + ): + deprecated_architecture = True + if deprecated_method or deprecated_architecture: + issue_id = "deprecated" + if deprecated_method: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2025.12.0", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": installation_type, + "arch": arch, + }, + ) return True diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index d34aed608fb..f74ed852a49 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, patch from aiohasupervisor import SupervisorError from aiohasupervisor.models import AddonsStats +from freezegun.api import FrozenDateTimeFactory import pytest from voluptuous import Invalid @@ -23,10 +24,13 @@ from homeassistant.components.hassio import ( is_hassio as deprecated_is_hassio, ) from homeassistant.components.hassio.config import STORAGE_KEY -from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY +from homeassistant.components.hassio.const import ( + HASSIO_UPDATE_INTERVAL, + REQUEST_REFRESH_DELAY, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.setup import async_setup_component @@ -1140,3 +1144,285 @@ def test_deprecated_constants( replacement, "2025.11", ) + + +@pytest.mark.parametrize( + ("board", "issue_id"), + [ + ("rpi3", "deprecated_os_aarch64"), + ("rpi4", "deprecated_os_aarch64"), + ("tinker", "deprecated_os_armv7"), + ("odroid-xu4", "deprecated_os_armv7"), + ("rpi2", "deprecated_os_armv7"), + ], +) +async def test_deprecated_installation_issue_aarch64( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + board: str, + issue_id: str, +) -> None: + """Test deprecated installation issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.hassio.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": "armv7", + }, + ), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": "armv7", + }, + ), + patch( + "homeassistant.components.hassio.get_os_info", return_value={"board": board} + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": True} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", issue_id) + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_guide": "https://www.home-assistant.io/installation/", + } + + +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armhf", + "armv7", + ], +) +async def test_deprecated_installation_issue_32bit_method( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.hassio.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "rpi3-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": True} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", "deprecated_architecture") + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == {"installation_type": "OS", "arch": arch} + + +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armhf", + "armv7", + ], +) +async def test_deprecated_installation_issue_32bit_supervised( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.hassio.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Supervised", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Supervised", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "rpi3-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": None} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + "homeassistant", "deprecated_method_architecture" + ) + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Supervised", + "arch": arch, + } + + +@pytest.mark.parametrize( + ("board", "issue_id"), + [ + ("rpi5", "deprecated_os_aarch64"), + ], +) +async def test_deprecated_installation_issue_supported_board( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + board: str, + issue_id: str, +) -> None: + """Test no deprecated installation issue for a supported board.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.hassio.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": "aarch64", + }, + ), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant OS", + "arch": "aarch64", + }, + ), + patch( + "homeassistant.components.hassio.get_os_info", return_value={"board": board} + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": True} + ), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 0 diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index fe5d2155f58..0010422cd28 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -640,13 +640,6 @@ async def test_reload_all( assert len(jinja) == 1 -@pytest.mark.parametrize( - "installation_type", - [ - "Home Assistant Core", - "Home Assistant Supervised", - ], -) @pytest.mark.parametrize( "arch", [ @@ -658,14 +651,13 @@ async def test_reload_all( async def test_deprecated_installation_issue_32bit_method( hass: HomeAssistant, issue_registry: ir.IssueRegistry, - installation_type: str, arch: str, ) -> None: """Test deprecated installation issue.""" with patch( "homeassistant.components.homeassistant.async_get_system_info", return_value={ - "installation_type": installation_type, + "installation_type": "Home Assistant Core", "arch": arch, }, ): @@ -679,18 +671,11 @@ async def test_deprecated_installation_issue_32bit_method( assert issue.domain == HOMEASSISTANT_DOMAIN assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { - "installation_type": installation_type[15:], + "installation_type": "Core", "arch": arch, } -@pytest.mark.parametrize( - "installation_type", - [ - "Home Assistant Container", - "Home Assistant OS", - ], -) @pytest.mark.parametrize( "arch", [ @@ -701,14 +686,13 @@ async def test_deprecated_installation_issue_32bit_method( async def test_deprecated_installation_issue_32bit( hass: HomeAssistant, issue_registry: ir.IssueRegistry, - installation_type: str, arch: str, ) -> None: """Test deprecated installation issue.""" with patch( "homeassistant.components.homeassistant.async_get_system_info", return_value={ - "installation_type": installation_type, + "installation_type": "Home Assistant Container", "arch": arch, }, ): @@ -722,28 +706,19 @@ async def test_deprecated_installation_issue_32bit( assert issue.domain == HOMEASSISTANT_DOMAIN assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { - "installation_type": installation_type[15:], + "installation_type": "Container", "arch": arch, } -@pytest.mark.parametrize( - "installation_type", - [ - "Home Assistant Core", - "Home Assistant Supervised", - ], -) async def test_deprecated_installation_issue_method( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - installation_type: str, + hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: """Test deprecated installation issue.""" with patch( "homeassistant.components.homeassistant.async_get_system_info", return_value={ - "installation_type": installation_type, + "installation_type": "Home Assistant Core", "arch": "generic-x86-64", }, ): @@ -755,52 +730,11 @@ async def test_deprecated_installation_issue_method( assert issue.domain == HOMEASSISTANT_DOMAIN assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { - "installation_type": installation_type[15:], + "installation_type": "Core", "arch": "generic-x86-64", } -@pytest.mark.parametrize( - ("board", "issue_id"), - [ - ("rpi3", "deprecated_os_aarch64"), - ("rpi4", "deprecated_os_aarch64"), - ("tinker", "deprecated_os_armv7"), - ("odroid-xu4", "deprecated_os_armv7"), - ("rpi2", "deprecated_os_armv7"), - ], -) -async def test_deprecated_installation_issue_aarch64( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, - board: str, - issue_id: str, -) -> None: - """Test deprecated installation issue.""" - with ( - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "armv7", - }, - ), - patch( - "homeassistant.components.hassio.get_os_info", return_value={"board": board} - ), - ): - assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, issue_id) - assert issue.domain == HOMEASSISTANT_DOMAIN - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders == { - "installation_guide": "https://www.home-assistant.io/installation/", - } - - async def test_deprecated_installation_issue_armv7_container( hass: HomeAssistant, issue_registry: ir.IssueRegistry, From f1df6dcda57b6a7b15003cf9742caffbab1676f6 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Tue, 10 Jun 2025 22:25:47 +0300 Subject: [PATCH 0164/1664] Fix Jewish calendar not updating (#146465) --- .../components/jewish_calendar/sensor.py | 9 ++---- .../components/jewish_calendar/test_sensor.py | 31 ++++++++++++++++++- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 230adef9894..cb38a3797eb 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -225,7 +225,7 @@ async def async_setup_entry( JewishCalendarTimeSensor(config_entry, description) for description in TIME_SENSORS ) - async_add_entities(sensors) + async_add_entities(sensors, update_before_add=True) class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): @@ -233,12 +233,7 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - await self.async_update_data() - - async def async_update_data(self) -> None: + async def async_update(self) -> None: """Update the state of the sensor.""" now = dt_util.now() _LOGGER.debug("Now: %s Location: %r", now, self.data.location) diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 0cc1e60efc8..38a3dd12206 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime as dt from typing import Any +from freezegun.api import FrozenDateTimeFactory from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest @@ -14,7 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize("language", ["en", "he"]) @@ -542,6 +543,34 @@ async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None: assert hass.states.get("sensor.jewish_calendar_daf_yomi").state == results +@pytest.mark.parametrize( + ("test_time", "results"), + [ + ( + dt(2025, 6, 10, 17), + { + "initial_state": "14 Sivan 5785", + "move_to": dt(2025, 6, 10, 23, 0), + "new_state": "15 Sivan 5785", + }, + ), + ], + indirect=True, +) +@pytest.mark.usefixtures("setup_at_time") +async def test_sensor_does_not_update_on_time_change( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any] +) -> None: + """Test that the Jewish calendar sensor does not update after time advances (regression test for update bug).""" + sensor_id = "sensor.jewish_calendar_date" + assert hass.states.get(sensor_id).state == results["initial_state"] + + freezer.move_to(results["move_to"]) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get(sensor_id).state == results["new_state"] + + async def test_no_discovery_info( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 69ba2aab11308c66e8a3cf1a895d64eb62be199a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 10 Jun 2025 20:55:00 +0200 Subject: [PATCH 0165/1664] Remove DHCP discovery from Amazon Devices (#146476) --- .../components/amazon_devices/manifest.json | 110 ----- .../amazon_devices/quality_scale.yaml | 4 +- homeassistant/generated/dhcp.py | 432 ------------------ .../amazon_devices/test_config_flow.py | 64 +-- 4 files changed, 4 insertions(+), 606 deletions(-) diff --git a/homeassistant/components/amazon_devices/manifest.json b/homeassistant/components/amazon_devices/manifest.json index 37a56486a08..f63893c1598 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/amazon_devices/manifest.json @@ -3,116 +3,6 @@ "name": "Amazon Devices", "codeowners": ["@chemelli74"], "config_flow": true, - "dhcp": [ - { "macaddress": "007147*" }, - { "macaddress": "00FC8B*" }, - { "macaddress": "0812A5*" }, - { "macaddress": "086AE5*" }, - { "macaddress": "08849D*" }, - { "macaddress": "089115*" }, - { "macaddress": "08A6BC*" }, - { "macaddress": "08C224*" }, - { "macaddress": "0CDC91*" }, - { "macaddress": "0CEE99*" }, - { "macaddress": "1009F9*" }, - { "macaddress": "109693*" }, - { "macaddress": "10BF67*" }, - { "macaddress": "10CE02*" }, - { "macaddress": "140AC5*" }, - { "macaddress": "149138*" }, - { "macaddress": "1848BE*" }, - { "macaddress": "1C12B0*" }, - { "macaddress": "1C4D66*" }, - { "macaddress": "1C93C4*" }, - { "macaddress": "1CFE2B*" }, - { "macaddress": "244CE3*" }, - { "macaddress": "24CE33*" }, - { "macaddress": "2873F6*" }, - { "macaddress": "2C71FF*" }, - { "macaddress": "34AFB3*" }, - { "macaddress": "34D270*" }, - { "macaddress": "38F73D*" }, - { "macaddress": "3C5CC4*" }, - { "macaddress": "3CE441*" }, - { "macaddress": "440049*" }, - { "macaddress": "40A2DB*" }, - { "macaddress": "40A9CF*" }, - { "macaddress": "40B4CD*" }, - { "macaddress": "443D54*" }, - { "macaddress": "44650D*" }, - { "macaddress": "485F2D*" }, - { "macaddress": "48785E*" }, - { "macaddress": "48B423*" }, - { "macaddress": "4C1744*" }, - { "macaddress": "4CEFC0*" }, - { "macaddress": "5007C3*" }, - { "macaddress": "50D45C*" }, - { "macaddress": "50DCE7*" }, - { "macaddress": "50F5DA*" }, - { "macaddress": "5C415A*" }, - { "macaddress": "6837E9*" }, - { "macaddress": "6854FD*" }, - { "macaddress": "689A87*" }, - { "macaddress": "68B691*" }, - { "macaddress": "68DBF5*" }, - { "macaddress": "68F63B*" }, - { "macaddress": "6C0C9A*" }, - { "macaddress": "6C5697*" }, - { "macaddress": "7458F3*" }, - { "macaddress": "74C246*" }, - { "macaddress": "74D637*" }, - { "macaddress": "74E20C*" }, - { "macaddress": "74ECB2*" }, - { "macaddress": "786C84*" }, - { "macaddress": "78A03F*" }, - { "macaddress": "7C6166*" }, - { "macaddress": "7C6305*" }, - { "macaddress": "7CD566*" }, - { "macaddress": "8871E5*" }, - { "macaddress": "901195*" }, - { "macaddress": "90235B*" }, - { "macaddress": "90A822*" }, - { "macaddress": "90F82E*" }, - { "macaddress": "943A91*" }, - { "macaddress": "98226E*" }, - { "macaddress": "98CCF3*" }, - { "macaddress": "9CC8E9*" }, - { "macaddress": "A002DC*" }, - { "macaddress": "A0D2B1*" }, - { "macaddress": "A40801*" }, - { "macaddress": "A8E621*" }, - { "macaddress": "AC416A*" }, - { "macaddress": "AC63BE*" }, - { "macaddress": "ACCCFC*" }, - { "macaddress": "B0739C*" }, - { "macaddress": "B0CFCB*" }, - { "macaddress": "B0F7C4*" }, - { "macaddress": "B85F98*" }, - { "macaddress": "C091B9*" }, - { "macaddress": "C095CF*" }, - { "macaddress": "C49500*" }, - { "macaddress": "C86C3D*" }, - { "macaddress": "CC9EA2*" }, - { "macaddress": "CCF735*" }, - { "macaddress": "DC54D7*" }, - { "macaddress": "D8BE65*" }, - { "macaddress": "D8FBD6*" }, - { "macaddress": "DC91BF*" }, - { "macaddress": "DCA0D0*" }, - { "macaddress": "E0F728*" }, - { "macaddress": "EC2BEB*" }, - { "macaddress": "EC8AC4*" }, - { "macaddress": "ECA138*" }, - { "macaddress": "F02F9E*" }, - { "macaddress": "F0272D*" }, - { "macaddress": "F0F0A4*" }, - { "macaddress": "F4032A*" }, - { "macaddress": "F854B8*" }, - { "macaddress": "FC492D*" }, - { "macaddress": "FC65DE*" }, - { "macaddress": "FCA183*" }, - { "macaddress": "FCE9D8*" } - ], "documentation": "https://www.home-assistant.io/integrations/amazon_devices", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/amazon_devices/quality_scale.yaml b/homeassistant/components/amazon_devices/quality_scale.yaml index 23a7cd22a66..881a02bc6d3 100644 --- a/homeassistant/components/amazon_devices/quality_scale.yaml +++ b/homeassistant/components/amazon_devices/quality_scale.yaml @@ -45,7 +45,9 @@ rules: discovery-update-info: status: exempt comment: Network information not relevant - discovery: done + discovery: + status: exempt + comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration docs-data-update: todo docs-examples: todo docs-known-limitations: todo diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 5285ab7a1db..349c69358ba 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -26,438 +26,6 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "airzone", "macaddress": "E84F25*", }, - { - "domain": "amazon_devices", - "macaddress": "007147*", - }, - { - "domain": "amazon_devices", - "macaddress": "00FC8B*", - }, - { - "domain": "amazon_devices", - "macaddress": "0812A5*", - }, - { - "domain": "amazon_devices", - "macaddress": "086AE5*", - }, - { - "domain": "amazon_devices", - "macaddress": "08849D*", - }, - { - "domain": "amazon_devices", - "macaddress": "089115*", - }, - { - "domain": "amazon_devices", - "macaddress": "08A6BC*", - }, - { - "domain": "amazon_devices", - "macaddress": "08C224*", - }, - { - "domain": "amazon_devices", - "macaddress": "0CDC91*", - }, - { - "domain": "amazon_devices", - "macaddress": "0CEE99*", - }, - { - "domain": "amazon_devices", - "macaddress": "1009F9*", - }, - { - "domain": "amazon_devices", - "macaddress": "109693*", - }, - { - "domain": "amazon_devices", - "macaddress": "10BF67*", - }, - { - "domain": "amazon_devices", - "macaddress": "10CE02*", - }, - { - "domain": "amazon_devices", - "macaddress": "140AC5*", - }, - { - "domain": "amazon_devices", - "macaddress": "149138*", - }, - { - "domain": "amazon_devices", - "macaddress": "1848BE*", - }, - { - "domain": "amazon_devices", - "macaddress": "1C12B0*", - }, - { - "domain": "amazon_devices", - "macaddress": "1C4D66*", - }, - { - "domain": "amazon_devices", - "macaddress": "1C93C4*", - }, - { - "domain": "amazon_devices", - "macaddress": "1CFE2B*", - }, - { - "domain": "amazon_devices", - "macaddress": "244CE3*", - }, - { - "domain": "amazon_devices", - "macaddress": "24CE33*", - }, - { - "domain": "amazon_devices", - "macaddress": "2873F6*", - }, - { - "domain": "amazon_devices", - "macaddress": "2C71FF*", - }, - { - "domain": "amazon_devices", - "macaddress": "34AFB3*", - }, - { - "domain": "amazon_devices", - "macaddress": "34D270*", - }, - { - "domain": "amazon_devices", - "macaddress": "38F73D*", - }, - { - "domain": "amazon_devices", - "macaddress": "3C5CC4*", - }, - { - "domain": "amazon_devices", - "macaddress": "3CE441*", - }, - { - "domain": "amazon_devices", - "macaddress": "440049*", - }, - { - "domain": "amazon_devices", - "macaddress": "40A2DB*", - }, - { - "domain": "amazon_devices", - "macaddress": "40A9CF*", - }, - { - "domain": "amazon_devices", - "macaddress": "40B4CD*", - }, - { - "domain": "amazon_devices", - "macaddress": "443D54*", - }, - { - "domain": "amazon_devices", - "macaddress": "44650D*", - }, - { - "domain": "amazon_devices", - "macaddress": "485F2D*", - }, - { - "domain": "amazon_devices", - "macaddress": "48785E*", - }, - { - "domain": "amazon_devices", - "macaddress": "48B423*", - }, - { - "domain": "amazon_devices", - "macaddress": "4C1744*", - }, - { - "domain": "amazon_devices", - "macaddress": "4CEFC0*", - }, - { - "domain": "amazon_devices", - "macaddress": "5007C3*", - }, - { - "domain": "amazon_devices", - "macaddress": "50D45C*", - }, - { - "domain": "amazon_devices", - "macaddress": "50DCE7*", - }, - { - "domain": "amazon_devices", - "macaddress": "50F5DA*", - }, - { - "domain": "amazon_devices", - "macaddress": "5C415A*", - }, - { - "domain": "amazon_devices", - "macaddress": "6837E9*", - }, - { - "domain": "amazon_devices", - "macaddress": "6854FD*", - }, - { - "domain": "amazon_devices", - "macaddress": "689A87*", - }, - { - "domain": "amazon_devices", - "macaddress": "68B691*", - }, - { - "domain": "amazon_devices", - "macaddress": "68DBF5*", - }, - { - "domain": "amazon_devices", - "macaddress": "68F63B*", - }, - { - "domain": "amazon_devices", - "macaddress": "6C0C9A*", - }, - { - "domain": "amazon_devices", - "macaddress": "6C5697*", - }, - { - "domain": "amazon_devices", - "macaddress": "7458F3*", - }, - { - "domain": "amazon_devices", - "macaddress": "74C246*", - }, - { - "domain": "amazon_devices", - "macaddress": "74D637*", - }, - { - "domain": "amazon_devices", - "macaddress": "74E20C*", - }, - { - "domain": "amazon_devices", - "macaddress": "74ECB2*", - }, - { - "domain": "amazon_devices", - "macaddress": "786C84*", - }, - { - "domain": "amazon_devices", - "macaddress": "78A03F*", - }, - { - "domain": "amazon_devices", - "macaddress": "7C6166*", - }, - { - "domain": "amazon_devices", - "macaddress": "7C6305*", - }, - { - "domain": "amazon_devices", - "macaddress": "7CD566*", - }, - { - "domain": "amazon_devices", - "macaddress": "8871E5*", - }, - { - "domain": "amazon_devices", - "macaddress": "901195*", - }, - { - "domain": "amazon_devices", - "macaddress": "90235B*", - }, - { - "domain": "amazon_devices", - "macaddress": "90A822*", - }, - { - "domain": "amazon_devices", - "macaddress": "90F82E*", - }, - { - "domain": "amazon_devices", - "macaddress": "943A91*", - }, - { - "domain": "amazon_devices", - "macaddress": "98226E*", - }, - { - "domain": "amazon_devices", - "macaddress": "98CCF3*", - }, - { - "domain": "amazon_devices", - "macaddress": "9CC8E9*", - }, - { - "domain": "amazon_devices", - "macaddress": "A002DC*", - }, - { - "domain": "amazon_devices", - "macaddress": "A0D2B1*", - }, - { - "domain": "amazon_devices", - "macaddress": "A40801*", - }, - { - "domain": "amazon_devices", - "macaddress": "A8E621*", - }, - { - "domain": "amazon_devices", - "macaddress": "AC416A*", - }, - { - "domain": "amazon_devices", - "macaddress": "AC63BE*", - }, - { - "domain": "amazon_devices", - "macaddress": "ACCCFC*", - }, - { - "domain": "amazon_devices", - "macaddress": "B0739C*", - }, - { - "domain": "amazon_devices", - "macaddress": "B0CFCB*", - }, - { - "domain": "amazon_devices", - "macaddress": "B0F7C4*", - }, - { - "domain": "amazon_devices", - "macaddress": "B85F98*", - }, - { - "domain": "amazon_devices", - "macaddress": "C091B9*", - }, - { - "domain": "amazon_devices", - "macaddress": "C095CF*", - }, - { - "domain": "amazon_devices", - "macaddress": "C49500*", - }, - { - "domain": "amazon_devices", - "macaddress": "C86C3D*", - }, - { - "domain": "amazon_devices", - "macaddress": "CC9EA2*", - }, - { - "domain": "amazon_devices", - "macaddress": "CCF735*", - }, - { - "domain": "amazon_devices", - "macaddress": "DC54D7*", - }, - { - "domain": "amazon_devices", - "macaddress": "D8BE65*", - }, - { - "domain": "amazon_devices", - "macaddress": "D8FBD6*", - }, - { - "domain": "amazon_devices", - "macaddress": "DC91BF*", - }, - { - "domain": "amazon_devices", - "macaddress": "DCA0D0*", - }, - { - "domain": "amazon_devices", - "macaddress": "E0F728*", - }, - { - "domain": "amazon_devices", - "macaddress": "EC2BEB*", - }, - { - "domain": "amazon_devices", - "macaddress": "EC8AC4*", - }, - { - "domain": "amazon_devices", - "macaddress": "ECA138*", - }, - { - "domain": "amazon_devices", - "macaddress": "F02F9E*", - }, - { - "domain": "amazon_devices", - "macaddress": "F0272D*", - }, - { - "domain": "amazon_devices", - "macaddress": "F0F0A4*", - }, - { - "domain": "amazon_devices", - "macaddress": "F4032A*", - }, - { - "domain": "amazon_devices", - "macaddress": "F854B8*", - }, - { - "domain": "amazon_devices", - "macaddress": "FC492D*", - }, - { - "domain": "amazon_devices", - "macaddress": "FC65DE*", - }, - { - "domain": "amazon_devices", - "macaddress": "FCA183*", - }, - { - "domain": "amazon_devices", - "macaddress": "FCE9D8*", - }, { "domain": "august", "hostname": "connect", diff --git a/tests/components/amazon_devices/test_config_flow.py b/tests/components/amazon_devices/test_config_flow.py index 41b65c33bd5..ce1ac44d102 100644 --- a/tests/components/amazon_devices/test_config_flow.py +++ b/tests/components/amazon_devices/test_config_flow.py @@ -6,22 +6,15 @@ from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect import pytest from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import TEST_CODE, TEST_COUNTRY, TEST_PASSWORD, TEST_USERNAME from tests.common import MockConfigEntry -DHCP_DISCOVERY = DhcpServiceInfo( - ip="1.1.1.1", - hostname="", - macaddress="c095cfebf19f", -) - async def test_full_flow( hass: HomeAssistant, @@ -140,58 +133,3 @@ async def test_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_dhcp_flow( - hass: HomeAssistant, - mock_amazon_devices_client: AsyncMock, - mock_setup_entry: AsyncMock, -) -> None: - """Test full DHCP flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_DHCP}, - data=DHCP_DISCOVERY, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_COUNTRY: TEST_COUNTRY, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_CODE: TEST_CODE, - }, - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == TEST_USERNAME - assert result["data"] == { - CONF_COUNTRY: TEST_COUNTRY, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - CONF_LOGIN_DATA: { - "customer_info": {"user_id": TEST_USERNAME}, - }, - } - assert result["result"].unique_id == TEST_USERNAME - - -async def test_dhcp_already_configured( - hass: HomeAssistant, - mock_amazon_devices_client: AsyncMock, - mock_setup_entry: AsyncMock, - mock_config_entry: MockConfigEntry, -) -> None: - """Test duplicate flow.""" - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_DHCP}, - data=DHCP_DISCOVERY, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" From 8fd52248b78e9be47ce910455829d29cd4a3a321 Mon Sep 17 00:00:00 2001 From: Felix Schneider Date: Wed, 11 Jun 2025 10:26:01 +0200 Subject: [PATCH 0166/1664] Bump `apsystems` to `2.7.0` (#146485) --- homeassistant/components/apsystems/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json index eb1acb40d17..e86b4a8431e 100644 --- a/homeassistant/components/apsystems/manifest.json +++ b/homeassistant/components/apsystems/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["APsystemsEZ1"], - "requirements": ["apsystems-ez1==2.6.0"] + "requirements": ["apsystems-ez1==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 98b43cb4635..1409a24ab25 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -498,7 +498,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.6.0 +apsystems-ez1==2.7.0 # homeassistant.components.aqualogic aqualogic==2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 23ace366641..7a283276028 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -471,7 +471,7 @@ apprise==1.9.1 aprslib==0.7.2 # homeassistant.components.apsystems -apsystems-ez1==2.6.0 +apsystems-ez1==2.7.0 # homeassistant.components.aranet aranet4==2.5.1 From 7afc469306f6298a3d8ffc0e9847db69dc6be0b8 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 10 Jun 2025 18:16:18 -0500 Subject: [PATCH 0167/1664] Bump intents to 2025.6.10 (#146491) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 6078d73e99b..5221e89deee 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.5.28"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index af9b0472bb1..b5d1af412f9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.0 -home-assistant-intents==2025.5.28 +home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/pyproject.toml b/pyproject.toml index 58ad46b63e3..b096fcb8040 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ dependencies = [ # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its # dependencies to stage 0. - "home-assistant-intents==2025.5.28", + "home-assistant-intents==2025.6.10", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", diff --git a/requirements.txt b/requirements.txt index bff95490470..e353adac9d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.5.28 +home-assistant-intents==2025.6.10 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1409a24ab25..967a6defd14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1167,7 +1167,7 @@ holidays==0.74 home-assistant-frontend==20250531.0 # homeassistant.components.conversation -home-assistant-intents==2025.5.28 +home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud homematicip==2.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a283276028..6632e45ed6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1013,7 +1013,7 @@ holidays==0.74 home-assistant-frontend==20250531.0 # homeassistant.components.conversation -home-assistant-intents==2025.5.28 +home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud homematicip==2.0.4 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 830bdc4445e..82150d031a4 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==2.2.3 \ - home-assistant-intents==2025.5.28 \ + home-assistant-intents==2025.6.10 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 From 671a33b31c88817bea2c21f05d24ddd702a403e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 11:19:44 +0200 Subject: [PATCH 0168/1664] Do not remove derivative config entry when input sensor is removed (#146506) * Do not remove derivative config entry when input sensor is removed * Add comments * Update homeassistant/helpers/helper_integration.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .../components/derivative/__init__.py | 5 ++++ .../components/switch_as_x/__init__.py | 6 ++++ homeassistant/helpers/device.py | 6 ++-- homeassistant/helpers/helper_integration.py | 14 +++++++-- tests/components/derivative/test_init.py | 8 ++--- tests/helpers/test_helper_integration.py | 30 ++++++++++++------- 6 files changed, 51 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 5eb499b0efd..6d539817875 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -29,6 +29,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE: source_entity_id}, ) + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + entity_registry = er.async_get(hass) entry.async_on_unload( async_handle_source_entity_changes( @@ -42,6 +46,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry.options[CONF_SOURCE] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE], + source_entity_removed=source_entity_removed, ) ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 6e9e3a93b45..7d12ae4aec2 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -63,6 +63,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # switch_as_x does not allow replacing the wrapped entity. + await hass.config_entries.async_remove(entry.entry_id) + entry.async_on_unload( async_handle_source_entity_changes( hass, @@ -73,6 +78,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_add_to_device(hass, entry, entity_id), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, ) ) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index a7d888900b1..f1404bb068b 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -62,7 +62,7 @@ def async_device_info_to_link_from_device_id( def async_remove_stale_devices_links_keep_entity_device( hass: HomeAssistant, entry_id: str, - source_entity_id_or_uuid: str, + source_entity_id_or_uuid: str | None, ) -> None: """Remove entry_id from all devices except that of source_entity_id_or_uuid. @@ -73,7 +73,9 @@ def async_remove_stale_devices_links_keep_entity_device( async_remove_stale_devices_links_keep_current_device( hass=hass, entry_id=entry_id, - current_device_id=async_entity_id_to_device_id(hass, source_entity_id_or_uuid), + current_device_id=async_entity_id_to_device_id(hass, source_entity_id_or_uuid) + if source_entity_id_or_uuid + else None, ) diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index 4f39ef4c843..37aa246178e 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Coroutine +from typing import Any from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, valid_entity_id @@ -18,6 +19,7 @@ def async_handle_source_entity_changes( set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, + source_entity_removed: Callable[[], Coroutine[Any, Any, None]], ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. @@ -34,6 +36,14 @@ def async_handle_source_entity_changes( - Source entity removed from the device: The helper entity is updated to link to no device, and the helper config entry removed from the old device. Then the helper config entry is reloaded. + + :param get_helper_entity: A function which returns the helper entity's entity ID, + or None if the helper entity does not exist. + :param set_source_entity_id_or_uuid: A function which updates the source entity + ID or UUID, e.g., in the helper config entry options. + :param source_entity_removed: A function which is called when the source entity + is removed. This can be used to clean up any resources related to the source + entity or ask the user to select a new source entity. """ async def async_registry_updated( @@ -44,7 +54,7 @@ def async_handle_source_entity_changes( data = event.data if data["action"] == "remove": - await hass.config_entries.async_remove(helper_config_entry_id) + await source_entity_removed() if data["action"] != "update": return diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index f75d5940da7..d237703eb2e 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -268,17 +268,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( ) await hass.async_block_till_done() await hass.async_block_till_done() - mock_unload_entry.assert_called_once() + mock_unload_entry.assert_not_called() # Check that the derivative config entry is removed from the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries - # Check that the derivative config entry is removed - assert derivative_config_entry.entry_id not in hass.config_entries.async_entry_ids() + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events - assert events == ["remove"] + assert events == ["update"] async def test_async_handle_source_entity_changes_source_entity_removed_from_device( diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 25d490c27bb..12433894dc7 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -132,11 +132,17 @@ def async_unload_entry() -> AsyncMock: @pytest.fixture -def set_source_entity_id_or_uuid() -> AsyncMock: - """Fixture to mock async_unload_entry.""" +def set_source_entity_id_or_uuid() -> Mock: + """Fixture to mock set_source_entity_id_or_uuid.""" return Mock() +@pytest.fixture +def source_entity_removed() -> AsyncMock: + """Fixture to mock source_entity_removed.""" + return AsyncMock() + + @pytest.fixture def mock_helper_integration( hass: HomeAssistant, @@ -146,6 +152,7 @@ def mock_helper_integration( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, + source_entity_removed: AsyncMock, ) -> None: """Mock the helper integration.""" @@ -164,6 +171,7 @@ def mock_helper_integration( set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=source_entity_entry.device_id, source_entity_id_or_uuid=helper_config_entry.options["source"], + source_entity_removed=source_entity_removed, ) return True @@ -206,6 +214,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, + source_entity_removed: AsyncMock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" # Add the helper config entry to the source device @@ -238,20 +247,21 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() await hass.async_block_till_done() - # Check that the helper config entry is unloaded and removed - async_unload_entry.assert_called_once() - async_remove_entry.assert_called_once() + # Check that the source_entity_removed callback was called + source_entity_removed.assert_called_once() + async_unload_entry.assert_not_called() + async_remove_entry.assert_not_called() set_source_entity_id_or_uuid.assert_not_called() - # Check that the helper config entry is removed from the device + # Check that the helper config entry is not removed from the device source_device = device_registry.async_get(source_device.id) - assert helper_config_entry.entry_id not in source_device.config_entries + assert helper_config_entry.entry_id in source_device.config_entries - # Check that the helper config entry is removed - assert helper_config_entry.entry_id not in hass.config_entries.async_entry_ids() + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events - assert events == ["remove"] + assert events == [] @pytest.mark.parametrize("use_entity_registry_id", [True, False]) From 6d1f621e550311eb915ba31dbd9f1037a7b9519f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Jun 2025 09:49:38 +0100 Subject: [PATCH 0169/1664] Bump deebot-client to 13.3.0 (#146507) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 12fd8e01215..8a7388da735 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.2.1"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 967a6defd14..95a5f919add 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,7 +765,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.2.1 +deebot-client==13.3.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6632e45ed6e..1ed51e54164 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -665,7 +665,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.2.1 +deebot-client==13.3.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From c8b70cc0fb72ce52e60c01b60f2b252005f642b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Wed, 11 Jun 2025 11:55:28 +0200 Subject: [PATCH 0170/1664] Graceful handling of missing datapoint in myuplink (#146517) --- homeassistant/components/myuplink/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py index 3b14cdd4630..0a3f7d2ebb6 100644 --- a/homeassistant/components/myuplink/sensor.py +++ b/homeassistant/components/myuplink/sensor.py @@ -293,8 +293,8 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity): @property def native_value(self) -> StateType: """Sensor state value.""" - device_point = self.coordinator.data.points[self.device_id][self.point_id] - if device_point.value == MARKER_FOR_UNKNOWN_VALUE: + device_point = self.coordinator.data.points[self.device_id].get(self.point_id) + if device_point is None or device_point.value == MARKER_FOR_UNKNOWN_VALUE: return None return device_point.value # type: ignore[no-any-return] From b6c8718ae43c186615095124783528b98ff884de Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Jun 2025 10:17:18 +0000 Subject: [PATCH 0171/1664] Bump version to 2025.6.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b07549d387f..6cbe42d6dbe 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index b096fcb8040..f0aa8169054 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b6" +version = "2025.6.0b7" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 864e44068575403b77211862664c1a9afc25db0e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Jun 2025 18:39:46 +0200 Subject: [PATCH 0172/1664] Make issue creation check architecture instead of uname (#146537) --- homeassistant/components/hassio/__init__.py | 35 ++++-- .../components/homeassistant/__init__.py | 30 +++-- .../components/homeassistant/strings.json | 4 +- tests/components/hassio/conftest.py | 13 ++ tests/components/hassio/test_init.py | 113 ++++++++++++++---- tests/components/homeassistant/test_init.py | 106 ++++++++-------- 6 files changed, 202 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 041877e3944..6772034e53f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,8 +9,10 @@ from functools import partial import logging import os import re +import struct from typing import Any, NamedTuple +import aiofiles from aiohasupervisor import SupervisorError import voluptuous as vol @@ -56,7 +58,6 @@ from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) -from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -233,6 +234,17 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( ) +def _is_32_bit() -> bool: + size = struct.calcsize("P") + return size * 8 == 32 + + +async def _get_arch() -> str: + async with aiofiles.open("/etc/apk/arch") as arch_file: + raw_arch = await arch_file.read() + return {"x86": "i386"}.get(raw_arch, raw_arch) + + class APIEndpointSettings(NamedTuple): """Settings for API endpoint.""" @@ -554,7 +566,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator - system_info = await async_get_system_info(hass) + arch = await _get_arch() def deprecated_setup_issue() -> None: os_info = get_os_info(hass) @@ -562,20 +574,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if os_info is None or info is None: return is_haos = info.get("hassos") is not None - arch = system_info["arch"] board = os_info.get("board") - supported_board = board in {"rpi3", "rpi4", "tinker", "odroid-xu4", "rpi2"} - if is_haos and arch == "armv7" and supported_board: + unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} + unsupported_os_on_board = board in {"rpi3", "rpi4"} + if is_haos and (unsupported_board or unsupported_os_on_board): issue_id = "deprecated_os_" - if board in {"rpi3", "rpi4"}: + if unsupported_os_on_board: issue_id += "aarch64" - elif board in {"tinker", "odroid-xu4", "rpi2"}: + elif unsupported_board: issue_id += "armv7" ir.async_create_issue( hass, "homeassistant", issue_id, - breaks_in_ha_version="2025.12.0", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, @@ -584,9 +595,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "installation_guide": "https://www.home-assistant.io/installation/", }, ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or (arch == "armv7" and not supported_board): - deprecated_architecture = True + bit32 = _is_32_bit() + deprecated_architecture = bit32 and not ( + unsupported_board or unsupported_os_on_board + ) if not is_haos or deprecated_architecture: issue_id = "deprecated" if not is_haos: @@ -597,7 +609,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, "homeassistant", issue_id, - breaks_in_ha_version="2025.12.0", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 1433358b568..4360fa9c16e 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -4,8 +4,10 @@ import asyncio from collections.abc import Callable, Coroutine import itertools as it import logging +import struct from typing import Any +import aiofiles import voluptuous as vol from homeassistant import config as conf_util, core_config @@ -94,6 +96,17 @@ DEPRECATION_URL = ( ) +def _is_32_bit() -> bool: + size = struct.calcsize("P") + return size * 8 == 32 + + +async def _get_arch() -> str: + async with aiofiles.open("/etc/apk/arch") as arch_file: + raw_arch = (await arch_file.read()).strip() + return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" @@ -403,23 +416,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: installation_type = info["installation_type"][15:] if installation_type in {"Core", "Container"}: deprecated_method = installation_type == "Core" + bit32 = _is_32_bit() arch = info["arch"] - if arch == "armv7" and installation_type == "Container": + if bit32 and installation_type == "Container": + arch = await _get_arch() ir.async_create_issue( hass, DOMAIN, - "deprecated_container_armv7", - breaks_in_ha_version="2025.12.0", + "deprecated_container", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_container_armv7", + translation_key="deprecated_container", + translation_placeholders={"arch": arch}, ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or ( - arch == "armv7" and installation_type != "Container" - ): - deprecated_architecture = True + deprecated_architecture = bit32 and installation_type != "Container" if deprecated_method or deprecated_architecture: issue_id = "deprecated" if deprecated_method: @@ -430,7 +441,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass, DOMAIN, issue_id, - breaks_in_ha_version="2025.12.0", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 93b4105c702..940af999c4d 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -107,9 +107,9 @@ "title": "Deprecation notice: 32-bit architecture", "description": "This system uses 32-bit hardware (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. As your hardware is no longer capable of running newer versions of Home Assistant, you will need to migrate to new hardware." }, - "deprecated_container_armv7": { + "deprecated_container": { "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", - "description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware." + "description": "This system is running on a 32-bit operating system (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware." }, "deprecated_os_aarch64": { "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a71ee370b32..56f7ffaa5b9 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -260,3 +260,16 @@ def all_setup_requests( }, }, ) + + +@pytest.fixture +def arch() -> str: + """Arch found in apk file.""" + return "amd64" + + +@pytest.fixture(autouse=True) +def mock_arch_file(arch: str) -> Generator[None]: + """Mock arch file.""" + with patch("homeassistant.components.hassio._get_arch", return_value=arch): + yield diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f74ed852a49..f424beedc85 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1156,7 +1156,11 @@ def test_deprecated_constants( ("rpi2", "deprecated_os_armv7"), ], ) -async def test_deprecated_installation_issue_aarch64( +@pytest.mark.parametrize( + "arch", + ["armv7"], +) +async def test_deprecated_installation_issue_os_armv7( hass: HomeAssistant, issue_registry: ir.IssueRegistry, freezer: FrozenDateTimeFactory, @@ -1167,18 +1171,15 @@ async def test_deprecated_installation_issue_aarch64( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": "armv7", }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "armv7", - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", return_value={"board": board} @@ -1228,7 +1229,7 @@ async def test_deprecated_installation_issue_aarch64( "armv7", ], ) -async def test_deprecated_installation_issue_32bit_method( +async def test_deprecated_installation_issue_32bit_os( hass: HomeAssistant, issue_registry: ir.IssueRegistry, freezer: FrozenDateTimeFactory, @@ -1238,18 +1239,15 @@ async def test_deprecated_installation_issue_32bit_method( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": arch, }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": arch, - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", @@ -1308,18 +1306,15 @@ async def test_deprecated_installation_issue_32bit_supervised( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant Supervised", "arch": arch, }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", @@ -1365,6 +1360,75 @@ async def test_deprecated_installation_issue_32bit_supervised( } +@pytest.mark.parametrize( + "arch", + [ + "amd64", + "aarch64", + ], +) +async def test_deprecated_installation_issue_64bit_supervised( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Supervised", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=False, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "generic-x86-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": None} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", "deprecated_method") + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Supervised", + "arch": arch, + } + + @pytest.mark.parametrize( ("board", "issue_id"), [ @@ -1382,18 +1446,15 @@ async def test_deprecated_installation_issue_supported_board( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": "aarch64", }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "aarch64", - }, + "homeassistant.components.hassio._is_32_bit", + return_value=False, ), patch( "homeassistant.components.hassio.get_os_info", return_value={"board": board} diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index a5e454221d3..0779339cf65 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -648,18 +648,24 @@ async def test_reload_all( "armv7", ], ) -async def test_deprecated_installation_issue_32bit_method( +async def test_deprecated_installation_issue_32bit_core( hass: HomeAssistant, issue_registry: ir.IssueRegistry, arch: str, ) -> None: """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Core", - "arch": arch, - }, + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=True, + ), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -677,46 +683,28 @@ async def test_deprecated_installation_issue_32bit_method( @pytest.mark.parametrize( "arch", [ - "i386", - "armhf", + "aarch64", + "generic-x86-64", ], ) -async def test_deprecated_installation_issue_32bit( +async def test_deprecated_installation_issue_64bit_core( hass: HomeAssistant, issue_registry: ir.IssueRegistry, arch: str, ) -> None: """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Container", - "arch": arch, - }, - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_architecture") - assert issue.domain == DOMAIN - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders == { - "installation_type": "Container", - "arch": arch, - } - - -async def test_deprecated_installation_issue_method( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Core", - "arch": "generic-x86-64", - }, + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=False, + ), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() @@ -727,26 +715,46 @@ async def test_deprecated_installation_issue_method( assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { "installation_type": "Core", - "arch": "generic-x86-64", + "arch": arch, } -async def test_deprecated_installation_issue_armv7_container( +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armv7", + "armhf", + ], +) +async def test_deprecated_installation_issue_32bit( hass: HomeAssistant, issue_registry: ir.IssueRegistry, + arch: str, ) -> None: """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Container", - "arch": "armv7", - }, + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Container", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant._get_arch", + return_value=arch, + ), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue(DOMAIN, "deprecated_container_armv7") + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_container") assert issue.domain == DOMAIN assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == {"arch": arch} From 66be2f924020842225fb44ad0d66a16a1b3be09f Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 11 Jun 2025 09:08:10 -0400 Subject: [PATCH 0173/1664] Fix `delay_on` and `delay_off` restarting when a new trigger occurs during the delay (#145050) --- .../components/template/binary_sensor.py | 24 ++++++-- .../components/template/test_binary_sensor.py | 56 +++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 7ef64e8077b..f0ec64eae2a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -352,6 +352,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self._to_render_simple.append(key) self._parse_result.add(key) + self._last_delay_from: bool | None = None + self._last_delay_to: bool | None = None self._delay_cancel: CALLBACK_TYPE | None = None self._auto_off_cancel: CALLBACK_TYPE | None = None self._auto_off_time: datetime | None = None @@ -388,6 +390,20 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity """Handle update of the data.""" self._process_data() + raw = self._rendered.get(CONF_STATE) + state = template.result_as_boolean(raw) + + key = CONF_DELAY_ON if state else CONF_DELAY_OFF + delay = self._rendered.get(key) or self._config.get(key) + + if ( + self._delay_cancel + and delay + and self._attr_is_on == self._last_delay_from + and state == self._last_delay_to + ): + return + if self._delay_cancel: self._delay_cancel() self._delay_cancel = None @@ -401,12 +417,6 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self.async_write_ha_state() return - raw = self._rendered.get(CONF_STATE) - state = template.result_as_boolean(raw) - - key = CONF_DELAY_ON if state else CONF_DELAY_OFF - delay = self._rendered.get(key) or self._config.get(key) - # state without delay. None means rendering failed. if self._attr_is_on == state or state is None or delay is None: self._set_state(state) @@ -422,6 +432,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity return # state with delay. Cancelled if new trigger received + self._last_delay_from = self._attr_is_on + self._last_delay_to = state self._delay_cancel = async_call_later( self.hass, delay.total_seconds(), partial(self._set_state, state) ) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index a7ee953bb09..122801e6c59 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1225,6 +1225,62 @@ async def test_template_with_trigger_templated_delay_on(hass: HomeAssistant) -> assert state.state == STATE_OFF +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + ("config", "delay_state"), + [ + ( + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.data.beer == 2 }}", + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 10 }) }}', + }, + }, + }, + STATE_ON, + ), + ( + { + "template": { + "trigger": {"platform": "event", "event_type": "test_event"}, + "binary_sensor": { + "name": "test", + "state": "{{ trigger.event.data.beer != 2 }}", + "device_class": "motion", + "delay_off": '{{ ({ "seconds": 10 }) }}', + }, + }, + }, + STATE_OFF, + ), + ], +) +@pytest.mark.usefixtures("start_ha") +async def test_trigger_template_delay_with_multiple_triggers( + hass: HomeAssistant, delay_state: str +) -> None: + """Test trigger based binary sensor with multiple triggers occurring during the delay.""" + future = dt_util.utcnow() + for _ in range(10): + # State should still be unknown + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) + await hass.async_block_till_done() + + future += timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.test") + assert state.state == delay_state + + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", From 6f4e16eed1a0c90657f107a793d40d42e6bb87b9 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 11 Jun 2025 18:17:11 +0200 Subject: [PATCH 0174/1664] Fix stale options in here_travel_time (#145911) --- .../components/here_travel_time/__init__.py | 33 +--- .../here_travel_time/coordinator.py | 144 +++++++++++------- .../components/here_travel_time/model.py | 18 +-- 3 files changed, 94 insertions(+), 101 deletions(-) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 525da15bd74..5393dfa5050 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -5,26 +5,13 @@ from __future__ import annotations from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from homeassistant.util import dt as dt_util -from .const import ( - CONF_ARRIVAL_TIME, - CONF_DEPARTURE_TIME, - CONF_DESTINATION_ENTITY_ID, - CONF_DESTINATION_LATITUDE, - CONF_DESTINATION_LONGITUDE, - CONF_ORIGIN_ENTITY_ID, - CONF_ORIGIN_LATITUDE, - CONF_ORIGIN_LONGITUDE, - CONF_ROUTE_MODE, - TRAVEL_MODE_PUBLIC, -) +from .const import TRAVEL_MODE_PUBLIC from .coordinator import ( HereConfigEntry, HERERoutingDataUpdateCoordinator, HERETransitDataUpdateCoordinator, ) -from .model import HERETravelTimeConfig PLATFORMS = [Platform.SENSOR] @@ -33,29 +20,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: HereConfigEntry) """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] - arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) - departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) - - here_travel_time_config = HERETravelTimeConfig( - destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE), - destination_longitude=config_entry.data.get(CONF_DESTINATION_LONGITUDE), - destination_entity_id=config_entry.data.get(CONF_DESTINATION_ENTITY_ID), - origin_latitude=config_entry.data.get(CONF_ORIGIN_LATITUDE), - origin_longitude=config_entry.data.get(CONF_ORIGIN_LONGITUDE), - origin_entity_id=config_entry.data.get(CONF_ORIGIN_ENTITY_ID), - travel_mode=config_entry.data[CONF_MODE], - route_mode=config_entry.options[CONF_ROUTE_MODE], - arrival=arrival, - departure=departure, - ) - cls: type[HERETransitDataUpdateCoordinator | HERERoutingDataUpdateCoordinator] if config_entry.data[CONF_MODE] in {TRAVEL_MODE_PUBLIC, "publicTransportTimeTable"}: cls = HERETransitDataUpdateCoordinator else: cls = HERERoutingDataUpdateCoordinator - data_coordinator = cls(hass, config_entry, api_key, here_travel_time_config) + data_coordinator = cls(hass, config_entry, api_key) config_entry.runtime_data = data_coordinator async def _async_update_at_start(_: HomeAssistant) -> None: diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index aa36404c584..447a45f5d2b 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -26,7 +26,7 @@ from here_transit import ( import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength +from homeassistant.const import CONF_MODE, UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.location import find_coordinates @@ -34,8 +34,21 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST -from .model import HERETravelTimeConfig, HERETravelTimeData +from .const import ( + CONF_ARRIVAL_TIME, + CONF_DEPARTURE_TIME, + CONF_DESTINATION_ENTITY_ID, + CONF_DESTINATION_LATITUDE, + CONF_DESTINATION_LONGITUDE, + CONF_ORIGIN_ENTITY_ID, + CONF_ORIGIN_LATITUDE, + CONF_ORIGIN_LONGITUDE, + CONF_ROUTE_MODE, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + ROUTE_MODE_FASTEST, +) +from .model import HERETravelTimeAPIParams, HERETravelTimeData BACKOFF_MULTIPLIER = 1.1 @@ -47,7 +60,7 @@ type HereConfigEntry = ConfigEntry[ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData]): - """here_routing DataUpdateCoordinator.""" + """HERETravelTime DataUpdateCoordinator for the routing API.""" config_entry: HereConfigEntry @@ -56,7 +69,6 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] hass: HomeAssistant, config_entry: HereConfigEntry, api_key: str, - config: HERETravelTimeConfig, ) -> None: """Initialize.""" super().__init__( @@ -67,41 +79,34 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self._api = HERERoutingApi(api_key) - self.config = config async def _async_update_data(self) -> HERETravelTimeData: """Get the latest data from the HERE Routing API.""" - origin, destination, arrival, departure = prepare_parameters( - self.hass, self.config - ) - - route_mode = ( - RoutingMode.FAST - if self.config.route_mode == ROUTE_MODE_FASTEST - else RoutingMode.SHORT - ) + params = prepare_parameters(self.hass, self.config_entry) _LOGGER.debug( ( "Requesting route for origin: %s, destination: %s, route_mode: %s," " mode: %s, arrival: %s, departure: %s" ), - origin, - destination, - route_mode, - TransportMode(self.config.travel_mode), - arrival, - departure, + params.origin, + params.destination, + params.route_mode, + TransportMode(params.travel_mode), + params.arrival, + params.departure, ) try: response = await self._api.route( - transport_mode=TransportMode(self.config.travel_mode), - origin=here_routing.Place(origin[0], origin[1]), - destination=here_routing.Place(destination[0], destination[1]), - routing_mode=route_mode, - arrival_time=arrival, - departure_time=departure, + transport_mode=TransportMode(params.travel_mode), + origin=here_routing.Place(params.origin[0], params.origin[1]), + destination=here_routing.Place( + params.destination[0], params.destination[1] + ), + routing_mode=params.route_mode, + arrival_time=params.arrival, + departure_time=params.departure, return_values=[Return.POLYINE, Return.SUMMARY], spans=[Spans.NAMES], ) @@ -175,7 +180,7 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] class HERETransitDataUpdateCoordinator( DataUpdateCoordinator[HERETravelTimeData | None] ): - """HERETravelTime DataUpdateCoordinator.""" + """HERETravelTime DataUpdateCoordinator for the transit API.""" config_entry: HereConfigEntry @@ -184,7 +189,6 @@ class HERETransitDataUpdateCoordinator( hass: HomeAssistant, config_entry: HereConfigEntry, api_key: str, - config: HERETravelTimeConfig, ) -> None: """Initialize.""" super().__init__( @@ -195,32 +199,31 @@ class HERETransitDataUpdateCoordinator( update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) self._api = HERETransitApi(api_key) - self.config = config async def _async_update_data(self) -> HERETravelTimeData | None: """Get the latest data from the HERE Routing API.""" - origin, destination, arrival, departure = prepare_parameters( - self.hass, self.config - ) + params = prepare_parameters(self.hass, self.config_entry) _LOGGER.debug( ( "Requesting transit route for origin: %s, destination: %s, arrival: %s," " departure: %s" ), - origin, - destination, - arrival, - departure, + params.origin, + params.destination, + params.arrival, + params.departure, ) try: response = await self._api.route( - origin=here_transit.Place(latitude=origin[0], longitude=origin[1]), - destination=here_transit.Place( - latitude=destination[0], longitude=destination[1] + origin=here_transit.Place( + latitude=params.origin[0], longitude=params.origin[1] ), - arrival_time=arrival, - departure_time=departure, + destination=here_transit.Place( + latitude=params.destination[0], longitude=params.destination[1] + ), + arrival_time=params.arrival, + departure_time=params.departure, return_values=[ here_transit.Return.POLYLINE, here_transit.Return.TRAVEL_SUMMARY, @@ -285,8 +288,8 @@ class HERETransitDataUpdateCoordinator( def prepare_parameters( hass: HomeAssistant, - config: HERETravelTimeConfig, -) -> tuple[list[str], list[str], str | None, str | None]: + config_entry: HereConfigEntry, +) -> HERETravelTimeAPIParams: """Prepare parameters for the HERE api.""" def _from_entity_id(entity_id: str) -> list[str]: @@ -305,32 +308,55 @@ def prepare_parameters( return formatted_coordinates # Destination - if config.destination_entity_id is not None: - destination = _from_entity_id(config.destination_entity_id) + if ( + destination_entity_id := config_entry.data.get(CONF_DESTINATION_ENTITY_ID) + ) is not None: + destination = _from_entity_id(str(destination_entity_id)) else: destination = [ - str(config.destination_latitude), - str(config.destination_longitude), + str(config_entry.data[CONF_DESTINATION_LATITUDE]), + str(config_entry.data[CONF_DESTINATION_LONGITUDE]), ] # Origin - if config.origin_entity_id is not None: - origin = _from_entity_id(config.origin_entity_id) + if (origin_entity_id := config_entry.data.get(CONF_ORIGIN_ENTITY_ID)) is not None: + origin = _from_entity_id(str(origin_entity_id)) else: origin = [ - str(config.origin_latitude), - str(config.origin_longitude), + str(config_entry.data[CONF_ORIGIN_LATITUDE]), + str(config_entry.data[CONF_ORIGIN_LONGITUDE]), ] # Arrival/Departure - arrival: str | None = None - departure: str | None = None - if config.arrival is not None: - arrival = next_datetime(config.arrival).isoformat() - if config.departure is not None: - departure = next_datetime(config.departure).isoformat() + arrival: datetime | None = None + if ( + conf_arrival := dt_util.parse_time( + config_entry.options.get(CONF_ARRIVAL_TIME, "") + ) + ) is not None: + arrival = next_datetime(conf_arrival) + departure: datetime | None = None + if ( + conf_departure := dt_util.parse_time( + config_entry.options.get(CONF_DEPARTURE_TIME, "") + ) + ) is not None: + departure = next_datetime(conf_departure) - return (origin, destination, arrival, departure) + route_mode = ( + RoutingMode.FAST + if config_entry.options[CONF_ROUTE_MODE] == ROUTE_MODE_FASTEST + else RoutingMode.SHORT + ) + + return HERETravelTimeAPIParams( + destination=destination, + origin=origin, + travel_mode=config_entry.data[CONF_MODE], + route_mode=route_mode, + arrival=arrival, + departure=departure, + ) def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index 178c0d8c805..cbac2b1c353 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import time +from datetime import datetime from typing import TypedDict @@ -21,16 +21,12 @@ class HERETravelTimeData(TypedDict): @dataclass -class HERETravelTimeConfig: - """Configuration for HereTravelTimeDataUpdateCoordinator.""" +class HERETravelTimeAPIParams: + """Configuration for polling the HERE API.""" - destination_latitude: float | None - destination_longitude: float | None - destination_entity_id: str | None - origin_latitude: float | None - origin_longitude: float | None - origin_entity_id: str | None + destination: list[str] + origin: list[str] travel_mode: str route_mode: str - arrival: time | None - departure: time | None + arrival: datetime | None + departure: datetime | None From 0cff7cbccde6dbc9bb1f2fd613146ec5beddecfa Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Wed, 11 Jun 2025 18:39:49 +0300 Subject: [PATCH 0175/1664] Remove stale Shelly BLU TRV devices (#145994) * Remove stale Shelly BLU TRV devices * Add test * Remove config entry from device --- homeassistant/components/shelly/__init__.py | 2 + .../components/shelly/quality_scale.yaml | 4 +- homeassistant/components/shelly/utils.py | 30 ++++++++++++ tests/components/shelly/conftest.py | 49 +++++++++++++++++++ tests/components/shelly/test_init.py | 49 ++++++++++++++++++- 5 files changed, 131 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 3130acff538..75fedf9b16d 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -64,6 +64,7 @@ from .utils import ( get_http_port, get_rpc_scripts_event_types, get_ws_context, + remove_stale_blu_trv_devices, ) PLATFORMS: Final = [ @@ -300,6 +301,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) runtime_data.rpc_script_events = await get_rpc_scripts_event_types( device, ignore_scripts=[BLE_SCRIPT_NAME] ) + remove_stale_blu_trv_devices(hass, device, entry) except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err: await device.shutdown() raise ConfigEntryNotReady( diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml index 753b2ee4a93..39667b556dd 100644 --- a/homeassistant/components/shelly/quality_scale.yaml +++ b/homeassistant/components/shelly/quality_scale.yaml @@ -61,8 +61,8 @@ rules: reconfiguration-flow: done repair-issues: done stale-devices: - status: todo - comment: BLU TRV needs to be removed when un-paired + status: done + comment: BLU TRV is removed when un-paired # Platinum async-dependency: done diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index eff5c95125c..cc0f2cf75d5 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -16,6 +16,7 @@ from aioshelly.const import ( DEFAULT_COAP_PORT, DEFAULT_HTTP_PORT, MODEL_1L, + MODEL_BLU_GATEWAY_G3, MODEL_DIMMER, MODEL_DIMMER_2, MODEL_EM3, @@ -821,3 +822,32 @@ def get_block_device_info( manufacturer="Shelly", via_device=(DOMAIN, mac), ) + + +@callback +def remove_stale_blu_trv_devices( + hass: HomeAssistant, rpc_device: RpcDevice, entry: ConfigEntry +) -> None: + """Remove stale BLU TRV devices.""" + if rpc_device.model != MODEL_BLU_GATEWAY_G3: + return + + dev_reg = dr.async_get(hass) + devices = dev_reg.devices.get_devices_for_config_entry_id(entry.entry_id) + config = rpc_device.config + blutrv_keys = get_rpc_key_ids(config, BLU_TRV_IDENTIFIER) + trv_addrs = [config[f"{BLU_TRV_IDENTIFIER}:{key}"]["addr"] for key in blutrv_keys] + + for device in devices: + if not device.via_device_id: + # Device is not a sub-device, skip + continue + + if any( + identifier[0] == DOMAIN and identifier[1] in trv_addrs + for identifier in device.identifiers + ): + continue + + LOGGER.debug("Removing stale BLU TRV device %s", device.name) + dev_reg.async_update_device(device.id, remove_config_entry_id=entry.entry_id) diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index ac70226a20a..4eccb075b67 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -260,6 +260,33 @@ MOCK_BLU_TRV_REMOTE_CONFIG = { "meta": {}, }, }, + { + "key": "blutrv:201", + "status": { + "id": 201, + "target_C": 17.1, + "current_C": 17.1, + "pos": 0, + "rssi": -60, + "battery": 100, + "packet_id": 58, + "last_updated_ts": 1734967725, + "paired": True, + "rpc": True, + "rsv": 61, + }, + "config": { + "id": 201, + "addr": "f8:44:77:25:f0:de", + "name": "TRV-201", + "key": None, + "trv": "bthomedevice:201", + "temp_sensors": [], + "dw_sensors": [], + "override_delay": 30, + "meta": {}, + }, + }, ], "blutrv:200": { "id": 0, @@ -272,6 +299,17 @@ MOCK_BLU_TRV_REMOTE_CONFIG = { "name": "TRV-Name", "local_name": "SBTR-001AEU", }, + "blutrv:201": { + "id": 1, + "enable": True, + "min_valve_position": 0, + "default_boost_duration": 1800, + "default_override_duration": 2147483647, + "default_override_target_C": 8, + "addr": "f8:44:77:25:f0:de", + "name": "TRV-201", + "local_name": "SBTR-001AEU", + }, } @@ -287,6 +325,17 @@ MOCK_BLU_TRV_REMOTE_STATUS = { "battery": 100, "errors": [], }, + "blutrv:201": { + "id": 0, + "pos": 0, + "steps": 0, + "current_C": 15.2, + "target_C": 17.1, + "schedule_rev": 0, + "rssi": -60, + "battery": 100, + "errors": [], + }, } diff --git a/tests/components/shelly/test_init.py b/tests/components/shelly/test_init.py index 283de897d8d..703df09bb61 100644 --- a/tests/components/shelly/test_init.py +++ b/tests/components/shelly/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock, call, patch from aioshelly.block_device import COAP from aioshelly.common import ConnectionOptions -from aioshelly.const import MODEL_PLUS_2PM +from aioshelly.const import MODEL_BLU_GATEWAY_G3, MODEL_PLUS_2PM from aioshelly.exceptions import ( DeviceConnectionError, InvalidAuthError, @@ -38,6 +38,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import DeviceRegistry, format_mac +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import MOCK_MAC, init_integration, mutate_rpc_device_status @@ -606,3 +607,49 @@ async def test_ble_scanner_unsupported_firmware_fixed( assert not issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 0 + + +async def test_blu_trv_stale_device_removal( + hass: HomeAssistant, + mock_blu_trv: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test BLU TRV removal of stale a device after un-pairing.""" + trv_200_entity_id = "climate.trv_name" + trv_201_entity_id = "climate.trv_201" + + monkeypatch.setattr(mock_blu_trv, "model", MODEL_BLU_GATEWAY_G3) + gw_entry = await init_integration(hass, 3, model=MODEL_BLU_GATEWAY_G3) + + # verify that both trv devices are present + assert hass.states.get(trv_200_entity_id) is not None + trv_200_entry = entity_registry.async_get(trv_200_entity_id) + assert trv_200_entry + + trv_200_device_entry = device_registry.async_get(trv_200_entry.device_id) + assert trv_200_device_entry + assert trv_200_device_entry.name == "TRV-Name" + + assert hass.states.get(trv_201_entity_id) is not None + trv_201_entry = entity_registry.async_get(trv_201_entity_id) + assert trv_201_entry + + trv_201_device_entry = device_registry.async_get(trv_201_entry.device_id) + assert trv_201_device_entry + assert trv_201_device_entry.name == "TRV-201" + + # simulate un-pairing of trv 201 device + monkeypatch.delitem(mock_blu_trv.config, "blutrv:201") + monkeypatch.delitem(mock_blu_trv.status, "blutrv:201") + + await hass.config_entries.async_reload(gw_entry.entry_id) + await hass.async_block_till_done() + + # verify that trv 201 is removed + assert hass.states.get(trv_200_entity_id) is not None + assert device_registry.async_get(trv_200_entry.device_id) is not None + + assert hass.states.get(trv_201_entity_id) is None + assert device_registry.async_get(trv_201_entry.device_id) is None From af72d1854f3a8763a1b65656c6a5841396c4ffe8 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 11 Jun 2025 15:24:37 +0100 Subject: [PATCH 0176/1664] Add guide for Honeywell Lyric application credentials setup (#146281) * Add guide for Honeywell Lyric application credentials setup * Fix --------- Co-authored-by: Joostlek --- homeassistant/components/lyric/application_credentials.py | 8 ++++++++ homeassistant/components/lyric/strings.json | 5 ++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lyric/application_credentials.py b/homeassistant/components/lyric/application_credentials.py index 2ccdca72bb6..9c53395bb6d 100644 --- a/homeassistant/components/lyric/application_credentials.py +++ b/homeassistant/components/lyric/application_credentials.py @@ -24,3 +24,11 @@ async def async_get_auth_implementation( token_url=OAUTH2_TOKEN, ), ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "developer_dashboard_url": "https://developer.honeywellhome.com", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 41598dfbdd0..786f49e5300 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "To be able to log in to Honeywell Lyric the integration requires a client ID and secret. To acquire those, please follow the following steps.\n\n1. Go to the [Honeywell Lyric Developer Apps Dashboard]({developer_dashboard_url}).\n1. Sign up for a developer account if you don't have one yet. This is a separate account from your Honeywell account.\n1. Log in with your Honeywell Lyric developer account.\n1. Go to the **My Apps** section.\n1. Press the **CREATE NEW APP** button.\n1. Give the application a name of your choice.\n1. Set the **Callback URL** to `{redirect_url}`.\n1. Save your changes.\\n1. Copy the **Consumer Key** and paste it here as the **Client ID**, then copy the **Consumer Secret** and paste it here as the **Client Secret**." + }, "config": { "step": { "pick_implementation": { @@ -9,7 +12,7 @@ "description": "The Lyric integration needs to re-authenticate your account." }, "oauth_discovery": { - "description": "Home Assistant has found a Honeywell Lyric device on your network. Press **Submit** to continue setting up Honeywell Lyric." + "description": "Home Assistant has found a Honeywell Lyric device on your network. Be aware that the setup of the Lyric integration is more complicated than other integrations. Press **Submit** to continue setting up Honeywell Lyric." } }, "abort": { From 82de2ed8e184ea89bca1d99e22fef29a9f6b7d60 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 11 Jun 2025 09:35:26 -0700 Subject: [PATCH 0177/1664] Rename Amazon Devices to Alexa Devices (#146362) Co-authored-by: Simone Chemelli Co-authored-by: Joostlek --- .strict-typing | 2 +- CODEOWNERS | 4 ++-- homeassistant/brands/amazon.json | 2 +- .../{amazon_devices => alexa_devices}/__init__.py | 4 ++-- .../binary_sensor.py | 4 ++-- .../{amazon_devices => alexa_devices}/config_flow.py | 4 ++-- .../{amazon_devices => alexa_devices}/const.py | 4 ++-- .../{amazon_devices => alexa_devices}/coordinator.py | 4 ++-- .../{amazon_devices => alexa_devices}/diagnostics.py | 2 +- .../{amazon_devices => alexa_devices}/entity.py | 4 ++-- .../{amazon_devices => alexa_devices}/icons.json | 0 .../{amazon_devices => alexa_devices}/manifest.json | 6 +++--- .../{amazon_devices => alexa_devices}/notify.py | 4 ++-- .../quality_scale.yaml | 0 .../{amazon_devices => alexa_devices}/strings.json | 12 ++++++------ .../{amazon_devices => alexa_devices}/switch.py | 4 ++-- homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/integrations.json | 4 ++-- mypy.ini | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../{amazon_devices => alexa_devices}/__init__.py | 2 +- .../{amazon_devices => alexa_devices}/conftest.py | 12 ++++++------ .../{amazon_devices => alexa_devices}/const.py | 2 +- .../snapshots/test_binary_sensor.ambr | 4 ++-- .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_init.ambr | 2 +- .../snapshots/test_notify.ambr | 4 ++-- .../snapshots/test_switch.ambr | 2 +- .../test_binary_sensor.py | 6 +++--- .../test_config_flow.py | 4 ++-- .../test_diagnostics.py | 4 ++-- .../{amazon_devices => alexa_devices}/test_init.py | 4 ++-- .../{amazon_devices => alexa_devices}/test_notify.py | 6 +++--- .../{amazon_devices => alexa_devices}/test_switch.py | 6 +++--- 35 files changed, 67 insertions(+), 67 deletions(-) rename homeassistant/components/{amazon_devices => alexa_devices}/__init__.py (91%) rename homeassistant/components/{amazon_devices => alexa_devices}/binary_sensor.py (93%) rename homeassistant/components/{amazon_devices => alexa_devices}/config_flow.py (95%) rename homeassistant/components/{amazon_devices => alexa_devices}/const.py (60%) rename homeassistant/components/{amazon_devices => alexa_devices}/coordinator.py (95%) rename homeassistant/components/{amazon_devices => alexa_devices}/diagnostics.py (97%) rename homeassistant/components/{amazon_devices => alexa_devices}/entity.py (95%) rename homeassistant/components/{amazon_devices => alexa_devices}/icons.json (100%) rename homeassistant/components/{amazon_devices => alexa_devices}/manifest.json (79%) rename homeassistant/components/{amazon_devices => alexa_devices}/notify.py (94%) rename homeassistant/components/{amazon_devices => alexa_devices}/quality_scale.yaml (100%) rename homeassistant/components/{amazon_devices => alexa_devices}/strings.json (75%) rename homeassistant/components/{amazon_devices => alexa_devices}/switch.py (95%) rename tests/components/{amazon_devices => alexa_devices}/__init__.py (88%) rename tests/components/{amazon_devices => alexa_devices}/conftest.py (84%) rename tests/components/{amazon_devices => alexa_devices}/const.py (82%) rename tests/components/{amazon_devices => alexa_devices}/snapshots/test_binary_sensor.ambr (97%) rename tests/components/{amazon_devices => alexa_devices}/snapshots/test_diagnostics.ambr (98%) rename tests/components/{amazon_devices => alexa_devices}/snapshots/test_init.ambr (96%) rename tests/components/{amazon_devices => alexa_devices}/snapshots/test_notify.ambr (97%) rename tests/components/{amazon_devices => alexa_devices}/snapshots/test_switch.ambr (97%) rename tests/components/{amazon_devices => alexa_devices}/test_binary_sensor.py (92%) rename tests/components/{amazon_devices => alexa_devices}/test_config_flow.py (96%) rename tests/components/{amazon_devices => alexa_devices}/test_diagnostics.py (93%) rename tests/components/{amazon_devices => alexa_devices}/test_init.py (87%) rename tests/components/{amazon_devices => alexa_devices}/test_notify.py (92%) rename tests/components/{amazon_devices => alexa_devices}/test_switch.py (94%) diff --git a/.strict-typing b/.strict-typing index 4febfd68486..b34cbfa5fca 100644 --- a/.strict-typing +++ b/.strict-typing @@ -65,8 +65,8 @@ homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* homeassistant.components.alexa.* +homeassistant.components.alexa_devices.* homeassistant.components.alpha_vantage.* -homeassistant.components.amazon_devices.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambient_network.* diff --git a/CODEOWNERS b/CODEOWNERS index 3f3ce07ce84..b447c878128 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -89,8 +89,8 @@ build.json @home-assistant/supervisor /tests/components/alert/ @home-assistant/core @frenck /homeassistant/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh -/homeassistant/components/amazon_devices/ @chemelli74 -/tests/components/amazon_devices/ @chemelli74 +/homeassistant/components/alexa_devices/ @chemelli74 +/tests/components/alexa_devices/ @chemelli74 /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot diff --git a/homeassistant/brands/amazon.json b/homeassistant/brands/amazon.json index d2e25468388..126b69c848d 100644 --- a/homeassistant/brands/amazon.json +++ b/homeassistant/brands/amazon.json @@ -3,7 +3,7 @@ "name": "Amazon", "integrations": [ "alexa", - "amazon_devices", + "alexa_devices", "amazon_polly", "aws", "aws_s3", diff --git a/homeassistant/components/amazon_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py similarity index 91% rename from homeassistant/components/amazon_devices/__init__.py rename to homeassistant/components/alexa_devices/__init__.py index 1db41d335ef..7a4139a65da 100644 --- a/homeassistant/components/amazon_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -1,4 +1,4 @@ -"""Amazon Devices integration.""" +"""Alexa Devices integration.""" from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -13,7 +13,7 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: - """Set up Amazon Devices platform.""" + """Set up Alexa Devices platform.""" coordinator = AmazonDevicesCoordinator(hass, entry) diff --git a/homeassistant/components/amazon_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py similarity index 93% rename from homeassistant/components/amazon_devices/binary_sensor.py rename to homeassistant/components/alexa_devices/binary_sensor.py index ab1fadc7548..16cf73aee9f 100644 --- a/homeassistant/components/amazon_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): - """Amazon Devices binary sensor entity description.""" + """Alexa Devices binary sensor entity description.""" is_on_fn: Callable[[AmazonDevice], bool] @@ -52,7 +52,7 @@ async def async_setup_entry( entry: AmazonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Amazon Devices binary sensors based on a config entry.""" + """Set up Alexa Devices binary sensors based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/amazon_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py similarity index 95% rename from homeassistant/components/amazon_devices/config_flow.py rename to homeassistant/components/alexa_devices/config_flow.py index d0c3d067cee..5add7ceb711 100644 --- a/homeassistant/components/amazon_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Amazon Devices integration.""" +"""Config flow for Alexa Devices integration.""" from __future__ import annotations @@ -17,7 +17,7 @@ from .const import CONF_LOGIN_DATA, DOMAIN class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Amazon Devices.""" + """Handle a config flow for Alexa Devices.""" async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/amazon_devices/const.py b/homeassistant/components/alexa_devices/const.py similarity index 60% rename from homeassistant/components/amazon_devices/const.py rename to homeassistant/components/alexa_devices/const.py index b8cf2c264b1..ca0290a10bc 100644 --- a/homeassistant/components/amazon_devices/const.py +++ b/homeassistant/components/alexa_devices/const.py @@ -1,8 +1,8 @@ -"""Amazon Devices constants.""" +"""Alexa Devices constants.""" import logging _LOGGER = logging.getLogger(__package__) -DOMAIN = "amazon_devices" +DOMAIN = "alexa_devices" CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/amazon_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py similarity index 95% rename from homeassistant/components/amazon_devices/coordinator.py rename to homeassistant/components/alexa_devices/coordinator.py index 48e31cb3f94..8e58441d46c 100644 --- a/homeassistant/components/amazon_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -1,4 +1,4 @@ -"""Support for Amazon Devices.""" +"""Support for Alexa Devices.""" from datetime import timedelta @@ -23,7 +23,7 @@ type AmazonConfigEntry = ConfigEntry[AmazonDevicesCoordinator] class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): - """Base coordinator for Amazon Devices.""" + """Base coordinator for Alexa Devices.""" config_entry: AmazonConfigEntry diff --git a/homeassistant/components/amazon_devices/diagnostics.py b/homeassistant/components/alexa_devices/diagnostics.py similarity index 97% rename from homeassistant/components/amazon_devices/diagnostics.py rename to homeassistant/components/alexa_devices/diagnostics.py index e9a0773cd3f..0c4cb794416 100644 --- a/homeassistant/components/amazon_devices/diagnostics.py +++ b/homeassistant/components/alexa_devices/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for Amazon Devices integration.""" +"""Diagnostics support for Alexa Devices integration.""" from __future__ import annotations diff --git a/homeassistant/components/amazon_devices/entity.py b/homeassistant/components/alexa_devices/entity.py similarity index 95% rename from homeassistant/components/amazon_devices/entity.py rename to homeassistant/components/alexa_devices/entity.py index 962e2f55ae6..f539079602f 100644 --- a/homeassistant/components/amazon_devices/entity.py +++ b/homeassistant/components/alexa_devices/entity.py @@ -1,4 +1,4 @@ -"""Defines a base Amazon Devices entity.""" +"""Defines a base Alexa Devices entity.""" from aioamazondevices.api import AmazonDevice from aioamazondevices.const import SPEAKER_GROUP_MODEL @@ -12,7 +12,7 @@ from .coordinator import AmazonDevicesCoordinator class AmazonEntity(CoordinatorEntity[AmazonDevicesCoordinator]): - """Defines a base Amazon Devices entity.""" + """Defines a base Alexa Devices entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/amazon_devices/icons.json b/homeassistant/components/alexa_devices/icons.json similarity index 100% rename from homeassistant/components/amazon_devices/icons.json rename to homeassistant/components/alexa_devices/icons.json diff --git a/homeassistant/components/amazon_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json similarity index 79% rename from homeassistant/components/amazon_devices/manifest.json rename to homeassistant/components/alexa_devices/manifest.json index f63893c1598..2a9e88cfd85 100644 --- a/homeassistant/components/amazon_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -1,9 +1,9 @@ { - "domain": "amazon_devices", - "name": "Amazon Devices", + "domain": "alexa_devices", + "name": "Alexa Devices", "codeowners": ["@chemelli74"], "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/amazon_devices", + "documentation": "https://www.home-assistant.io/integrations/alexa_devices", "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], diff --git a/homeassistant/components/amazon_devices/notify.py b/homeassistant/components/alexa_devices/notify.py similarity index 94% rename from homeassistant/components/amazon_devices/notify.py rename to homeassistant/components/alexa_devices/notify.py index 3762a7a3264..ff0cd4e59ea 100644 --- a/homeassistant/components/amazon_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) class AmazonNotifyEntityDescription(NotifyEntityDescription): - """Amazon Devices notify entity description.""" + """Alexa Devices notify entity description.""" method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]] subkey: str @@ -49,7 +49,7 @@ async def async_setup_entry( entry: AmazonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Amazon Devices notification entity based on a config entry.""" + """Set up Alexa Devices notification entity based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/amazon_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml similarity index 100% rename from homeassistant/components/amazon_devices/quality_scale.yaml rename to homeassistant/components/alexa_devices/quality_scale.yaml diff --git a/homeassistant/components/amazon_devices/strings.json b/homeassistant/components/alexa_devices/strings.json similarity index 75% rename from homeassistant/components/amazon_devices/strings.json rename to homeassistant/components/alexa_devices/strings.json index 47e6234cd9c..9d615b248ed 100644 --- a/homeassistant/components/amazon_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -12,16 +12,16 @@ "step": { "user": { "data": { - "country": "[%key:component::amazon_devices::common::data_country%]", + "country": "[%key:component::alexa_devices::common::data_country%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "code": "[%key:component::amazon_devices::common::data_description_code%]" + "code": "[%key:component::alexa_devices::common::data_description_code%]" }, "data_description": { - "country": "[%key:component::amazon_devices::common::data_description_country%]", - "username": "[%key:component::amazon_devices::common::data_description_username%]", - "password": "[%key:component::amazon_devices::common::data_description_password%]", - "code": "[%key:component::amazon_devices::common::data_description_code%]" + "country": "[%key:component::alexa_devices::common::data_description_country%]", + "username": "[%key:component::alexa_devices::common::data_description_username%]", + "password": "[%key:component::alexa_devices::common::data_description_password%]", + "code": "[%key:component::alexa_devices::common::data_description_code%]" } } }, diff --git a/homeassistant/components/amazon_devices/switch.py b/homeassistant/components/alexa_devices/switch.py similarity index 95% rename from homeassistant/components/amazon_devices/switch.py rename to homeassistant/components/alexa_devices/switch.py index 428ef3e3b45..b8f78134feb 100644 --- a/homeassistant/components/amazon_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -20,7 +20,7 @@ PARALLEL_UPDATES = 1 @dataclass(frozen=True, kw_only=True) class AmazonSwitchEntityDescription(SwitchEntityDescription): - """Amazon Devices switch entity description.""" + """Alexa Devices switch entity description.""" is_on_fn: Callable[[AmazonDevice], bool] subkey: str @@ -43,7 +43,7 @@ async def async_setup_entry( entry: AmazonConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up Amazon Devices switches based on a config entry.""" + """Set up Alexa Devices switches based on a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 44a9b19e8c2..2d246f53ca3 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -47,7 +47,7 @@ FLOWS = { "airzone", "airzone_cloud", "alarmdecoder", - "amazon_devices", + "alexa_devices", "amberelectric", "ambient_network", "ambient_station", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 775272f77c4..846a5c74ddb 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -207,11 +207,11 @@ "amazon": { "name": "Amazon", "integrations": { - "amazon_devices": { + "alexa_devices": { "integration_type": "hub", "config_flow": true, "iot_class": "cloud_polling", - "name": "Amazon Devices" + "name": "Alexa Devices" }, "amazon_polly": { "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index da76e4ae2cd..1fdab75663e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -405,7 +405,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.alpha_vantage.*] +[mypy-homeassistant.components.alexa_devices.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true @@ -415,7 +415,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.amazon_devices.*] +[mypy-homeassistant.components.alpha_vantage.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true diff --git a/requirements_all.txt b/requirements_all.txt index 95a5f919add..d9a9014a6b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,7 +181,7 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 -# homeassistant.components.amazon_devices +# homeassistant.components.alexa_devices aioamazondevices==3.0.6 # homeassistant.components.ambient_network diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ed51e54164..66f34dd6d69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -169,7 +169,7 @@ aioairzone-cloud==0.6.12 # homeassistant.components.airzone aioairzone==1.0.0 -# homeassistant.components.amazon_devices +# homeassistant.components.alexa_devices aioamazondevices==3.0.6 # homeassistant.components.ambient_network diff --git a/tests/components/amazon_devices/__init__.py b/tests/components/alexa_devices/__init__.py similarity index 88% rename from tests/components/amazon_devices/__init__.py rename to tests/components/alexa_devices/__init__.py index 47ee520b124..24348248e0c 100644 --- a/tests/components/amazon_devices/__init__.py +++ b/tests/components/alexa_devices/__init__.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices integration.""" +"""Tests for the Alexa Devices integration.""" from homeassistant.core import HomeAssistant diff --git a/tests/components/amazon_devices/conftest.py b/tests/components/alexa_devices/conftest.py similarity index 84% rename from tests/components/amazon_devices/conftest.py rename to tests/components/alexa_devices/conftest.py index f0ee29d44e5..4ce2eb743ea 100644 --- a/tests/components/amazon_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -1,4 +1,4 @@ -"""Amazon Devices tests configuration.""" +"""Alexa Devices tests configuration.""" from collections.abc import Generator from unittest.mock import AsyncMock, patch @@ -7,7 +7,7 @@ from aioamazondevices.api import AmazonDevice from aioamazondevices.const import DEVICE_TYPE_TO_MODEL import pytest -from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from .const import TEST_COUNTRY, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME @@ -19,7 +19,7 @@ from tests.common import MockConfigEntry def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( - "homeassistant.components.amazon_devices.async_setup_entry", + "homeassistant.components.alexa_devices.async_setup_entry", return_value=True, ) as mock_setup_entry: yield mock_setup_entry @@ -27,14 +27,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture def mock_amazon_devices_client() -> Generator[AsyncMock]: - """Mock an Amazon Devices client.""" + """Mock an Alexa Devices client.""" with ( patch( - "homeassistant.components.amazon_devices.coordinator.AmazonEchoApi", + "homeassistant.components.alexa_devices.coordinator.AmazonEchoApi", autospec=True, ) as mock_client, patch( - "homeassistant.components.amazon_devices.config_flow.AmazonEchoApi", + "homeassistant.components.alexa_devices.config_flow.AmazonEchoApi", new=mock_client, ), ): diff --git a/tests/components/amazon_devices/const.py b/tests/components/alexa_devices/const.py similarity index 82% rename from tests/components/amazon_devices/const.py rename to tests/components/alexa_devices/const.py index a2600ba98a6..8a2f5b6b158 100644 --- a/tests/components/amazon_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -1,4 +1,4 @@ -"""Amazon Devices tests const.""" +"""Alexa Devices tests const.""" TEST_CODE = "023123" TEST_COUNTRY = "IT" diff --git a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr similarity index 97% rename from tests/components/amazon_devices/snapshots/test_binary_sensor.ambr rename to tests/components/alexa_devices/snapshots/test_binary_sensor.ambr index e914541d19c..16f9eeaedae 100644 --- a/tests/components/amazon_devices/snapshots/test_binary_sensor.ambr +++ b/tests/components/alexa_devices/snapshots/test_binary_sensor.ambr @@ -25,7 +25,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Bluetooth', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, @@ -73,7 +73,7 @@ 'original_device_class': , 'original_icon': None, 'original_name': 'Connectivity', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, diff --git a/tests/components/amazon_devices/snapshots/test_diagnostics.ambr b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr similarity index 98% rename from tests/components/amazon_devices/snapshots/test_diagnostics.ambr rename to tests/components/alexa_devices/snapshots/test_diagnostics.ambr index 0b5164418aa..95798fca817 100644 --- a/tests/components/amazon_devices/snapshots/test_diagnostics.ambr +++ b/tests/components/alexa_devices/snapshots/test_diagnostics.ambr @@ -57,7 +57,7 @@ 'disabled_by': None, 'discovery_keys': dict({ }), - 'domain': 'amazon_devices', + 'domain': 'alexa_devices', 'minor_version': 1, 'options': dict({ }), diff --git a/tests/components/amazon_devices/snapshots/test_init.ambr b/tests/components/alexa_devices/snapshots/test_init.ambr similarity index 96% rename from tests/components/amazon_devices/snapshots/test_init.ambr rename to tests/components/alexa_devices/snapshots/test_init.ambr index be0a5894eea..e0460c4c173 100644 --- a/tests/components/amazon_devices/snapshots/test_init.ambr +++ b/tests/components/alexa_devices/snapshots/test_init.ambr @@ -13,7 +13,7 @@ 'id': , 'identifiers': set({ tuple( - 'amazon_devices', + 'alexa_devices', 'echo_test_serial_number', ), }), diff --git a/tests/components/amazon_devices/snapshots/test_notify.ambr b/tests/components/alexa_devices/snapshots/test_notify.ambr similarity index 97% rename from tests/components/amazon_devices/snapshots/test_notify.ambr rename to tests/components/alexa_devices/snapshots/test_notify.ambr index a47bf7a63ae..64776c14420 100644 --- a/tests/components/amazon_devices/snapshots/test_notify.ambr +++ b/tests/components/alexa_devices/snapshots/test_notify.ambr @@ -25,7 +25,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Announce', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, @@ -74,7 +74,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Speak', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, diff --git a/tests/components/amazon_devices/snapshots/test_switch.ambr b/tests/components/alexa_devices/snapshots/test_switch.ambr similarity index 97% rename from tests/components/amazon_devices/snapshots/test_switch.ambr rename to tests/components/alexa_devices/snapshots/test_switch.ambr index 8a2ce8d529a..c622cc67ea7 100644 --- a/tests/components/amazon_devices/snapshots/test_switch.ambr +++ b/tests/components/alexa_devices/snapshots/test_switch.ambr @@ -25,7 +25,7 @@ 'original_device_class': None, 'original_icon': None, 'original_name': 'Do not disturb', - 'platform': 'amazon_devices', + 'platform': 'alexa_devices', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, diff --git a/tests/components/amazon_devices/test_binary_sensor.py b/tests/components/alexa_devices/test_binary_sensor.py similarity index 92% rename from tests/components/amazon_devices/test_binary_sensor.py rename to tests/components/alexa_devices/test_binary_sensor.py index b31d85e06aa..a2e38b3459b 100644 --- a/tests/components/amazon_devices/test_binary_sensor.py +++ b/tests/components/alexa_devices/test_binary_sensor.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices binary sensor platform.""" +"""Tests for the Alexa Devices binary sensor platform.""" from unittest.mock import AsyncMock, patch @@ -11,7 +11,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -31,7 +31,7 @@ async def test_all_entities( ) -> None: """Test all entities.""" with patch( - "homeassistant.components.amazon_devices.PLATFORMS", [Platform.BINARY_SENSOR] + "homeassistant.components.alexa_devices.PLATFORMS", [Platform.BINARY_SENSOR] ): await setup_integration(hass, mock_config_entry) diff --git a/tests/components/amazon_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py similarity index 96% rename from tests/components/amazon_devices/test_config_flow.py rename to tests/components/alexa_devices/test_config_flow.py index ce1ac44d102..9bf174c5955 100644 --- a/tests/components/amazon_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -1,11 +1,11 @@ -"""Tests for the Amazon Devices config flow.""" +"""Tests for the Alexa Devices config flow.""" from unittest.mock import AsyncMock from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect import pytest -from homeassistant.components.amazon_devices.const import CONF_LOGIN_DATA, DOMAIN +from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant diff --git a/tests/components/amazon_devices/test_diagnostics.py b/tests/components/alexa_devices/test_diagnostics.py similarity index 93% rename from tests/components/amazon_devices/test_diagnostics.py rename to tests/components/alexa_devices/test_diagnostics.py index e548702650b..3c18d432543 100644 --- a/tests/components/amazon_devices/test_diagnostics.py +++ b/tests/components/alexa_devices/test_diagnostics.py @@ -1,4 +1,4 @@ -"""Tests for Amazon Devices diagnostics platform.""" +"""Tests for Alexa Devices diagnostics platform.""" from __future__ import annotations @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.amazon_devices.const import DOMAIN +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/tests/components/amazon_devices/test_init.py b/tests/components/alexa_devices/test_init.py similarity index 87% rename from tests/components/amazon_devices/test_init.py rename to tests/components/alexa_devices/test_init.py index 489952dbd4c..3100cfe5fa9 100644 --- a/tests/components/amazon_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -1,10 +1,10 @@ -"""Tests for the Amazon Devices integration.""" +"""Tests for the Alexa Devices integration.""" from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion -from homeassistant.components.amazon_devices.const import DOMAIN +from homeassistant.components.alexa_devices.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr diff --git a/tests/components/amazon_devices/test_notify.py b/tests/components/alexa_devices/test_notify.py similarity index 92% rename from tests/components/amazon_devices/test_notify.py rename to tests/components/alexa_devices/test_notify.py index b486380fd07..6067874e370 100644 --- a/tests/components/amazon_devices/test_notify.py +++ b/tests/components/alexa_devices/test_notify.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices notify platform.""" +"""Tests for the Alexa Devices notify platform.""" from unittest.mock import AsyncMock, patch @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.components.notify import ( ATTR_MESSAGE, DOMAIN as NOTIFY_DOMAIN, @@ -32,7 +32,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - with patch("homeassistant.components.amazon_devices.PLATFORMS", [Platform.NOTIFY]): + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.NOTIFY]): await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/amazon_devices/test_switch.py b/tests/components/alexa_devices/test_switch.py similarity index 94% rename from tests/components/amazon_devices/test_switch.py rename to tests/components/alexa_devices/test_switch.py index 24af96db280..26a18fb731a 100644 --- a/tests/components/amazon_devices/test_switch.py +++ b/tests/components/alexa_devices/test_switch.py @@ -1,4 +1,4 @@ -"""Tests for the Amazon Devices switch platform.""" +"""Tests for the Alexa Devices switch platform.""" from unittest.mock import AsyncMock, patch @@ -6,7 +6,7 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.amazon_devices.coordinator import SCAN_INTERVAL +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -37,7 +37,7 @@ async def test_all_entities( entity_registry: er.EntityRegistry, ) -> None: """Test all entities.""" - with patch("homeassistant.components.amazon_devices.PLATFORMS", [Platform.SWITCH]): + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.SWITCH]): await setup_integration(hass, mock_config_entry) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 6384c800c313f27f8a02d6b487156c3c1a9e033d Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:46:40 +1200 Subject: [PATCH 0178/1664] Fix solax state class of `Today's Generated Energy` (#146492) --- homeassistant/components/solax/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 1cdec0389fe..61420c152a5 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -42,7 +42,7 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = { key=f"{Units.KWH}_{False}", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, ), (Units.KWH, True): SensorEntityDescription( key=f"{Units.KWH}_{True}", From e0f32cfd54a068820c6158e5e62337812fe39fa7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 12:42:09 +0200 Subject: [PATCH 0179/1664] Allow removing entity registry items twice (#146519) --- homeassistant/helpers/entity_registry.py | 8 ++++++++ tests/helpers/test_entity_registry.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b503ba5f787..72689bc4997 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -980,6 +980,14 @@ class EntityRegistry(BaseRegistry): def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" self.hass.verify_event_loop_thread("entity_registry.async_remove") + if entity_id not in self.entities: + # Allow attempts to remove an entity which does not exist. If this is + # not allowed, there will be races during cleanup where we iterate over + # lists of entities to remove, but there are listeners for entity + # registry events which delete entities at the same time. + # For example, if we clean up entities A and B, there might be a listener + # which deletes entity B when entity A is being removed. + return entity = self.entities.pop(entity_id) config_entry_id = entity.config_entry_id key = (entity.domain, entity.platform, entity.unique_id) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index cef52810fa0..554adff3700 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -289,6 +289,24 @@ def test_get_or_create_suggested_object_id_conflict_existing( assert entry.entity_id == "light.hue_1234_2" +def test_remove(entity_registry: er.EntityRegistry) -> None: + """Test that we can remove an item.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + + assert not entity_registry.deleted_entities + assert list(entity_registry.entities) == [entry.entity_id] + + # Remove the item + entity_registry.async_remove(entry.entity_id) + assert list(entity_registry.deleted_entities) == [("light", "hue", "1234")] + assert not entity_registry.entities + + # Remove the item again + entity_registry.async_remove(entry.entity_id) + assert list(entity_registry.deleted_entities) == [("light", "hue", "1234")] + assert not entity_registry.entities + + def test_create_triggers_save(entity_registry: er.EntityRegistry) -> None: """Test that registering entry triggers a save.""" with patch.object(entity_registry, "async_schedule_save") as mock_schedule_save: From cc972d20f64a44045c2127cd1d948fc9755d8241 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 11 Jun 2025 12:31:07 +0200 Subject: [PATCH 0180/1664] Remove Z-Wave useless reconfigure options (#146520) * Remove emulate hardware option * Remove log level option --- .../components/zwave_js/config_flow.py | 24 -------- homeassistant/components/zwave_js/const.py | 2 - .../components/zwave_js/strings.json | 2 - tests/components/zwave_js/test_config_flow.py | 58 +------------------ 4 files changed, 1 insertion(+), 85 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 08c9ec2e2b2..5e8e7022839 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -46,8 +46,6 @@ from .addon import get_addon_manager from .const import ( ADDON_SLUG, CONF_ADDON_DEVICE, - CONF_ADDON_EMULATE_HARDWARE, - CONF_ADDON_LOG_LEVEL, CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY, CONF_ADDON_LR_S2_AUTHENTICATED_KEY, CONF_ADDON_NETWORK_KEY, @@ -78,17 +76,7 @@ TITLE = "Z-Wave JS" ADDON_SETUP_TIMEOUT = 5 ADDON_SETUP_TIMEOUT_ROUNDS = 40 -CONF_EMULATE_HARDWARE = "emulate_hardware" -CONF_LOG_LEVEL = "log_level" -ADDON_LOG_LEVELS = { - "error": "Error", - "warn": "Warn", - "info": "Info", - "verbose": "Verbose", - "debug": "Debug", - "silly": "Silly", -} ADDON_USER_INPUT_MAP = { CONF_ADDON_DEVICE: CONF_USB_PATH, CONF_ADDON_S0_LEGACY_KEY: CONF_S0_LEGACY_KEY, @@ -97,8 +85,6 @@ ADDON_USER_INPUT_MAP = { CONF_ADDON_S2_UNAUTHENTICATED_KEY: CONF_S2_UNAUTHENTICATED_KEY, CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: CONF_LR_S2_ACCESS_CONTROL_KEY, CONF_ADDON_LR_S2_AUTHENTICATED_KEY: CONF_LR_S2_AUTHENTICATED_KEY, - CONF_ADDON_LOG_LEVEL: CONF_LOG_LEVEL, - CONF_ADDON_EMULATE_HARDWARE: CONF_EMULATE_HARDWARE, } ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) @@ -1097,10 +1083,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, - CONF_ADDON_LOG_LEVEL: user_input[CONF_LOG_LEVEL], - CONF_ADDON_EMULATE_HARDWARE: user_input.get( - CONF_EMULATE_HARDWARE, False - ), } await self._async_set_addon_config(addon_config_updates) @@ -1135,8 +1117,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): lr_s2_authenticated_key = addon_config.get( CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info") - emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False) try: ports = await async_get_usb_ports(self.hass) @@ -1163,10 +1143,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): vol.Optional( CONF_LR_S2_AUTHENTICATED_KEY, default=lr_s2_authenticated_key ): str, - vol.Optional(CONF_LOG_LEVEL, default=log_level): vol.In( - ADDON_LOG_LEVELS - ), - vol.Optional(CONF_EMULATE_HARDWARE, default=emulate_hardware): bool, } ) diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 6d5cbb98902..3d626710d52 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -16,8 +16,6 @@ LR_ADDON_VERSION = AwesomeVersion("0.5.0") USER_AGENT = {APPLICATION_NAME: HA_VERSION} CONF_ADDON_DEVICE = "device" -CONF_ADDON_EMULATE_HARDWARE = "emulate_hardware" -CONF_ADDON_LOG_LEVEL = "log_level" CONF_ADDON_NETWORK_KEY = "network_key" CONF_ADDON_S0_LEGACY_KEY = "s0_legacy_key" CONF_ADDON_S2_ACCESS_CONTROL_KEY = "s2_access_control_key" diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 439fc7b1aad..d1d4cc94346 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -52,8 +52,6 @@ }, "configure_addon_reconfigure": { "data": { - "emulate_hardware": "Emulate Hardware", - "log_level": "Log level", "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index fc01c9b29b1..dd8838e0775 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2566,8 +2566,6 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, ), @@ -2591,8 +2589,6 @@ async def test_reconfigure_not_addon_with_addon_stop_fail( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 1, ), @@ -2706,8 +2702,6 @@ async def test_reconfigure_addon_running( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/test", @@ -2717,8 +2711,6 @@ async def test_reconfigure_addon_running( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, ), ], @@ -2836,8 +2828,6 @@ async def different_device_server_version(*args): "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/new", @@ -2847,35 +2837,6 @@ async def different_device_server_version(*args): "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, - }, - 0, - different_device_server_version, - ), - ( - {}, - { - "device": "/test", - "network_key": "old123", - "s0_legacy_key": "old123", - "s2_access_control_key": "old456", - "s2_authenticated_key": "old789", - "s2_unauthenticated_key": "old987", - "lr_s2_access_control_key": "old654", - "lr_s2_authenticated_key": "old321", - "log_level": "info", - }, - { - "usb_path": "/new", - "s0_legacy_key": "new123", - "s2_access_control_key": "new456", - "s2_authenticated_key": "new789", - "s2_unauthenticated_key": "new987", - "lr_s2_access_control_key": "new654", - "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, different_device_server_version, @@ -2946,8 +2907,7 @@ async def test_reconfigure_different_device( result = await hass.config_entries.flow.async_configure(result["flow_id"]) await hass.async_block_till_done() - # Default emulate_hardware is False. - addon_options = {"emulate_hardware": False} | old_addon_options + addon_options = {} | old_addon_options # Legacy network key is not reset. addon_options.pop("network_key") @@ -2994,8 +2954,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/new", @@ -3005,8 +2963,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, [SupervisorError(), None], @@ -3022,8 +2978,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, }, { "usb_path": "/new", @@ -3033,8 +2987,6 @@ async def test_reconfigure_different_device( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, [ @@ -3151,8 +3103,6 @@ async def test_reconfigure_addon_running_server_info_failure( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, } new_addon_options = { "usb_path": "/test", @@ -3162,8 +3112,6 @@ async def test_reconfigure_addon_running_server_info_failure( "s2_unauthenticated_key": "old987", "lr_s2_access_control_key": "old654", "lr_s2_authenticated_key": "old321", - "log_level": "info", - "emulate_hardware": False, } addon_options.update(old_addon_options) entry = integration @@ -3236,8 +3184,6 @@ async def test_reconfigure_addon_running_server_info_failure( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 0, ), @@ -3261,8 +3207,6 @@ async def test_reconfigure_addon_running_server_info_failure( "s2_unauthenticated_key": "new987", "lr_s2_access_control_key": "new654", "lr_s2_authenticated_key": "new321", - "log_level": "info", - "emulate_hardware": False, }, 1, ), From 5ee39df3305ee9ecceb1d43e2e444182c98d6a1a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:16:51 +0200 Subject: [PATCH 0181/1664] Handle changes to source entity in history_stats helper (#146521) --- .../components/history_stats/__init__.py | 26 ++ .../components/history_stats/config_flow.py | 2 +- tests/components/history_stats/test_init.py | 294 +++++++++++++++++- 3 files changed, 314 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index 63f32138dba..a3565f9ed77 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -8,8 +8,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from homeassistant.helpers.template import Template from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS @@ -51,6 +53,30 @@ async def async_setup_entry( entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # history_stats does not allow replacing the input entity. + await hass.config_entries.async_remove(entry.entry_id) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index 96c8f319fbc..ca3d5229b6b 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -107,7 +107,7 @@ OPTIONS_FLOW = { } -class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): +class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" config_flow = CONFIG_FLOW diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index 4cd999ba31c..37b5416fdbb 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -2,24 +2,107 @@ from __future__ import annotations +from unittest.mock import patch + +import pytest + +from homeassistant.components import history_stats +from homeassistant.components.history_stats.config_flow import ( + HistoryStatsConfigFlowHandler, +) from homeassistant.components.history_stats.const import ( CONF_END, CONF_START, DEFAULT_NAME, DOMAIN as HISTORY_STATS_DOMAIN, ) -from homeassistant.components.recorder import Recorder -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry -async def test_unload_entry( - recorder_mock: Recorder, hass: HomeAssistant, loaded_entry: MockConfigEntry -) -> None: +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def history_stats_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a history_stats config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: sensor_entity_entry.entity_id, + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=HistoryStatsConfigFlowHandler.VERSION, + minor_version=HistoryStatsConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + +@pytest.mark.usefixtures("recorder_mock") +async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" assert loaded_entry.state is ConfigEntryState.LOADED @@ -28,8 +111,8 @@ async def test_unload_entry( assert loaded_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("recorder_mock") async def test_device_cleaning( - recorder_mock: Recorder, hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -116,3 +199,200 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the history_stats config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + # Check that the history_stats config entry is removed + assert ( + history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + # Check that the history_stats config entry is not removed + assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert history_stats_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the history_stats config entry is not removed + assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the history_stats config entry is updated with the new entity ID + assert history_stats_config_entry.options[CONF_ENTITY_ID] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + # Check that the history_stats config entry is not removed + assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] From 0cf1fd1d41bee8170e57d5b201d7299103cda910 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:17:08 +0200 Subject: [PATCH 0182/1664] Handle changes to source entity in integration helper (#146522) --- .../components/integration/__init__.py | 25 ++ tests/components/integration/test_init.py | 276 +++++++++++++++++- 2 files changed, 300 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 4ccf0dec258..0a64ce7140f 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -6,8 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from .const import CONF_SOURCE_SENSOR @@ -21,6 +23,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_SOURCE_SENSOR], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_SOURCE_SENSOR] + ), + source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 9fee54f4500..0ce3297a2ff 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -1,14 +1,97 @@ """Test the Integration - Riemann sum integral integration.""" +from unittest.mock import patch + import pytest +from homeassistant.components import integration +from homeassistant.components.integration.config_flow import ConfigFlowHandler from homeassistant.components.integration.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def integration_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create an integration config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + @pytest.mark.parametrize("platform", ["sensor"]) async def test_setup_and_remove_config_entry( hass: HomeAssistant, @@ -209,3 +292,194 @@ async def test_device_cleaning( integration_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the integration config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the integration config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the integration config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert integration_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the integration config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert integration_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the integration config entry is updated with the new entity ID + assert integration_config_entry.options["source"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] From caaa4d5f3516038a9bcd6dc87d959e403e1640f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:17:34 +0200 Subject: [PATCH 0183/1664] Handle changes to source entity in threshold helper (#146524) --- .../components/threshold/__init__.py | 25 ++ tests/components/threshold/test_init.py | 274 +++++++++++++++++- 2 files changed, 298 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index ea8b469fd32..9460a50db80 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -4,8 +4,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,6 +19,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups( entry, (Platform.BINARY_SENSOR,) ) diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 6e85d659922..599612ce0b7 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -1,14 +1,95 @@ """Test the Min/Max integration.""" +from unittest.mock import patch + import pytest +from homeassistant.components import threshold +from homeassistant.components.threshold.config_flow import ConfigFlowHandler from homeassistant.components.threshold.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def threshold_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a threshold config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": sensor_entity_entry.entity_id, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + @pytest.mark.parametrize("platform", ["binary_sensor"]) async def test_setup_and_remove_config_entry( hass: HomeAssistant, @@ -208,3 +289,194 @@ async def test_device_cleaning( threshold_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the threshold config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the threshold config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the threshold config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert threshold_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the threshold config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert threshold_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the threshold config entry is updated with the new entity ID + assert threshold_config_entry.options["entity_id"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] From 273ccb3929c921dbe3f53f15474182852aa96f06 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:17:42 +0200 Subject: [PATCH 0184/1664] Handle changes to source entity in trend helper (#146525) --- homeassistant/components/trend/__init__.py | 26 ++ tests/components/trend/test_init.py | 275 ++++++++++++++++++++- 2 files changed, 299 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index c38730e7591..086ac818c8e 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -6,8 +6,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes PLATFORMS = [Platform.BINARY_SENSOR] @@ -21,6 +23,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # trend does not allow replacing the input entity. + await hass.config_entries.async_remove(entry.entry_id) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 7ffb18de297..4ff6213d082 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -1,15 +1,95 @@ """Test the Trend integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components import trend +from homeassistant.components.trend.config_flow import ConfigFlowHandler from homeassistant.components.trend.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from .conftest import ComponentSetup from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def trend_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a trend config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": sensor_entity_entry.entity_id, + "invert": False, + }, + title="My trend", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_setup_and_remove_config_entry( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -135,3 +215,194 @@ async def test_device_cleaning( trend_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the trend config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + # Check that the trend config entry is removed + assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + # Check that the trend config entry is not removed + assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert trend_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert trend_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the trend config entry is not removed + assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the trend config entry is updated with the new entity ID + assert trend_config_entry.options["entity_id"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + # Check that the trend config entry is not removed + assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] From 2ab32220ed2ad926272f87b34b5a02737760de73 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:17:52 +0200 Subject: [PATCH 0185/1664] Handle changes to source entity in utility_meter (#146526) --- .../components/utility_meter/__init__.py | 25 ++ tests/components/utility_meter/test_init.py | 369 +++++++++++++++++- 2 files changed, 393 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index e2b3411c193..64fa3342c08 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -17,9 +17,11 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from homeassistant.helpers.typing import ConfigType from .const import ( @@ -217,6 +219,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_SOURCE_SENSOR] + ), + source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], + source_entity_removed=source_entity_removed, + ) + ) + if not entry.options.get(CONF_TARIFFS): # Only a single meter sensor is required hass.data[DATA_UTILITY][entry.entry_id][CONF_TARIFF_ENTITY] = None diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index eba7cf913db..ea4af741e19 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -3,10 +3,12 @@ from __future__ import annotations from datetime import timedelta +from unittest.mock import patch from freezegun import freeze_time import pytest +from homeassistant.components import utility_meter from homeassistant.components.select import ( ATTR_OPTION, DOMAIN as SELECT_DOMAIN, @@ -16,7 +18,9 @@ from homeassistant.components.utility_meter import ( select as um_select, sensor as um_sensor, ) +from homeassistant.components.utility_meter.config_flow import ConfigFlowHandler from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -25,14 +29,94 @@ from homeassistant.const import ( Platform, UnitOfEnergy, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, mock_restore_cache +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def utility_meter_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, + tariffs: list[str], +) -> MockConfigEntry: + """Fixture to create a utility_meter config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": sensor_entity_entry.entity_id, + "tariffs": tariffs, + }, + title="My utility meter", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_restore_state(hass: HomeAssistant) -> None: """Test utility sensor restore state.""" config = { @@ -533,3 +617,286 @@ async def test_device_cleaning( utility_meter_config_entry.entry_id ) assert len(devices_after_reload) == 1 + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the utility_meter config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the utility_meter config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Remove the source sensor from the device + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the utility_meter config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries + + # Move the source sensor to another device + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the utility_meter config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert utility_meter_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Change the source entity's entity ID + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the utility_meter config entry is updated with the new entity ID + assert utility_meter_config_entry.options["source"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == [] From bcedb06862e6ac57e9c1891e385a2060fb9b2134 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:10:00 +0200 Subject: [PATCH 0186/1664] Bump linkplay to v0.2.11 (#146530) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index 1bbf70ed3ac..d6319c7a506 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.10"], + "requirements": ["python-linkplay==0.2.11"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index d9a9014a6b9..500fa0676ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.10 +python-linkplay==0.2.11 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66f34dd6d69..1136433d059 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.10 +python-linkplay==0.2.11 # homeassistant.components.lirc # python-lirc==1.2.3 From 91e296a0c81ad41879949943817ad978d3db7e31 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 11 Jun 2025 16:46:52 +0300 Subject: [PATCH 0187/1664] Bump hdate to 1.1.1 (#146536) --- homeassistant/components/jewish_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index c93844dd559..550a6514593 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.1.0"], + "requirements": ["hdate[astral]==1.1.1"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 500fa0676ab..c98f22f9d91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.0 +hdate[astral]==1.1.1 # homeassistant.components.heatmiser heatmiserV3==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1136433d059..d1265ec260d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.0 +hdate[astral]==1.1.1 # homeassistant.components.here_travel_time here-routing==1.0.1 From 232f853d6804d717671cf3c929a7450e12a117a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 12:27:51 +0200 Subject: [PATCH 0188/1664] Simplify helper_integration.async_handle_source_entity_changes (#146516) --- homeassistant/components/derivative/__init__.py | 8 -------- homeassistant/components/switch_as_x/__init__.py | 8 +------- homeassistant/helpers/helper_integration.py | 14 ++++++-------- tests/helpers/test_helper_integration.py | 7 ------- 4 files changed, 7 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 6d539817875..0806a8f824d 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -2,19 +2,15 @@ from __future__ import annotations -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.helper_integration import async_handle_source_entity_changes -from .const import DOMAIN - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" @@ -33,14 +29,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # The source entity has been removed, we need to clean the device links. async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entity_registry = er.async_get(hass) entry.async_on_unload( async_handle_source_entity_changes( hass, helper_config_entry_id=entry.entry_id, - get_helper_entity_id=lambda: entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, entry.entry_id - ), set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE] diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index 7d12ae4aec2..c77eda9b294 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -13,10 +13,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.helper_integration import async_handle_source_entity_changes -from .const import CONF_INVERT, CONF_TARGET_DOMAIN, DOMAIN -from .light import LightSwitch - -__all__ = ["LightSwitch"] +from .const import CONF_INVERT, CONF_TARGET_DOMAIN _LOGGER = logging.getLogger(__name__) @@ -72,9 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_handle_source_entity_changes( hass, helper_config_entry_id=entry.entry_id, - get_helper_entity_id=lambda: entity_registry.async_get_entity_id( - entry.options[CONF_TARGET_DOMAIN], DOMAIN, entry.entry_id - ), set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_add_to_device(hass, entry, entity_id), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index 37aa246178e..61bb0bcd45d 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -15,7 +15,6 @@ def async_handle_source_entity_changes( hass: HomeAssistant, *, helper_config_entry_id: str, - get_helper_entity_id: Callable[[], str | None], set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, @@ -37,8 +36,6 @@ def async_handle_source_entity_changes( to no device, and the helper config entry removed from the old device. Then the helper config entry is reloaded. - :param get_helper_entity: A function which returns the helper entity's entity ID, - or None if the helper entity does not exist. :param set_source_entity_id_or_uuid: A function which updates the source entity ID or UUID, e.g., in the helper config entry options. :param source_entity_removed: A function which is called when the source entity @@ -81,13 +78,14 @@ def async_handle_source_entity_changes( return # The source entity has been moved to a different device, update the helper - # helper entity to link to the new device and the helper device to include - # the helper config entry - helper_entity_id = get_helper_entity_id() - if helper_entity_id: + # entities to link to the new device and the helper device to include the + # helper config entry + for helper_entity in entity_registry.entities.get_entries_for_config_entry_id( + helper_config_entry_id + ): # Update the helper entity to link to the new device (or no device) entity_registry.async_update_entity( - helper_entity_id, device_id=source_entity_entry.device_id + helper_entity.entity_id, device_id=source_entity_entry.device_id ) if source_entity_entry.device_id is not None: diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 12433894dc7..47f1b62feb7 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -156,18 +156,11 @@ def mock_helper_integration( ) -> None: """Mock the helper integration.""" - def get_helper_entity_id() -> str | None: - """Get the helper entity ID.""" - return entity_registry.async_get_entity_id( - "sensor", HELPER_DOMAIN, helper_config_entry.entry_id - ) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Mock setup entry.""" async_handle_source_entity_changes( hass, helper_config_entry_id=helper_config_entry.entry_id, - get_helper_entity_id=get_helper_entity_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=source_entity_entry.device_id, source_entity_id_or_uuid=helper_config_entry.options["source"], From c02707a90f09e30ba1eab5a7c8c990f19de4569b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:17:19 +0200 Subject: [PATCH 0189/1664] Handle changes to source entity in statistics helper (#146523) --- .../components/statistics/__init__.py | 26 ++ tests/components/statistics/test_init.py | 283 +++++++++++++++++- 2 files changed, 305 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index f71274e0ee7..f800c82f1f9 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -4,8 +4,10 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] @@ -20,6 +22,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_ENTITY_ID], ) + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_ENTITY_ID: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we remove the config entry because + # statistics does not allow replacing the input entity. + await hass.config_entries.async_remove(entry.entry_id) + + entry.async_on_unload( + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_ENTITY_ID] + ), + source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], + source_entity_removed=source_entity_removed, + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index 64829ea7d66..c11045a2eb2 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -2,14 +2,98 @@ from __future__ import annotations -from homeassistant.components.statistics import DOMAIN as STATISTICS_DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import statistics +from homeassistant.components.statistics import DOMAIN +from homeassistant.components.statistics.config_flow import StatisticsConfigFlowHandler +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def statistics_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a statistics config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": sensor_entity_entry.entity_id, + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=StatisticsConfigFlowHandler.VERSION, + minor_version=StatisticsConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" @@ -51,7 +135,7 @@ async def test_device_cleaning( # Configure the configuration entry for Statistics statistics_config_entry = MockConfigEntry( data={}, - domain=STATISTICS_DOMAIN, + domain=DOMAIN, options={ "name": "Statistics", "entity_id": "sensor.test_source", @@ -107,3 +191,194 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the statistics config entry is removed when the source entity is removed.""" + # Add another config entry to the sensor device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + # Check that the statistics config entry is removed + assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity removed from the source device.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor from the device + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is removed from the device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + # Check that the statistics config entry is not removed + assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity is moved to another device.""" + sensor_device_2 = device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert statistics_config_entry.entry_id not in sensor_device_2.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Move the source sensor to another device + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, device_id=sensor_device_2.id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is moved to the other device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + sensor_device_2 = device_registry.async_get(sensor_device_2.id) + assert statistics_config_entry.entry_id in sensor_device_2.config_entries + + # Check that the statistics config entry is not removed + assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the source entity's entity ID is changed.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + sensor_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the statistics config entry is updated with the new entity ID + assert statistics_config_entry.options["entity_id"] == "sensor.new_entity_id" + + # Check that the helper config is still in the device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + # Check that the statistics config entry is not removed + assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] From e73bcc73b58360377dd04b839987c0d0cd3d3016 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Jun 2025 17:26:20 +0000 Subject: [PATCH 0190/1664] Bump version to 2025.6.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6cbe42d6dbe..8c4fd0bf774 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index f0aa8169054..21717881d35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b7" +version = "2025.6.0b8" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 59aba339d8edb18a079f0ef4a496ca0198475476 Mon Sep 17 00:00:00 2001 From: rappenze Date: Wed, 11 Jun 2025 19:56:38 +0200 Subject: [PATCH 0191/1664] Add support for more cover devices in Fibaro (#146486) --- homeassistant/components/fibaro/cover.py | 109 +++++++---- tests/components/fibaro/conftest.py | 40 +++- tests/components/fibaro/test_cover.py | 228 +++++++++++++++++++++-- 3 files changed, 329 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index 0008b56345e..e2027120d43 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -28,45 +28,36 @@ async def async_setup_entry( ) -> None: """Set up the Fibaro covers.""" controller = entry.runtime_data - async_add_entities( - [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], - True, - ) + + entities: list[FibaroEntity] = [] + for device in controller.fibaro_devices[Platform.COVER]: + # Positionable covers report the position over value + if device.value.has_value: + entities.append(PositionableFibaroCover(device)) + else: + entities.append(FibaroCover(device)) + async_add_entities(entities, True) -class FibaroCover(FibaroEntity, CoverEntity): - """Representation a Fibaro Cover.""" +class PositionableFibaroCover(FibaroEntity, CoverEntity): + """Representation of a fibaro cover which supports positioning.""" def __init__(self, fibaro_device: DeviceModel) -> None: - """Initialize the Vera device.""" + """Initialize the device.""" super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - if self._is_open_close_only(): - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - ) - if "stop" in self.fibaro_device.actions: - self._attr_supported_features |= CoverEntityFeature.STOP - @staticmethod - def bound(position): + def bound(position: int | None) -> int | None: """Normalize the position.""" if position is None: return None - position = int(position) if position <= 5: return 0 if position >= 95: return 100 return position - def _is_open_close_only(self) -> bool: - """Return if only open / close is supported.""" - # Normally positionable devices report the position over value, - # so if it is missing we have a device which supports open / close only - return not self.fibaro_device.value.has_value - def update(self) -> None: """Update the state.""" super().update() @@ -74,20 +65,15 @@ class FibaroCover(FibaroEntity, CoverEntity): self._attr_current_cover_position = self.bound(self.level) self._attr_current_cover_tilt_position = self.bound(self.level2) - device_state = self.fibaro_device.state - # Be aware that opening and closing is only available for some modern # devices. # For example the Fibaro Roller Shutter 4 reports this correctly. - if device_state.has_value: - self._attr_is_opening = device_state.str_value().lower() == "opening" - self._attr_is_closing = device_state.str_value().lower() == "closing" + device_state = self.fibaro_device.state.str_value(default="").lower() + self._attr_is_opening = device_state == "opening" + self._attr_is_closing = device_state == "closing" closed: bool | None = None - if self._is_open_close_only(): - if device_state.has_value and device_state.str_value().lower() != "unknown": - closed = device_state.str_value().lower() == "closed" - elif self.current_cover_position is not None: + if self.current_cover_position is not None: closed = self.current_cover_position == 0 self._attr_is_closed = closed @@ -96,7 +82,7 @@ class FibaroCover(FibaroEntity, CoverEntity): self.set_level(cast(int, kwargs.get(ATTR_POSITION))) def set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" + """Move the slats to a specific position.""" self.set_level2(cast(int, kwargs.get(ATTR_TILT_POSITION))) def open_cover(self, **kwargs: Any) -> None: @@ -118,3 +104,62 @@ class FibaroCover(FibaroEntity, CoverEntity): def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.action("stop") + + +class FibaroCover(FibaroEntity, CoverEntity): + """Representation of a fibaro cover which supports only open / close commands.""" + + def __init__(self, fibaro_device: DeviceModel) -> None: + """Initialize the device.""" + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + if "stop" in self.fibaro_device.actions: + self._attr_supported_features |= CoverEntityFeature.STOP + if "rotateSlatsUp" in self.fibaro_device.actions: + self._attr_supported_features |= CoverEntityFeature.OPEN_TILT + if "rotateSlatsDown" in self.fibaro_device.actions: + self._attr_supported_features |= CoverEntityFeature.CLOSE_TILT + if "stopSlats" in self.fibaro_device.actions: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + + def update(self) -> None: + """Update the state.""" + super().update() + + device_state = self.fibaro_device.state.str_value(default="").lower() + + self._attr_is_opening = device_state == "opening" + self._attr_is_closing = device_state == "closing" + + closed: bool | None = None + if device_state not in {"", "unknown"}: + closed = device_state == "closed" + self._attr_is_closed = closed + + def open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + self.action("open") + + def close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + self.action("close") + + def stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + self.action("stop") + + def open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover slats.""" + self.action("rotateSlatsUp") + + def close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover slats.""" + self.action("rotateSlatsDown") + + def stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover slats turning.""" + self.action("stopSlats") diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index fde92faa673..bf1fb53621a 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -83,8 +83,8 @@ def mock_power_sensor() -> Mock: @pytest.fixture -def mock_cover() -> Mock: - """Fixture for a cover.""" +def mock_positionable_cover() -> Mock: + """Fixture for a positionable cover.""" cover = Mock() cover.fibaro_id = 3 cover.parent_fibaro_id = 0 @@ -112,6 +112,42 @@ def mock_cover() -> Mock: return cover +@pytest.fixture +def mock_cover() -> Mock: + """Fixture for a cover supporting slats but without positioning.""" + cover = Mock() + cover.fibaro_id = 4 + cover.parent_fibaro_id = 0 + cover.name = "Test cover" + cover.room_id = 1 + cover.dead = False + cover.visible = True + cover.enabled = True + cover.type = "com.fibaro.baseShutter" + cover.base_type = "com.fibaro.actor" + cover.properties = {"manufacturer": ""} + cover.actions = { + "open": 0, + "close": 0, + "stop": 0, + "rotateSlatsUp": 0, + "rotateSlatsDown": 0, + "stopSlats": 0, + } + cover.supported_features = {} + value_mock = Mock() + value_mock.has_value = False + cover.value = value_mock + value2_mock = Mock() + value2_mock.has_value = False + cover.value_2 = value2_mock + state_mock = Mock() + state_mock.has_value = True + state_mock.str_value.return_value = "closed" + cover.state = state_mock + return cover + + @pytest.fixture def mock_light() -> Mock: """Fixture for a dimmmable light.""" diff --git a/tests/components/fibaro/test_cover.py b/tests/components/fibaro/test_cover.py index d5b08f7d1f8..23c704415da 100644 --- a/tests/components/fibaro/test_cover.py +++ b/tests/components/fibaro/test_cover.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch -from homeassistant.components.cover import CoverState +from homeassistant.components.cover import CoverEntityFeature, CoverState from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -12,6 +12,98 @@ from .conftest import init_integration from tests.common import MockConfigEntry +async def test_positionable_cover_setup( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_positionable_cover: Mock, + mock_room: Mock, +) -> None: + """Test that the cover creates an entity.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_positionable_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + entry = entity_registry.async_get("cover.room_1_test_cover_3") + assert entry + assert entry.supported_features == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + assert entry.unique_id == "hc2_111111.3" + assert entry.original_name == "Room 1 Test cover" + + +async def test_cover_opening( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_positionable_cover: Mock, + mock_room: Mock, +) -> None: + """Test that the cover opening state is reported.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_positionable_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPENING + + +async def test_cover_opening_closing_none( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_positionable_cover: Mock, + mock_room: Mock, +) -> None: + """Test that the cover opening closing states return None if not available.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_positionable_cover.state.str_value.return_value = "" + mock_fibaro_client.read_devices.return_value = [mock_positionable_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPEN + + +async def test_cover_closing( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_positionable_cover: Mock, + mock_room: Mock, +) -> None: + """Test that the cover closing state is reported.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_positionable_cover.state.str_value.return_value = "closing" + mock_fibaro_client.read_devices.return_value = [mock_positionable_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.CLOSING + + async def test_cover_setup( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -30,20 +122,28 @@ async def test_cover_setup( # Act await init_integration(hass, mock_config_entry) # Assert - entry = entity_registry.async_get("cover.room_1_test_cover_3") + entry = entity_registry.async_get("cover.room_1_test_cover_4") assert entry - assert entry.unique_id == "hc2_111111.3" + assert entry.supported_features == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + ) + assert entry.unique_id == "hc2_111111.4" assert entry.original_name == "Room 1 Test cover" -async def test_cover_opening( +async def test_cover_open_action( hass: HomeAssistant, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_cover: Mock, mock_room: Mock, ) -> None: - """Test that the cover opening state is reported.""" + """Test that open_cover works.""" # Arrange mock_fibaro_client.read_rooms.return_value = [mock_room] @@ -52,47 +152,147 @@ async def test_cover_opening( with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): # Act await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "cover", + "open_cover", + {"entity_id": "cover.room_1_test_cover_4"}, + blocking=True, + ) + # Assert - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPENING + mock_cover.execute_action.assert_called_once_with("open", ()) -async def test_cover_opening_closing_none( +async def test_cover_close_action( hass: HomeAssistant, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_cover: Mock, mock_room: Mock, ) -> None: - """Test that the cover opening closing states return None if not available.""" + """Test that close_cover works.""" # Arrange mock_fibaro_client.read_rooms.return_value = [mock_room] - mock_cover.state.has_value = False mock_fibaro_client.read_devices.return_value = [mock_cover] with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): # Act await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "cover", + "close_cover", + {"entity_id": "cover.room_1_test_cover_4"}, + blocking=True, + ) + # Assert - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.OPEN + mock_cover.execute_action.assert_called_once_with("close", ()) -async def test_cover_closing( +async def test_cover_stop_action( hass: HomeAssistant, mock_fibaro_client: Mock, mock_config_entry: MockConfigEntry, mock_cover: Mock, mock_room: Mock, ) -> None: - """Test that the cover closing state is reported.""" + """Test that stop_cover works.""" # Arrange mock_fibaro_client.read_rooms.return_value = [mock_room] - mock_cover.state.str_value.return_value = "closing" mock_fibaro_client.read_devices.return_value = [mock_cover] with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): # Act await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "cover", + "stop_cover", + {"entity_id": "cover.room_1_test_cover_4"}, + blocking=True, + ) + # Assert - assert hass.states.get("cover.room_1_test_cover_3").state == CoverState.CLOSING + mock_cover.execute_action.assert_called_once_with("stop", ()) + + +async def test_cover_open_slats_action( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_cover: Mock, + mock_room: Mock, +) -> None: + """Test that open_cover_tilt works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "cover", + "open_cover_tilt", + {"entity_id": "cover.room_1_test_cover_4"}, + blocking=True, + ) + + # Assert + mock_cover.execute_action.assert_called_once_with("rotateSlatsUp", ()) + + +async def test_cover_close_tilt_action( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_cover: Mock, + mock_room: Mock, +) -> None: + """Test that close_cover_tilt works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "cover", + "close_cover_tilt", + {"entity_id": "cover.room_1_test_cover_4"}, + blocking=True, + ) + + # Assert + mock_cover.execute_action.assert_called_once_with("rotateSlatsDown", ()) + + +async def test_cover_stop_slats_action( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_cover: Mock, + mock_room: Mock, +) -> None: + """Test that stop_cover_tilt works.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_cover] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.COVER]): + # Act + await init_integration(hass, mock_config_entry) + await hass.services.async_call( + "cover", + "stop_cover_tilt", + {"entity_id": "cover.room_1_test_cover_4"}, + blocking=True, + ) + + # Assert + mock_cover.execute_action.assert_called_once_with("stopSlats", ()) From f4e503627574d28d12b2489abc4c8868b1ab1ab9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 11 Jun 2025 19:58:28 +0200 Subject: [PATCH 0192/1664] New helper for templating args in command_line (#145899) --- .../components/command_line/notify.py | 27 ++--------- .../components/command_line/sensor.py | 33 ++----------- .../components/command_line/utils.py | 47 +++++++++++++++---- tests/components/command_line/test_notify.py | 3 +- 4 files changed, 48 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 50bfbe651ef..b0031e4d5ee 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -9,12 +9,11 @@ from typing import Any from homeassistant.components.notify import BaseNotificationService from homeassistant.const import CONF_COMMAND from homeassistant.core import HomeAssistant -from homeassistant.exceptions import TemplateError -from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess from .const import CONF_COMMAND_TIMEOUT, LOGGER +from .utils import render_template_args _LOGGER = logging.getLogger(__name__) @@ -45,28 +44,10 @@ class CommandLineNotificationService(BaseNotificationService): def send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a command line.""" - command = self.command - if " " not in command: - prog = command - args = None - args_compiled = None - else: - prog, args = command.split(" ", 1) - args_compiled = Template(args, self.hass) + if not (command := render_template_args(self.hass, self.command)): + return - rendered_args = None - if args_compiled: - args_to_render = {"arguments": args} - try: - rendered_args = args_compiled.async_render(args_to_render) - except TemplateError as ex: - LOGGER.exception("Error rendering command template: %s", ex) - return - - if rendered_args != args: - command = f"{prog} {rendered_args}" - - LOGGER.debug("Running command: %s, with message: %s", command, message) + LOGGER.debug("Running with message: %s", message) with subprocess.Popen( # noqa: S602 # shell by design command, diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 5ce50edc4e7..dfc31b4581b 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -19,7 +19,6 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import TemplateError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.template import Template @@ -37,7 +36,7 @@ from .const import ( LOGGER, TRIGGER_ENTITY_OPTIONS, ) -from .utils import async_check_output_or_log +from .utils import async_check_output_or_log, render_template_args DEFAULT_NAME = "Command Sensor" @@ -222,32 +221,6 @@ class CommandSensorData: async def async_update(self) -> None: """Get the latest data with a shell command.""" - command = self.command - - if " " not in command: - prog = command - args = None - args_compiled = None - else: - prog, args = command.split(" ", 1) - args_compiled = Template(args, self.hass) - - if args_compiled: - try: - args_to_render = {"arguments": args} - rendered_args = args_compiled.async_render(args_to_render) - except TemplateError as ex: - LOGGER.exception("Error rendering command template: %s", ex) - return - else: - rendered_args = None - - if rendered_args == args: - # No template used. default behavior - pass - else: - # Template used. Construct the string used in the shell - command = f"{prog} {rendered_args}" - - LOGGER.debug("Running command: %s", command) + if not (command := render_template_args(self.hass, self.command)): + return self.value = await async_check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index c1926546950..607340c4853 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -3,9 +3,13 @@ from __future__ import annotations import asyncio -import logging -_LOGGER = logging.getLogger(__name__) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers.template import Template + +from .const import LOGGER + _EXEC_FAILED_CODE = 127 @@ -18,7 +22,7 @@ async def async_call_shell_with_timeout( return code is returned. """ try: - _LOGGER.debug("Running command: %s", command) + LOGGER.debug("Running command: %s", command) proc = await asyncio.create_subprocess_shell( # shell by design command, close_fds=False, # required for posix_spawn @@ -26,14 +30,14 @@ async def async_call_shell_with_timeout( async with asyncio.timeout(timeout): await proc.communicate() except TimeoutError: - _LOGGER.error("Timeout for command: %s", command) + LOGGER.error("Timeout for command: %s", command) return -1 return_code = proc.returncode if return_code == _EXEC_FAILED_CODE: - _LOGGER.error("Error trying to exec command: %s", command) + LOGGER.error("Error trying to exec command: %s", command) elif log_return_code and return_code != 0: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", proc.returncode, command, @@ -53,12 +57,39 @@ async def async_check_output_or_log(command: str, timeout: int) -> str | None: stdout, _ = await proc.communicate() if proc.returncode != 0: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", proc.returncode, command ) else: return stdout.strip().decode("utf-8") except TimeoutError: - _LOGGER.error("Timeout for command: %s", command) + LOGGER.error("Timeout for command: %s", command) return None + + +def render_template_args(hass: HomeAssistant, command: str) -> str | None: + """Render template arguments for command line utilities.""" + if " " not in command: + prog = command + args = None + args_compiled = None + else: + prog, args = command.split(" ", 1) + args_compiled = Template(args, hass) + + rendered_args = None + if args_compiled: + args_to_render = {"arguments": args} + try: + rendered_args = args_compiled.async_render(args_to_render) + except TemplateError as ex: + LOGGER.exception("Error rendering command template: %s", ex) + return None + + if rendered_args != args: + command = f"{prog} {rendered_args}" + + LOGGER.debug("Running command: %s", command) + + return command diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index a0c69765c9a..30523e8c740 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -126,7 +126,8 @@ async def test_command_line_output_single_command( await hass.services.async_call( NOTIFY_DOMAIN, "test3", {"message": "test message"}, blocking=True ) - assert "Running command: echo, with message: test message" in caplog.text + assert "Running command: echo" in caplog.text + assert "Running with message: test message" in caplog.text async def test_command_template(hass: HomeAssistant) -> None: From fd605e0abe97d7176aa1f1e376ec2f0dc5e2e1a3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:18:04 +0200 Subject: [PATCH 0193/1664] Handle changes to source entities in generic_hygrostat helper (#146538) --- .../components/generic_hygrostat/__init__.py | 59 ++- .../components/generic_hygrostat/test_init.py | 428 +++++++++++++++++- 2 files changed, 480 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index b4a6014c5a4..a12994c1a75 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -5,11 +5,18 @@ import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import ( + config_validation as cv, + discovery, + entity_registry as er, +) from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes from homeassistant.helpers.typing import ConfigType DOMAIN = "generic_hygrostat" @@ -88,6 +95,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.options[CONF_HUMIDIFIER], ) + def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the humidifer, + # but not the humidity sensor because the generic_hygrostat adds itself to the + # humidifier's device. + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_HUMIDIFIER] + ), + source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER], + source_entity_removed=source_entity_removed, + ) + ) + + async def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SENSOR: data["entity_id"]}, + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[CONF_SENSOR], async_sensor_updated + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, (Platform.HUMIDIFIER,)) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index bd4792f939d..254d4da5806 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -2,17 +2,136 @@ from __future__ import annotations -from homeassistant.components.generic_hygrostat import ( - DOMAIN as GENERIC_HYDROSTAT_DOMAIN, -) -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import generic_hygrostat +from homeassistant.components.generic_hygrostat import DOMAIN +from homeassistant.components.generic_hygrostat.config_flow import ConfigFlowHandler +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from .test_humidifier import ENT_SENSOR from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def switch_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a switch config entry.""" + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + return switch_config_entry + + +@pytest.fixture +def switch_device( + device_registry: dr.DeviceRegistry, switch_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a switch device.""" + return device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def switch_entity_entry( + entity_registry: er.EntityRegistry, + switch_config_entry: ConfigEntry, + switch_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a switch entity entry.""" + return entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=switch_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def generic_hygrostat_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, + switch_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a generic_hygrostat config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": switch_entity_entry.entity_id, + "name": "My generic hygrostat", + "target_sensor": sensor_entity_entry.entity_id, + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_device_cleaning( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -45,7 +164,7 @@ async def test_device_cleaning( # Configure the configuration entry for helper helper_config_entry = MockConfigEntry( data={}, - domain=GENERIC_HYDROSTAT_DOMAIN, + domain=DOMAIN, options={ "device_class": "humidifier", "dry_tolerance": 2.0, @@ -100,3 +219,302 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "expected_events"), + [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + expected_events: list[str], +) -> None: + """Test the generic_hygrostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check if the generic_hygrostat config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity from the device + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_hygrostat config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + source_device_2 = device_registry.async_get(source_device_2.id) + assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Move the source entity to another device + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_hygrostat config entry is moved to the other device + source_device = device_registry.async_get(source_device.id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device_2.config_entries + ) == helper_in_device + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + [ + ("switch.test_unique", "switch.new_entity_id", True, "humidifier"), + ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + new_entity_id: str, + helper_in_device: bool, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id=new_entity_id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the generic_hygrostat config entry is updated with the new entity ID + assert generic_hygrostat_config_entry.options[config_key] == new_entity_id + + # Check that the helper config is still in the device + source_device = device_registry.async_get(source_device.id) + assert ( + generic_hygrostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == [] From 89637a618eb82626c2b70ac902418e7e85efac71 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Jun 2025 15:26:52 +0200 Subject: [PATCH 0194/1664] Handle changes to source entities in generic_thermostat helper (#146541) --- .../components/generic_thermostat/__init__.py | 57 ++- .../generic_thermostat/test_init.py | 428 +++++++++++++++++- 2 files changed, 482 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index dc43049a262..3e2af8598de 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,12 +1,16 @@ """The generic_thermostat component.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device import ( + async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import async_handle_source_entity_changes -from .const import CONF_HEATER, PLATFORMS +from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -17,6 +21,55 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.entry_id, entry.options[CONF_HEATER], ) + + def set_humidifier_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_HEATER: source_entity_id}, + ) + + async def source_entity_removed() -> None: + # The source entity has been removed, we need to clean the device links. + async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the heater, but + # not the temperature sensor because the generic_hygrostat adds itself to the + # heater's device. + async_handle_source_entity_changes( + hass, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_HEATER] + ), + source_entity_id_or_uuid=entry.options[CONF_HEATER], + source_entity_removed=source_entity_removed, + ) + ) + + async def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_SENSOR: data["entity_id"]}, + ) + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[CONF_SENSOR], async_sensor_updated + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) return True diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index addae2f684e..9131e3ffdd4 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -2,13 +2,134 @@ from __future__ import annotations +from unittest.mock import patch + +import pytest + +from homeassistant.components import generic_thermostat +from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler from homeassistant.components.generic_thermostat.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def switch_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a switch config entry.""" + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + return switch_config_entry + + +@pytest.fixture +def switch_device( + device_registry: dr.DeviceRegistry, switch_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a switch device.""" + return device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def switch_entity_entry( + entity_registry: er.EntityRegistry, + switch_config_entry: ConfigEntry, + switch_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a switch entity entry.""" + return entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=switch_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def generic_thermostat_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, + switch_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a generic_thermostat config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": switch_entity_entry.entity_id, + "target_sensor": sensor_entity_entry.entity_id, + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_device_cleaning( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -96,3 +217,308 @@ async def test_device_cleaning( assert len(devices_after_reload) == 1 assert devices_after_reload[0].id == source_device1_entry.id + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "expected_events"), + [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + expected_events: list[str], +) -> None: + """Test the generic_thermostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check if the generic_thermostat config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity from the device + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_thermostat config entry is removed from the device + source_device = device_registry.async_get(source_device.id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), + [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + helper_in_device: bool, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + source_device_2 = device_registry.async_get(source_device_2.id) + assert ( + generic_thermostat_config_entry.entry_id not in source_device_2.config_entries + ) + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Move the source entity to another device + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the generic_thermostat config entry is moved to the other device + source_device = device_registry.async_get(source_device.id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert ( + generic_thermostat_config_entry.entry_id in source_device_2.config_entries + ) == helper_in_device + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + [ + ("switch.test_unique", "switch.new_entity_id", True, "heater"), + ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + new_entity_id: str, + helper_in_device: bool, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id=new_entity_id + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the generic_thermostat config entry is updated with the new entity ID + assert generic_thermostat_config_entry.options[config_key] == new_entity_id + + # Check that the helper config is still in the device + source_device = device_registry.async_get(source_device.id) + assert ( + generic_thermostat_config_entry.entry_id in source_device.config_entries + ) == helper_in_device + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == [] From 43797c03cccf42f0df991607e099edea05cd1552 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 11 Jun 2025 15:46:19 +0200 Subject: [PATCH 0195/1664] Update frontend to 20250531.1 (#146542) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7282482f329..5c3b8ed2264 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.0"] + "requirements": ["home-assistant-frontend==20250531.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b5d1af412f9..921de18a732 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.0 +home-assistant-frontend==20250531.1 home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c98f22f9d91..91268677b43 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.0 +home-assistant-frontend==20250531.1 # homeassistant.components.conversation home-assistant-intents==2025.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1265ec260d..a9ac282ad4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,7 +1010,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.0 +home-assistant-frontend==20250531.1 # homeassistant.components.conversation home-assistant-intents==2025.6.10 From 1f221712a25635d68f975d3b38e0378ab36d1f7f Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 11 Jun 2025 16:39:02 +0300 Subject: [PATCH 0196/1664] Remove the Delete button on the ZwaveJS device page (#146544) --- homeassistant/components/zwave_js/__init__.py | 32 ------------------- tests/components/zwave_js/test_init.py | 21 ------------ 2 files changed, 53 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index abbf10fb494..e8f2bf6f2d4 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -1119,38 +1119,6 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) -async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry -) -> bool: - """Remove a config entry from a device.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] - - # Driver may not be ready yet so we can't allow users to remove a device since - # we need to check if the device is still known to the controller - if (driver := client.driver) is None: - LOGGER.error("Driver for %s is not ready", config_entry.title) - return False - - # If a node is found on the controller that matches the hardware based identifier - # on the device, prevent the device from being removed. - if next( - ( - node - for node in driver.controller.nodes.values() - if get_device_id_ext(driver, node) in device_entry.identifiers - ), - None, - ): - return False - - controller_events: ControllerEvents = config_entry.runtime_data[ - DATA_DRIVER_EVENTS - ].controller_events - controller_events.registered_unique_ids.pop(device_entry.id, None) - controller_events.discovered_value_ids.pop(device_entry.id, None) - return True - - async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" addon_manager = _get_addon_manager(hass) diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index a0423efdf52..ef74373ad9e 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1692,27 +1692,6 @@ async def test_replace_different_node( (DOMAIN, multisensor_6_device_id_ext), } - ws_client = await hass_ws_client(hass) - - # Simulate the driver not being ready to ensure that the device removal handler - # does not crash - driver = client.driver - client.driver = None - - response = await ws_client.remove_device(hank_device.id, integration.entry_id) - assert not response["success"] - - client.driver = driver - - # Attempting to remove the hank device should pass, but removing the multisensor should not - response = await ws_client.remove_device(hank_device.id, integration.entry_id) - assert response["success"] - - response = await ws_client.remove_device( - multisensor_6_device.id, integration.entry_id - ) - assert not response["success"] - async def test_node_model_change( hass: HomeAssistant, From 75e6f23a82bf40f41913a4bf6d9991a4444068e5 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 11 Jun 2025 17:12:34 +0200 Subject: [PATCH 0197/1664] Update frontend to 20250531.2 (#146551) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 5c3b8ed2264..4299d2b7503 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.1"] + "requirements": ["home-assistant-frontend==20250531.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 921de18a732..d0904cd12b3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.1 +home-assistant-frontend==20250531.2 home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 91268677b43..c73829cf379 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.1 +home-assistant-frontend==20250531.2 # homeassistant.components.conversation home-assistant-intents==2025.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9ac282ad4e..6af2c4ecc6e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,7 +1010,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.1 +home-assistant-frontend==20250531.2 # homeassistant.components.conversation home-assistant-intents==2025.6.10 From 60b8230eccb213b16faf92a9c7e117247f020906 Mon Sep 17 00:00:00 2001 From: andreimoraru Date: Wed, 11 Jun 2025 18:53:25 +0300 Subject: [PATCH 0198/1664] Bump yt-dlp to 2025.06.09 (#146553) * Bumped yt-dlp to 2025.06.09 * fix --------- Co-authored-by: Joostlek --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 3ce80f497ef..20068efccef 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -8,6 +8,6 @@ "iot_class": "calculated", "loggers": ["yt_dlp"], "quality_scale": "internal", - "requirements": ["yt-dlp[default]==2025.05.22"], + "requirements": ["yt-dlp[default]==2025.06.09"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index c73829cf379..b70c806c1be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3162,7 +3162,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.05.22 +yt-dlp[default]==2025.06.09 # homeassistant.components.zabbix zabbix-utils==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6af2c4ecc6e..39fe3779466 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2606,7 +2606,7 @@ youless-api==2.2.0 youtubeaio==1.1.5 # homeassistant.components.media_extractor -yt-dlp[default]==2025.05.22 +yt-dlp[default]==2025.06.09 # homeassistant.components.zamg zamg==0.3.6 From aca0e6908109feae621fbf82e3d71dbefaf8753f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:01:13 +0200 Subject: [PATCH 0199/1664] Simplify service registration in recorder (#146237) --- homeassistant/components/recorder/__init__.py | 4 +- homeassistant/components/recorder/services.py | 216 ++++++++---------- 2 files changed, 101 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index c0bffbe9615..a350feac519 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -45,7 +45,7 @@ from .const import ( # noqa: F401 SupportedDialect, ) from .core import Recorder -from .services import async_register_services +from .services import async_setup_services from .tasks import AddRecorderPlatformTask from .util import get_instance @@ -174,7 +174,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: instance.async_initialize() instance.async_register() instance.start() - async_register_services(hass, instance) + async_setup_services(hass) websocket_api.async_setup(hass) await _async_setup_integration_platform(hass, instance) diff --git a/homeassistant/components/recorder/services.py b/homeassistant/components/recorder/services.py index ba454c59bf3..ca92a2131d8 100644 --- a/homeassistant/components/recorder/services.py +++ b/homeassistant/components/recorder/services.py @@ -17,6 +17,7 @@ from homeassistant.core import ( ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entityfilter import generate_filter +from homeassistant.helpers.recorder import DATA_INSTANCE from homeassistant.helpers.service import ( async_extract_entity_ids, async_register_admin_service, @@ -25,7 +26,6 @@ from homeassistant.util import dt as dt_util from homeassistant.util.json import JsonArrayType, JsonObjectType from .const import ATTR_APPLY_FILTER, ATTR_KEEP_DAYS, ATTR_REPACK, DOMAIN -from .core import Recorder from .statistics import statistics_during_period from .tasks import PurgeEntitiesTask, PurgeTask @@ -87,155 +87,137 @@ SERVICE_GET_STATISTICS_SCHEMA = vol.Schema( ) -@callback -def _async_register_purge_service(hass: HomeAssistant, instance: Recorder) -> None: - async def async_handle_purge_service(service: ServiceCall) -> None: - """Handle calls to the purge service.""" - kwargs = service.data - keep_days = kwargs.get(ATTR_KEEP_DAYS, instance.keep_days) - repack = cast(bool, kwargs[ATTR_REPACK]) - apply_filter = cast(bool, kwargs[ATTR_APPLY_FILTER]) - purge_before = dt_util.utcnow() - timedelta(days=keep_days) - instance.queue_task(PurgeTask(purge_before, repack, apply_filter)) +async def _async_handle_purge_service(service: ServiceCall) -> None: + """Handle calls to the purge service.""" + hass = service.hass + instance = hass.data[DATA_INSTANCE] + kwargs = service.data + keep_days = kwargs.get(ATTR_KEEP_DAYS, instance.keep_days) + repack = cast(bool, kwargs[ATTR_REPACK]) + apply_filter = cast(bool, kwargs[ATTR_APPLY_FILTER]) + purge_before = dt_util.utcnow() - timedelta(days=keep_days) + instance.queue_task(PurgeTask(purge_before, repack, apply_filter)) + +async def _async_handle_purge_entities_service(service: ServiceCall) -> None: + """Handle calls to the purge entities service.""" + hass = service.hass + entity_ids = await async_extract_entity_ids(hass, service) + domains = service.data.get(ATTR_DOMAINS, []) + keep_days = service.data.get(ATTR_KEEP_DAYS, 0) + entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) + entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) + purge_before = dt_util.utcnow() - timedelta(days=keep_days) + hass.data[DATA_INSTANCE].queue_task(PurgeEntitiesTask(entity_filter, purge_before)) + + +async def _async_handle_enable_service(service: ServiceCall) -> None: + service.hass.data[DATA_INSTANCE].set_enable(True) + + +async def _async_handle_disable_service(service: ServiceCall) -> None: + service.hass.data[DATA_INSTANCE].set_enable(False) + + +async def _async_handle_get_statistics_service( + service: ServiceCall, +) -> ServiceResponse: + """Handle calls to the get_statistics service.""" + hass = service.hass + start_time = dt_util.as_utc(service.data["start_time"]) + end_time = ( + dt_util.as_utc(service.data["end_time"]) if "end_time" in service.data else None + ) + + statistic_ids = service.data["statistic_ids"] + types = service.data["types"] + period = service.data["period"] + units = service.data.get("units") + + result = await hass.data[DATA_INSTANCE].async_add_executor_job( + statistics_during_period, + hass, + start_time, + end_time, + statistic_ids, + period, + units, + types, + ) + + formatted_result: JsonObjectType = {} + for statistic_id, statistic_rows in result.items(): + formatted_statistic_rows: JsonArrayType = [] + + for row in statistic_rows: + formatted_row: JsonObjectType = { + "start": dt_util.utc_from_timestamp(row["start"]).isoformat(), + "end": dt_util.utc_from_timestamp(row["end"]).isoformat(), + } + if (last_reset := row.get("last_reset")) is not None: + formatted_row["last_reset"] = dt_util.utc_from_timestamp( + last_reset + ).isoformat() + if (state := row.get("state")) is not None: + formatted_row["state"] = state + if (sum_value := row.get("sum")) is not None: + formatted_row["sum"] = sum_value + if (min_value := row.get("min")) is not None: + formatted_row["min"] = min_value + if (max_value := row.get("max")) is not None: + formatted_row["max"] = max_value + if (mean := row.get("mean")) is not None: + formatted_row["mean"] = mean + if (change := row.get("change")) is not None: + formatted_row["change"] = change + + formatted_statistic_rows.append(formatted_row) + + formatted_result[statistic_id] = formatted_statistic_rows + + return {"statistics": formatted_result} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register recorder services.""" async_register_admin_service( hass, DOMAIN, SERVICE_PURGE, - async_handle_purge_service, + _async_handle_purge_service, schema=SERVICE_PURGE_SCHEMA, ) - -@callback -def _async_register_purge_entities_service( - hass: HomeAssistant, instance: Recorder -) -> None: - async def async_handle_purge_entities_service(service: ServiceCall) -> None: - """Handle calls to the purge entities service.""" - entity_ids = await async_extract_entity_ids(hass, service) - domains = service.data.get(ATTR_DOMAINS, []) - keep_days = service.data.get(ATTR_KEEP_DAYS, 0) - entity_globs = service.data.get(ATTR_ENTITY_GLOBS, []) - entity_filter = generate_filter(domains, list(entity_ids), [], [], entity_globs) - purge_before = dt_util.utcnow() - timedelta(days=keep_days) - instance.queue_task(PurgeEntitiesTask(entity_filter, purge_before)) - async_register_admin_service( hass, DOMAIN, SERVICE_PURGE_ENTITIES, - async_handle_purge_entities_service, + _async_handle_purge_entities_service, schema=SERVICE_PURGE_ENTITIES_SCHEMA, ) - -@callback -def _async_register_enable_service(hass: HomeAssistant, instance: Recorder) -> None: - async def async_handle_enable_service(service: ServiceCall) -> None: - instance.set_enable(True) - async_register_admin_service( hass, DOMAIN, SERVICE_ENABLE, - async_handle_enable_service, + _async_handle_enable_service, schema=SERVICE_ENABLE_SCHEMA, ) - -@callback -def _async_register_disable_service(hass: HomeAssistant, instance: Recorder) -> None: - async def async_handle_disable_service(service: ServiceCall) -> None: - instance.set_enable(False) - async_register_admin_service( hass, DOMAIN, SERVICE_DISABLE, - async_handle_disable_service, + _async_handle_disable_service, schema=SERVICE_DISABLE_SCHEMA, ) - -@callback -def _async_register_get_statistics_service( - hass: HomeAssistant, instance: Recorder -) -> None: - async def async_handle_get_statistics_service( - service: ServiceCall, - ) -> ServiceResponse: - """Handle calls to the get_statistics service.""" - start_time = dt_util.as_utc(service.data["start_time"]) - end_time = ( - dt_util.as_utc(service.data["end_time"]) - if "end_time" in service.data - else None - ) - - statistic_ids = service.data["statistic_ids"] - types = service.data["types"] - period = service.data["period"] - units = service.data.get("units") - - result = await instance.async_add_executor_job( - statistics_during_period, - hass, - start_time, - end_time, - statistic_ids, - period, - units, - types, - ) - - formatted_result: JsonObjectType = {} - for statistic_id, statistic_rows in result.items(): - formatted_statistic_rows: JsonArrayType = [] - - for row in statistic_rows: - formatted_row: JsonObjectType = { - "start": dt_util.utc_from_timestamp(row["start"]).isoformat(), - "end": dt_util.utc_from_timestamp(row["end"]).isoformat(), - } - if (last_reset := row.get("last_reset")) is not None: - formatted_row["last_reset"] = dt_util.utc_from_timestamp( - last_reset - ).isoformat() - if (state := row.get("state")) is not None: - formatted_row["state"] = state - if (sum_value := row.get("sum")) is not None: - formatted_row["sum"] = sum_value - if (min_value := row.get("min")) is not None: - formatted_row["min"] = min_value - if (max_value := row.get("max")) is not None: - formatted_row["max"] = max_value - if (mean := row.get("mean")) is not None: - formatted_row["mean"] = mean - if (change := row.get("change")) is not None: - formatted_row["change"] = change - - formatted_statistic_rows.append(formatted_row) - - formatted_result[statistic_id] = formatted_statistic_rows - - return {"statistics": formatted_result} - async_register_admin_service( hass, DOMAIN, SERVICE_GET_STATISTICS, - async_handle_get_statistics_service, + _async_handle_get_statistics_service, schema=SERVICE_GET_STATISTICS_SCHEMA, supports_response=SupportsResponse.ONLY, ) - - -@callback -def async_register_services(hass: HomeAssistant, instance: Recorder) -> None: - """Register recorder services.""" - _async_register_purge_service(hass, instance) - _async_register_purge_entities_service(hass, instance) - _async_register_enable_service(hass, instance) - _async_register_disable_service(hass, instance) - _async_register_get_statistics_service(hass, instance) From 8d24d775f1d116bbe44c70b44e62b35195519b1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Wed, 11 Jun 2025 20:04:03 +0200 Subject: [PATCH 0200/1664] Set suggested precision for Airthings sensors (#145966) --- homeassistant/components/airthings/sensor.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index a3e4cebe771..ff30fb2f2ae 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -37,30 +37,35 @@ SENSORS: dict[str, SensorEntityDescription] = { key="radonShortTermAvg", native_unit_of_measurement="Bq/m³", translation_key="radon", + suggested_display_precision=0, ), "temp": SensorEntityDescription( key="temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), "sla": SensorEntityDescription( key="sla", device_class=SensorDeviceClass.SOUND_PRESSURE, native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "battery": SensorEntityDescription( key="battery", @@ -68,40 +73,47 @@ SENSORS: dict[str, SensorEntityDescription] = { native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "co2": SensorEntityDescription( key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "voc": SensorEntityDescription( key="voc", device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "light": SensorEntityDescription( key="light", native_unit_of_measurement=PERCENTAGE, translation_key="light", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "lux": SensorEntityDescription( key="lux", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "virusRisk": SensorEntityDescription( key="virusRisk", translation_key="virus_risk", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "mold": SensorEntityDescription( key="mold", translation_key="mold", state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "rssi": SensorEntityDescription( key="rssi", @@ -110,18 +122,21 @@ SENSORS: dict[str, SensorEntityDescription] = { entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "pm1": SensorEntityDescription( key="pm1", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM1, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), "pm25": SensorEntityDescription( key="pm25", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, ), } From 02524b8b9b287556c65c0d361422ab536cefdd83 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Jun 2025 18:39:46 +0200 Subject: [PATCH 0201/1664] Make issue creation check architecture instead of uname (#146537) --- homeassistant/components/hassio/__init__.py | 35 ++++-- .../components/homeassistant/__init__.py | 30 +++-- .../components/homeassistant/strings.json | 4 +- tests/components/hassio/conftest.py | 13 ++ tests/components/hassio/test_init.py | 113 ++++++++++++++---- tests/components/homeassistant/test_init.py | 110 +++++++++-------- 6 files changed, 202 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 041877e3944..6772034e53f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -9,8 +9,10 @@ from functools import partial import logging import os import re +import struct from typing import Any, NamedTuple +import aiofiles from aiohasupervisor import SupervisorError import voluptuous as vol @@ -56,7 +58,6 @@ from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service_info.hassio import ( HassioServiceInfo as _HassioServiceInfo, ) -from homeassistant.helpers.system_info import async_get_system_info from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -233,6 +234,17 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( ) +def _is_32_bit() -> bool: + size = struct.calcsize("P") + return size * 8 == 32 + + +async def _get_arch() -> str: + async with aiofiles.open("/etc/apk/arch") as arch_file: + raw_arch = await arch_file.read() + return {"x86": "i386"}.get(raw_arch, raw_arch) + + class APIEndpointSettings(NamedTuple): """Settings for API endpoint.""" @@ -554,7 +566,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator - system_info = await async_get_system_info(hass) + arch = await _get_arch() def deprecated_setup_issue() -> None: os_info = get_os_info(hass) @@ -562,20 +574,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if os_info is None or info is None: return is_haos = info.get("hassos") is not None - arch = system_info["arch"] board = os_info.get("board") - supported_board = board in {"rpi3", "rpi4", "tinker", "odroid-xu4", "rpi2"} - if is_haos and arch == "armv7" and supported_board: + unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} + unsupported_os_on_board = board in {"rpi3", "rpi4"} + if is_haos and (unsupported_board or unsupported_os_on_board): issue_id = "deprecated_os_" - if board in {"rpi3", "rpi4"}: + if unsupported_os_on_board: issue_id += "aarch64" - elif board in {"tinker", "odroid-xu4", "rpi2"}: + elif unsupported_board: issue_id += "armv7" ir.async_create_issue( hass, "homeassistant", issue_id, - breaks_in_ha_version="2025.12.0", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, @@ -584,9 +595,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "installation_guide": "https://www.home-assistant.io/installation/", }, ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or (arch == "armv7" and not supported_board): - deprecated_architecture = True + bit32 = _is_32_bit() + deprecated_architecture = bit32 and not ( + unsupported_board or unsupported_os_on_board + ) if not is_haos or deprecated_architecture: issue_id = "deprecated" if not is_haos: @@ -597,7 +609,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, "homeassistant", issue_id, - breaks_in_ha_version="2025.12.0", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 1433358b568..4360fa9c16e 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -4,8 +4,10 @@ import asyncio from collections.abc import Callable, Coroutine import itertools as it import logging +import struct from typing import Any +import aiofiles import voluptuous as vol from homeassistant import config as conf_util, core_config @@ -94,6 +96,17 @@ DEPRECATION_URL = ( ) +def _is_32_bit() -> bool: + size = struct.calcsize("P") + return size * 8 == 32 + + +async def _get_arch() -> str: + async with aiofiles.open("/etc/apk/arch") as arch_file: + raw_arch = (await arch_file.read()).strip() + return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" @@ -403,23 +416,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: installation_type = info["installation_type"][15:] if installation_type in {"Core", "Container"}: deprecated_method = installation_type == "Core" + bit32 = _is_32_bit() arch = info["arch"] - if arch == "armv7" and installation_type == "Container": + if bit32 and installation_type == "Container": + arch = await _get_arch() ir.async_create_issue( hass, DOMAIN, - "deprecated_container_armv7", - breaks_in_ha_version="2025.12.0", + "deprecated_container", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, - translation_key="deprecated_container_armv7", + translation_key="deprecated_container", + translation_placeholders={"arch": arch}, ) - deprecated_architecture = False - if arch in {"i386", "armhf"} or ( - arch == "armv7" and installation_type != "Container" - ): - deprecated_architecture = True + deprecated_architecture = bit32 and installation_type != "Container" if deprecated_method or deprecated_architecture: issue_id = "deprecated" if deprecated_method: @@ -430,7 +441,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass, DOMAIN, issue_id, - breaks_in_ha_version="2025.12.0", learn_more_url=DEPRECATION_URL, is_fixable=False, severity=IssueSeverity.WARNING, diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 93b4105c702..940af999c4d 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -107,9 +107,9 @@ "title": "Deprecation notice: 32-bit architecture", "description": "This system uses 32-bit hardware (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. As your hardware is no longer capable of running newer versions of Home Assistant, you will need to migrate to new hardware." }, - "deprecated_container_armv7": { + "deprecated_container": { "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", - "description": "This system is running on a 32-bit operating system (`armv7`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware." + "description": "This system is running on a 32-bit operating system (`{arch}`), which has been deprecated and will no longer receive updates after the release of Home Assistant 2025.12. Check if your system is capable of running a 64-bit operating system. If not, you will need to migrate to new hardware." }, "deprecated_os_aarch64": { "title": "[%key:component::homeassistant::issues::deprecated_architecture::title%]", diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index a71ee370b32..56f7ffaa5b9 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -260,3 +260,16 @@ def all_setup_requests( }, }, ) + + +@pytest.fixture +def arch() -> str: + """Arch found in apk file.""" + return "amd64" + + +@pytest.fixture(autouse=True) +def mock_arch_file(arch: str) -> Generator[None]: + """Mock arch file.""" + with patch("homeassistant.components.hassio._get_arch", return_value=arch): + yield diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f74ed852a49..f424beedc85 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1156,7 +1156,11 @@ def test_deprecated_constants( ("rpi2", "deprecated_os_armv7"), ], ) -async def test_deprecated_installation_issue_aarch64( +@pytest.mark.parametrize( + "arch", + ["armv7"], +) +async def test_deprecated_installation_issue_os_armv7( hass: HomeAssistant, issue_registry: ir.IssueRegistry, freezer: FrozenDateTimeFactory, @@ -1167,18 +1171,15 @@ async def test_deprecated_installation_issue_aarch64( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": "armv7", }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "armv7", - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", return_value={"board": board} @@ -1228,7 +1229,7 @@ async def test_deprecated_installation_issue_aarch64( "armv7", ], ) -async def test_deprecated_installation_issue_32bit_method( +async def test_deprecated_installation_issue_32bit_os( hass: HomeAssistant, issue_registry: ir.IssueRegistry, freezer: FrozenDateTimeFactory, @@ -1238,18 +1239,15 @@ async def test_deprecated_installation_issue_32bit_method( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": arch, }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": arch, - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", @@ -1308,18 +1306,15 @@ async def test_deprecated_installation_issue_32bit_supervised( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant Supervised", "arch": arch, }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, + "homeassistant.components.hassio._is_32_bit", + return_value=True, ), patch( "homeassistant.components.hassio.get_os_info", @@ -1365,6 +1360,75 @@ async def test_deprecated_installation_issue_32bit_supervised( } +@pytest.mark.parametrize( + "arch", + [ + "amd64", + "aarch64", + ], +) +async def test_deprecated_installation_issue_64bit_supervised( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, + arch: str, +) -> None: + """Test deprecated architecture issue.""" + with ( + patch.dict(os.environ, MOCK_ENVIRON), + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Supervised", + "arch": arch, + }, + ), + patch( + "homeassistant.components.hassio._is_32_bit", + return_value=False, + ), + patch( + "homeassistant.components.hassio.get_os_info", + return_value={"board": "generic-x86-64"}, + ), + patch( + "homeassistant.components.hassio.get_info", return_value={"hassos": None} + ), + patch("homeassistant.components.hardware.async_setup", return_value=True), + ): + assert await async_setup_component(hass, "homeassistant", {}) + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(REQUEST_REFRESH_DELAY) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + freezer.tick(HASSIO_UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue("homeassistant", "deprecated_method") + assert issue.domain == "homeassistant" + assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == { + "installation_type": "Supervised", + "arch": arch, + } + + @pytest.mark.parametrize( ("board", "issue_id"), [ @@ -1382,18 +1446,15 @@ async def test_deprecated_installation_issue_supported_board( with ( patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.hassio.async_get_system_info", + "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant OS", "arch": "aarch64", }, ), patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "aarch64", - }, + "homeassistant.components.hassio._is_32_bit", + return_value=False, ), patch( "homeassistant.components.hassio.get_os_info", return_value={"board": board} diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0010422cd28..0646b4dcfa6 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -648,18 +648,24 @@ async def test_reload_all( "armv7", ], ) -async def test_deprecated_installation_issue_32bit_method( +async def test_deprecated_installation_issue_32bit_core( hass: HomeAssistant, issue_registry: ir.IssueRegistry, arch: str, ) -> None: """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Core", - "arch": arch, - }, + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=True, + ), ): assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) await hass.async_block_till_done() @@ -679,48 +685,28 @@ async def test_deprecated_installation_issue_32bit_method( @pytest.mark.parametrize( "arch", [ - "i386", - "armhf", + "aarch64", + "generic-x86-64", ], ) -async def test_deprecated_installation_issue_32bit( +async def test_deprecated_installation_issue_64bit_core( hass: HomeAssistant, issue_registry: ir.IssueRegistry, arch: str, ) -> None: """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Container", - "arch": arch, - }, - ): - assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) - await hass.async_block_till_done() - - assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_architecture" - ) - assert issue.domain == HOMEASSISTANT_DOMAIN - assert issue.severity == ir.IssueSeverity.WARNING - assert issue.translation_placeholders == { - "installation_type": "Container", - "arch": arch, - } - - -async def test_deprecated_installation_issue_method( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: - """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Core", - "arch": "generic-x86-64", - }, + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Core", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=False, + ), ): assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) await hass.async_block_till_done() @@ -731,28 +717,46 @@ async def test_deprecated_installation_issue_method( assert issue.severity == ir.IssueSeverity.WARNING assert issue.translation_placeholders == { "installation_type": "Core", - "arch": "generic-x86-64", + "arch": arch, } -async def test_deprecated_installation_issue_armv7_container( +@pytest.mark.parametrize( + "arch", + [ + "i386", + "armv7", + "armhf", + ], +) +async def test_deprecated_installation_issue_32bit( hass: HomeAssistant, issue_registry: ir.IssueRegistry, + arch: str, ) -> None: """Test deprecated installation issue.""" - with patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Container", - "arch": "armv7", - }, + with ( + patch( + "homeassistant.components.homeassistant.async_get_system_info", + return_value={ + "installation_type": "Home Assistant Container", + "arch": arch, + }, + ), + patch( + "homeassistant.components.homeassistant._is_32_bit", + return_value=True, + ), + patch( + "homeassistant.components.homeassistant._get_arch", + return_value=arch, + ), ): assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 - issue = issue_registry.async_get_issue( - HOMEASSISTANT_DOMAIN, "deprecated_container_armv7" - ) + issue = issue_registry.async_get_issue(HOMEASSISTANT_DOMAIN, "deprecated_container") assert issue.domain == HOMEASSISTANT_DOMAIN assert issue.severity == ir.IssueSeverity.WARNING + assert issue.translation_placeholders == {"arch": arch} From dc4627f4136647503f70b4422f9b16d4202d00d8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Jun 2025 18:07:37 +0000 Subject: [PATCH 0202/1664] Bump version to 2025.6.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8c4fd0bf774..3992fc93730 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 21717881d35..aa4b2a9c176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b8" +version = "2025.6.0b9" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From cada2f84a9e719122ee0ab9893e07ca07ed84e2f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Jun 2025 18:13:03 +0000 Subject: [PATCH 0203/1664] Hotfix ruff warnings --- tests/components/history_stats/test_init.py | 2 +- tests/components/homematicip_cloud/test_hap.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index 37b5416fdbb..f418b1f7ef1 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -69,7 +69,7 @@ def history_stats_config_entry( """Fixture to create a history_stats config entry.""" config_entry = MockConfigEntry( data={}, - domain=DOMAIN, + domain=HISTORY_STATS_DOMAIN, options={ CONF_NAME: DEFAULT_NAME, CONF_ENTITY_ID: sensor_entity_entry.entity_id, diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 94d6f9d5dd6..c258c85ac93 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -237,7 +237,7 @@ async def test_get_state_after_disconnect( hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home ) -> None: """Test get state after disconnect.""" - hass.config.components.add(DOMAIN) + hass.config.components.add(HMIPC_DOMAIN) hap = HomematicipHAP(hass, hmip_config_entry) assert hap From 4a15f12a0b0e3335e2532f231301a1a5ffd9472c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Jun 2025 19:32:38 +0100 Subject: [PATCH 0204/1664] Add aiofiles to pyproject.toml (#146561) --- homeassistant/package_constraints.txt | 9 +-------- pyproject.toml | 1 + requirements.txt | 1 + script/gen_requirements_all.py | 8 -------- 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf1a6f4e06d..6264dd7c048 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,6 +3,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 aiodns==3.4.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 @@ -201,14 +202,6 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 -# aiofiles keeps getting downgraded by custom components -# causing newer methods to not be available and breaking -# some integrations at startup -# https://github.com/home-assistant/core/issues/127529 -# https://github.com/home-assistant/core/issues/122508 -# https://github.com/home-assistant/core/issues/118004 -aiofiles>=24.1.0 - # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/pyproject.toml b/pyproject.toml index ab84b14dc63..19d8a877f38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ requires-python = ">=3.13.2" dependencies = [ "aiodns==3.4.0", + "aiofiles==24.1.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 diff --git a/requirements.txt b/requirements.txt index 73433860f71..087ea13ae87 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ # Home Assistant Core aiodns==3.4.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp==3.12.12 aiohttp_cors==0.8.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 486434c6b00..d59c40f7cc5 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -226,14 +226,6 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 -# aiofiles keeps getting downgraded by custom components -# causing newer methods to not be available and breaking -# some integrations at startup -# https://github.com/home-assistant/core/issues/127529 -# https://github.com/home-assistant/core/issues/122508 -# https://github.com/home-assistant/core/issues/118004 -aiofiles>=24.1.0 - # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 From fb4c77d43b3e45e22cbd075d894459e9970e0587 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Jun 2025 19:32:38 +0100 Subject: [PATCH 0205/1664] Add aiofiles to pyproject.toml (#146561) --- homeassistant/package_constraints.txt | 9 +-------- pyproject.toml | 1 + requirements.txt | 1 + script/gen_requirements_all.py | 8 -------- 4 files changed, 3 insertions(+), 16 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d0904cd12b3..57a037f0fb7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,6 +3,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 aiodns==3.4.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 @@ -201,14 +202,6 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 -# aiofiles keeps getting downgraded by custom components -# causing newer methods to not be available and breaking -# some integrations at startup -# https://github.com/home-assistant/core/issues/127529 -# https://github.com/home-assistant/core/issues/122508 -# https://github.com/home-assistant/core/issues/118004 -aiofiles>=24.1.0 - # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/pyproject.toml b/pyproject.toml index aa4b2a9c176..88bd59a95dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ classifiers = [ requires-python = ">=3.13.2" dependencies = [ "aiodns==3.4.0", + "aiofiles==24.1.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 diff --git a/requirements.txt b/requirements.txt index e353adac9d3..6dc604d877b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ # Home Assistant Core aiodns==3.4.0 +aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp==3.12.12 aiohttp_cors==0.7.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 0ea69b365a2..8d1ce521b28 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -228,14 +228,6 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 -# aiofiles keeps getting downgraded by custom components -# causing newer methods to not be available and breaking -# some integrations at startup -# https://github.com/home-assistant/core/issues/127529 -# https://github.com/home-assistant/core/issues/122508 -# https://github.com/home-assistant/core/issues/118004 -aiofiles>=24.1.0 - # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 From 54d8d71de5c2d198ff5a71ba60c467a51e3259cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Jun 2025 19:14:05 +0000 Subject: [PATCH 0206/1664] Bump version to 2025.6.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3992fc93730..c006cd9dbed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 88bd59a95dc..07f19628d0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0b9" +version = "2025.6.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From c01f521199899dd00e3b708347529cef9bb5db3e Mon Sep 17 00:00:00 2001 From: Calvin C <2412685+ToniCipriani@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:20:22 -0400 Subject: [PATCH 0207/1664] Bump hyperion-py to 0.7.6 and add switch for Audio Capture to Hyperion Integration (#145952) Co-authored-by: ToniCipriani Co-authored-by: Robert Resch --- homeassistant/components/hyperion/manifest.json | 2 +- homeassistant/components/hyperion/strings.json | 3 +++ homeassistant/components/hyperion/switch.py | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json index 684fb276f53..6c14b2ddf6c 100644 --- a/homeassistant/components/hyperion/manifest.json +++ b/homeassistant/components/hyperion/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/hyperion", "iot_class": "local_push", "loggers": ["hyperion"], - "requirements": ["hyperion-py==0.7.5"], + "requirements": ["hyperion-py==0.7.6"], "ssdp": [ { "manufacturer": "Hyperion Open Source Ambient Lighting", diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index ea7bc9e39fa..c53754c712a 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -82,6 +82,9 @@ }, "usb_capture": { "name": "Component USB capture" + }, + "audio_capture": { + "name": "Component Audio capture" } }, "sensor": { diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index c082c685304..b1288936636 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -9,6 +9,7 @@ from hyperion import client from hyperion.const import ( KEY_COMPONENT, KEY_COMPONENTID_ALL, + KEY_COMPONENTID_AUDIO, KEY_COMPONENTID_BLACKBORDER, KEY_COMPONENTID_BOBLIGHTSERVER, KEY_COMPONENTID_FORWARDER, @@ -59,6 +60,7 @@ COMPONENT_SWITCHES = [ KEY_COMPONENTID_GRABBER, KEY_COMPONENTID_LEDDEVICE, KEY_COMPONENTID_V4L, + KEY_COMPONENTID_AUDIO, ] @@ -83,6 +85,7 @@ def _component_to_translation_key(component: str) -> str: KEY_COMPONENTID_GRABBER: "platform_capture", KEY_COMPONENTID_LEDDEVICE: "led_device", KEY_COMPONENTID_V4L: "usb_capture", + KEY_COMPONENTID_AUDIO: "audio_capture", }[component] diff --git a/requirements_all.txt b/requirements_all.txt index e18d8242071..14b2b81b3af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1185,7 +1185,7 @@ huawei-lte-api==1.11.0 huum==0.7.12 # homeassistant.components.hyperion -hyperion-py==0.7.5 +hyperion-py==0.7.6 # homeassistant.components.iammeter iammeter==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 217ce5d97a0..e714ffb1c93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1028,7 +1028,7 @@ huawei-lte-api==1.11.0 huum==0.7.12 # homeassistant.components.hyperion -hyperion-py==0.7.5 +hyperion-py==0.7.6 # homeassistant.components.iaqualink iaqualink==0.5.3 From e46e7f5a812b5a81f88ad7b683bea45dfc7a2403 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 11 Jun 2025 23:52:31 +0200 Subject: [PATCH 0208/1664] Bump reolink-aio to 0.14.0 (#146566) --- homeassistant/components/reolink/entity.py | 2 +- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/conftest.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index d7e8817b1b7..0d91670fc84 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -82,7 +82,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, name=self._host.api.nvr_name, model=self._host.api.model, - model_id=self._host.api.item_number, + model_id=self._host.api.item_number(), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.hardware_version, sw_version=self._host.api.sw_version, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5ae8b0305e4..917ef9e73f7 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.13.5"] + "requirements": ["reolink-aio==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14b2b81b3af..27d4d350b42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2652,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.5 +reolink-aio==0.14.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e714ffb1c93..48d2738c0bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.5 +reolink-aio==0.14.0 # homeassistant.components.rflink rflink==0.0.66 diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a2155ba00eb..1d8244a890a 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -99,7 +99,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.sw_upload_progress.return_value = 100 host_mock.manufacturer = "Reolink" host_mock.model = TEST_HOST_MODEL - host_mock.item_number = TEST_ITEM_NUMBER + host_mock.item_number.return_value = TEST_ITEM_NUMBER host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_hardware_version.return_value = "IPC_00001" From 8c9acf5a4d3bfef9c7b34dd2cd212392f75b6c3c Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 12 Jun 2025 00:54:01 +0300 Subject: [PATCH 0209/1664] Separate steps for openai_conversation options flow (#141533) --- .../openai_conversation/config_flow.py | 312 +++++++----- .../openai_conversation/strings.json | 25 +- .../openai_conversation/test_config_flow.py | 481 ++++++++++++++---- 3 files changed, 567 insertions(+), 251 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 6d3f461981c..60d81bf6745 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -2,10 +2,8 @@ from __future__ import annotations -from collections.abc import Mapping import json import logging -from types import MappingProxyType from typing import Any import openai @@ -77,7 +75,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -142,55 +140,193 @@ class OpenAIOptionsFlow(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) + self.options = config_entry.options.copy() async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options - errors: dict[str, str] = {} + """Manage initial options.""" + options = self.options + + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(self.hass) + ] + if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( + suggested_llm_apis, str + ): + options[CONF_LLM_HASS_API] = [suggested_llm_apis] + + step_schema: VolDictType = { + vol.Optional( + CONF_PROMPT, + description={"suggested_value": llm.DEFAULT_INSTRUCTIONS_PROMPT}, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } if user_input is not None: - if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if not user_input.get(CONF_LLM_HASS_API): - user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: - errors[CONF_CHAT_MODEL] = "model_not_supported" + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get(CONF_WEB_SEARCH): - if ( - user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - not in WEB_SEARCH_MODELS - ): - errors[CONF_WEB_SEARCH] = "web_search_not_supported" - elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION): - user_input.update(await self.get_location_data()) + if user_input[CONF_RECOMMENDED]: + return self.async_create_entry(title="", data=user_input) - if not errors: - return self.async_create_entry(title="", data=user_input) - else: - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + options.update(user_input) + if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input: + options.pop(CONF_LLM_HASS_API) + return await self.async_step_advanced() - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ), - CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), - } - - schema = openai_config_option_schema(self.hass, options) return self.async_show_form( step_id="init", - data_schema=vol.Schema(schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), + ) + + async def async_step_advanced( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage advanced options.""" + options = self.options + errors: dict[str, str] = {} + + step_schema: VolDictType = { + vol.Optional( + CONF_CHAT_MODEL, + default=RECOMMENDED_CHAT_MODEL, + ): str, + vol.Optional( + CONF_MAX_TOKENS, + default=RECOMMENDED_MAX_TOKENS, + ): int, + vol.Optional( + CONF_TOP_P, + default=RECOMMENDED_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TEMPERATURE, + default=RECOMMENDED_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), + } + + if user_input is not None: + options.update(user_input) + if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS: + errors[CONF_CHAT_MODEL] = "model_not_supported" + + if not errors: + return await self.async_step_model() + + return self.async_show_form( + step_id="advanced", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), errors=errors, ) - async def get_location_data(self) -> dict[str, str]: + async def async_step_model( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage model-specific options.""" + options = self.options + errors: dict[str, str] = {} + + step_schema: VolDictType = {} + + model = options[CONF_CHAT_MODEL] + + if model.startswith("o"): + step_schema.update( + { + vol.Optional( + CONF_REASONING_EFFORT, + default=RECOMMENDED_REASONING_EFFORT, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_REASONING_EFFORT, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ) + elif CONF_REASONING_EFFORT in options: + options.pop(CONF_REASONING_EFFORT) + + if model.startswith(tuple(WEB_SEARCH_MODELS)): + step_schema.update( + { + vol.Optional( + CONF_WEB_SEARCH, + default=RECOMMENDED_WEB_SEARCH, + ): bool, + vol.Optional( + CONF_WEB_SEARCH_CONTEXT_SIZE, + default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, + ): SelectSelector( + SelectSelectorConfig( + options=["low", "medium", "high"], + translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional( + CONF_WEB_SEARCH_USER_LOCATION, + default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, + ): bool, + } + ) + elif CONF_WEB_SEARCH in options: + options = { + k: v + for k, v in options.items() + if k + not in ( + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_USER_LOCATION, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_TIMEZONE, + ) + } + + if not step_schema: + return self.async_create_entry(title="", data=options) + + if user_input is not None: + if user_input.get(CONF_WEB_SEARCH): + if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): + user_input.update(await self._get_location_data()) + else: + options.pop(CONF_WEB_SEARCH_CITY, None) + options.pop(CONF_WEB_SEARCH_REGION, None) + options.pop(CONF_WEB_SEARCH_COUNTRY, None) + options.pop(CONF_WEB_SEARCH_TIMEZONE, None) + + options.update(user_input) + return self.async_create_entry(title="", data=options) + + return self.async_show_form( + step_id="model", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(step_schema), options + ), + errors=errors, + ) + + async def _get_location_data(self) -> dict[str, str]: """Get approximate location data of the user.""" location_data: dict[str, str] = {} zone_home = self.hass.states.get(ENTITY_ID_HOME) @@ -242,103 +378,3 @@ class OpenAIOptionsFlow(OptionsFlow): _LOGGER.debug("Location data: %s", location_data) return location_data - - -def openai_config_option_schema( - hass: HomeAssistant, - options: Mapping[str, Any], -) -> VolDictType: - """Return a schema for OpenAI completion options.""" - hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label=api.name, - value=api.id, - ) - for api in llm.async_get_apis(hass) - ] - if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance( - suggested_llm_apis, str - ): - suggested_llm_apis = [suggested_llm_apis] - schema: VolDictType = { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } - - if options.get(CONF_RECOMMENDED): - return schema - - schema.update( - { - vol.Optional( - CONF_CHAT_MODEL, - description={"suggested_value": options.get(CONF_CHAT_MODEL)}, - default=RECOMMENDED_CHAT_MODEL, - ): str, - vol.Optional( - CONF_MAX_TOKENS, - description={"suggested_value": options.get(CONF_MAX_TOKENS)}, - default=RECOMMENDED_MAX_TOKENS, - ): int, - vol.Optional( - CONF_TOP_P, - description={"suggested_value": options.get(CONF_TOP_P)}, - default=RECOMMENDED_TOP_P, - ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), - vol.Optional( - CONF_TEMPERATURE, - description={"suggested_value": options.get(CONF_TEMPERATURE)}, - default=RECOMMENDED_TEMPERATURE, - ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)), - vol.Optional( - CONF_REASONING_EFFORT, - description={"suggested_value": options.get(CONF_REASONING_EFFORT)}, - default=RECOMMENDED_REASONING_EFFORT, - ): SelectSelector( - SelectSelectorConfig( - options=["low", "medium", "high"], - translation_key=CONF_REASONING_EFFORT, - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - CONF_WEB_SEARCH, - description={"suggested_value": options.get(CONF_WEB_SEARCH)}, - default=RECOMMENDED_WEB_SEARCH, - ): bool, - vol.Optional( - CONF_WEB_SEARCH_CONTEXT_SIZE, - description={ - "suggested_value": options.get(CONF_WEB_SEARCH_CONTEXT_SIZE) - }, - default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, - ): SelectSelector( - SelectSelectorConfig( - options=["low", "medium", "high"], - translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE, - mode=SelectSelectorMode.DROPDOWN, - ) - ), - vol.Optional( - CONF_WEB_SEARCH_USER_LOCATION, - description={ - "suggested_value": options.get(CONF_WEB_SEARCH_USER_LOCATION) - }, - default=RECOMMENDED_WEB_SEARCH_USER_LOCATION, - ): bool, - } - ) - return schema diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 0a07fa354b2..351e82ec11f 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -18,20 +18,32 @@ "init": { "data": { "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." + } + }, + "advanced": { + "title": "Advanced settings", + "data": { "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", - "top_p": "Top P", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings", + "top_p": "Top P" + } + }, + "model": { + "title": "Model-specific options", + "data": { "reasoning_effort": "Reasoning effort", "web_search": "Enable web search", "search_context_size": "Search context size", "user_location": "Include home location" }, "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)", + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "web_search": "Allow the model to search the web for the latest information before generating a response", "search_context_size": "High level guidance for the amount of context window space to use for the search", "user_location": "Refine search results based on geography" @@ -39,8 +51,7 @@ } }, "error": { - "model_not_supported": "This model is not supported, please select a different model", - "web_search_not_supported": "Web search is not supported by this model" + "model_not_supported": "This model is not supported, please select a different model" } }, "selector": { diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 9cf27b4f147..ad5bbffaed3 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -27,7 +27,6 @@ from homeassistant.components.openai_conversation.const import ( DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, - RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TOP_P, ) from homeassistant.const import CONF_LLM_HASS_API @@ -77,10 +76,10 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( +async def test_options_recommended( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form.""" + """Test the options flow with recommended settings.""" options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) @@ -88,14 +87,12 @@ async def test_options( options_flow["flow_id"], { "prompt": "Speak like a pirate", - "max_tokens": 200, + "recommended": True, }, ) await hass.async_block_till_done() assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"]["prompt"] == "Speak like a pirate" - assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL async def test_options_unsupported_model( @@ -105,18 +102,32 @@ async def test_options_unsupported_model( options_flow = await hass.config_entries.options.async_init( mock_config_entry.entry_id ) - result = await hass.config_entries.options.async_configure( + assert options_flow["type"] == FlowResultType.FORM + assert options_flow["step_id"] == "init" + + # Configure initial step + options_flow = await hass.config_entries.options.async_configure( options_flow["flow_id"], { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", - CONF_CHAT_MODEL: "o1-mini", CONF_LLM_HASS_API: ["assist"], }, ) await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"chat_model": "model_not_supported"} + assert options_flow["type"] == FlowResultType.FORM + assert options_flow["step_id"] == "advanced" + + # Configure advanced step + options_flow = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + CONF_CHAT_MODEL: "o1-mini", + }, + ) + await hass.async_block_till_done() + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["errors"] == {"chat_model": "model_not_supported"} @pytest.mark.parametrize( @@ -165,70 +176,322 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non @pytest.mark.parametrize( ("current_options", "new_options", "expected_options"), [ - ( - { - CONF_RECOMMENDED: True, - CONF_PROMPT: "bla", - }, - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 0.3, - }, - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 0.3, - CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, - CONF_WEB_SEARCH: False, - CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", - CONF_WEB_SEARCH_USER_LOCATION: False, - }, - ), - ( - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 0.3, - CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, - CONF_WEB_SEARCH: False, - CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", - CONF_WEB_SEARCH_USER_LOCATION: False, - }, - { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: ["assist"], - CONF_PROMPT: "", - }, - { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: ["assist"], - CONF_PROMPT: "", - }, - ), - ( + ( # Test converting single llm api format to list { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: "assist", CONF_PROMPT: "", }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, + ), + ( # options with no model-specific settings + {}, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "gpt-4.5-preview", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "gpt-4.5-preview", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ), + ( # options for reasoning models + {}, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: 10000, + }, + { + CONF_REASONING_EFFORT: "high", + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: 10000, + CONF_REASONING_EFFORT: "high", + }, + ), + ( # options for web search without user location + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "bla", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.3, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + # Test that current options are showed as suggested values + ( # Case 1: web search + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like super Mario", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + ( # Case 2: reasoning model + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + {CONF_REASONING_EFFORT: "high"}, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pro", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o1-pro", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + }, + ), + # Test that old options are removed after reconfiguration + ( # Case 1: web search to recommended + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ( + { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: ["assist"], + CONF_PROMPT: "", + }, + ), { CONF_RECOMMENDED: True, CONF_LLM_HASS_API: ["assist"], CONF_PROMPT: "", }, ), + ( # Case 2: reasoning to recommended + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "high", + }, + ( + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "Speak like a pirate", + }, + ), + { + CONF_RECOMMENDED: True, + CONF_PROMPT: "Speak like a pirate", + }, + ), + ( # Case 3: web search to reasoning + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "low", + CONF_WEB_SEARCH_USER_LOCATION: True, + CONF_WEB_SEARCH_CITY: "San Francisco", + CONF_WEB_SEARCH_REGION: "California", + CONF_WEB_SEARCH_COUNTRY: "US", + CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_REASONING_EFFORT: "low", + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + }, + ), + ( # Case 4: reasoning to web search + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_LLM_HASS_API: ["assist"], + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "o3-mini", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_REASONING_EFFORT: "low", + }, + ( + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, + { + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + }, + { + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + CONF_TEMPERATURE: 0.8, + CONF_CHAT_MODEL: "gpt-4o", + CONF_TOP_P: 0.9, + CONF_MAX_TOKENS: 1000, + CONF_WEB_SEARCH: True, + CONF_WEB_SEARCH_CONTEXT_SIZE: "high", + CONF_WEB_SEARCH_USER_LOCATION: False, + }, + ), ], ) async def test_options_switching( @@ -241,22 +504,31 @@ async def test_options_switching( ) -> None: """Test the options form.""" hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - **current_options, - CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], - }, + options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert options["step_id"] == "init" + + for step_options in new_options: + assert options["type"] == FlowResultType.FORM + + # Test that current options are showed as suggested values: + for key in options["data_schema"].schema: + if ( + isinstance(key.description, dict) + and "suggested_value" in key.description + and key in current_options + ): + current_option = current_options[key] + if key == CONF_LLM_HASS_API and isinstance(current_option, str): + current_option = [current_option] + assert key.description["suggested_value"] == current_option + + # Configure current step + options = await hass.config_entries.options.async_configure( + options["flow_id"], + step_options, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - new_options, - ) - await hass.async_block_till_done() + await hass.async_block_till_done() + assert options["type"] is FlowResultType.CREATE_ENTRY assert options["data"] == expected_options @@ -265,9 +537,35 @@ async def test_options_web_search_user_location( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test fetching user location.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "init" + + # Configure initial step + options = await hass.config_entries.options.async_configure( + options["flow_id"], + { + CONF_RECOMMENDED: False, + CONF_PROMPT: "Speak like a pirate", + }, ) + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "advanced" + + # Configure advanced step + options = await hass.config_entries.options.async_configure( + options["flow_id"], + { + CONF_TEMPERATURE: 1.0, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TOP_P: RECOMMENDED_TOP_P, + CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, + }, + ) + await hass.async_block_till_done() + assert options["type"] == FlowResultType.FORM + assert options["step_id"] == "model" + hass.config.country = "US" hass.config.time_zone = "America/Los_Angeles" hass.states.async_set( @@ -302,16 +600,10 @@ async def test_options_web_search_user_location( ], ) + # Configure model step options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + options["flow_id"], { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -330,7 +622,6 @@ async def test_options_web_search_user_location( CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - CONF_REASONING_EFFORT: RECOMMENDED_REASONING_EFFORT, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -339,25 +630,3 @@ async def test_options_web_search_user_location( CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", } - - -async def test_options_web_search_unsupported_model( - hass: HomeAssistant, mock_config_entry, mock_init_component -) -> None: - """Test the options form giving error about web search not being available.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id - ) - result = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_CHAT_MODEL: "o1-pro", - CONF_LLM_HASS_API: ["assist"], - CONF_WEB_SEARCH: True, - }, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"web_search": "web_search_not_supported"} From f44f2522efb69fef5f0630420502705c3a6912ee Mon Sep 17 00:00:00 2001 From: Christopher Boyd Date: Wed, 11 Jun 2025 14:54:22 -0700 Subject: [PATCH 0210/1664] Add 'AdvancedToggle' to list of supported Lutron button types (#145676) --- homeassistant/components/lutron/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index a494a37cb52..6ea3754ddde 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -113,6 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b "Toggle", "SingleSceneRaiseLower", "MasterRaiseLower", + "AdvancedToggle", ): # Associate an LED with a button if there is one led = next( From 7cb3c397b2b909bb61038e9232ecbd2e6e44e4a3 Mon Sep 17 00:00:00 2001 From: rappenze Date: Wed, 11 Jun 2025 23:55:38 +0200 Subject: [PATCH 0211/1664] Support more dimmer devices in fibaro (#145864) --- homeassistant/components/fibaro/light.py | 4 +-- tests/components/fibaro/conftest.py | 33 ++++++++++++++++++++++++ tests/components/fibaro/test_light.py | 22 ++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 446b9b9f7ff..a82769bf9ee 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -83,8 +83,8 @@ class FibaroLight(FibaroEntity, LightEntity): ) supports_dimming = ( fibaro_device.has_interface("levelChange") - and "setValue" in fibaro_device.actions - ) + or fibaro_device.type == "com.fibaro.multilevelSwitch" + ) and "setValue" in fibaro_device.actions if supports_color and supports_white_v: self._attr_supported_color_modes = {ColorMode.RGBW} diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index bf1fb53621a..952efbbb8ec 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -172,6 +172,39 @@ def mock_light() -> Mock: return light +@pytest.fixture +def mock_zigbee_light() -> Mock: + """Fixture for a dimmmable zigbee light.""" + light = Mock() + light.fibaro_id = 12 + light.parent_fibaro_id = 0 + light.name = "Test light" + light.room_id = 1 + light.dead = False + light.visible = True + light.enabled = True + light.type = "com.fibaro.multilevelSwitch" + light.base_type = "com.fibaro.binarySwitch" + light.properties = { + "manufacturer": "", + "isLight": True, + "interfaces": ["autoTurnOff", "favoritePosition", "light", "zigbee"], + } + light.actions = {"setValue": 1, "toggle": 0, "turnOn": 0, "turnOff": 0} + light.supported_features = {} + light.has_interface.return_value = False + light.raw_data = { + "fibaro_id": 12, + "name": "Test light", + "properties": {"value": 20}, + } + value_mock = Mock() + value_mock.has_value = True + value_mock.int_value.return_value = 20 + light.value = value_mock + return light + + @pytest.fixture def mock_thermostat() -> Mock: """Fixture for a thermostat.""" diff --git a/tests/components/fibaro/test_light.py b/tests/components/fibaro/test_light.py index 88576e86dc6..e44036e3f08 100644 --- a/tests/components/fibaro/test_light.py +++ b/tests/components/fibaro/test_light.py @@ -58,6 +58,28 @@ async def test_light_brightness( assert state.state == "on" +async def test_zigbee_light_brightness( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_zigbee_light: Mock, + mock_room: Mock, +) -> None: + """Test that the zigbee dimmable light is detected.""" + + # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_devices.return_value = [mock_zigbee_light] + + with patch("homeassistant.components.fibaro.PLATFORMS", [Platform.LIGHT]): + # Act + await init_integration(hass, mock_config_entry) + # Assert + state = hass.states.get("light.room_1_test_light_12") + assert state.attributes["brightness"] == 51 + assert state.state == "on" + + async def test_light_turn_off( hass: HomeAssistant, mock_fibaro_client: Mock, From 8bf562b7b69a64f66b60f5653f14cf984a459dd5 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 12 Jun 2025 06:02:26 +0200 Subject: [PATCH 0212/1664] Add strings for pick implementation (#146557) * Add string for pick implementation * add missing --- homeassistant/components/electric_kiwi/strings.json | 8 +++++++- homeassistant/components/fitbit/strings.json | 8 +++++++- homeassistant/components/geocaching/strings.json | 8 +++++++- homeassistant/components/google/strings.json | 8 +++++++- .../components/google_assistant_sdk/strings.json | 8 +++++++- homeassistant/components/google_drive/strings.json | 8 +++++++- homeassistant/components/google_mail/strings.json | 8 +++++++- homeassistant/components/google_photos/strings.json | 8 +++++++- homeassistant/components/google_sheets/strings.json | 8 +++++++- homeassistant/components/google_tasks/strings.json | 8 +++++++- homeassistant/components/home_connect/strings.json | 8 +++++++- homeassistant/components/husqvarna_automower/strings.json | 8 +++++++- homeassistant/components/iotty/strings.json | 8 +++++++- homeassistant/components/lametric/strings.json | 8 +++++++- homeassistant/components/lyric/strings.json | 8 +++++++- homeassistant/components/mcp/strings.json | 4 ++-- homeassistant/components/microbees/strings.json | 8 +++++++- homeassistant/components/miele/strings.json | 8 +++++++- homeassistant/components/monzo/strings.json | 8 +++++++- homeassistant/components/myuplink/strings.json | 8 +++++++- homeassistant/components/neato/strings.json | 8 +++++++- homeassistant/components/nest/strings.json | 8 +++++++- homeassistant/components/netatmo/strings.json | 8 +++++++- homeassistant/components/ondilo_ico/strings.json | 8 +++++++- homeassistant/components/onedrive/strings.json | 8 +++++++- homeassistant/components/point/strings.json | 8 +++++++- homeassistant/components/senz/strings.json | 8 +++++++- homeassistant/components/smappee/strings.json | 8 +++++++- homeassistant/components/smartthings/strings.json | 8 +++++++- homeassistant/components/spotify/strings.json | 8 +++++++- homeassistant/components/tesla_fleet/strings.json | 8 +++++++- homeassistant/components/toon/strings.json | 8 +++++++- homeassistant/components/weheat/strings.json | 8 +++++++- homeassistant/components/withings/strings.json | 8 +++++++- homeassistant/components/xbox/strings.json | 8 +++++++- homeassistant/components/yale/strings.json | 8 +++++++- homeassistant/components/yolink/strings.json | 8 +++++++- homeassistant/strings.json | 4 +++- 38 files changed, 257 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/electric_kiwi/strings.json b/homeassistant/components/electric_kiwi/strings.json index 5e0a2ef168d..903c16543bb 100644 --- a/homeassistant/components/electric_kiwi/strings.json +++ b/homeassistant/components/electric_kiwi/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 9029a8265bb..37e1259a35c 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "auth": { "title": "Link Fitbit" diff --git a/homeassistant/components/geocaching/strings.json b/homeassistant/components/geocaching/strings.json index 9989af9a75c..ca6e9d5e67f 100644 --- a/homeassistant/components/geocaching/strings.json +++ b/homeassistant/components/geocaching/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 4f3e27af27e..7ac16ab0af6 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 885ff0aad71..2622333e15f 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_drive/strings.json b/homeassistant/components/google_drive/strings.json index e6658fb08e9..3dc958b7dfc 100644 --- a/homeassistant/components/google_drive/strings.json +++ b/homeassistant/components/google_drive/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_mail/strings.json b/homeassistant/components/google_mail/strings.json index 759242593ff..c856b0d3329 100644 --- a/homeassistant/components/google_mail/strings.json +++ b/homeassistant/components/google_mail/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_photos/strings.json b/homeassistant/components/google_photos/strings.json index 5695192dd27..503f27d8125 100644 --- a/homeassistant/components/google_photos/strings.json +++ b/homeassistant/components/google_photos/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_sheets/strings.json b/homeassistant/components/google_sheets/strings.json index 406c4440d00..9a5ed48767d 100644 --- a/homeassistant/components/google_sheets/strings.json +++ b/homeassistant/components/google_sheets/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index b58678f6d30..3a7ef8a1ec8 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 1445a8eae08..99c89ec8788 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -9,7 +9,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 5b815e79263..9e808c66878 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -10,7 +10,13 @@ "description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})." }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/iotty/strings.json b/homeassistant/components/iotty/strings.json index cb0dc509d9a..cf9a8fbb877 100644 --- a/homeassistant/components/iotty/strings.json +++ b/homeassistant/components/iotty/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 0656454bb01..dbf25f6680b 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -9,7 +9,13 @@ } }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "manual_entry": { "data": { diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index 786f49e5300..a934d8eda2e 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json index 2b59d4ffa51..780b4818666 100644 --- a/homeassistant/components/mcp/strings.json +++ b/homeassistant/components/mcp/strings.json @@ -12,10 +12,10 @@ "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", "data": { - "implementation": "Credentials" + "implementation": "[%key:common::config_flow::data::implementation%]" }, "data_description": { - "implementation": "The credentials to use for the OAuth2 flow" + "implementation": "[%key:common::config_flow::description::implementation%]" } } }, diff --git a/homeassistant/components/microbees/strings.json b/homeassistant/components/microbees/strings.json index 8635753a564..5337bf149b7 100644 --- a/homeassistant/components/microbees/strings.json +++ b/homeassistant/components/microbees/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "error": { diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index cf01d01e476..94aef8d6d3f 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -8,7 +8,13 @@ "description": "[%key:common::config_flow::description::confirm_setup%]" }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/monzo/strings.json b/homeassistant/components/monzo/strings.json index e4ec34a8459..fa916021138 100644 --- a/homeassistant/components/monzo/strings.json +++ b/homeassistant/components/monzo/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/myuplink/strings.json b/homeassistant/components/myuplink/strings.json index 939aa2f17c8..d599836b8ef 100644 --- a/homeassistant/components/myuplink/strings.json +++ b/homeassistant/components/myuplink/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/neato/strings.json b/homeassistant/components/neato/strings.json index 0324fdb8fad..c16b7bc1903 100644 --- a/homeassistant/components/neato/strings.json +++ b/homeassistant/components/neato/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::description::confirm_setup%]" diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 5146d04af0b..1fc3de9be6b 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -23,7 +23,13 @@ } }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "pubsub_topic": { "title": "Configure Cloud Pub/Sub topic", diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index 580b49ea646..f47b9e993aa 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/ondilo_ico/strings.json b/homeassistant/components/ondilo_ico/strings.json index 360c0b124a7..3a5e7445a0c 100644 --- a/homeassistant/components/ondilo_ico/strings.json +++ b/homeassistant/components/ondilo_ico/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index b8fa7f8189d..8c01ad85d4a 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json index b2e8d9309d9..2ef55d6204a 100644 --- a/homeassistant/components/point/strings.json +++ b/homeassistant/components/point/strings.json @@ -20,7 +20,13 @@ }, "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/senz/strings.json b/homeassistant/components/senz/strings.json index cb1f056d72d..32398c64c52 100644 --- a/homeassistant/components/senz/strings.json +++ b/homeassistant/components/senz/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json index 3037fbc98f6..ddb5c96db0a 100644 --- a/homeassistant/components/smappee/strings.json +++ b/homeassistant/components/smappee/strings.json @@ -19,7 +19,13 @@ "title": "Discovered Smappee device" }, "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 8e972ac8aea..b322b73062b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json index 66d837c503f..352a2fb7fa2 100644 --- a/homeassistant/components/spotify/strings.json +++ b/homeassistant/components/spotify/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 04ccbd13b44..c0ca80e6c50 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -17,7 +17,13 @@ }, "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 3072896653d..07843de1a05 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "Choose your tenant to authenticate with" + "title": "Choose your tenant to authenticate with", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "agreement": { "title": "Select your agreement", diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index 93a3fbaad30..b3c2af71803 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "find_devices": { "title": "Select your heat pump" diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 14c7bf640e9..4792e3362bd 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -5,7 +5,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/components/xbox/strings.json b/homeassistant/components/xbox/strings.json index 0d9a12137ce..a59e8b90221 100644 --- a/homeassistant/components/xbox/strings.json +++ b/homeassistant/components/xbox/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/yale/strings.json b/homeassistant/components/yale/strings.json index 3fb1345a3b0..f5078ac2ece 100644 --- a/homeassistant/components/yale/strings.json +++ b/homeassistant/components/yale/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } } }, "abort": { diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json index d38ea248c31..0eb9de97469 100644 --- a/homeassistant/components/yolink/strings.json +++ b/homeassistant/components/yolink/strings.json @@ -2,7 +2,13 @@ "config": { "step": { "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]", + "data": { + "implementation": "[%key:common::config_flow::data::implementation%]" + }, + "data_description": { + "implementation": "[%key:common::config_flow::description::implementation%]" + } }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 6175f587318..6e47163e90a 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -53,6 +53,7 @@ "email": "Email", "host": "Host", "ip": "IP address", + "implementation": "Application Credentials", "language": "Language", "latitude": "Latitude", "llm_hass_api": "Control Home Assistant", @@ -71,7 +72,8 @@ "verify_ssl": "Verify SSL certificate" }, "description": { - "confirm_setup": "Do you want to start setup?" + "confirm_setup": "Do you want to start setup?", + "implementation": "The credentials you want to use to authenticate." }, "error": { "cannot_connect": "Failed to connect", From 25e6eab008bfaa7b18981e90ac7b74c9e8010f4b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Jun 2025 07:15:07 +0200 Subject: [PATCH 0213/1664] Not valid hvac modes now fails in Climate (#145242) * Not valid hvac modes now fails * Fix some tests * Some more * More * fix ruff * HVAC * Fritzbox * Clean up * Use dict[key] --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/climate/__init__.py | 21 -------------- homeassistant/components/climate/strings.json | 3 ++ homeassistant/components/deconz/climate.py | 2 -- .../components/freedompro/climate.py | 2 -- .../components/homematicip_cloud/climate.py | 2 -- homeassistant/components/maxcube/climate.py | 2 -- homeassistant/components/nest/climate.py | 2 -- homeassistant/components/plugwise/climate.py | 12 -------- .../components/plugwise/strings.json | 3 -- .../components/tesla_fleet/climate.py | 6 ---- .../components/tesla_fleet/strings.json | 3 -- homeassistant/components/tplink/climate.py | 11 +------ homeassistant/components/tplink/strings.json | 3 -- homeassistant/components/whirlpool/climate.py | 4 +-- homeassistant/components/zwave_js/climate.py | 4 +-- .../components/advantage_air/test_climate.py | 2 +- tests/components/balboa/test_climate.py | 3 -- tests/components/climate/test_init.py | 29 ++++++++++--------- tests/components/deconz/test_climate.py | 4 +-- tests/components/freedompro/test_climate.py | 3 +- .../generic_thermostat/test_climate.py | 2 +- .../homematicip_cloud/test_climate.py | 15 +++++----- .../maxcube/test_maxcube_climate.py | 2 +- tests/components/mqtt/test_climate.py | 7 ----- tests/components/nest/test_climate.py | 4 +-- .../components/nibe_heatpump/test_climate.py | 1 - tests/components/plugwise/test_climate.py | 5 +++- tests/components/tesla_fleet/test_climate.py | 3 +- tests/components/tplink/test_climate.py | 2 +- tests/components/whirlpool/test_climate.py | 2 +- tests/components/zha/test_climate.py | 22 +++++++++----- tests/components/zwave_js/test_climate.py | 4 +-- 32 files changed, 63 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 03acaa08294..59749cd58ee 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -27,7 +27,6 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import async_suggest_report_issue from homeassistant.util.hass_dict import HassKey from homeassistant.util.unit_conversion import TemperatureConverter @@ -535,26 +534,6 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return modes_str: str = ", ".join(modes) if modes else "" translation_key = f"not_valid_{mode_type}_mode" - if mode_type == "hvac": - report_issue = async_suggest_report_issue( - self.hass, - integration_domain=self.platform.platform_name, - module=type(self).__module__, - ) - _LOGGER.warning( - ( - "%s::%s sets the hvac_mode %s which is not " - "valid for this entity with modes: %s. " - "This will stop working in 2025.4 and raise an error instead. " - "Please %s" - ), - self.platform.platform_name, - self.__class__.__name__, - mode, - modes_str, - report_issue, - ) - return raise ServiceValidationError( translation_domain=DOMAIN, translation_key=translation_key, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index bd6ed083650..ad0bccb25ce 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -258,6 +258,9 @@ "not_valid_preset_mode": { "message": "Preset mode {mode} is not valid. Valid preset modes are: {modes}." }, + "not_valid_hvac_mode": { + "message": "HVAC mode {mode} is not valid. Valid HVAC modes are: {modes}." + }, "not_valid_swing_mode": { "message": "Swing mode {mode} is not valid. Valid swing modes are: {modes}." }, diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 26597c195e7..af10bf7e3c3 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -164,8 +164,6 @@ class DeconzThermostat(DeconzDevice[Thermostat], ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if hvac_mode not in self._attr_hvac_modes: - raise ValueError(f"Unsupported HVAC mode {hvac_mode}") if len(self._attr_hvac_modes) == 2: # Only allow turn on and off thermostat await self.hub.api.sensors.thermostat.set_config( diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py index 0145dea27bb..4e4660bc545 100644 --- a/homeassistant/components/freedompro/climate.py +++ b/homeassistant/components/freedompro/climate.py @@ -125,8 +125,6 @@ class Device(CoordinatorEntity[FreedomproDataUpdateCoordinator], ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Async function to set mode to climate.""" - if hvac_mode not in SUPPORTED_HVAC_MODES: - raise ValueError(f"Got unsupported hvac_mode {hvac_mode}") payload = {"heatingCoolingState": HVAC_INVERT_MAP[hvac_mode]} await put_state( diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py index 7f393cf52bd..18f169bb91b 100644 --- a/homeassistant/components/homematicip_cloud/climate.py +++ b/homeassistant/components/homematicip_cloud/climate.py @@ -216,8 +216,6 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if hvac_mode not in self.hvac_modes: - return if hvac_mode == HVACMode.AUTO: await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM) diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index 69a0eb8a553..65b1795023f 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -133,8 +133,6 @@ class MaxCubeClimate(ClimateEntity): self._set_target(MAX_DEVICE_MODE_MANUAL, temp) elif hvac_mode == HVACMode.AUTO: self._set_target(MAX_DEVICE_MODE_AUTOMATIC, None) - else: - raise ValueError(f"unsupported HVAC mode {hvac_mode}") def _set_target(self, mode: int | None, temp: float | None) -> None: """Set the mode and/or temperature of the thermostat. diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index f5eff664f83..25f39704393 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -267,8 +267,6 @@ class ThermostatEntity(ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if hvac_mode not in self.hvac_modes: - raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] try: diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index c7fac07f1cb..834ff8bce76 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -15,7 +15,6 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MASTER_THERMOSTATS @@ -216,17 +215,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @plugwise_command async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the hvac mode.""" - if hvac_mode not in self.hvac_modes: - hvac_modes = ", ".join(self.hvac_modes) - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unsupported_hvac_mode_requested", - translation_placeholders={ - "hvac_mode": hvac_mode, - "hvac_modes": hvac_modes, - }, - ) - if hvac_mode == self.hvac_mode: return diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index fdbe8c39015..9c005c4c0df 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -316,9 +316,6 @@ }, "unsupported_firmware": { "message": "[%key:component::plugwise::config::error::unsupported%]" - }, - "unsupported_hvac_mode_requested": { - "message": "Unsupported mode {hvac_mode} requested, valid modes are: {hvac_modes}." } } } diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py index f752509ee17..2628a9e134f 100644 --- a/homeassistant/components/tesla_fleet/climate.py +++ b/homeassistant/components/tesla_fleet/climate.py @@ -164,12 +164,6 @@ class TeslaFleetClimateEntity(TeslaFleetVehicleEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the climate mode and state.""" - if hvac_mode not in self.hvac_modes: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_hvac_mode", - translation_placeholders={"hvac_mode": hvac_mode}, - ) if hvac_mode == HVACMode.OFF: await self.async_turn_off() else: diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index c0ca80e6c50..276858bb3dd 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -579,9 +579,6 @@ "invalid_cop_temp": { "message": "Cabin overheat protection does not support that temperature." }, - "invalid_hvac_mode": { - "message": "Climate mode {hvac_mode} is not supported." - }, "missing_temperature": { "message": "Temperature is required for this action." }, diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py index 66037d7476e..45e4575b4e3 100644 --- a/homeassistant/components/tplink/climate.py +++ b/homeassistant/components/tplink/climate.py @@ -21,11 +21,10 @@ from homeassistant.components.climate import ( ) from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TPLinkConfigEntry, legacy_device_id -from .const import DOMAIN, UNIT_MAPPING +from .const import UNIT_MAPPING from .coordinator import TPLinkDataUpdateCoordinator from .entity import ( CoordinatedTPLinkModuleEntity, @@ -161,14 +160,6 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity): await self._thermostat_module.set_state(True) elif hvac_mode is HVACMode.OFF: await self._thermostat_module.set_state(False) - else: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unsupported_mode", - translation_placeholders={ - "mode": hvac_mode, - }, - ) @async_refresh_after async def async_turn_on(self) -> None: diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json index 856b4d339a5..a7f9dfbcb09 100644 --- a/homeassistant/components/tplink/strings.json +++ b/homeassistant/components/tplink/strings.json @@ -487,9 +487,6 @@ "unexpected_device": { "message": "Unexpected device found at {host}; expected {expected}, found {found}" }, - "unsupported_mode": { - "message": "Tried to set unsupported mode: {mode}" - }, "invalid_alarm_duration": { "message": "Invalid duration {duration} available: 1-{duration_max}s" } diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index 75967bb81d4..0113d3c99d6 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -130,9 +130,7 @@ class AirConEntity(WhirlpoolEntity, ClimateEntity): await self._appliance.set_power_on(False) return - if not (mode := HVAC_MODE_TO_AIRCON_MODE.get(hvac_mode)): - raise ValueError(f"Invalid hvac mode {hvac_mode}") - + mode = HVAC_MODE_TO_AIRCON_MODE[hvac_mode] await self._appliance.set_mode(mode) if not self._appliance.get_power_on(): await self._appliance.set_power_on(True) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index b27dbdad1a0..809d3543fe4 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -492,8 +492,6 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - if (hvac_mode_id := self._hvac_modes.get(hvac_mode)) is None: - raise ValueError(f"Received an invalid hvac mode: {hvac_mode}") if not self._current_mode: # Thermostat(valve) has no support for setting a mode, so we make it a no-op @@ -503,7 +501,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # can set it again when turning the device back on. if hvac_mode == HVACMode.OFF and self._current_mode.value != ThermostatMode.OFF: self._last_hvac_mode_id_before_off = self._current_mode.value - await self._async_set_value(self._current_mode, hvac_mode_id) + await self._async_set_value(self._current_mode, self._hvac_modes[hvac_mode]) async def async_turn_off(self) -> None: """Turn the entity off.""" diff --git a/tests/components/advantage_air/test_climate.py b/tests/components/advantage_air/test_climate.py index 69094a80d30..c7fe200e66d 100644 --- a/tests/components/advantage_air/test_climate.py +++ b/tests/components/advantage_air/test_climate.py @@ -177,7 +177,7 @@ async def test_climate_myzone_zone( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.FAN_ONLY}, + {ATTR_ENTITY_ID: [entity_id], ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, blocking=True, ) mock_update.assert_called_once() diff --git a/tests/components/balboa/test_climate.py b/tests/components/balboa/test_climate.py index 5cd5bc9091a..4ccbe91fbcd 100644 --- a/tests/components/balboa/test_climate.py +++ b/tests/components/balboa/test_climate.py @@ -127,9 +127,6 @@ async def test_spa_hvac_action( state = await _patch_spa_heatstate(hass, client, 1) assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.HEATING - state = await _patch_spa_heatstate(hass, client, 2) - assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.IDLE - async def test_spa_preset_modes( hass: HomeAssistant, client: MagicMock, integration: MockConfigEntry diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index a81efa1640c..06bd9c0c096 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -323,22 +323,23 @@ async def test_mode_validation( assert state.attributes.get(ATTR_SWING_MODE) == "off" assert state.attributes.get(ATTR_SWING_HORIZONTAL_MODE) == "off" - await hass.services.async_call( - DOMAIN, - SERVICE_SET_HVAC_MODE, - { - "entity_id": "climate.test", - "hvac_mode": "auto", - }, - blocking=True, - ) - + with pytest.raises( + ServiceValidationError, + match="HVAC mode auto is not valid. Valid HVAC modes are: off, heat", + ) as exc: + await hass.services.async_call( + DOMAIN, + SERVICE_SET_HVAC_MODE, + { + "entity_id": "climate.test", + "hvac_mode": "auto", + }, + blocking=True, + ) assert ( - "MockClimateEntity sets the hvac_mode auto which is not valid " - "for this entity with modes: off, heat. This will stop working " - "in 2025.4 and raise an error instead. " - "Please" in caplog.text + str(exc.value) == "HVAC mode auto is not valid. Valid HVAC modes are: off, heat" ) + assert exc.value.translation_key == "not_valid_hvac_mode" with pytest.raises( ServiceValidationError, diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 723ff12ad37..9f6ee5afec1 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -136,7 +136,7 @@ async def test_simple_climate_device( # Service set HVAC mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -239,7 +239,7 @@ async def test_climate_device_without_cooling_support( # Service set HVAC mode to unsupported value - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/freedompro/test_climate.py b/tests/components/freedompro/test_climate.py index 9a8f0c5030c..d6e97d62ac9 100644 --- a/tests/components/freedompro/test_climate.py +++ b/tests/components/freedompro/test_climate.py @@ -19,6 +19,7 @@ from homeassistant.components.climate import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow @@ -135,7 +136,7 @@ async def test_climate_set_unsupported_hvac_mode( assert entry assert entry.unique_id == uid - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 7d606bee93a..d082308236a 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -896,7 +896,7 @@ async def test_heating_cooling_switch_toggles_when_outside_min_cycle_duration( "expected_triggered_service_call", ), [ - (True, HVACMode.COOL, False, 30, 25, HVACMode.HEAT, SERVICE_TURN_ON), + (True, HVACMode.COOL, False, 30, 25, HVACMode.COOL, SERVICE_TURN_ON), (True, HVACMode.COOL, True, 25, 30, HVACMode.OFF, SERVICE_TURN_OFF), (False, HVACMode.HEAT, False, 25, 30, HVACMode.HEAT, SERVICE_TURN_ON), (False, HVACMode.HEAT, True, 30, 25, HVACMode.OFF, SERVICE_TURN_OFF), diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index 434f26e0e6f..67dbb55bb12 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -205,13 +205,14 @@ async def test_hmip_heating_group_heat( ha_state = hass.states.get(entity_id) assert ha_state.state == HVACMode.AUTO - # hvac mode "dry" is not available. expect a valueerror. - await hass.services.async_call( - "climate", - "set_hvac_mode", - {"entity_id": entity_id, "hvac_mode": "dry"}, - blocking=True, - ) + # hvac mode "dry" is not available. + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_hvac_mode", + {"entity_id": entity_id, "hvac_mode": "dry"}, + blocking=True, + ) assert len(hmip_device.mock_calls) == service_call_counter + 24 # Only fire event from last async_manipulate_test_data available. diff --git a/tests/components/maxcube/test_maxcube_climate.py b/tests/components/maxcube/test_maxcube_climate.py index 8b56ee6a6de..40603344325 100644 --- a/tests/components/maxcube/test_maxcube_climate.py +++ b/tests/components/maxcube/test_maxcube_climate.py @@ -179,7 +179,7 @@ async def test_thermostat_set_invalid_hvac_mode( hass: HomeAssistant, cube: MaxCube, thermostat: MaxThermostat ) -> None: """Set hvac mode to heat.""" - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index fd0b95f2b13..568fb7ea39d 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -405,13 +405,6 @@ async def test_turn_on_and_off_optimistic_with_power_command( "heat", None, ), - ( - help_custom_config( - climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "dry"]},) - ), - None, - "off", - ), ( help_custom_config( climate.DOMAIN, DEFAULT_CONFIG, ({"modes": ["off", "cool"]},) diff --git a/tests/components/nest/test_climate.py b/tests/components/nest/test_climate.py index fe148c2529d..76a9a52f2de 100644 --- a/tests/components/nest/test_climate.py +++ b/tests/components/nest/test_climate.py @@ -520,7 +520,7 @@ async def test_thermostat_invalid_hvac_mode( assert thermostat.state == HVACMode.OFF assert thermostat.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_hvac_mode(hass, HVACMode.DRY) assert thermostat.state == HVACMode.OFF @@ -1396,7 +1396,7 @@ async def test_thermostat_unexpected_hvac_status( assert ATTR_FAN_MODE not in thermostat.attributes assert ATTR_FAN_MODES not in thermostat.attributes - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await common.async_set_hvac_mode(hass, HVACMode.DRY) assert thermostat.state == HVACMode.OFF diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 91245503eb3..a9620b5ddb3 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -297,7 +297,6 @@ async def test_set_temperature_unsupported_cooling( [ (Model.S320, "s1", "climate.climate_system_s1"), (Model.F1155, "s2", "climate.climate_system_s2"), - (Model.F730, "s1", "climate.climate_system_s1"), ], ) @pytest.mark.usefixtures("entity_registry_enabled_by_default") diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 7a481285be0..3787cbf7150 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -242,7 +242,10 @@ async def test_adam_climate_entity_climate_changes( "c50f167537524366a5af7aa3942feb1e", HVACMode.OFF ) - with pytest.raises(ServiceValidationError, match="valid modes are"): + with pytest.raises( + ServiceValidationError, + match="HVAC mode dry is not valid. Valid HVAC modes are: auto, heat", + ): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, diff --git a/tests/components/tesla_fleet/test_climate.py b/tests/components/tesla_fleet/test_climate.py index fae79c795c2..6f700f7e939 100644 --- a/tests/components/tesla_fleet/test_climate.py +++ b/tests/components/tesla_fleet/test_climate.py @@ -401,7 +401,8 @@ async def test_climate_noscope( entity_id = "climate.test_climate" with pytest.raises( - ServiceValidationError, match="Climate mode off is not supported" + ServiceValidationError, + match="HVAC mode off is not valid. Valid HVAC modes are: heat_cool", ): await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/tplink/test_climate.py b/tests/components/tplink/test_climate.py index adcca24886b..6d5b498b922 100644 --- a/tests/components/tplink/test_climate.py +++ b/tests/components/tplink/test_climate.py @@ -161,7 +161,7 @@ async def test_set_hvac_mode( ) therm_module.set_state.assert_called_with(True) - msg = "Tried to set unsupported mode: dry" + msg = "HVAC mode dry is not valid. Valid HVAC modes are: heat, off" with pytest.raises(ServiceValidationError, match=msg): await hass.services.async_call( CLIMATE_DOMAIN, diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 2c36c713546..6157da04256 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -300,7 +300,7 @@ async def test_service_hvac_mode_turn_on( ( SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.DRY}, - ValueError, + ServiceValidationError, ), ( SERVICE_SET_FAN_MODE, diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 7b94db51d04..3425c1eb2b6 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -522,20 +522,28 @@ async def test_set_hvac_mode( state = hass.states.get(entity_id) assert state.state == HVACMode.OFF - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, - blocking=True, - ) - state = hass.states.get(entity_id) if sys_mode is not None: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + state = hass.states.get(entity_id) assert state.state == hvac_mode assert thrm_cluster.write_attributes.call_count == 1 assert thrm_cluster.write_attributes.call_args[0][0] == { "system_mode": sys_mode } else: + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: hvac_mode}, + blocking=True, + ) + state = hass.states.get(entity_id) assert thrm_cluster.write_attributes.call_count == 0 assert state.state == HVACMode.OFF diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index f312284d897..a356613aa7a 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -264,7 +264,7 @@ async def test_thermostat_v2( client.async_send_command.reset_mock() # Test setting invalid hvac mode - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, @@ -574,7 +574,7 @@ async def test_setpoint_thermostat( ) # Test setting illegal mode raises an error - with pytest.raises(ValueError): + with pytest.raises(ServiceValidationError): await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, From 30dbd5a9009c1758af985efd96400601c748b69d Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:42:40 +0200 Subject: [PATCH 0214/1664] Simplify synology_dsm service actions (#146612) --- .../components/synology_dsm/__init__.py | 2 +- .../components/synology_dsm/services.py | 102 +++++++++--------- 2 files changed, 52 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index b3b40d975ce..e568ce5a6d1 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -45,7 +45,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Synology DSM component.""" - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/synology_dsm/services.py b/homeassistant/components/synology_dsm/services.py index 40b6fd4bc30..9522361d500 100644 --- a/homeassistant/components/synology_dsm/services.py +++ b/homeassistant/components/synology_dsm/services.py @@ -7,7 +7,7 @@ from typing import cast from synology_dsm.exceptions import SynologyDSMException -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES from .coordinator import SynologyDSMConfigEntry @@ -15,63 +15,63 @@ from .coordinator import SynologyDSMConfigEntry LOGGER = logging.getLogger(__name__) -async def async_setup_services(hass: HomeAssistant) -> None: - """Service handler setup.""" +async def _service_handler(call: ServiceCall) -> None: + """Handle service call.""" + serial: str | None = call.data.get(CONF_SERIAL) + entries: list[SynologyDSMConfigEntry] = ( + call.hass.config_entries.async_loaded_entries(DOMAIN) + ) + dsm_devices = {cast(str, entry.unique_id): entry.runtime_data for entry in entries} - async def service_handler(call: ServiceCall) -> None: - """Handle service call.""" - serial: str | None = call.data.get(CONF_SERIAL) - entries: list[SynologyDSMConfigEntry] = ( - hass.config_entries.async_loaded_entries(DOMAIN) + if serial: + entry: SynologyDSMConfigEntry | None = ( + call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) ) - dsm_devices = { - cast(str, entry.unique_id): entry.runtime_data for entry in entries - } + assert entry + dsm_device = entry.runtime_data + elif len(dsm_devices) == 1: + dsm_device = next(iter(dsm_devices.values())) + serial = next(iter(dsm_devices)) + else: + LOGGER.error( + "More than one DSM configured, must specify one of serials %s", + sorted(dsm_devices), + ) + return - if serial: - entry: SynologyDSMConfigEntry | None = ( - hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) - ) - assert entry - dsm_device = entry.runtime_data - elif len(dsm_devices) == 1: - dsm_device = next(iter(dsm_devices.values())) - serial = next(iter(dsm_devices)) - else: - LOGGER.error( - "More than one DSM configured, must specify one of serials %s", - sorted(dsm_devices), - ) - return + if not dsm_device: + LOGGER.error("DSM with specified serial %s not found", serial) + return - if not dsm_device: + if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: + if serial not in dsm_devices: LOGGER.error("DSM with specified serial %s not found", serial) return - - if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if serial not in dsm_devices: - LOGGER.error("DSM with specified serial %s not found", serial) - return - LOGGER.debug("%s DSM with serial %s", call.service, serial) - LOGGER.warning( - ( - "The %s service is deprecated and will be removed in future" - " release. Please use the corresponding button entity" - ), + LOGGER.debug("%s DSM with serial %s", call.service, serial) + LOGGER.warning( + ( + "The %s service is deprecated and will be removed in future" + " release. Please use the corresponding button entity" + ), + call.service, + ) + dsm_device = dsm_devices[serial] + dsm_api = dsm_device.api + try: + await getattr(dsm_api, f"async_{call.service}")() + except SynologyDSMException as ex: + LOGGER.error( + "%s of DSM with serial %s not possible, because of %s", call.service, + serial, + ex, ) - dsm_device = dsm_devices[serial] - dsm_api = dsm_device.api - try: - await getattr(dsm_api, f"async_{call.service}")() - except SynologyDSMException as ex: - LOGGER.error( - "%s of DSM with serial %s not possible, because of %s", - call.service, - serial, - ex, - ) - return + return + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Service handler setup.""" for service in SERVICES: - hass.services.async_register(DOMAIN, service, service_handler) + hass.services.async_register(DOMAIN, service, _service_handler) From e14cf8a5b9e5510a195d1c094be4d8e0d70242e2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:43:03 +0200 Subject: [PATCH 0215/1664] Remove deprecated service in plex (#146608) * Remove deprecated service in plex * Update json/yaml --- homeassistant/components/plex/const.py | 1 - homeassistant/components/plex/icons.json | 3 --- homeassistant/components/plex/services.py | 22 +-------------------- homeassistant/components/plex/services.yaml | 2 -- homeassistant/components/plex/strings.json | 4 ---- tests/components/plex/test_services.py | 10 ---------- 6 files changed, 1 insertion(+), 41 deletions(-) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index d5d70219471..b43a1eca135 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -56,7 +56,6 @@ AUTOMATIC_SETUP_STRING = "Obtain a new token from plex.tv" MANUAL_SETUP_STRING = "Configure Plex server manually" SERVICE_REFRESH_LIBRARY = "refresh_library" -SERVICE_SCAN_CLIENTS = "scan_for_clients" PLEX_URI_SCHEME = "plex://" diff --git a/homeassistant/components/plex/icons.json b/homeassistant/components/plex/icons.json index 2d3a7342ad2..21a48fd274e 100644 --- a/homeassistant/components/plex/icons.json +++ b/homeassistant/components/plex/icons.json @@ -9,9 +9,6 @@ "services": { "refresh_library": { "service": "mdi:refresh" - }, - "scan_for_clients": { - "service": "mdi:database-refresh" } } } diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index c70ddb6ed53..6412a4c3927 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -9,16 +9,8 @@ from yarl import URL from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import ( - DOMAIN, - PLEX_UPDATE_PLATFORMS_SIGNAL, - PLEX_URI_SCHEME, - SERVERS, - SERVICE_REFRESH_LIBRARY, - SERVICE_SCAN_CLIENTS, -) +from .const import DOMAIN, PLEX_URI_SCHEME, SERVERS, SERVICE_REFRESH_LIBRARY from .errors import MediaNotFound from .helpers import get_plex_data from .models import PlexMediaSearchResult @@ -37,24 +29,12 @@ async def async_setup_services(hass: HomeAssistant) -> None: async def async_refresh_library_service(service_call: ServiceCall) -> None: await hass.async_add_executor_job(refresh_library, hass, service_call) - async def async_scan_clients_service(_: ServiceCall) -> None: - _LOGGER.warning( - "This service is deprecated in favor of the scan_clients button entity." - " Service calls will still work for now but the service will be removed in" - " a future release" - ) - for server_id in get_plex_data(hass)[SERVERS]: - async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) - hass.services.async_register( DOMAIN, SERVICE_REFRESH_LIBRARY, async_refresh_library_service, schema=REFRESH_LIBRARY_SCHEMA, ) - hass.services.async_register( - DOMAIN, SERVICE_SCAN_CLIENTS, async_scan_clients_service - ) def refresh_library(hass: HomeAssistant, service_call: ServiceCall) -> None: diff --git a/homeassistant/components/plex/services.yaml b/homeassistant/components/plex/services.yaml index 5ed655b7d78..ee4a2a234ea 100644 --- a/homeassistant/components/plex/services.yaml +++ b/homeassistant/components/plex/services.yaml @@ -9,5 +9,3 @@ refresh_library: example: "TV Shows" selector: text: - -scan_for_clients: diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 0c8eae86f73..0eb83a64a5d 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -83,10 +83,6 @@ "description": "Name of the Plex library to refresh." } } - }, - "scan_for_clients": { - "name": "Scan for clients", - "description": "Scans for available clients from the Plex server(s), local network, and plex.tv." } } } diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index c84322e1c14..8a6dceb1e47 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -17,7 +17,6 @@ from homeassistant.components.plex.const import ( PLEX_SERVER_CONFIG, PLEX_URI_SCHEME, SERVICE_REFRESH_LIBRARY, - SERVICE_SCAN_CLIENTS, ) from homeassistant.components.plex.services import process_plex_payload from homeassistant.const import CONF_URL @@ -107,15 +106,6 @@ async def test_refresh_library( assert refresh.call_count == 1 -async def test_scan_clients(hass: HomeAssistant, mock_plex_server) -> None: - """Test scan_for_clients service call.""" - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN_CLIENTS, - blocking=True, - ) - - async def test_lookup_media_for_other_integrations( hass: HomeAssistant, entry, From 14c30ef2df1ad78d05975fd3a6730e36f91e9f99 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:34:56 +0200 Subject: [PATCH 0216/1664] Mark async_setup_services as callback (#146617) --- homeassistant/components/fritz/__init__.py | 2 +- homeassistant/components/fritz/services.py | 5 +++-- homeassistant/components/homematicip_cloud/__init__.py | 2 +- homeassistant/components/homematicip_cloud/services.py | 5 +++-- homeassistant/components/plex/__init__.py | 2 +- homeassistant/components/plex/services.py | 5 +++-- 6 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index 9610fe4b34d..faf82b4b516 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -33,7 +33,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up fritzboxtools integration.""" - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index 02e6c91f4bf..bba80eadf98 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -10,7 +10,7 @@ from fritzconnection.core.exceptions import ( from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.service import async_extract_config_entry_ids @@ -64,7 +64,8 @@ async def _async_set_guest_wifi_password(service_call: ServiceCall) -> None: ) from ex -async def async_setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Fritz integration.""" hass.services.async_register( diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 9cf9ab28db7..30038d1f897 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -63,7 +63,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) ) - await async_setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index a0308b14d7e..1cfb3a55552 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -12,7 +12,7 @@ from homematicip.group import HeatingGroup import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids @@ -120,7 +120,8 @@ SCHEMA_SET_HOME_COOLING_MODE = vol.Schema( ) -async def async_setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the HomematicIP Cloud services.""" @verify_domain_control(hass, DOMAIN) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index eb57dc46727..bc117e4c7f4 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -105,7 +105,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) hass.data.setdefault(DOMAIN, hass_data) - await async_setup_services(hass) + async_setup_services(hass) hass.http.register_view(PlexImageView()) diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 6412a4c3927..1ff7820a2c0 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -7,7 +7,7 @@ from plexapi.exceptions import NotFound import voluptuous as vol from yarl import URL -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN, PLEX_URI_SCHEME, SERVERS, SERVICE_REFRESH_LIBRARY @@ -23,7 +23,8 @@ REFRESH_LIBRARY_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__package__) -async def async_setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for the Plex component.""" async def async_refresh_library_service(service_call: ServiceCall) -> None: From 41605213494548323c021b341eec361fcfa048ca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:17:00 +0200 Subject: [PATCH 0217/1664] Simplify overseerr service actions (#146607) --- .../components/overseerr/__init__.py | 4 +- .../components/overseerr/services.py | 71 ++++++++++--------- 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/overseerr/__init__.py b/homeassistant/components/overseerr/__init__.py index 597d44f66cf..3e7b5f32272 100644 --- a/homeassistant/components/overseerr/__init__.py +++ b/homeassistant/components/overseerr/__init__.py @@ -25,7 +25,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, EVENT_KEY, JSON_PAYLOAD, LOGGER, REGISTERED_NOTIFICATIONS from .coordinator import OverseerrConfigEntry, OverseerrCoordinator -from .services import setup_services +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR] CONF_CLOUDHOOK_URL = "cloudhook_url" @@ -35,7 +35,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Overseerr component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 4631e578af8..4e72f555603 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -12,6 +12,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util.json import JsonValueType @@ -39,7 +40,7 @@ SERVICE_GET_REQUESTS_SCHEMA = vol.Schema( ) -def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfigEntry: +def _async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfigEntry: """Get the Overseerr config entry.""" if not (entry := hass.config_entries.async_get_entry(config_entry_id)): raise ServiceValidationError( @@ -56,7 +57,7 @@ def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> OverseerrConfi return cast(OverseerrConfigEntry, entry) -async def get_media( +async def _get_media( client: OverseerrClient, media_type: str, identifier: int ) -> dict[str, Any]: """Get media details.""" @@ -73,43 +74,45 @@ async def get_media( return media -def setup_services(hass: HomeAssistant) -> None: +async def _async_get_requests(call: ServiceCall) -> ServiceResponse: + """Get requests made to Overseerr.""" + entry = _async_get_entry(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + kwargs: dict[str, Any] = {} + if status := call.data.get(ATTR_STATUS): + kwargs["status"] = status + if sort_order := call.data.get(ATTR_SORT_ORDER): + kwargs["sort"] = sort_order + if requested_by := call.data.get(ATTR_REQUESTED_BY): + kwargs["requested_by"] = requested_by + try: + requests = await client.get_requests(**kwargs) + except OverseerrConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + translation_placeholders={"error": str(err)}, + ) from err + result: list[dict[str, Any]] = [] + for request in requests: + req = asdict(request) + assert request.media.tmdb_id + req["media"] = await _get_media( + client, request.media.media_type, request.media.tmdb_id + ) + result.append(req) + + return {"requests": cast(list[JsonValueType], result)} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Overseerr integration.""" - async def async_get_requests(call: ServiceCall) -> ServiceResponse: - """Get requests made to Overseerr.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - client = entry.runtime_data.client - kwargs: dict[str, Any] = {} - if status := call.data.get(ATTR_STATUS): - kwargs["status"] = status - if sort_order := call.data.get(ATTR_SORT_ORDER): - kwargs["sort"] = sort_order - if requested_by := call.data.get(ATTR_REQUESTED_BY): - kwargs["requested_by"] = requested_by - try: - requests = await client.get_requests(**kwargs) - except OverseerrConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - translation_placeholders={"error": str(err)}, - ) from err - result: list[dict[str, Any]] = [] - for request in requests: - req = asdict(request) - assert request.media.tmdb_id - req["media"] = await get_media( - client, request.media.media_type, request.media.tmdb_id - ) - result.append(req) - - return {"requests": cast(list[JsonValueType], result)} - hass.services.async_register( DOMAIN, SERVICE_GET_REQUESTS, - async_get_requests, + _async_get_requests, schema=SERVICE_GET_REQUESTS_SCHEMA, supports_response=SupportsResponse.ONLY, ) From 9d1e60cf7e12935ca8cb62c5875fada275c4900f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:17:27 +0200 Subject: [PATCH 0218/1664] Simplify mealie service actions (#146601) --- homeassistant/components/mealie/__init__.py | 4 +- homeassistant/components/mealie/services.py | 218 ++++++++++---------- 2 files changed, 115 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/mealie/__init__.py b/homeassistant/components/mealie/__init__.py index e019dae2c33..0221fd45051 100644 --- a/homeassistant/components/mealie/__init__.py +++ b/homeassistant/components/mealie/__init__.py @@ -24,7 +24,7 @@ from .coordinator import ( MealieShoppingListCoordinator, MealieStatisticsCoordinator, ) -from .services import setup_services +from .services import async_setup_services from .utils import create_version PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.SENSOR, Platform.TODO] @@ -34,7 +34,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Mealie component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index 15e3348adbe..0d9a29392a4 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -19,6 +19,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -98,9 +99,10 @@ SERVICE_SET_MEALPLAN_SCHEMA = vol.Any( ) -def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MealieConfigEntry: +def _async_get_entry(call: ServiceCall) -> MealieConfigEntry: """Get the Mealie config entry.""" - if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + config_entry_id: str = call.data[ATTR_CONFIG_ENTRY_ID] + if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="integration_not_found", @@ -115,143 +117,149 @@ def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> MealieConfigEn return cast(MealieConfigEntry, entry) -def setup_services(hass: HomeAssistant) -> None: - """Set up the services for the Mealie integration.""" +async def _async_get_mealplan(call: ServiceCall) -> ServiceResponse: + """Get the mealplan for a specific range.""" + entry = _async_get_entry(call) + start_date = call.data.get(ATTR_START_DATE, date.today()) + end_date = call.data.get(ATTR_END_DATE, date.today()) + if end_date < start_date: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="end_date_before_start_date", + ) + client = entry.runtime_data.client + try: + mealplans = await client.get_mealplans(start_date, end_date) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + return {"mealplan": [asdict(x) for x in mealplans.items]} - async def async_get_mealplan(call: ServiceCall) -> ServiceResponse: - """Get the mealplan for a specific range.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - start_date = call.data.get(ATTR_START_DATE, date.today()) - end_date = call.data.get(ATTR_END_DATE, date.today()) - if end_date < start_date: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="end_date_before_start_date", - ) - client = entry.runtime_data.client - try: - mealplans = await client.get_mealplans(start_date, end_date) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - return {"mealplan": [asdict(x) for x in mealplans.items]} - async def async_get_recipe(call: ServiceCall) -> ServiceResponse: - """Get a recipe.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - recipe_id = call.data[ATTR_RECIPE_ID] - client = entry.runtime_data.client - try: - recipe = await client.get_recipe(recipe_id) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - except MealieNotFoundError as err: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="recipe_not_found", - translation_placeholders={"recipe_id": recipe_id}, - ) from err +async def _async_get_recipe(call: ServiceCall) -> ServiceResponse: + """Get a recipe.""" + entry = _async_get_entry(call) + recipe_id = call.data[ATTR_RECIPE_ID] + client = entry.runtime_data.client + try: + recipe = await client.get_recipe(recipe_id) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + except MealieNotFoundError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="recipe_not_found", + translation_placeholders={"recipe_id": recipe_id}, + ) from err + return {"recipe": asdict(recipe)} + + +async def _async_import_recipe(call: ServiceCall) -> ServiceResponse: + """Import a recipe.""" + entry = _async_get_entry(call) + url = call.data[ATTR_URL] + include_tags = call.data.get(ATTR_INCLUDE_TAGS, False) + client = entry.runtime_data.client + try: + recipe = await client.import_recipe(url, include_tags) + except MealieValidationError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="could_not_import_recipe", + ) from err + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: return {"recipe": asdict(recipe)} + return None - async def async_import_recipe(call: ServiceCall) -> ServiceResponse: - """Import a recipe.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - url = call.data[ATTR_URL] - include_tags = call.data.get(ATTR_INCLUDE_TAGS, False) - client = entry.runtime_data.client - try: - recipe = await client.import_recipe(url, include_tags) - except MealieValidationError as err: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="could_not_import_recipe", - ) from err - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - if call.return_response: - return {"recipe": asdict(recipe)} - return None - async def async_set_random_mealplan(call: ServiceCall) -> ServiceResponse: - """Set a random mealplan.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - mealplan_date = call.data[ATTR_DATE] - entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) - client = entry.runtime_data.client - try: - mealplan = await client.random_mealplan(mealplan_date, entry_type) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - if call.return_response: - return {"mealplan": asdict(mealplan)} - return None +async def _async_set_random_mealplan(call: ServiceCall) -> ServiceResponse: + """Set a random mealplan.""" + entry = _async_get_entry(call) + mealplan_date = call.data[ATTR_DATE] + entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) + client = entry.runtime_data.client + try: + mealplan = await client.random_mealplan(mealplan_date, entry_type) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: + return {"mealplan": asdict(mealplan)} + return None - async def async_set_mealplan(call: ServiceCall) -> ServiceResponse: - """Set a mealplan.""" - entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - mealplan_date = call.data[ATTR_DATE] - entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) - client = entry.runtime_data.client - try: - mealplan = await client.set_mealplan( - mealplan_date, - entry_type, - recipe_id=call.data.get(ATTR_RECIPE_ID), - note_title=call.data.get(ATTR_NOTE_TITLE), - note_text=call.data.get(ATTR_NOTE_TEXT), - ) - except MealieConnectionError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="connection_error", - ) from err - if call.return_response: - return {"mealplan": asdict(mealplan)} - return None + +async def _async_set_mealplan(call: ServiceCall) -> ServiceResponse: + """Set a mealplan.""" + entry = _async_get_entry(call) + mealplan_date = call.data[ATTR_DATE] + entry_type = MealplanEntryType(call.data[ATTR_ENTRY_TYPE]) + client = entry.runtime_data.client + try: + mealplan = await client.set_mealplan( + mealplan_date, + entry_type, + recipe_id=call.data.get(ATTR_RECIPE_ID), + note_title=call.data.get(ATTR_NOTE_TITLE), + note_text=call.data.get(ATTR_NOTE_TEXT), + ) + except MealieConnectionError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + if call.return_response: + return {"mealplan": asdict(mealplan)} + return None + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Mealie integration.""" hass.services.async_register( DOMAIN, SERVICE_GET_MEALPLAN, - async_get_mealplan, + _async_get_mealplan, schema=SERVICE_GET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_GET_RECIPE, - async_get_recipe, + _async_get_recipe, schema=SERVICE_GET_RECIPE_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_IMPORT_RECIPE, - async_import_recipe, + _async_import_recipe, schema=SERVICE_IMPORT_RECIPE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_SET_RANDOM_MEALPLAN, - async_set_random_mealplan, + _async_set_random_mealplan, schema=SERVICE_SET_RANDOM_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) hass.services.async_register( DOMAIN, SERVICE_SET_MEALPLAN, - async_set_mealplan, + _async_set_mealplan, schema=SERVICE_SET_MEALPLAN_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) From 64e503bc277e8560bf08f284472c18ceeda1120c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:18:23 +0200 Subject: [PATCH 0219/1664] Fix fan is_on status in xiaomi_miio (#146592) --- homeassistant/components/xiaomi_miio/fan.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index c69bd150226..de2750f3c81 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -330,6 +330,12 @@ class XiaomiGenericDevice( """Return the percentage based speed of the fan.""" return None + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + # Base FanEntity uses percentage to determine if the device is on. + return self._attr_is_on + async def async_turn_on( self, percentage: int | None = None, From 9dfbccf0cb22239b305fb8a9bbee1d6499118896 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:18:46 +0200 Subject: [PATCH 0220/1664] Improve type hints in xiaomi_miio fan (#146596) --- homeassistant/components/xiaomi_miio/fan.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index de2750f3c81..d10bdaad217 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -25,6 +25,8 @@ from miio.integrations.airpurifier.zhimi.airpurifier import ( from miio.integrations.airpurifier.zhimi.airpurifier_miot import ( OperationMode as AirpurifierMiotOperationMode, ) +from miio.integrations.fan.dmaker.fan import FanStatusP5 +from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot from miio.integrations.fan.zhimi.zhimi_miot import ( OperationModeFanZA5 as FanZA5OperationMode, ) @@ -1083,12 +1085,14 @@ class XiaomiFan(XiaomiGenericFan): class XiaomiFanP5(XiaomiGenericFan): """Representation of a Xiaomi Fan P5.""" + coordinator: DataUpdateCoordinator[FanStatusP5] + def __init__( self, device: MiioDevice, entry: XiaomiMiioConfigEntry, unique_id: str | None, - coordinator: DataUpdateCoordinator[Any], + coordinator: DataUpdateCoordinator[FanStatusP5], ) -> None: """Initialize the fan.""" super().__init__(device, entry, unique_id, coordinator) @@ -1146,13 +1150,15 @@ class XiaomiFanP5(XiaomiGenericFan): class XiaomiFanMiot(XiaomiGenericFan): """Representation of a Xiaomi Fan Miot.""" + coordinator: DataUpdateCoordinator[FanStatusMiot] + @property - def operation_mode_class(self): + def operation_mode_class(self) -> type[FanOperationMode]: """Hold operation mode class.""" return FanOperationMode @callback - def _handle_coordinator_update(self): + def _handle_coordinator_update(self) -> None: """Fetch state from the device.""" self._attr_is_on = self.coordinator.data.is_on self._attr_preset_mode = self.coordinator.data.mode.name From e19f1788647d47a6de34a6a51d29db738465534f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 12 Jun 2025 13:55:26 +0200 Subject: [PATCH 0221/1664] Make duplicate issue detection more strict (#146633) --- .github/workflows/detect-duplicate-issues.yml | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 509868541fd..b01a0d68352 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -133,12 +133,18 @@ jobs: // Build search query for issues with any of the current integration labels const labelQueries = integrationLabels.map(label => `label:"${label}"`); + + // Calculate date 6 months ago + const sixMonthsAgo = new Date(); + sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6); + const dateFilter = `created:>=${sixMonthsAgo.toISOString().split('T')[0]}`; + let searchQuery; if (labelQueries.length === 1) { - searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]}`; + searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue ${labelQueries[0]} ${dateFilter}`; } else { - searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')})`; + searchQuery = `repo:${context.repo.owner}/${context.repo.repo} is:issue (${labelQueries.join(' OR ')}) ${dateFilter}`; } console.log(`Search query: ${searchQuery}`); @@ -227,29 +233,34 @@ jobs: if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' uses: actions/ai-inference@v1.1.0 with: - model: openai/gpt-4o-mini + model: openai/gpt-4o system-prompt: | - You are a Home Assistant issue duplicate detector. Your task is to identify potential duplicate issues based on their content. + You are a Home Assistant issue duplicate detector. Your task is to identify TRUE DUPLICATES - issues that report the EXACT SAME problem, not just similar or related issues. + + CRITICAL: An issue is ONLY a duplicate if: + - It describes the SAME problem with the SAME root cause + - Issues about the same integration but different problems are NOT duplicates + - Issues with similar symptoms but different causes are NOT duplicates Important considerations: - Open issues are more relevant than closed ones for duplicate detection - Recently updated issues may indicate ongoing work or discussion - Issues with more comments are generally more relevant and active - - Higher comment count often indicates community engagement and importance - Older closed issues might be resolved differently than newer approaches - Consider the time between issues - very old issues may have different contexts Rules: - 1. Compare the current issue with the provided similar issues + 1. ONLY mark as duplicate if the issues describe IDENTICAL problems 2. Look for issues that report the same problem or request the same functionality - 3. Consider different wording but same underlying issue as duplicates + 3. Different error messages = NOT a duplicate (even if same integration) 4. For CLOSED issues, only mark as duplicate if they describe the EXACT same problem - 5. For OPEN issues, use a lower threshold (70%+ similarity) + 5. For OPEN issues, use a lower threshold (90%+ similarity) 6. Prioritize issues with higher comment counts as they indicate more activity/relevance - 7. Return ONLY a JSON array of issue numbers that are potential duplicates - 8. If no duplicates are found, return an empty array: [] - 9. Maximum 5 potential duplicates, prioritize open issues with comments - 10. Consider the age of issues - prefer recent duplicates over very old ones + 7. When in doubt, do NOT mark as duplicate + 8. Return ONLY a JSON array of issue numbers that are duplicates + 9. If no duplicates are found, return an empty array: [] + 10. Maximum 5 potential duplicates, prioritize open issues with comments + 11. Consider the age of issues - prefer recent duplicates over very old ones Example response format: [1234, 5678, 9012] @@ -259,10 +270,10 @@ jobs: Title: ${{ steps.extract.outputs.current_title }} Body: ${{ steps.extract.outputs.current_body }} - Similar issues to compare against (each includes state, creation date, last update, and comment count): + Other issues to compare against (each includes state, creation date, last update, and comment count): ${{ steps.fetch_similar.outputs.similar_issues }} - Analyze these issues and identify which ones are potential duplicates of the current issue. Consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant). + Analyze these issues and identify which ones describe IDENTICAL problems and thus are duplicates of the current issue. When sorting them, consider their state (open/closed), how recently they were updated, and their comment count (higher = more relevant). max-tokens: 100 From 74a92e2cd824602fe347d014779a1ad2769ca7fd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:01:45 +0200 Subject: [PATCH 0222/1664] Simplify tado service actions (#146614) --- homeassistant/components/tado/__init__.py | 4 +-- homeassistant/components/tado/services.py | 37 ++++++++++++----------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 74768ee01fa..0513d63b893 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -35,7 +35,7 @@ from .const import ( ) from .coordinator import TadoDataUpdateCoordinator, TadoMobileDeviceUpdateCoordinator from .models import TadoData -from .services import setup_services +from .services import async_setup_services PLATFORMS = [ Platform.BINARY_SENSOR, @@ -58,7 +58,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Tado.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/tado/services.py b/homeassistant/components/tado/services.py index d931ea303e9..a855f323978 100644 --- a/homeassistant/components/tado/services.py +++ b/homeassistant/components/tado/services.py @@ -29,26 +29,27 @@ SCHEMA_ADD_METER_READING = vol.Schema( ) +async def _add_meter_reading(call: ServiceCall) -> None: + """Send meter reading to Tado.""" + entry_id: str = call.data[CONF_CONFIG_ENTRY] + reading: int = call.data[CONF_READING] + _LOGGER.debug("Add meter reading %s", reading) + + entry = call.hass.config_entries.async_get_entry(entry_id) + if entry is None: + raise ServiceValidationError("Config entry not found") + + coordinator = entry.runtime_data.coordinator + response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) + + if ATTR_MESSAGE in response: + raise HomeAssistantError(response[ATTR_MESSAGE]) + + @callback -def setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Tado integration.""" - async def add_meter_reading(call: ServiceCall) -> None: - """Send meter reading to Tado.""" - entry_id: str = call.data[CONF_CONFIG_ENTRY] - reading: int = call.data[CONF_READING] - _LOGGER.debug("Add meter reading %s", reading) - - entry = hass.config_entries.async_get_entry(entry_id) - if entry is None: - raise ServiceValidationError("Config entry not found") - - coordinator = entry.runtime_data.coordinator - response: dict = await coordinator.set_meter_reading(call.data[CONF_READING]) - - if ATTR_MESSAGE in response: - raise HomeAssistantError(response[ATTR_MESSAGE]) - hass.services.async_register( - DOMAIN, SERVICE_ADD_METER_READING, add_meter_reading, SCHEMA_ADD_METER_READING + DOMAIN, SERVICE_ADD_METER_READING, _add_meter_reading, SCHEMA_ADD_METER_READING ) From c34596e54d06fdca9467ba6056c918f348ad83c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:01:53 +0200 Subject: [PATCH 0223/1664] Simplify seventeentrack service actions (#146610) * Simplify seventeentrack service actions * callback --- .../components/seventeentrack/__init__.py | 4 +- .../components/seventeentrack/services.py | 189 +++++++++--------- 2 files changed, 100 insertions(+), 93 deletions(-) diff --git a/homeassistant/components/seventeentrack/__init__.py b/homeassistant/components/seventeentrack/__init__.py index 235a5338cb6..90fe9f325fa 100644 --- a/homeassistant/components/seventeentrack/__init__.py +++ b/homeassistant/components/seventeentrack/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import SeventeenTrackCoordinator -from .services import setup_services +from .services import async_setup_services PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -23,7 +23,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the 17Track component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 5ba0b569b19..531ff2aea43 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -12,6 +12,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector @@ -70,100 +71,106 @@ SERVICE_ARCHIVE_PACKAGE_SCHEMA: Final = vol.Schema( ) -def setup_services(hass: HomeAssistant) -> None: +async def _get_packages(call: ServiceCall) -> ServiceResponse: + """Get packages from 17Track.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + package_states = call.data.get(ATTR_PACKAGE_STATE, []) + + await _validate_service(call.hass, config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ + config_entry_id + ] + live_packages = sorted( + await seventeen_coordinator.client.profile.packages( + show_archived=seventeen_coordinator.show_archived + ) + ) + + return { + "packages": [ + _package_to_dict(package) + for package in live_packages + if slugify(package.status) in package_states or package_states == [] + ] + } + + +async def _add_package(call: ServiceCall) -> None: + """Add a new package to 17Track.""" + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] + friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] + + await _validate_service(call.hass, config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ + config_entry_id + ] + + await seventeen_coordinator.client.profile.add_package( + tracking_number, friendly_name + ) + + +async def _archive_package(call: ServiceCall) -> None: + config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] + tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] + + await _validate_service(call.hass, config_entry_id) + + seventeen_coordinator: SeventeenTrackCoordinator = call.hass.data[DOMAIN][ + config_entry_id + ] + + await seventeen_coordinator.client.profile.archive_package(tracking_number) + + +def _package_to_dict(package: Package) -> dict[str, Any]: + result = { + ATTR_DESTINATION_COUNTRY: package.destination_country, + ATTR_ORIGIN_COUNTRY: package.origin_country, + ATTR_PACKAGE_TYPE: package.package_type, + ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, + ATTR_TRACKING_NUMBER: package.tracking_number, + ATTR_LOCATION: package.location, + ATTR_STATUS: package.status, + ATTR_INFO_TEXT: package.info_text, + ATTR_FRIENDLY_NAME: package.friendly_name, + } + if timestamp := package.timestamp: + result[ATTR_TIMESTAMP] = timestamp.isoformat() + return result + + +async def _validate_service(hass: HomeAssistant, config_entry_id: str) -> None: + entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) + if not entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_config_entry", + translation_placeholders={ + "config_entry_id": config_entry_id, + }, + ) + if entry.state != ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unloaded_config_entry", + translation_placeholders={ + "config_entry_id": entry.title, + }, + ) + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the seventeentrack integration.""" - async def get_packages(call: ServiceCall) -> ServiceResponse: - """Get packages from 17Track.""" - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - package_states = call.data.get(ATTR_PACKAGE_STATE, []) - - await _validate_service(config_entry_id) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - live_packages = sorted( - await seventeen_coordinator.client.profile.packages( - show_archived=seventeen_coordinator.show_archived - ) - ) - - return { - "packages": [ - package_to_dict(package) - for package in live_packages - if slugify(package.status) in package_states or package_states == [] - ] - } - - async def add_package(call: ServiceCall) -> None: - """Add a new package to 17Track.""" - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] - friendly_name = call.data[ATTR_PACKAGE_FRIENDLY_NAME] - - await _validate_service(config_entry_id) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - - await seventeen_coordinator.client.profile.add_package( - tracking_number, friendly_name - ) - - async def archive_package(call: ServiceCall) -> None: - config_entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - tracking_number = call.data[ATTR_PACKAGE_TRACKING_NUMBER] - - await _validate_service(config_entry_id) - - seventeen_coordinator: SeventeenTrackCoordinator = hass.data[DOMAIN][ - config_entry_id - ] - - await seventeen_coordinator.client.profile.archive_package(tracking_number) - - def package_to_dict(package: Package) -> dict[str, Any]: - result = { - ATTR_DESTINATION_COUNTRY: package.destination_country, - ATTR_ORIGIN_COUNTRY: package.origin_country, - ATTR_PACKAGE_TYPE: package.package_type, - ATTR_TRACKING_INFO_LANGUAGE: package.tracking_info_language, - ATTR_TRACKING_NUMBER: package.tracking_number, - ATTR_LOCATION: package.location, - ATTR_STATUS: package.status, - ATTR_INFO_TEXT: package.info_text, - ATTR_FRIENDLY_NAME: package.friendly_name, - } - if timestamp := package.timestamp: - result[ATTR_TIMESTAMP] = timestamp.isoformat() - return result - - async def _validate_service(config_entry_id): - entry: ConfigEntry | None = hass.config_entries.async_get_entry(config_entry_id) - if not entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="invalid_config_entry", - translation_placeholders={ - "config_entry_id": config_entry_id, - }, - ) - if entry.state != ConfigEntryState.LOADED: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="unloaded_config_entry", - translation_placeholders={ - "config_entry_id": entry.title, - }, - ) - hass.services.async_register( DOMAIN, SERVICE_GET_PACKAGES, - get_packages, + _get_packages, schema=SERVICE_GET_PACKAGES_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -171,13 +178,13 @@ def setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_ADD_PACKAGE, - add_package, + _add_package, schema=SERVICE_ADD_PACKAGE_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_ARCHIVE_PACKAGE, - archive_package, + _archive_package, schema=SERVICE_ARCHIVE_PACKAGE_SCHEMA, ) From 2991726d357a2ed199009bd25a0c9db6f06940d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:02:06 +0200 Subject: [PATCH 0224/1664] Simplify screenlogic service actions (#146609) --- .../components/screenlogic/__init__.py | 4 +- .../components/screenlogic/services.py | 175 +++++++++--------- 2 files changed, 92 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 972837f7d75..c6e4f0c279c 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -18,7 +18,7 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info from .data import ENTITY_MIGRATIONS -from .services import async_load_screenlogic_services +from .services import async_setup_services from .util import generate_unique_id type ScreenLogicConfigEntry = ConfigEntry[ScreenlogicDataUpdateCoordinator] @@ -48,7 +48,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Screenlogic.""" - async_load_screenlogic_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/screenlogic/services.py b/homeassistant/components/screenlogic/services.py index 44d8ad3ed81..3901f1cfd37 100644 --- a/homeassistant/components/screenlogic/services.py +++ b/homeassistant/components/screenlogic/services.py @@ -54,105 +54,110 @@ TURN_ON_SUPER_CHLOR_SCHEMA = BASE_SERVICE_SCHEMA.extend( ) +async def _get_coordinators( + service_call: ServiceCall, +) -> list[ScreenlogicDataUpdateCoordinator]: + entry_ids = {service_call.data[ATTR_CONFIG_ENTRY]} + coordinators: list[ScreenlogicDataUpdateCoordinator] = [] + for entry_id in entry_ids: + config_entry = cast( + ScreenLogicConfigEntry | None, + service_call.hass.config_entries.async_get_entry(entry_id), + ) + if not config_entry: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not found" + ) + if not config_entry.domain == DOMAIN: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' is not a {DOMAIN} config" + ) + if not config_entry.state == ConfigEntryState.LOADED: + raise ServiceValidationError( + f"Failed to call service '{service_call.service}'. Config entry " + f"'{entry_id}' not loaded" + ) + coordinators.append(config_entry.runtime_data) + + return coordinators + + +async def _async_set_color_mode(service_call: ServiceCall) -> None: + color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await _get_coordinators(service_call): + _LOGGER.debug( + "Service %s called on %s with mode %s", + SERVICE_SET_COLOR_MODE, + coordinator.gateway.name, + color_num, + ) + try: + await coordinator.gateway.async_set_color_lights(color_num) + # Debounced refresh to catch any secondary changes in the device + await coordinator.async_request_refresh() + except ScreenLogicError as error: + raise HomeAssistantError(error) from error + + +async def _async_set_super_chlor( + service_call: ServiceCall, + is_on: bool, + runtime: int | None = None, +) -> None: + coordinator: ScreenlogicDataUpdateCoordinator + for coordinator in await _get_coordinators(service_call): + if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: + raise ServiceValidationError( + f"Equipment configuration for {coordinator.gateway.name} does not" + f" support {service_call.service}" + ) + rt_log = f" with runtime {runtime}" if runtime else "" + _LOGGER.debug( + "Service %s called on %s%s", + service_call.service, + coordinator.gateway.name, + rt_log, + ) + try: + await coordinator.gateway.async_set_scg_config( + super_chlor_timer=runtime, super_chlorinate=is_on + ) + # Debounced refresh to catch any secondary changes in the device + await coordinator.async_request_refresh() + except ScreenLogicError as error: + raise HomeAssistantError(error) from error + + +async def _async_start_super_chlor(service_call: ServiceCall) -> None: + runtime = service_call.data[ATTR_RUNTIME] + await _async_set_super_chlor(service_call, True, runtime) + + +async def _async_stop_super_chlor(service_call: ServiceCall) -> None: + await _async_set_super_chlor(service_call, False) + + @callback -def async_load_screenlogic_services(hass: HomeAssistant): +def async_setup_services(hass: HomeAssistant): """Set up services for the ScreenLogic integration.""" - async def get_coordinators( - service_call: ServiceCall, - ) -> list[ScreenlogicDataUpdateCoordinator]: - entry_ids = {service_call.data[ATTR_CONFIG_ENTRY]} - coordinators: list[ScreenlogicDataUpdateCoordinator] = [] - for entry_id in entry_ids: - config_entry = cast( - ScreenLogicConfigEntry | None, - hass.config_entries.async_get_entry(entry_id), - ) - if not config_entry: - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry " - f"'{entry_id}' not found" - ) - if not config_entry.domain == DOMAIN: - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry " - f"'{entry_id}' is not a {DOMAIN} config" - ) - if not config_entry.state == ConfigEntryState.LOADED: - raise ServiceValidationError( - f"Failed to call service '{service_call.service}'. Config entry " - f"'{entry_id}' not loaded" - ) - coordinators.append(config_entry.runtime_data) - - return coordinators - - async def async_set_color_mode(service_call: ServiceCall) -> None: - color_num = SUPPORTED_COLOR_MODES[service_call.data[ATTR_COLOR_MODE]] - coordinator: ScreenlogicDataUpdateCoordinator - for coordinator in await get_coordinators(service_call): - _LOGGER.debug( - "Service %s called on %s with mode %s", - SERVICE_SET_COLOR_MODE, - coordinator.gateway.name, - color_num, - ) - try: - await coordinator.gateway.async_set_color_lights(color_num) - # Debounced refresh to catch any secondary changes in the device - await coordinator.async_request_refresh() - except ScreenLogicError as error: - raise HomeAssistantError(error) from error - - async def async_set_super_chlor( - service_call: ServiceCall, - is_on: bool, - runtime: int | None = None, - ) -> None: - coordinator: ScreenlogicDataUpdateCoordinator - for coordinator in await get_coordinators(service_call): - if EQUIPMENT_FLAG.CHLORINATOR not in coordinator.gateway.equipment_flags: - raise ServiceValidationError( - f"Equipment configuration for {coordinator.gateway.name} does not" - f" support {service_call.service}" - ) - rt_log = f" with runtime {runtime}" if runtime else "" - _LOGGER.debug( - "Service %s called on %s%s", - service_call.service, - coordinator.gateway.name, - rt_log, - ) - try: - await coordinator.gateway.async_set_scg_config( - super_chlor_timer=runtime, super_chlorinate=is_on - ) - # Debounced refresh to catch any secondary changes in the device - await coordinator.async_request_refresh() - except ScreenLogicError as error: - raise HomeAssistantError(error) from error - - async def async_start_super_chlor(service_call: ServiceCall) -> None: - runtime = service_call.data[ATTR_RUNTIME] - await async_set_super_chlor(service_call, True, runtime) - - async def async_stop_super_chlor(service_call: ServiceCall) -> None: - await async_set_super_chlor(service_call, False) - hass.services.async_register( - DOMAIN, SERVICE_SET_COLOR_MODE, async_set_color_mode, SET_COLOR_MODE_SCHEMA + DOMAIN, SERVICE_SET_COLOR_MODE, _async_set_color_mode, SET_COLOR_MODE_SCHEMA ) hass.services.async_register( DOMAIN, SERVICE_START_SUPER_CHLORINATION, - async_start_super_chlor, + _async_start_super_chlor, TURN_ON_SUPER_CHLOR_SCHEMA, ) hass.services.async_register( DOMAIN, SERVICE_STOP_SUPER_CHLORINATION, - async_stop_super_chlor, + _async_stop_super_chlor, BASE_SERVICE_SCHEMA, ) From 78ed1097c4d3c92fccdc936861ade9c6b8cc627f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:02:17 +0200 Subject: [PATCH 0225/1664] Simplify netgear_lte service actions (#146606) --- .../components/netgear_lte/__init__.py | 2 +- .../components/netgear_lte/services.py | 58 +++++++++++-------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 47a39a39be0..a6df67a7c83 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -96,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) - await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - await async_setup_services(hass, modem) + async_setup_services(hass) await discovery.async_load_platform( hass, diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py index 77ed1b91f31..5cac48c2634 100644 --- a/homeassistant/components/netgear_lte/services.py +++ b/homeassistant/components/netgear_lte/services.py @@ -1,9 +1,9 @@ """Services for the Netgear LTE integration.""" -from eternalegypt.eternalegypt import Modem import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -16,6 +16,7 @@ from .const import ( FAILOVER_MODES, LOGGER, ) +from .coordinator import NetgearLTEConfigEntry SERVICE_DELETE_SMS = "delete_sms" SERVICE_SET_OPTION = "set_option" @@ -45,30 +46,37 @@ CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) -async def async_setup_services(hass: HomeAssistant, modem: Modem) -> None: +async def _service_handler(call: ServiceCall) -> None: + """Apply a service.""" + host = call.data.get(ATTR_HOST) + + entry: NetgearLTEConfigEntry | None = None + for entry in call.hass.config_entries.async_loaded_entries(DOMAIN): + if entry.data.get(CONF_HOST) == host: + break + + if not entry or not (modem := entry.runtime_data.modem).token: + LOGGER.error("%s: host %s unavailable", call.service, host) + return + + if call.service == SERVICE_DELETE_SMS: + for sms_id in call.data[ATTR_SMS_ID]: + await modem.delete_sms(sms_id) + elif call.service == SERVICE_SET_OPTION: + if failover := call.data.get(ATTR_FAILOVER): + await modem.set_failover_mode(failover) + if autoconnect := call.data.get(ATTR_AUTOCONNECT): + await modem.set_autoconnect_mode(autoconnect) + elif call.service == SERVICE_CONNECT_LTE: + await modem.connect_lte() + elif call.service == SERVICE_DISCONNECT_LTE: + await modem.disconnect_lte() + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Netgear LTE integration.""" - async def service_handler(call: ServiceCall) -> None: - """Apply a service.""" - host = call.data.get(ATTR_HOST) - - if not modem.token: - LOGGER.error("%s: host %s unavailable", call.service, host) - return - - if call.service == SERVICE_DELETE_SMS: - for sms_id in call.data[ATTR_SMS_ID]: - await modem.delete_sms(sms_id) - elif call.service == SERVICE_SET_OPTION: - if failover := call.data.get(ATTR_FAILOVER): - await modem.set_failover_mode(failover) - if autoconnect := call.data.get(ATTR_AUTOCONNECT): - await modem.set_autoconnect_mode(autoconnect) - elif call.service == SERVICE_CONNECT_LTE: - await modem.connect_lte() - elif call.service == SERVICE_DISCONNECT_LTE: - await modem.disconnect_lte() - service_schemas = { SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, SERVICE_SET_OPTION: SET_OPTION_SCHEMA, @@ -77,4 +85,4 @@ async def async_setup_services(hass: HomeAssistant, modem: Modem) -> None: } for service, schema in service_schemas.items(): - hass.services.async_register(DOMAIN, service, service_handler, schema=schema) + hass.services.async_register(DOMAIN, service, _service_handler, schema=schema) From afc0a2789d40106e36b32706f90cf83212f3586b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Thu, 12 Jun 2025 08:05:51 -0400 Subject: [PATCH 0226/1664] Update Sonos to use SonosConfigEntry and runtime data (#145512) * fix: initial * fix: cleanup * fix: cleanup * fix: cleanup * fix: SonosConfigEntry * add config_entry.py * fix: sonos_data to runtime_data * fix: move to helpers.py --- homeassistant/components/sonos/__init__.py | 68 +++++++------------ homeassistant/components/sonos/alarms.py | 4 +- .../components/sonos/binary_sensor.py | 17 +++-- homeassistant/components/sonos/const.py | 1 - homeassistant/components/sonos/diagnostics.py | 22 +++--- homeassistant/components/sonos/entity.py | 10 +-- homeassistant/components/sonos/helpers.py | 34 ++++++++++ .../components/sonos/household_coordinator.py | 13 ++-- .../components/sonos/media_player.py | 43 +++++++----- homeassistant/components/sonos/number.py | 17 +++-- homeassistant/components/sonos/sensor.py | 19 +++--- homeassistant/components/sonos/speaker.py | 57 ++++++++++------ homeassistant/components/sonos/switch.py | 43 +++++++----- tests/components/sonos/test_services.py | 18 +++-- tests/components/sonos/test_speaker.py | 15 ++-- tests/components/sonos/test_statistics.py | 11 ++- 16 files changed, 234 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 24580971ae2..cbce25197b0 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -3,8 +3,6 @@ from __future__ import annotations import asyncio -from collections import OrderedDict -from dataclasses import dataclass, field import datetime from functools import partial from ipaddress import AddressValueError, IPv4Address @@ -25,9 +23,8 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -46,7 +43,6 @@ from homeassistant.util.async_ import create_eager_task from .alarms import SonosAlarms from .const import ( AVAILABILITY_CHECK_INTERVAL, - DATA_SONOS, DATA_SONOS_DISCOVERY_MANAGER, DISCOVERY_INTERVAL, DOMAIN, @@ -62,7 +58,7 @@ from .const import ( ) from .exception import SonosUpdateError from .favorites import SonosFavorites -from .helpers import sync_get_visible_zones +from .helpers import SonosConfigEntry, SonosData, sync_get_visible_zones from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -95,32 +91,6 @@ CONFIG_SCHEMA = vol.Schema( ) -@dataclass -class UnjoinData: - """Class to track data necessary for unjoin coalescing.""" - - speakers: list[SonosSpeaker] - event: asyncio.Event = field(default_factory=asyncio.Event) - - -class SonosData: - """Storage class for platform global data.""" - - def __init__(self) -> None: - """Initialize the data.""" - # OrderedDict behavior used by SonosAlarms and SonosFavorites - self.discovered: OrderedDict[str, SonosSpeaker] = OrderedDict() - self.favorites: dict[str, SonosFavorites] = {} - self.alarms: dict[str, SonosAlarms] = {} - self.topology_condition = asyncio.Condition() - self.hosts_heartbeat: CALLBACK_TYPE | None = None - self.discovery_known: set[str] = set() - self.boot_counts: dict[str, int] = {} - self.mdns_names: dict[str, str] = {} - self.entity_id_mappings: dict[str, SonosSpeaker] = {} - self.unjoin_data: dict[str, UnjoinData] = {} - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Sonos component.""" conf = config.get(DOMAIN) @@ -137,17 +107,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: SonosConfigEntry) -> bool: """Set up Sonos from a config entry.""" soco_config.EVENTS_MODULE = events_asyncio soco_config.REQUEST_TIMEOUT = 9.5 soco_config.ZGT_EVENT_FALLBACK = False zonegroupstate.EVENT_CACHE_TIMEOUT = SUBSCRIPTION_TIMEOUT - if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = SonosData() + data = entry.runtime_data = SonosData() - data = hass.data[DATA_SONOS] config = hass.data[DOMAIN].get("media_player", {}) hosts = config.get(CONF_HOSTS, []) _LOGGER.debug("Reached async_setup_entry, config=%s", config) @@ -172,12 +140,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: SonosConfigEntry +) -> bool: """Unload a Sonos config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + unload_ok = await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ) await hass.data[DATA_SONOS_DISCOVERY_MANAGER].async_shutdown() - hass.data.pop(DATA_SONOS) - hass.data.pop(DATA_SONOS_DISCOVERY_MANAGER) return unload_ok @@ -185,7 +155,11 @@ class SonosDiscoveryManager: """Manage sonos discovery.""" def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str] + self, + hass: HomeAssistant, + entry: SonosConfigEntry, + data: SonosData, + hosts: list[str], ) -> None: """Init discovery manager.""" self.hass = hass @@ -380,7 +354,9 @@ class SonosDiscoveryManager: if soco.uid not in self.data.boot_counts: self.data.boot_counts[soco.uid] = soco.boot_seqnum _LOGGER.debug("Adding new speaker: %s", speaker_info) - speaker = SonosSpeaker(self.hass, soco, speaker_info, zone_group_state_sub) + speaker = SonosSpeaker( + self.hass, self.entry, soco, speaker_info, zone_group_state_sub + ) self.data.discovered[soco.uid] = speaker for coordinator, coord_dict in ( (SonosAlarms, self.data.alarms), @@ -388,7 +364,9 @@ class SonosDiscoveryManager: ): c_dict: dict[str, Any] = coord_dict if soco.household_id not in c_dict: - new_coordinator = coordinator(self.hass, soco.household_id) + new_coordinator = coordinator( + self.hass, soco.household_id, self.entry + ) new_coordinator.setup(soco) c_dict[soco.household_id] = new_coordinator speaker.setup(self.entry) @@ -622,10 +600,10 @@ class SonosDiscoveryManager: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: SonosConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove Sonos config entry from a device.""" - known_devices = hass.data[DATA_SONOS].discovered.keys() + known_devices = config_entry.runtime_data.discovered.keys() for identifier in device_entry.identifiers: if identifier[0] != DOMAIN: continue diff --git a/homeassistant/components/sonos/alarms.py b/homeassistant/components/sonos/alarms.py index afbff8baa6d..c3c3b14545f 100644 --- a/homeassistant/components/sonos/alarms.py +++ b/homeassistant/components/sonos/alarms.py @@ -12,7 +12,7 @@ from soco.events_base import Event as SonosEvent from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DATA_SONOS, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM +from .const import SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM from .helpers import soco_error from .household_coordinator import SonosHouseholdCoordinator @@ -52,7 +52,7 @@ class SonosAlarms(SonosHouseholdCoordinator): for alarm_id, alarm in self.alarms.alarms.items(): if alarm_id in self.created_alarm_ids: continue - speaker = self.hass.data[DATA_SONOS].discovered.get(alarm.zone.uid) + speaker = self.config_entry.runtime_data.discovered.get(alarm.zone.uid) if speaker: async_dispatcher_send( self.hass, SONOS_CREATE_ALARM, speaker, [alarm_id] diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py index e2e981b293c..8a4c3abe248 100644 --- a/homeassistant/components/sonos/binary_sensor.py +++ b/homeassistant/components/sonos/binary_sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -17,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR from .entity import SonosEntity -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker ATTR_BATTERY_POWER_SOURCE = "power_source" @@ -27,7 +26,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" @@ -35,13 +34,13 @@ async def async_setup_entry( @callback def _async_create_battery_entity(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating battery binary_sensor on %s", speaker.zone_name) - entity = SonosPowerEntity(speaker) + entity = SonosPowerEntity(speaker, config_entry) async_add_entities([entity]) @callback def _async_create_mic_entity(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating microphone binary_sensor on %s", speaker.zone_name) - async_add_entities([SonosMicrophoneSensorEntity(speaker)]) + async_add_entities([SonosMicrophoneSensorEntity(speaker, config_entry)]) config_entry.async_on_unload( async_dispatcher_connect( @@ -62,9 +61,9 @@ class SonosPowerEntity(SonosEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the power entity binary sensor.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-power" async def _async_fallback_poll(self) -> None: @@ -95,9 +94,9 @@ class SonosMicrophoneSensorEntity(SonosEntity, BinarySensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_translation_key = "microphone" - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the microphone binary sensor entity.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-microphone" async def _async_fallback_poll(self) -> None: diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 614be2b1817..76e0a915060 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -10,7 +10,6 @@ from homeassistant.const import Platform UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1" DOMAIN = "sonos" -DATA_SONOS = "sonos_media_player" DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index a0207af77ab..35d81edbea0 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -5,11 +5,11 @@ from __future__ import annotations import time from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from .const import DATA_SONOS, DOMAIN +from .const import DOMAIN +from .helpers import SonosConfigEntry from .speaker import SonosSpeaker MEDIA_DIAGNOSTIC_ATTRIBUTES = ( @@ -45,27 +45,29 @@ SPEAKER_DIAGNOSTIC_ATTRIBUTES = ( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: SonosConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" payload: dict[str, Any] = {"current_timestamp": time.monotonic()} for section in ("discovered", "discovery_known"): payload[section] = {} - data: set[Any] | dict[str, Any] = getattr(hass.data[DATA_SONOS], section) + data: set[Any] | dict[str, Any] = getattr(config_entry.runtime_data, section) if isinstance(data, set): payload[section] = data continue for key, value in data.items(): if isinstance(value, SonosSpeaker): - payload[section][key] = await async_generate_speaker_info(hass, value) + payload[section][key] = await async_generate_speaker_info( + hass, config_entry, value + ) else: payload[section][key] = value return payload async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, config_entry: SonosConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" uid = next( @@ -75,10 +77,10 @@ async def async_get_device_diagnostics( if uid is None: return {} - if (speaker := hass.data[DATA_SONOS].discovered.get(uid)) is None: + if (speaker := config_entry.runtime_data.discovered.get(uid)) is None: return {} - return await async_generate_speaker_info(hass, speaker) + return await async_generate_speaker_info(hass, config_entry, speaker) async def async_generate_media_info( @@ -107,7 +109,7 @@ async def async_generate_media_info( async def async_generate_speaker_info( - hass: HomeAssistant, speaker: SonosSpeaker + hass: HomeAssistant, config_entry: SonosConfigEntry, speaker: SonosSpeaker ) -> dict[str, Any]: """Generate the diagnostic payload for a specific speaker.""" payload: dict[str, Any] = {} @@ -132,7 +134,7 @@ async def async_generate_speaker_info( payload["enabled_entities"] = sorted( entity_id - for entity_id, s in hass.data[DATA_SONOS].entity_id_mappings.items() + for entity_id, s in config_entry.runtime_data.entity_id_mappings.items() if s is speaker ) payload["media"] = await async_generate_media_info(hass, speaker) diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index a9a76b3b4d0..58108f9974c 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -13,8 +13,9 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DATA_SONOS, DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED +from .const import DOMAIN, SONOS_FALLBACK_POLL, SONOS_STATE_UPDATED from .exception import SonosUpdateError +from .helpers import SonosConfigEntry from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -26,13 +27,14 @@ class SonosEntity(Entity): _attr_should_poll = False _attr_has_entity_name = True - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize a SonosEntity.""" self.speaker = speaker + self.config_entry = config_entry async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id] = self.speaker + self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker self.async_on_remove( async_dispatcher_connect( self.hass, @@ -50,7 +52,7 @@ class SonosEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - del self.hass.data[DATA_SONOS].entity_id_mappings[self.entity_id] + del self.config_entry.runtime_data.entity_id_mappings[self.entity_id] async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 8ced5a87b28..3350df430f8 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio +from collections import OrderedDict from collections.abc import Callable +from dataclasses import dataclass, field import logging from typing import TYPE_CHECKING, Any, Concatenate, overload @@ -10,13 +13,17 @@ from requests.exceptions import Timeout from soco import SoCo from soco.exceptions import SoCoException, SoCoUPnPException +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE from homeassistant.helpers.dispatcher import dispatcher_send from .const import SONOS_SPEAKER_ACTIVITY from .exception import SonosUpdateError if TYPE_CHECKING: + from .alarms import SonosAlarms from .entity import SonosEntity + from .favorites import SonosFavorites from .household_coordinator import SonosHouseholdCoordinator from .media import SonosMedia from .speaker import SonosSpeaker @@ -120,3 +127,30 @@ def sync_get_visible_zones(soco: SoCo) -> set[SoCo]: _ = soco.household_id _ = soco.uid return soco.visible_zones + + +@dataclass +class UnjoinData: + """Class to track data necessary for unjoin coalescing.""" + + speakers: list[SonosSpeaker] = field(default_factory=list) + event: asyncio.Event = field(default_factory=asyncio.Event) + + +@dataclass +class SonosData: + """Storage class for platform global data.""" + + discovered: OrderedDict[str, SonosSpeaker] = field(default_factory=OrderedDict) + favorites: dict[str, SonosFavorites] = field(default_factory=dict) + alarms: dict[str, SonosAlarms] = field(default_factory=dict) + topology_condition: asyncio.Condition = field(default_factory=asyncio.Condition) + hosts_heartbeat: CALLBACK_TYPE | None = None + discovery_known: set[str] = field(default_factory=set) + boot_counts: dict[str, int] = field(default_factory=dict) + mdns_names: dict[str, str] = field(default_factory=dict) + entity_id_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) + unjoin_data: dict[str, UnjoinData] = field(default_factory=dict) + + +type SonosConfigEntry = ConfigEntry[SonosData] diff --git a/homeassistant/components/sonos/household_coordinator.py b/homeassistant/components/sonos/household_coordinator.py index 8fcecdf4d5e..a2c128dce94 100644 --- a/homeassistant/components/sonos/household_coordinator.py +++ b/homeassistant/components/sonos/household_coordinator.py @@ -5,16 +5,18 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine import logging -from typing import Any +from typing import TYPE_CHECKING, Any from soco import SoCo from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer -from .const import DATA_SONOS from .exception import SonosUpdateError +if TYPE_CHECKING: + from .helpers import SonosConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -23,12 +25,15 @@ class SonosHouseholdCoordinator: cache_update_lock: asyncio.Lock - def __init__(self, hass: HomeAssistant, household_id: str) -> None: + def __init__( + self, hass: HomeAssistant, household_id: str, config_entry: SonosConfigEntry + ) -> None: """Initialize the data.""" self.hass = hass self.household_id = household_id self.async_poll: Callable[[], Coroutine[None, None, None]] | None = None self.last_processed_event_id: int | None = None + self.config_entry = config_entry def setup(self, soco: SoCo) -> None: """Set up the SonosAlarm instance.""" @@ -54,7 +59,7 @@ class SonosHouseholdCoordinator: async def _async_poll(self) -> None: """Poll any known speaker.""" - discovered = self.hass.data[DATA_SONOS].discovered + discovered = self.config_entry.runtime_data.discovered for uid, speaker in discovered.items(): _LOGGER.debug("Polling %s using %s", self.class_type, speaker.soco) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index f1f95659469..96e4d34ddc4 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import datetime from functools import partial import logging -from typing import Any +from typing import TYPE_CHECKING, Any from soco import SoCo, alarms from soco.core import ( @@ -40,7 +40,6 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.plex import PLEX_URI_SCHEME from homeassistant.components.plex.services import process_plex_payload -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -49,9 +48,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from . import UnjoinData, media_browser +from . import media_browser from .const import ( - DATA_SONOS, DOMAIN, MEDIA_TYPE_DIRECTORY, MEDIA_TYPES_TO_SONOS, @@ -67,9 +65,12 @@ from .const import ( SOURCE_TV, ) from .entity import SonosEntity -from .helpers import soco_error +from .helpers import UnjoinData, soco_error from .speaker import SonosMedia, SonosSpeaker +if TYPE_CHECKING: + from .helpers import SonosConfigEntry + _LOGGER = logging.getLogger(__name__) LONG_SERVICE_TIMEOUT = 30.0 @@ -108,7 +109,7 @@ ATTR_QUEUE_POSITION = "queue_position" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" @@ -118,7 +119,7 @@ async def async_setup_entry( def async_create_entities(speaker: SonosSpeaker) -> None: """Handle device discovery and create entities.""" _LOGGER.debug("Creating media_player on %s", speaker.zone_name) - async_add_entities([SonosMediaPlayerEntity(speaker)]) + async_add_entities([SonosMediaPlayerEntity(speaker, config_entry)]) @service.verify_domain_control(hass, DOMAIN) async def async_service_handle(service_call: ServiceCall) -> None: @@ -136,11 +137,11 @@ async def async_setup_entry( if service_call.service == SERVICE_SNAPSHOT: await SonosSpeaker.snapshot_multi( - hass, speakers, service_call.data[ATTR_WITH_GROUP] + hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] ) elif service_call.service == SERVICE_RESTORE: await SonosSpeaker.restore_multi( - hass, speakers, service_call.data[ATTR_WITH_GROUP] + hass, config_entry, speakers, service_call.data[ATTR_WITH_GROUP] ) config_entry.async_on_unload( @@ -231,9 +232,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_device_class = MediaPlayerDeviceClass.SPEAKER - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the media player entity.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = self.soco.uid async def async_added_to_hass(self) -> None: @@ -298,9 +299,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def _async_fallback_poll(self) -> None: """Retrieve latest state by polling.""" - await ( - self.hass.data[DATA_SONOS].favorites[self.speaker.household_id].async_poll() - ) + favorites = self.config_entry.runtime_data.favorites[self.speaker.household_id] + assert favorites.async_poll + await favorites.async_poll() await self.hass.async_add_executor_job(self._update) def _update(self) -> None: @@ -880,12 +881,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """Join `group_members` as a player group with the current player.""" speakers = [] for entity_id in group_members: - if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id): + if speaker := self.config_entry.runtime_data.entity_id_mappings.get( + entity_id + ): speakers.append(speaker) else: raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") - await SonosSpeaker.join_multi(self.hass, self.speaker, speakers) + await SonosSpeaker.join_multi( + self.hass, self.config_entry, self.speaker, speakers + ) async def async_unjoin_player(self) -> None: """Remove this player from any group. @@ -894,7 +899,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): which optimizes the order in which speakers are removed from their groups. Removing coordinators last better preserves playqueues on the speakers. """ - sonos_data = self.hass.data[DATA_SONOS] + sonos_data = self.config_entry.runtime_data household_id = self.speaker.household_id async def async_process_unjoin(now: datetime.datetime) -> None: @@ -903,7 +908,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): _LOGGER.debug( "Processing unjoins for %s", [x.zone_name for x in unjoin_data.speakers] ) - await SonosSpeaker.unjoin_multi(self.hass, unjoin_data.speakers) + await SonosSpeaker.unjoin_multi( + self.hass, self.config_entry, unjoin_data.speakers + ) unjoin_data.event.set() if unjoin_data := sonos_data.unjoin_data.get(household_id): diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index c23ba51a877..8e4b4fb5b42 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -6,7 +6,6 @@ import logging from typing import cast from homeassistant.components.number import NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import SONOS_CREATE_LEVELS from .entity import SonosEntity -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker LEVEL_TYPES = { @@ -69,7 +68,7 @@ LEVEL_FROM_NUMBER = {"balance": _balance_from_number} async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Sonos number platform from a config entry.""" @@ -93,7 +92,9 @@ async def async_setup_entry( _LOGGER.debug( "Creating %s number control on %s", level_type, speaker.zone_name ) - entities.append(SonosLevelEntity(speaker, level_type, valid_range)) + entities.append( + SonosLevelEntity(speaker, config_entry, level_type, valid_range) + ) async_add_entities(entities) config_entry.async_on_unload( @@ -107,10 +108,14 @@ class SonosLevelEntity(SonosEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, speaker: SonosSpeaker, level_type: str, valid_range: tuple[int, int] + self, + speaker: SonosSpeaker, + config_entry: SonosConfigEntry, + level_type: str, + valid_range: tuple[int, int], ) -> None: """Initialize the level entity.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-{level_type}" self._attr_translation_key = level_type self.level_type = level_type diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index d888ee669bb..6b507ec910a 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -5,7 +5,6 @@ from __future__ import annotations import logging from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -20,7 +19,7 @@ from .const import ( ) from .entity import SonosEntity, SonosPollingEntity from .favorites import SonosFavorites -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -28,7 +27,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" @@ -38,13 +37,13 @@ async def async_setup_entry( speaker: SonosSpeaker, audio_format: str ) -> None: _LOGGER.debug("Creating audio input format sensor on %s", speaker.zone_name) - entity = SonosAudioInputFormatSensorEntity(speaker, audio_format) + entity = SonosAudioInputFormatSensorEntity(speaker, config_entry, audio_format) async_add_entities([entity]) @callback def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: _LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name) - entity = SonosBatteryEntity(speaker) + entity = SonosBatteryEntity(speaker, config_entry) async_add_entities([entity]) @callback @@ -82,9 +81,9 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_native_unit_of_measurement = PERCENTAGE - def __init__(self, speaker: SonosSpeaker) -> None: + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: """Initialize the battery sensor.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-battery" async def _async_fallback_poll(self) -> None: @@ -109,9 +108,11 @@ class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): _attr_translation_key = "audio_input_format" _attr_should_poll = True - def __init__(self, speaker: SonosSpeaker, audio_format: str) -> None: + def __init__( + self, speaker: SonosSpeaker, config_entry: SonosConfigEntry, audio_format: str + ) -> None: """Initialize the audio input format sensor.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"{self.soco.uid}-audio-format" self._attr_native_value = audio_format diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index d339e861a13..aee0a40c184 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -21,7 +21,6 @@ from soco.snapshot import Snapshot from sonos_websocket import SonosWebsocket from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -38,7 +37,6 @@ from .alarms import SonosAlarms from .const import ( AVAILABILITY_TIMEOUT, BATTERY_SCAN_INTERVAL, - DATA_SONOS, DOMAIN, SCAN_INTERVAL, SONOS_CHECK_ACTIVITY, @@ -66,7 +64,8 @@ from .media import SonosMedia from .statistics import ActivityStatistics, EventStatistics if TYPE_CHECKING: - from . import SonosData + from .helpers import SonosConfigEntry + NEVER_TIME = -1200.0 RESUB_COOLDOWN_SECONDS = 10.0 @@ -95,13 +94,15 @@ class SonosSpeaker: def __init__( self, hass: HomeAssistant, + config_entry: SonosConfigEntry, soco: SoCo, speaker_info: dict[str, Any], zone_group_state_sub: SubscriptionBase | None, ) -> None: """Initialize a SonosSpeaker.""" self.hass = hass - self.data: SonosData = hass.data[DATA_SONOS] + self.config_entry = config_entry + self.data = config_entry.runtime_data self.soco = soco self.websocket: SonosWebsocket | None = None self.household_id: str = soco.household_id @@ -179,7 +180,10 @@ class SonosSpeaker: self._group_members_missing: set[str] = set() async def async_setup( - self, entry: ConfigEntry, has_battery: bool, dispatches: list[tuple[Any, ...]] + self, + entry: SonosConfigEntry, + has_battery: bool, + dispatches: list[tuple[Any, ...]], ) -> None: """Complete setup in async context.""" # Battery events can be infrequent, polling is still necessary @@ -216,7 +220,7 @@ class SonosSpeaker: await self.async_subscribe() - def setup(self, entry: ConfigEntry) -> None: + def setup(self, entry: SonosConfigEntry) -> None: """Run initial setup of the speaker.""" self.media.play_mode = self.soco.play_mode self.update_volume() @@ -961,15 +965,16 @@ class SonosSpeaker: @staticmethod async def join_multi( hass: HomeAssistant, + config_entry: SonosConfigEntry, master: SonosSpeaker, speakers: list[SonosSpeaker], ) -> None: """Form a group with other players.""" - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: group: list[SonosSpeaker] = await hass.async_add_executor_job( master.join, speakers ) - await SonosSpeaker.wait_for_groups(hass, [group]) + await SonosSpeaker.wait_for_groups(hass, config_entry, [group]) @soco_error() def unjoin(self) -> None: @@ -980,7 +985,11 @@ class SonosSpeaker: self.coordinator = None @staticmethod - async def unjoin_multi(hass: HomeAssistant, speakers: list[SonosSpeaker]) -> None: + async def unjoin_multi( + hass: HomeAssistant, + config_entry: SonosConfigEntry, + speakers: list[SonosSpeaker], + ) -> None: """Unjoin several players from their group.""" def _unjoin_all(speakers: list[SonosSpeaker]) -> None: @@ -992,9 +1001,11 @@ class SonosSpeaker: for speaker in joined_speakers + coordinators: speaker.unjoin() - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: await hass.async_add_executor_job(_unjoin_all, speakers) - await SonosSpeaker.wait_for_groups(hass, [[s] for s in speakers]) + await SonosSpeaker.wait_for_groups( + hass, config_entry, [[s] for s in speakers] + ) @soco_error() def snapshot(self, with_group: bool) -> None: @@ -1008,7 +1019,10 @@ class SonosSpeaker: @staticmethod async def snapshot_multi( - hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool + hass: HomeAssistant, + config_entry: SonosConfigEntry, + speakers: list[SonosSpeaker], + with_group: bool, ) -> None: """Snapshot all the speakers and optionally their groups.""" @@ -1023,7 +1037,7 @@ class SonosSpeaker: for speaker in list(speakers_set): speakers_set.update(speaker.sonos_group) - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: await hass.async_add_executor_job(_snapshot_all, speakers_set) @soco_error() @@ -1041,7 +1055,10 @@ class SonosSpeaker: @staticmethod async def restore_multi( - hass: HomeAssistant, speakers: list[SonosSpeaker], with_group: bool + hass: HomeAssistant, + config_entry: SonosConfigEntry, + speakers: list[SonosSpeaker], + with_group: bool, ) -> None: """Restore snapshots for all the speakers.""" @@ -1119,16 +1136,18 @@ class SonosSpeaker: assert len(speaker.snapshot_group) speakers_set.update(speaker.snapshot_group) - async with hass.data[DATA_SONOS].topology_condition: + async with config_entry.runtime_data.topology_condition: groups = await hass.async_add_executor_job( _restore_groups, speakers_set, with_group ) - await SonosSpeaker.wait_for_groups(hass, groups) + await SonosSpeaker.wait_for_groups(hass, config_entry, groups) await hass.async_add_executor_job(_restore_players, speakers_set) @staticmethod async def wait_for_groups( - hass: HomeAssistant, groups: list[list[SonosSpeaker]] + hass: HomeAssistant, + config_entry: SonosConfigEntry, + groups: list[list[SonosSpeaker]], ) -> None: """Wait until all groups are present, or timeout.""" @@ -1151,11 +1170,11 @@ class SonosSpeaker: try: async with asyncio.timeout(5): while not _test_groups(groups): - await hass.data[DATA_SONOS].topology_condition.wait() + await config_entry.runtime_data.topology_condition.wait() except TimeoutError: _LOGGER.warning("Timeout waiting for target groups %s", groups) - any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values())) + any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() # diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 052dbd990b2..582845d10a2 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -10,7 +10,6 @@ from soco.alarms import Alarm from soco.exceptions import SoCoSlaveException, SoCoUPnPException from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -18,15 +17,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_track_time_change +from .alarms import SonosAlarms from .const import ( - DATA_SONOS, DOMAIN, SONOS_ALARMS_UPDATED, SONOS_CREATE_ALARM, SONOS_CREATE_SWITCHES, ) from .entity import SonosEntity, SonosPollingEntity -from .helpers import soco_error +from .helpers import SonosConfigEntry, soco_error from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) @@ -73,22 +72,22 @@ WEEKEND_DAYS = (0, 6) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: SonosConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Sonos from a config entry.""" async def _async_create_alarms(speaker: SonosSpeaker, alarm_ids: list[str]) -> None: entities = [] - created_alarms = ( - hass.data[DATA_SONOS].alarms[speaker.household_id].created_alarm_ids - ) + created_alarms = config_entry.runtime_data.alarms[ + speaker.household_id + ].created_alarm_ids for alarm_id in alarm_ids: if alarm_id in created_alarms: continue _LOGGER.debug("Creating alarm %s on %s", alarm_id, speaker.zone_name) created_alarms.add(alarm_id) - entities.append(SonosAlarmEntity(alarm_id, speaker)) + entities.append(SonosAlarmEntity(alarm_id, speaker, config_entry)) async_add_entities(entities) def available_soco_attributes(speaker: SonosSpeaker) -> list[str]: @@ -113,7 +112,7 @@ async def async_setup_entry( feature_type, speaker.zone_name, ) - entities.append(SonosSwitchEntity(feature_type, speaker)) + entities.append(SonosSwitchEntity(feature_type, speaker, config_entry)) async_add_entities(entities) config_entry.async_on_unload( @@ -127,9 +126,11 @@ async def async_setup_entry( class SonosSwitchEntity(SonosPollingEntity, SwitchEntity): """Representation of a Sonos feature switch.""" - def __init__(self, feature_type: str, speaker: SonosSpeaker) -> None: + def __init__( + self, feature_type: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry + ) -> None: """Initialize the switch.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self.feature_type = feature_type self.needs_coordinator = feature_type in COORDINATOR_FEATURES self._attr_entity_category = EntityCategory.CONFIG @@ -185,9 +186,11 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:alarm" - def __init__(self, alarm_id: str, speaker: SonosSpeaker) -> None: + def __init__( + self, alarm_id: str, speaker: SonosSpeaker, config_entry: SonosConfigEntry + ) -> None: """Initialize the switch.""" - super().__init__(speaker) + super().__init__(speaker, config_entry) self._attr_unique_id = f"alarm-{speaker.household_id}:{alarm_id}" self.alarm_id = alarm_id self.household_id = speaker.household_id @@ -218,7 +221,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): @property def alarm(self) -> Alarm: """Return the alarm instance.""" - return self.hass.data[DATA_SONOS].alarms[self.household_id].get(self.alarm_id) + return self.config_entry.runtime_data.alarms[self.household_id].get( + self.alarm_id + ) @property def name(self) -> str: @@ -230,7 +235,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): async def _async_fallback_poll(self) -> None: """Call the central alarm polling method.""" - await self.hass.data[DATA_SONOS].alarms[self.household_id].async_poll() + alarms: SonosAlarms = self.config_entry.runtime_data.alarms[self.household_id] + assert alarms.async_poll + await alarms.async_poll() @callback def async_check_if_available(self) -> bool: @@ -252,9 +259,9 @@ class SonosAlarmEntity(SonosEntity, SwitchEntity): return if self.speaker.soco.uid != self.alarm.zone.uid: - self.speaker = self.hass.data[DATA_SONOS].discovered.get( - self.alarm.zone.uid - ) + speaker = self.config_entry.runtime_data.discovered.get(self.alarm.zone.uid) + assert speaker + self.speaker = speaker if self.speaker is None: raise RuntimeError( "No configured Sonos speaker has been found to match the alarm." diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index da894ff4548..8f83ce2f814 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -5,12 +5,15 @@ from unittest.mock import Mock, patch import pytest from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, SERVICE_JOIN -from homeassistant.components.sonos.const import DATA_SONOS from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from tests.common import MockConfigEntry -async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> None: + +async def test_media_player_join( + hass: HomeAssistant, async_autosetup_sonos, config_entry: MockConfigEntry +) -> None: """Test join service.""" valid_entity_id = "media_player.zone_a" mocked_entity_id = "media_player.mocked" @@ -29,7 +32,10 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> mock_entity_id_mappings = {mocked_entity_id: mocked_speaker} with ( - patch.dict(hass.data[DATA_SONOS].entity_id_mappings, mock_entity_id_mappings), + patch.dict( + config_entry.runtime_data.entity_id_mappings, + mock_entity_id_mappings, + ), patch( "homeassistant.components.sonos.speaker.SonosSpeaker.join_multi" ) as mock_join_multi, @@ -41,5 +47,7 @@ async def test_media_player_join(hass: HomeAssistant, async_autosetup_sonos) -> blocking=True, ) - found_speaker = hass.data[DATA_SONOS].entity_id_mappings[valid_entity_id] - mock_join_multi.assert_called_with(hass, found_speaker, [mocked_speaker]) + found_speaker = config_entry.runtime_data.entity_id_mappings[valid_entity_id] + mock_join_multi.assert_called_with( + hass, config_entry, found_speaker, [mocked_speaker] + ) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 40d126c64f2..468b848dfb5 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -9,13 +9,18 @@ from homeassistant.components.media_player import ( SERVICE_MEDIA_PLAY, ) from homeassistant.components.sonos import DOMAIN -from homeassistant.components.sonos.const import DATA_SONOS, SCAN_INTERVAL +from homeassistant.components.sonos.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .conftest import MockSoCo, SonosMockEvent -from tests.common import async_fire_time_changed, load_fixture, load_json_value_fixture +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_fixture, + load_json_value_fixture, +) async def test_fallback_to_polling( @@ -33,7 +38,7 @@ async def test_fallback_to_polling( await hass.async_block_till_done() await fire_zgs_event() - speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + speaker = list(config_entry.runtime_data.discovered.values())[0] assert speaker.soco is soco assert speaker._subscriptions assert not speaker.subscriptions_failed @@ -56,7 +61,7 @@ async def test_fallback_to_polling( async def test_subscription_creation_fails( - hass: HomeAssistant, async_setup_sonos + hass: HomeAssistant, async_setup_sonos, config_entry: MockConfigEntry ) -> None: """Test that subscription creation failures are handled.""" with patch( @@ -66,7 +71,7 @@ async def test_subscription_creation_fails( await async_setup_sonos() await hass.async_block_till_done(wait_background_tasks=True) - speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + speaker = list(config_entry.runtime_data.discovered.values())[0] assert not speaker._subscriptions with patch.object(speaker, "_resub_cooldown_expires_at", None): diff --git a/tests/components/sonos/test_statistics.py b/tests/components/sonos/test_statistics.py index 4f28ec31412..84f8fca138e 100644 --- a/tests/components/sonos/test_statistics.py +++ b/tests/components/sonos/test_statistics.py @@ -1,14 +1,19 @@ """Tests for the Sonos statistics.""" -from homeassistant.components.sonos.const import DATA_SONOS from homeassistant.core import HomeAssistant +from tests.common import MockConfigEntry + async def test_statistics_duplicate( - hass: HomeAssistant, async_autosetup_sonos, soco, device_properties_event + hass: HomeAssistant, + async_autosetup_sonos, + soco, + device_properties_event, + config_entry: MockConfigEntry, ) -> None: """Test Sonos statistics.""" - speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + speaker = list(config_entry.runtime_data.discovered.values())[0] subscription = soco.deviceProperties.subscribe.return_value sub_callback = subscription.callback From af1eccabce67d1daef130f8442df930aa1178106 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:17:36 +0200 Subject: [PATCH 0227/1664] Bump github/codeql-action from 3.28.19 to 3.29.0 (#146595) 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 36902d13356..583cfdd211c 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.28.19 + uses: github/codeql-action/init@v3.29.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.28.19 + uses: github/codeql-action/analyze@v3.29.0 with: category: "/language:python" From 28bd90aeb05a3fe6fe050bf76dbdfbf8b0f0e4aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Jun 2025 14:18:04 +0200 Subject: [PATCH 0228/1664] Bump actions/attest-build-provenance from 2.3.0 to 2.4.0 (#146594) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index dd4bded2cc5..136f1b83d06 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -531,7 +531,7 @@ jobs: - name: Generate artifact attestation if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2.4.0 with: subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-digest: ${{ steps.push.outputs.digest }} From 8807c530a967273722061fb438c6270441bf4613 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 12 Jun 2025 22:32:04 +1000 Subject: [PATCH 0229/1664] Fix palette handling for LIFX Ceiling SKY effect (#146582) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manager.py | 9 ++++++--- tests/components/lifx/test_light.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 887bc3c3527..9fae2628f1d 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any import aiolifx_effects from aiolifx_themes.painter import ThemePainter @@ -31,9 +31,12 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN -from .coordinator import LIFXUpdateCoordinator, Light +from .coordinator import LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk +if TYPE_CHECKING: + from aiolifx.aiolifx import Light + SCAN_INTERVAL = timedelta(seconds=10) SERVICE_EFFECT_COLORLOOP = "effect_colorloop" @@ -426,8 +429,8 @@ class LIFXManager: ) -> None: """Start the firmware-based Sky effect.""" palette = kwargs.get(ATTR_PALETTE) + theme = Theme() if palette is not None: - theme = Theme() for hsbk in palette: theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 58843d63f9a..d66908c1b1a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -843,7 +843,7 @@ async def test_sky_effect(hass: HomeAssistant) -> None: SERVICE_EFFECT_SKY, { ATTR_ENTITY_ID: entity_id, - ATTR_PALETTE: [], + ATTR_PALETTE: None, ATTR_SKY_TYPE: "Clouds", ATTR_CLOUD_SATURATION_MAX: 180, ATTR_CLOUD_SATURATION_MIN: 50, @@ -854,7 +854,7 @@ async def test_sky_effect(hass: HomeAssistant) -> None: bulb.power_level = 65535 bulb.effect = { "effect": "SKY", - "palette": [], + "palette": None, "sky_type": 2, "cloud_saturation_min": 50, "cloud_saturation_max": 180, From 171f7c5f8181c42cfd226d62140bd71b3e8b88f6 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 12 Jun 2025 17:24:10 +0300 Subject: [PATCH 0230/1664] Fix cookies with aiohttp >= 3.12.7 for Vodafone Station (#146647) --- homeassistant/components/vodafone_station/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/utils.py b/homeassistant/components/vodafone_station/utils.py index 4f900412faf..faa498afdd6 100644 --- a/homeassistant/components/vodafone_station/utils.py +++ b/homeassistant/components/vodafone_station/utils.py @@ -9,5 +9,5 @@ from homeassistant.helpers import aiohttp_client async def async_client_session(hass: HomeAssistant) -> ClientSession: """Return a new aiohttp session.""" return aiohttp_client.async_create_clientsession( - hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True, quote_cookie=False) ) From b0cf974b345c9235d3c36ad44a56e5c78f1a24db Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:27:20 +0200 Subject: [PATCH 0231/1664] Simplify swiss public transport service actions (#146611) --- .../swiss_public_transport/__init__.py | 4 +- .../swiss_public_transport/services.py | 51 ++++++++++--------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py index 0d0c4dc6169..49fe9949772 100644 --- a/homeassistant/components/swiss_public_transport/__init__.py +++ b/homeassistant/components/swiss_public_transport/__init__.py @@ -35,7 +35,7 @@ from .coordinator import ( SwissPublicTransportDataUpdateCoordinator, ) from .helper import offset_opendata, unique_id_from_config -from .services import setup_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -47,7 +47,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Swiss public transport component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py index 3abf1a14b9f..1ac116b4ca9 100644 --- a/homeassistant/components/swiss_public_transport/services.py +++ b/homeassistant/components/swiss_public_transport/services.py @@ -8,6 +8,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.selector import ( @@ -39,7 +40,7 @@ SERVICE_FETCH_CONNECTIONS_SCHEMA = vol.Schema( ) -def async_get_entry( +def _async_get_entry( hass: HomeAssistant, config_entry_id: str ) -> SwissPublicTransportConfigEntry: """Get the Swiss public transport config entry.""" @@ -58,34 +59,36 @@ def async_get_entry( return entry -def setup_services(hass: HomeAssistant) -> None: +async def _async_fetch_connections( + call: ServiceCall, +) -> ServiceResponse: + """Fetch a set of connections.""" + config_entry = _async_get_entry(call.hass, call.data[ATTR_CONFIG_ENTRY_ID]) + + limit = call.data.get(ATTR_LIMIT) or CONNECTIONS_COUNT + try: + connections = await config_entry.runtime_data.fetch_connections_as_json( + limit=int(limit) + ) + except UpdateFailed as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "error": str(e), + }, + ) from e + return {"connections": connections} + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the Swiss public transport integration.""" - async def async_fetch_connections( - call: ServiceCall, - ) -> ServiceResponse: - """Fetch a set of connections.""" - config_entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) - - limit = call.data.get(ATTR_LIMIT) or CONNECTIONS_COUNT - try: - connections = await config_entry.runtime_data.fetch_connections_as_json( - limit=int(limit) - ) - except UpdateFailed as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="cannot_connect", - translation_placeholders={ - "error": str(e), - }, - ) from e - return {"connections": connections} - hass.services.async_register( DOMAIN, SERVICE_FETCH_CONNECTIONS, - async_fetch_connections, + _async_fetch_connections, schema=SERVICE_FETCH_CONNECTIONS_SCHEMA, supports_response=SupportsResponse.ONLY, ) From 48e4624ba00b1a590eb454501f3aea1d7560ae85 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 18:33:45 +0200 Subject: [PATCH 0232/1664] Add basic xiaomi_miio fan tests (#146593) --- .../xiaomi_miio/snapshots/test_fan.ambr | 127 +++++++++++++++++ tests/components/xiaomi_miio/test_fan.py | 130 ++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 tests/components/xiaomi_miio/snapshots/test_fan.ambr create mode 100644 tests/components/xiaomi_miio/test_fan.py diff --git a/tests/components/xiaomi_miio/snapshots/test_fan.ambr b/tests/components/xiaomi_miio/snapshots/test_fan.ambr new file mode 100644 index 00000000000..0a0ad2e6d31 --- /dev/null +++ b/tests/components/xiaomi_miio/snapshots/test_fan.ambr @@ -0,0 +1,127 @@ +# serializer version: 1 +# name: test_fan_status[dmaker.fan.p18][fan.test_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.test_fan', + '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': 'xiaomi_miio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'generic_fan', + 'unique_id': '123456', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_status[dmaker.fan.p18][fan.test_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': None, + 'friendly_name': 'test_fan', + 'oscillating': None, + 'percentage': None, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.test_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_fan_status[dmaker.fan.p5][fan.test_fan-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.test_fan', + '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': 'xiaomi_miio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'generic_fan', + 'unique_id': '123456', + 'unit_of_measurement': None, + }) +# --- +# name: test_fan_status[dmaker.fan.p5][fan.test_fan-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': None, + 'friendly_name': 'test_fan', + 'oscillating': False, + 'percentage': None, + 'percentage_step': 1.0, + 'preset_mode': 'Nature', + 'preset_modes': list([ + 'Normal', + 'Nature', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.test_fan', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/xiaomi_miio/test_fan.py b/tests/components/xiaomi_miio/test_fan.py new file mode 100644 index 00000000000..93aa3673187 --- /dev/null +++ b/tests/components/xiaomi_miio/test_fan.py @@ -0,0 +1,130 @@ +"""The tests for the xiaomi_miio fan component.""" + +from collections.abc import Generator +from unittest.mock import MagicMock, Mock, patch + +from miio.integrations.fan.dmaker.fan import FanStatusP5 +from miio.integrations.fan.dmaker.fan_miot import FanStatusMiot +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.xiaomi_miio import MODEL_TO_CLASS_MAP +from homeassistant.components.xiaomi_miio.const import CONF_FLOW_TYPE, DOMAIN +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_MAC, + CONF_MODEL, + CONF_TOKEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import TEST_MAC + +from tests.common import MockConfigEntry, snapshot_platform + +_MODEL_INFORMATION = { + "dmaker.fan.p5": { + "patch_class": "homeassistant.components.xiaomi_miio.FanP5", + "mock_status": FanStatusP5( + { + "roll_angle": 60, + "beep_sound": False, + "child_lock": False, + "time_off": 0, + "power": False, + "light": True, + "mode": "nature", + "roll_enable": False, + "speed": 64, + } + ), + }, + "dmaker.fan.p18": { + "patch_class": "homeassistant.components.xiaomi_miio.FanMiot", + "mock_status": FanStatusMiot( + { + "swing_mode_angle": 90, + "buzzer": False, + "child_lock": False, + "power_off_time": 0, + "power": False, + "light": True, + "mode": 0, + "swing_mode": False, + "fan_speed": 100, + } + ), + }, +} + + +@pytest.fixture( + name="model_code", + params=_MODEL_INFORMATION.keys(), +) +def get_model_code(request: pytest.FixtureRequest) -> str: + """Parametrize model code.""" + return request.param + + +@pytest.fixture(autouse=True) +def setup_device(model_code: str) -> Generator[MagicMock]: + """Initialize test xiaomi_miio for fan entity.""" + + model_information = _MODEL_INFORMATION[model_code] + + mock_fan = MagicMock() + mock_fan.status = Mock(return_value=model_information["mock_status"]) + + with ( + patch( + "homeassistant.components.xiaomi_miio.get_platforms", + return_value=[Platform.FAN], + ), + patch(model_information["patch_class"]) as mock_fan_cls, + patch.dict( + MODEL_TO_CLASS_MAP, + {model_code: mock_fan_cls} if model_code in MODEL_TO_CLASS_MAP else {}, + ), + ): + mock_fan_cls.return_value = mock_fan + yield mock_fan + + +async def setup_component( + hass: HomeAssistant, model_code: str, entry_title: str +) -> MockConfigEntry: + """Set up fan component.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="123456", + title=entry_title, + data={ + CONF_FLOW_TYPE: CONF_DEVICE, + CONF_HOST: "192.168.1.100", + CONF_TOKEN: "12345678901234567890123456789012", + CONF_MODEL: model_code, + CONF_MAC: TEST_MAC, + }, + ) + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +async def test_fan_status( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model_code: str, + snapshot: SnapshotAssertion, +) -> None: + """Test fan status.""" + + config_entry = await setup_component(hass, model_code, "test_fan") + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) From 8eebebc58647404cb175fefeceb5531b98333ccf Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Thu, 12 Jun 2025 18:36:50 +0200 Subject: [PATCH 0233/1664] Bump pypck to 0.8.7 (#146657) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index be5d6299f09..9575c01515b 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.6", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.7", "lcn-frontend==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27d4d350b42..32f693d2174 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2236,7 +2236,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.6 +pypck==0.8.7 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48d2738c0bf..a19cf3f79c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1857,7 +1857,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.6 +pypck==0.8.7 # homeassistant.components.pglab pypglab==0.0.5 From 680b70aa294be2f495f702f9a5a032e6047939a4 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 12 Jun 2025 19:26:37 +0200 Subject: [PATCH 0234/1664] Reolink add diagnostics for baichuan (#146667) * Add baichuan diagnostics * adjust tests --- homeassistant/components/reolink/diagnostics.py | 2 ++ tests/components/reolink/conftest.py | 1 + tests/components/reolink/snapshots/test_diagnostics.ambr | 2 ++ 3 files changed, 5 insertions(+) diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py index 1d0e5d919e7..c5085c9ca18 100644 --- a/homeassistant/components/reolink/diagnostics.py +++ b/homeassistant/components/reolink/diagnostics.py @@ -39,6 +39,8 @@ async def async_get_config_entry_diagnostics( "firmware version": api.sw_version, "HTTPS": api.use_https, "HTTP(S) port": api.port, + "Baichuan port": api.baichuan.port, + "Baichuan only": api.baichuan_only, "WiFi connection": api.wifi_connection, "WiFi signal": api.wifi_signal, "RTMP enabled": api.rtmp_enabled, diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 1d8244a890a..c94dd8d7d37 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -143,6 +143,7 @@ def reolink_connect_class() -> Generator[MagicMock]: # Baichuan host_mock.baichuan = create_autospec(Baichuan) + host_mock.baichuan_only = False # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 5eb80d16356..3b866aa14ca 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -10,6 +10,8 @@ 'pushAlarm': 7, }), }), + 'Baichuan only': False, + 'Baichuan port': 5678, 'Chimes': dict({ '12345678': dict({ 'channel': 0, From 7e6bb021cec1ab528c3e3ec088238197fc2e7a31 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Thu, 12 Jun 2025 20:29:47 +0300 Subject: [PATCH 0235/1664] Bump hdate to 1.1.2 (#146659) --- homeassistant/components/jewish_calendar/manifest.json | 2 +- homeassistant/components/jewish_calendar/sensor.py | 10 +++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 550a6514593..1ab967ecfa4 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.1.1"], + "requirements": ["hdate[astral]==1.1.2"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index cb38a3797eb..91c618e1c1c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -73,7 +73,7 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, options_fn=lambda _: [str(p) for p in Parasha], - value_fn=lambda results: str(results.after_tzais_date.upcoming_shabbat.parasha), + value_fn=lambda results: results.after_tzais_date.upcoming_shabbat.parasha, ), JewishCalendarSensorDescription( key="holiday", @@ -98,17 +98,13 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( key="omer_count", translation_key="omer_count", entity_registry_enabled_default=False, - value_fn=lambda results: ( - results.after_shkia_date.omer.total_days - if results.after_shkia_date.omer - else 0 - ), + value_fn=lambda results: results.after_shkia_date.omer.total_days, ), JewishCalendarSensorDescription( key="daf_yomi", translation_key="daf_yomi", entity_registry_enabled_default=False, - value_fn=lambda results: str(results.daytime_date.daf_yomi), + value_fn=lambda results: results.daytime_date.daf_yomi, ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 32f693d2174..81fa46b12e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.1 +hdate[astral]==1.1.2 # homeassistant.components.heatmiser heatmiserV3==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a19cf3f79c0..1c40f36bba2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.1 +hdate[astral]==1.1.2 # homeassistant.components.here_travel_time here-routing==1.0.1 From e86e79384218e7ec401dce7cc542e27277fcb810 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 12 Jun 2025 19:38:20 +0200 Subject: [PATCH 0236/1664] Tweak non-English issue detection (#146636) --- .../workflows/detect-non-english-issues.yml | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index e33260a9cc2..264b8ab9854 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -64,16 +64,19 @@ jobs: You are a language detection system. Your task is to determine if the provided text is written in English or another language. Rules: - 1. Analyze the text and determine the primary language + 1. Analyze the text and determine the primary language of the USER'S DESCRIPTION only 2. IGNORE markdown headers (lines starting with #, ##, ###, etc.) as these are from issue templates, not user input 3. IGNORE all code blocks (text between ``` or ` markers) as they may contain system-generated error messages in other languages - 4. Consider technical terms, code snippets, and URLs as neutral (they don't indicate non-English) - 5. Focus on the actual sentences and descriptions written by the user - 6. Return ONLY a JSON object with two fields: - - "is_english": boolean (true if the text is primarily in English, false otherwise) + 4. IGNORE error messages, logs, and system output even if not in code blocks - these often appear in the user's system language + 5. Consider technical terms, code snippets, URLs, and file paths as neutral (they don't indicate non-English) + 6. Focus ONLY on the actual sentences and descriptions written by the user explaining their issue + 7. If the user's explanation/description is in English but includes non-English error messages or logs, consider it ENGLISH + 8. Return ONLY a JSON object with two fields: + - "is_english": boolean (true if the user's description is primarily in English, false otherwise) - "detected_language": string (the name of the detected language, e.g., "English", "Spanish", "Chinese", etc.) - 7. Be lenient - if the text is mostly English with minor non-English elements, consider it English - 8. Common programming terms, error messages, and technical jargon should not be considered as non-English + 9. Be lenient - if the user's explanation is in English with non-English system output, it's still English + 10. Common programming terms, error messages, and technical jargon should not be considered as non-English + 11. If you cannot reliably determine the language, set detected_language to "undefined" Example response: {"is_english": false, "detected_language": "Spanish"} @@ -122,6 +125,12 @@ jobs: return; } + // If language is undefined or not detected, skip processing + if (!languageResult.detected_language || languageResult.detected_language === 'undefined') { + console.log('Language could not be determined, skipping processing'); + return; + } + console.log(`Issue detected as non-English: ${languageResult.detected_language}`); // Post comment explaining the language requirement From 8d13bf93ab1bccb802b18525d0c18bb0572e2b7a Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:38:42 +0200 Subject: [PATCH 0237/1664] Bump linkplay to v0.2.12 (#146669) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index d6319c7a506..335f1acf396 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.11"], + "requirements": ["python-linkplay==0.2.12"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 81fa46b12e3..4521f5cfcb5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.11 +python-linkplay==0.2.12 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c40f36bba2..d8d00fdb34e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.11 +python-linkplay==0.2.12 # homeassistant.components.lirc # python-lirc==1.2.3 From d756cf91ce090c9d71de140be2d98bde391a8599 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 12 Jun 2025 21:41:13 +0200 Subject: [PATCH 0238/1664] Add model_id to Reolink IPC camera (#146664) --- homeassistant/components/reolink/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 0d91670fc84..2e0f1ac9e6a 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -190,6 +190,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): via_device=(DOMAIN, self._host.unique_id), name=self._host.api.camera_name(dev_ch), model=self._host.api.camera_model(dev_ch), + model_id=self._host.api.item_number(dev_ch), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), From c78b66d5d54137dc7055e9a457f38ecd115beb64 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 12 Jun 2025 22:52:09 +0200 Subject: [PATCH 0239/1664] Update frontend to 20250531.3 (#146638) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4299d2b7503..efb4891debf 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.2"] + "requirements": ["home-assistant-frontend==20250531.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6264dd7c048..95c87b6a885 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==3.49.0 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.2 +home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4521f5cfcb5..cfa14b73b4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.2 +home-assistant-frontend==20250531.3 # homeassistant.components.conversation home-assistant-intents==2025.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8d00fdb34e..5eb8e0e3554 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,7 +1010,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.2 +home-assistant-frontend==20250531.3 # homeassistant.components.conversation home-assistant-intents==2025.6.10 From 89ae68c5af3037033f2b4e99079692b33bfe02cd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 12 Jun 2025 23:19:46 +0200 Subject: [PATCH 0240/1664] Reolink check if camera and motion supported (#146666) --- homeassistant/components/reolink/binary_sensor.py | 1 + homeassistant/components/reolink/camera.py | 4 ++++ tests/components/reolink/test_media_source.py | 15 ++++++++++----- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py index 2d08e42a6c8..5664bba25a3 100644 --- a/homeassistant/components/reolink/binary_sensor.py +++ b/homeassistant/components/reolink/binary_sensor.py @@ -63,6 +63,7 @@ BINARY_PUSH_SENSORS = ( cmd_id=33, device_class=BinarySensorDeviceClass.MOTION, value=lambda api, ch: api.motion_detected(ch), + supported=lambda api, ch: api.supported(ch, "motion_detection"), ), ReolinkBinarySensorEntityDescription( key=FACE_DETECTION_TYPE, diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 329ef9028de..119fb625349 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -37,23 +37,27 @@ CAMERA_ENTITIES = ( key="sub", stream="sub", translation_key="sub", + supported=lambda api, ch: api.supported(ch, "stream"), ), ReolinkCameraEntityDescription( key="main", stream="main", translation_key="main", + supported=lambda api, ch: api.supported(ch, "stream"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="snapshots_sub", stream="snapshots_sub", translation_key="snapshots_sub", + supported=lambda api, ch: api.supported(ch, "snapshot"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="snapshots", stream="snapshots_main", translation_key="snapshots_main", + supported=lambda api, ch: api.supported(ch, "snapshot"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 126d445ca01..7e4a0cfb7a7 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -333,7 +333,14 @@ async def test_browsing_rec_playback_unsupported( config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera which does not support playback of recordings.""" - reolink_connect.supported.return_value = 0 + + def test_supported(ch, key): + """Test supported function.""" + if key == "replay": + return False + return True + + reolink_connect.supported = test_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -347,6 +354,8 @@ 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, @@ -354,8 +363,6 @@ async def test_browsing_errors( config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera errors.""" - reolink_connect.supported.return_value = 1 - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -373,8 +380,6 @@ async def test_browsing_not_loaded( config_entry: MockConfigEntry, ) -> None: """Test browsing a Reolink camera integration which is not loaded.""" - reolink_connect.supported.return_value = 1 - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 1fb438fa6c9a5acbfb79fc1ca3060f8db6004432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 13 Jun 2025 06:43:21 +0100 Subject: [PATCH 0241/1664] Add missing mock value to Reolink test (#146689) --- tests/components/reolink/test_media_source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 7e4a0cfb7a7..59f0c6c195d 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -141,6 +141,7 @@ async def test_browsing( entry_id = config_entry.entry_id reolink_connect.supported.return_value = 1 reolink_connect.model = "Reolink TrackMix PoE" + reolink_connect.is_nvr = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(entry_id) is True From 7201171eb53ed1ea6b9ff1332242af82fa76a2e6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:45:54 +0200 Subject: [PATCH 0242/1664] Replace unnecessary pydantic import in matrix tests (#146693) --- tests/components/matrix/test_login.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py index ad9bf660402..0d72b914740 100644 --- a/tests/components/matrix/test_login.py +++ b/tests/components/matrix/test_login.py @@ -1,6 +1,7 @@ """Test MatrixBot._login.""" -from pydantic.dataclasses import dataclass +from dataclasses import dataclass + import pytest from homeassistant.components.matrix import MatrixBot @@ -17,7 +18,7 @@ class LoginTestParameters: access_token: dict[str, str] expected_login_state: bool expected_caplog_messages: set[str] - expected_expection: type(Exception) | None = None + expected_expection: type[Exception] | None = None good_password_missing_token = LoginTestParameters( From 6421973cd68c80572fb8a36d264a151fa3570802 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 10:46:26 +0200 Subject: [PATCH 0243/1664] Remove unnecessary patch from panel_custom tests (#146695) --- tests/components/panel_custom/test_init.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py index dc0f06d2a56..7a3545620ac 100644 --- a/tests/components/panel_custom/test_init.py +++ b/tests/components/panel_custom/test_init.py @@ -1,7 +1,5 @@ """The tests for the panel_custom component.""" -from unittest.mock import Mock, patch - from homeassistant import setup from homeassistant.components import frontend, panel_custom from homeassistant.core import HomeAssistant @@ -22,14 +20,13 @@ async def test_webcomponent_custom_path_not_found(hass: HomeAssistant) -> None: } } - with patch("os.path.isfile", Mock(return_value=False)): - result = await setup.async_setup_component(hass, "panel_custom", config) - assert not result + result = await setup.async_setup_component(hass, "panel_custom", config) + assert not result - panels = hass.data.get(frontend.DATA_PANELS, []) + panels = hass.data.get(frontend.DATA_PANELS, []) - assert panels - assert "nice_url" not in panels + assert panels + assert "nice_url" not in panels async def test_js_webcomponent(hass: HomeAssistant) -> None: From 5ef99a15a5c9b7b02b4c3330eba9f61b975694fc Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 13 Jun 2025 03:46:01 -0700 Subject: [PATCH 0244/1664] Revert scan interval change in local calendar (#146700) --- homeassistant/components/local_calendar/calendar.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 8534cc1bfbf..639cf5234d1 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -36,11 +36,6 @@ _LOGGER = logging.getLogger(__name__) PRODID = "-//homeassistant.io//local_calendar 1.0//EN" -# The calendar on disk is only changed when this entity is updated, so there -# is no need to poll for changes. The calendar enttiy base class will handle -# refreshing the entity state based on the start or end time of the event. -SCAN_INTERVAL = timedelta(days=1) - async def async_setup_entry( hass: HomeAssistant, From e70a2dd257b62b39271ccda78bba09dff68cb5eb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 13 Jun 2025 03:47:56 -0700 Subject: [PATCH 0245/1664] Partial revert of update to remote calendar to fix issue where calendar does not update (#146702) Partial revert --- .../components/remote_calendar/calendar.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 2f60918f010..f6918ea9706 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -4,6 +4,7 @@ from datetime import datetime import logging from ical.event import Event +from ical.timeline import Timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -48,12 +49,18 @@ class RemoteCalendarEntity( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + now = dt_util.now() + events = self._timeline.active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -79,15 +86,12 @@ class RemoteCalendarEntity( """ await super().async_update() - def next_timeline_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline | None: """Return the next active event.""" now = dt_util.now() - events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return self.coordinator.data.timeline_tz(now.tzinfo) - self._event = await self.hass.async_add_executor_job(next_timeline_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) def _get_calendar_event(event: Event) -> CalendarEvent: From f0357539ad16192027d9983a1dfe43c4f01f5213 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 13 Jun 2025 03:48:24 -0700 Subject: [PATCH 0246/1664] Add myself as a remote calendar code owner (#146703) --- CODEOWNERS | 4 ++-- homeassistant/components/remote_calendar/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b447c878128..6670b411df4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1274,8 +1274,8 @@ build.json @home-assistant/supervisor /tests/components/rehlko/ @bdraco @peterager /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core -/homeassistant/components/remote_calendar/ @Thomas55555 -/tests/components/remote_calendar/ @Thomas55555 +/homeassistant/components/remote_calendar/ @Thomas55555 @allenporter +/tests/components/remote_calendar/ @Thomas55555 @allenporter /homeassistant/components/renault/ @epenet /tests/components/renault/ @epenet /homeassistant/components/renson/ @jimmyd-be diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 7bdc5362ae7..052b409dfe7 100644 --- a/homeassistant/components/remote_calendar/manifest.json +++ b/homeassistant/components/remote_calendar/manifest.json @@ -1,7 +1,7 @@ { "domain": "remote_calendar", "name": "Remote Calendar", - "codeowners": ["@Thomas55555"], + "codeowners": ["@Thomas55555", "@allenporter"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/remote_calendar", "integration_type": "service", From ab3f11bfe7f79b64189ffa5de1b0197db1fe633d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 13 Jun 2025 12:50:12 +0200 Subject: [PATCH 0247/1664] Add Reolink IR brightness entity (#146717) --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/number.py | 14 ++++++++++++++ homeassistant/components/reolink/strings.json | 3 +++ .../reolink/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 24 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index fef175457f7..c79d9b895fa 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -172,6 +172,9 @@ "floodlight_brightness": { "default": "mdi:spotlight-beam" }, + "ir_brightness": { + "default": "mdi:led-off" + }, "volume": { "default": "mdi:volume-high", "state": { diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 2a6fb740ee0..e28b8c97697 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -122,6 +122,20 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.whiteled_brightness(ch), method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)), ), + ReolinkNumberEntityDescription( + key="ir_brightness", + cmd_key="208", + translation_key="ir_brightness", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=0, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "ir_brightness"), + value=lambda api, ch: api.baichuan.ir_brightness(ch), + method=lambda api, ch, value: ( + api.baichuan.set_status_led(ch, ir_brightness=int(value)) + ), + ), ReolinkNumberEntityDescription( key="volume", cmd_key="GetAudioCfg", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index d1d51d9229a..45448e2a03c 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -532,6 +532,9 @@ "floodlight_brightness": { "name": "Floodlight turn on brightness" }, + "ir_brightness": { + "name": "Infrared light brightness" + }, "volume": { "name": "Volume" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 3b866aa14ca..771aeba2a9e 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -64,6 +64,10 @@ 0, ]), 'cmd list': dict({ + '208': dict({ + '0': 1, + 'null': 1, + }), '296': dict({ '0': 1, 'null': 1, From 7c575d0316c34194046bf9071870022bc8d25f6a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:52:56 +0200 Subject: [PATCH 0248/1664] Fix asuswrt test patch (#146692) --- tests/components/asuswrt/test_config_flow.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/asuswrt/test_config_flow.py b/tests/components/asuswrt/test_config_flow.py index 14b70811cde..83c3204d239 100644 --- a/tests/components/asuswrt/test_config_flow.py +++ b/tests/components/asuswrt/test_config_flow.py @@ -175,7 +175,12 @@ async def test_error_invalid_ssh(hass: HomeAssistant, patch_is_file) -> None: config_data = {k: v for k, v in CONFIG_DATA_SSH.items() if k != CONF_PASSWORD} config_data[CONF_SSH_KEY] = SSH_KEY - patch_is_file.return_value = False + def mock_is_file(file) -> bool: + if str(file).endswith(SSH_KEY): + return False + return True + + patch_is_file.side_effect = mock_is_file result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}, From 704118b3d021434ac69f9d7cf420d073d654efb3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:53:33 +0200 Subject: [PATCH 0249/1664] Remove unnecessary patch from toon tests (#146691) --- tests/components/toon/test_config_flow.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py index 1ad5ea1ca3d..affdadd75c2 100644 --- a/tests/components/toon/test_config_flow.py +++ b/tests/components/toon/test_config_flow.py @@ -27,13 +27,12 @@ async def setup_component(hass: HomeAssistant) -> None: {"external_url": "https://example.com"}, ) - with patch("os.path.isfile", return_value=False): - assert await async_setup_component( - hass, - DOMAIN, - {DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}}, - ) - await hass.async_block_till_done() + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"}}, + ) + await hass.async_block_till_done() async def test_abort_if_no_configuration(hass: HomeAssistant) -> None: From 10874af19ac4fbf29db260d317493ef5d26755e7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:09:37 +0200 Subject: [PATCH 0250/1664] Ignore lingering pycares shutdown thread (#146733) --- tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c326f57ca2f..8b5c5e26c36 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -382,8 +382,10 @@ def verify_cleanup( # Verify no threads where left behind. threads = frozenset(threading.enumerate()) - threads_before for thread in threads: - assert isinstance(thread, threading._DummyThread) or thread.name.startswith( - "waitpid-" + assert ( + isinstance(thread, threading._DummyThread) + or thread.name.startswith("waitpid-") + or "_run_safe_shutdown_loop" in thread.name ) try: From 30c5df3eaa2bd0b057ce6060c5a553ce3adf1d7e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:16:28 +0200 Subject: [PATCH 0251/1664] Adjust core create_task tests with event_loop patch (#146699) --- tests/test_core.py | 79 +++++++++++++++++++++++++++------------------- 1 file changed, 47 insertions(+), 32 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 50f7f92727b..d4b5933aebe 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -255,45 +255,51 @@ async def test_async_add_hass_job_schedule_partial_callback() -> None: partial = functools.partial(ha.callback(job)) ha.HomeAssistant._async_add_hass_job(hass, ha.HassJob(partial)) - assert len(hass.loop.call_soon.mock_calls) == 1 - assert len(hass.loop.create_task.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + assert hass.loop.call_soon.call_count == 1 + assert hass.loop.create_task.call_count == 0 + assert hass.add_job.call_count == 0 async def test_async_add_hass_job_schedule_corofunction_eager_start() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass - with patch( - "homeassistant.core.create_eager_task", wraps=create_eager_task - ) as mock_create_eager_task: + with ( + patch( + "homeassistant.core.create_eager_task", wraps=create_eager_task + ) as mock_create_eager_task, + patch.object(loop, "call_soon") as mock_loop_call_soon, + ): hass_job = ha.HassJob(job) task = ha.HomeAssistant._async_add_hass_job(hass, hass_job) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + assert mock_loop_call_soon.call_count == 0 + assert hass.add_job.call_count == 0 assert mock_create_eager_task.mock_calls await task async def test_async_add_hass_job_schedule_partial_corofunction_eager_start() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass partial = functools.partial(job) - with patch( - "homeassistant.core.create_eager_task", wraps=create_eager_task - ) as mock_create_eager_task: + with ( + patch( + "homeassistant.core.create_eager_task", wraps=create_eager_task + ) as mock_create_eager_task, + patch.object(loop, "call_soon") as mock_loop_call_soon, + ): hass_job = ha.HassJob(partial) task = ha.HomeAssistant._async_add_hass_job(hass, hass_job) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + assert mock_loop_call_soon.call_count == 0 + assert hass.add_job.call_count == 0 assert mock_create_eager_task.mock_calls await task @@ -306,35 +312,42 @@ async def test_async_add_job_add_hass_threaded_job_to_pool() -> None: pass ha.HomeAssistant._async_add_hass_job(hass, ha.HassJob(job)) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 0 - assert len(hass.loop.run_in_executor.mock_calls) == 2 + assert hass.loop.call_soon.call_count == 0 + assert hass.loop.create_task.call_count == 0 + assert hass.loop.run_in_executor.call_count == 1 async def test_async_create_task_schedule_coroutine() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass - ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=False) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 1 - assert len(hass.add_job.mock_calls) == 0 + with ( + patch.object(loop, "call_soon") as mock_loop_call_soon, + patch.object(loop, "create_task") as mock_loop_create_task, + ): + coro = job() + ha.HomeAssistant.async_create_task_internal(hass, coro, eager_start=False) + assert mock_loop_call_soon.call_count == 0 + assert mock_loop_create_task.call_count == 1 + assert hass.add_job.call_count == 0 + await coro async def test_async_create_task_eager_start_schedule_coroutine() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) + hass = MagicMock(loop=(loop := asyncio.get_running_loop())) async def job(): pass - ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=True) - # Should create the task directly since 3.12 supports eager_start - assert len(hass.loop.create_task.mock_calls) == 0 - assert len(hass.add_job.mock_calls) == 0 + with patch.object(loop, "create_task") as mock_loop_create_task: + ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=True) + # Should create the task directly since 3.12 supports eager_start + assert mock_loop_create_task.call_count == 0 + assert hass.add_job.call_count == 0 async def test_async_create_task_schedule_coroutine_with_name() -> None: @@ -344,13 +357,15 @@ async def test_async_create_task_schedule_coroutine_with_name() -> None: async def job(): pass + coro = job() task = ha.HomeAssistant.async_create_task_internal( - hass, job(), "named task", eager_start=False + hass, coro, "named task", eager_start=False ) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 1 - assert len(hass.add_job.mock_calls) == 0 + assert hass.loop.call_soon.call_count == 0 + assert hass.loop.create_task.call_count == 1 + assert hass.add_job.call_count == 0 assert "named task" in str(task) + await coro async def test_async_run_eager_hass_job_calls_callback() -> None: From 355ee1178e3c69a65fc0086f812aa6b0600eaa28 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:16:55 +0200 Subject: [PATCH 0252/1664] Add callback decorator to async_setup_services (#146729) --- homeassistant/components/abode/services.py | 3 ++- homeassistant/components/amcrest/services.py | 3 ++- homeassistant/components/downloader/services.py | 3 ++- homeassistant/components/elkm1/services.py | 1 + homeassistant/components/ffmpeg/services.py | 3 ++- homeassistant/components/google_assistant_sdk/services.py | 2 ++ homeassistant/components/google_photos/services.py | 2 ++ homeassistant/components/google_sheets/services.py | 3 ++- homeassistant/components/habitica/services.py | 2 ++ homeassistant/components/hue/services.py | 3 ++- homeassistant/components/icloud/services.py | 3 ++- homeassistant/components/jewish_calendar/services.py | 2 ++ homeassistant/components/lcn/services.py | 2 ++ homeassistant/components/nordpool/services.py | 2 ++ homeassistant/components/nzbget/services.py | 3 ++- homeassistant/components/ohme/services.py | 2 ++ homeassistant/components/onedrive/services.py | 2 ++ homeassistant/components/onkyo/services.py | 3 ++- homeassistant/components/opentherm_gw/services.py | 3 ++- homeassistant/components/picnic/services.py | 3 ++- homeassistant/components/ps4/services.py | 3 ++- homeassistant/components/teslemetry/services.py | 3 ++- homeassistant/components/unifiprotect/services.py | 1 + homeassistant/components/yolink/services.py | 3 ++- homeassistant/components/zwave_js/services.py | 1 + 25 files changed, 47 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/abode/services.py b/homeassistant/components/abode/services.py index ffbdeb326f9..7862b3e6dfe 100644 --- a/homeassistant/components/abode/services.py +++ b/homeassistant/components/abode/services.py @@ -6,7 +6,7 @@ from jaraco.abode.exceptions import Exception as AbodeException import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send @@ -70,6 +70,7 @@ def _trigger_automation(call: ServiceCall) -> None: dispatcher_send(call.hass, signal) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Home Assistant services.""" diff --git a/homeassistant/components/amcrest/services.py b/homeassistant/components/amcrest/services.py index 1ba869ce2d5..084761c4978 100644 --- a/homeassistant/components/amcrest/services.py +++ b/homeassistant/components/amcrest/services.py @@ -5,7 +5,7 @@ from __future__ import annotations from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_CONTROL from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import Unauthorized, UnknownUser from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.service import async_extract_entity_ids @@ -15,6 +15,7 @@ from .const import CAMERAS, DATA_AMCREST, DOMAIN from .helpers import service_signal +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Amcrest IP Camera services.""" diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index 19f6e827fb0..7f651c6b1f9 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -10,7 +10,7 @@ import threading import requests import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_register_admin_service from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path @@ -141,6 +141,7 @@ def download_file(service: ServiceCall) -> None: threading.Thread(target=do_download).start() +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register the services for the downloader component.""" async_register_admin_service( diff --git a/homeassistant/components/elkm1/services.py b/homeassistant/components/elkm1/services.py index 622ce65ae5e..bfdd968680c 100644 --- a/homeassistant/components/elkm1/services.py +++ b/homeassistant/components/elkm1/services.py @@ -63,6 +63,7 @@ def _set_time_service(service: ServiceCall) -> None: _async_get_elk_panel(service).set_time(dt_util.now()) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Create ElkM1 services.""" diff --git a/homeassistant/components/ffmpeg/services.py b/homeassistant/components/ffmpeg/services.py index ad7946869ec..6b522799f4f 100644 --- a/homeassistant/components/ffmpeg/services.py +++ b/homeassistant/components/ffmpeg/services.py @@ -5,7 +5,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -35,6 +35,7 @@ async def _async_service_handle(service: ServiceCall) -> None: async_dispatcher_send(service.hass, SIGNAL_FFMPEG_RESTART, entity_ids) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register FFmpeg services.""" diff --git a/homeassistant/components/google_assistant_sdk/services.py b/homeassistant/components/google_assistant_sdk/services.py index 7f0227bf040..981f4d8ba5c 100644 --- a/homeassistant/components/google_assistant_sdk/services.py +++ b/homeassistant/components/google_assistant_sdk/services.py @@ -11,6 +11,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.helpers import config_validation as cv @@ -49,6 +50,7 @@ async def _send_text_command(call: ServiceCall) -> ServiceResponse: return None +@callback def async_setup_services(hass: HomeAssistant) -> None: """Add the services for Google Assistant SDK.""" diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index ab4fb86af5a..a74fabb3b77 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -77,6 +78,7 @@ def _read_file_contents( return results +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register Google Photos services.""" diff --git a/homeassistant/components/google_sheets/services.py b/homeassistant/components/google_sheets/services.py index ea0c1e5a4ed..6425aec4eb0 100644 --- a/homeassistant/components/google_sheets/services.py +++ b/homeassistant/components/google_sheets/services.py @@ -13,7 +13,7 @@ from gspread.utils import ValueInputOption import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ConfigEntrySelector @@ -76,6 +76,7 @@ async def _async_append_to_sheet(call: ServiceCall) -> None: await call.hass.async_add_executor_job(_append_to_sheet, call, entry) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Add the services for Google Sheets.""" diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index 8ef12a38f1c..c5207ae4ec0 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -35,6 +35,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -249,6 +250,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: return entry +@callback def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Set up services for Habitica integration.""" diff --git a/homeassistant/components/hue/services.py b/homeassistant/components/hue/services.py index 3fcf4aa45f9..0fd6e8bdae0 100644 --- a/homeassistant/components/hue/services.py +++ b/homeassistant/components/hue/services.py @@ -8,7 +8,7 @@ import logging from aiohue import HueBridgeV1, HueBridgeV2 import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import verify_domain_control @@ -25,6 +25,7 @@ from .const import ( LOGGER = logging.getLogger(__name__) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for Hue integration.""" diff --git a/homeassistant/components/icloud/services.py b/homeassistant/components/icloud/services.py index 6262710460f..dbb843e8216 100644 --- a/homeassistant/components/icloud/services.py +++ b/homeassistant/components/icloud/services.py @@ -4,7 +4,7 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify @@ -115,6 +115,7 @@ def _get_account(hass: HomeAssistant, account_identifier: str) -> IcloudAccount: return icloud_account +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register iCloud services.""" diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index a065ee9c969..6fdebe6f74d 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -15,6 +15,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -39,6 +40,7 @@ OMER_SCHEMA = vol.Schema( ) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Jewish Calendar services.""" diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index ef6343bdfef..33550d9785d 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -438,6 +439,7 @@ SERVICES = ( ) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for LCN.""" for service_name, service in SERVICES: diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 628962811e3..9bb97d0737b 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -22,6 +22,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -66,6 +67,7 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: return entry +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Nord Pool integration.""" diff --git a/homeassistant/components/nzbget/services.py b/homeassistant/components/nzbget/services.py index 1072000cfea..ebcdd362b0c 100644 --- a/homeassistant/components/nzbget/services.py +++ b/homeassistant/components/nzbget/services.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -48,6 +48,7 @@ def set_speed(call: ServiceCall) -> None: _get_coordinator(call).nzbget.rate(call.data[ATTR_SPEED]) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register integration-level services.""" diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py index 8ed29aa373d..bebfe718095 100644 --- a/homeassistant/components/ohme/services.py +++ b/homeassistant/components/ohme/services.py @@ -11,6 +11,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import selector @@ -70,6 +71,7 @@ def __get_client(call: ServiceCall) -> OhmeApiClient: return entry.runtime_data.charge_session_coordinator.client +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services.""" diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index f29133a4ca4..971a4da1f6b 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -16,6 +16,7 @@ from homeassistant.core import ( ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -70,6 +71,7 @@ def _read_file_contents( return results +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register OneDrive services.""" diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index e602c5a24e0..26a22523a0e 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.const import ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey @@ -40,6 +40,7 @@ ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register Onkyo services.""" diff --git a/homeassistant/components/opentherm_gw/services.py b/homeassistant/components/opentherm_gw/services.py index c8f5c748875..5031393e867 100644 --- a/homeassistant/components/opentherm_gw/services.py +++ b/homeassistant/components/opentherm_gw/services.py @@ -15,7 +15,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv @@ -61,6 +61,7 @@ def _get_gateway(call: ServiceCall) -> OpenThermGatewayHub: return gw_hub +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for the component.""" service_reset_schema = vol.Schema({vol.Required(ATTR_GW_ID): vol.All(cv.string)}) diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 0717b669da3..8ecae8dc301 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,7 +7,7 @@ from typing import cast from python_picnic_api2 import PicnicAPI import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -26,6 +26,7 @@ class PicnicServiceException(Exception): """Exception for Picnic services.""" +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for the Picnic integration, if not registered yet.""" diff --git a/homeassistant/components/ps4/services.py b/homeassistant/components/ps4/services.py index 88751660f75..583366602ed 100644 --- a/homeassistant/components/ps4/services.py +++ b/homeassistant/components/ps4/services.py @@ -5,7 +5,7 @@ from __future__ import annotations import voluptuous as vol from homeassistant.const import ATTR_COMMAND, ATTR_ENTITY_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import COMMANDS, DOMAIN, PS4_DATA @@ -29,6 +29,7 @@ async def async_service_command(call: ServiceCall) -> None: await device.async_send_command(command) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Handle for services.""" diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index d989e7b8f40..246cc097a2a 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -7,7 +7,7 @@ from voluptuous import All, Range from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_LATITUDE, CONF_LONGITUDE -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -98,6 +98,7 @@ def async_get_energy_site_for_entry( return energy_data +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the Teslemetry services.""" diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 402aae2eeba..40fe0a991f2 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -303,6 +303,7 @@ SERVICES = [ ] +@callback def async_setup_services(hass: HomeAssistant) -> None: """Set up the global UniFi Protect services.""" diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py index 10d90d274a4..5bc5f2f9660 100644 --- a/homeassistant/components/yolink/services.py +++ b/homeassistant/components/yolink/services.py @@ -4,7 +4,7 @@ import voluptuous as vol from yolink.client_request import ClientRequest from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -25,6 +25,7 @@ _SPEAKER_HUB_PLAY_CALL_OPTIONAL_ATTRS = ( ) +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register services for YoLink integration.""" diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 33195fe6c8b..076e3b6a50d 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -58,6 +58,7 @@ TARGET_VALIDATORS = { } +@callback def async_setup_services(hass: HomeAssistant) -> None: """Register integration services.""" services = ZWaveServices(hass, er.async_get(hass), dr.async_get(hass)) From a3496532820c1b54b6d8fc9ee36e9d4362590257 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 13 Jun 2025 16:53:18 +0300 Subject: [PATCH 0253/1664] Bump aioamazondevices to 3.1.2 (#146690) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/conftest.py | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 2a9e88cfd85..96f17d541fc 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": "bronze", - "requirements": ["aioamazondevices==3.0.6"] + "requirements": ["aioamazondevices==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index cfa14b73b4c..9bd7add2790 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.0.6 +aioamazondevices==3.1.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5eb8e0e3554..5ad03dc9069 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.0.6 +aioamazondevices==3.1.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 4ce2eb743ea..f1f40eebd27 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -56,6 +56,9 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: do_not_disturb=False, response_style=None, bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={}, ) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( From a8201009f359495c2131c9eed004c5843a715b07 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 13 Jun 2025 06:58:27 -0700 Subject: [PATCH 0254/1664] Fix opower to work with aiohttp>=3.12.7 by disabling cookie quoting (#146697) Co-authored-by: J. Nick Koston --- homeassistant/components/opower/config_flow.py | 3 ++- homeassistant/components/opower/coordinator.py | 6 ++++-- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 6396ba24a15..4753a77894e 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -10,6 +10,7 @@ from opower import ( CannotConnect, InvalidAuth, Opower, + create_cookie_jar, get_supported_utility_names, select_utility, ) @@ -39,7 +40,7 @@ async def _validate_login( ) -> dict[str, str]: """Validate login data and return any errors.""" api = Opower( - async_create_clientsession(hass), + async_create_clientsession(hass, cookie_jar=create_cookie_jar()), login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d03c30b7db0..189fa185cd1 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -12,6 +12,7 @@ from opower import ( MeterType, Opower, ReadResolution, + create_cookie_jar, ) from opower.exceptions import ApiException, CannotConnect, InvalidAuth @@ -30,7 +31,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, issue_registry as ir +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -62,7 +64,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): update_interval=timedelta(hours=12), ) self.api = Opower( - aiohttp_client.async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=create_cookie_jar()), config_entry.data[CONF_UTILITY], config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 0aa26dbb4b1..4e88c5a68cc 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.12.3"] + "requirements": ["opower==0.12.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9bd7add2790..7d0f42fc3c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,7 +1617,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.3 +opower==0.12.4 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ad03dc9069..a574f8a841e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1370,7 +1370,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.3 +opower==0.12.4 # homeassistant.components.oralb oralb-ble==0.17.6 From ff17d79e7383394111f27e06c8855200824fb2c7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:58:44 +0200 Subject: [PATCH 0255/1664] Bump wakeonlan to 3.1.0 (#146655) Co-authored-by: J. Nick Koston --- homeassistant/components/samsungtv/entity.py | 4 ++-- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/wake_on_lan/__init__.py | 2 +- homeassistant/components/wake_on_lan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 1918f6ef28c..2927dcf2683 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -76,10 +76,10 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) def _wake_on_lan(self) -> None: """Wake the device via wake on lan.""" - send_magic_packet(self._mac, ip_address=self._host) + send_magic_packet(self._mac, ip_address=self._host) # type: ignore[arg-type] # If the ip address changed since we last saw the device # broadcast a packet as well - send_magic_packet(self._mac) + send_magic_packet(self._mac) # type: ignore[arg-type] async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 5bb69e7f121..dc8133a1b1f 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -38,7 +38,7 @@ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", - "wakeonlan==2.1.0", + "wakeonlan==3.1.0", "async-upnp-client==0.44.0" ], "ssdp": [ diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index d68d950e641..b2b2bac6480 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -52,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await hass.async_add_executor_job( - partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) + partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) # type: ignore[arg-type] ) hass.services.async_register( diff --git a/homeassistant/components/wake_on_lan/manifest.json b/homeassistant/components/wake_on_lan/manifest.json index c716a851ae4..34e9ccd5d21 100644 --- a/homeassistant/components/wake_on_lan/manifest.json +++ b/homeassistant/components/wake_on_lan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wake_on_lan", "iot_class": "local_push", - "requirements": ["wakeonlan==2.1.0"] + "requirements": ["wakeonlan==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d0f42fc3c2..8e8631e8221 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3059,7 +3059,7 @@ vultr==0.1.2 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==2.1.0 +wakeonlan==3.1.0 # homeassistant.components.wallbox wallbox==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a574f8a841e..094fafb1afd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2518,7 +2518,7 @@ vultr==0.1.2 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==2.1.0 +wakeonlan==3.1.0 # homeassistant.components.wallbox wallbox==0.9.0 From 038a848d53e3fd54cc2aed4e146df6264297a809 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 16:25:09 +0200 Subject: [PATCH 0256/1664] Fix androidtv isfile patcher in tests (#146696) --- tests/components/androidtv/patchers.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py index 500b9e75cb3..27171d4366a 100644 --- a/tests/components/androidtv/patchers.py +++ b/tests/components/androidtv/patchers.py @@ -1,5 +1,6 @@ """Define patches used for androidtv tests.""" +import os.path from typing import Any from unittest.mock import patch @@ -12,6 +13,8 @@ from homeassistant.components.androidtv.const import ( DEVICE_FIRETV, ) +_original_isfile = os.path.isfile + ADB_SERVER_HOST = "127.0.0.1" KEY_PYTHON = "python" KEY_SERVER = "server" @@ -185,7 +188,9 @@ def patch_androidtv_update( def isfile(filepath): """Mock `os.path.isfile`.""" - return filepath.endswith("adbkey") + if str(filepath).endswith("adbkey"): + return True + return _original_isfile(filepath) PATCH_SCREENCAP = patch( From 2f8ad4d5bfa81e20aecffead7868c786c64d7870 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jun 2025 10:29:19 -0400 Subject: [PATCH 0257/1664] Clean up Ollama conversation entity (#146738) --- .../components/ollama/conversation.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 928d5565081..4b4f79d4eed 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -218,9 +218,6 @@ class OllamaConversationEntity( """Call the API.""" settings = {**self.entry.data, **self.entry.options} - client = self.hass.data[DOMAIN][self.entry.entry_id] - model = settings[CONF_MODEL] - try: await chat_log.async_update_llm_data( DOMAIN, @@ -231,6 +228,31 @@ class OllamaConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() + await self._async_handle_chat_log(chat_log) + + # Create intent response + intent_response = intent.IntentResponse(language=user_input.language) + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise TypeError( + f"Unexpected last message type: {type(chat_log.content[-1])}" + ) + intent_response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.entry.options} + + client = self.hass.data[DOMAIN][self.entry.entry_id] + model = settings[CONF_MODEL] + tools: list[dict[str, Any]] | None = None if chat_log.llm_api: tools = [ @@ -269,7 +291,7 @@ class OllamaConversationEntity( [ _convert_content(content) async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, _transform_stream(response_generator) + self.entity_id, _transform_stream(response_generator) ) ] ) @@ -277,19 +299,6 @@ class OllamaConversationEntity( if not chat_log.unresponded_tool_results: break - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - raise TypeError( - f"Unexpected last message type: {type(chat_log.content[-1])}" - ) - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: """Trims excess messages from a single history. From c96023dcaecf1644a3dda24577defedacabf241d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jun 2025 10:29:26 -0400 Subject: [PATCH 0258/1664] Clean up Anthropic conversation entity (#146737) --- .../components/anthropic/conversation.py | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 3e79be0b169..846249b1caf 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -375,6 +375,26 @@ class AnthropicConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() + await self._async_handle_chat_log(chat_log) + + response_content = chat_log.content[-1] + if not isinstance(response_content, conversation.AssistantContent): + raise TypeError("Last message must be an assistant message") + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(response_content.content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + tools: list[ToolParam] | None = None if chat_log.llm_api: tools = [ @@ -424,7 +444,7 @@ class AnthropicConversationEntity( [ content async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, + self.entity_id, _transform_stream(chat_log, stream, messages), ) if not isinstance(content, conversation.AssistantContent) @@ -435,17 +455,6 @@ class AnthropicConversationEntity( if not chat_log.unresponded_tool_results: break - response_content = chat_log.content[-1] - if not isinstance(response_content, conversation.AssistantContent): - raise TypeError("Last message must be an assistant message") - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_content.content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: From d880ce6bb4c96b1d0e693a33d3192cc771f577e7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jun 2025 10:30:14 -0400 Subject: [PATCH 0259/1664] Clean up Google conversation entity (#146736) --- .../conversation.py | 51 ++++++++++++------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index c466101e7e4..85183cfbf99 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -199,9 +199,11 @@ def _create_google_tool_response_content( def _convert_content( - content: conversation.UserContent - | conversation.AssistantContent - | conversation.SystemContent, + content: ( + conversation.UserContent + | conversation.AssistantContent + | conversation.SystemContent + ), ) -> Content: """Convert HA content to Google content.""" if content.role != "assistant" or not content.tool_calls: @@ -381,6 +383,29 @@ class GoogleGenerativeAIConversationEntity( except conversation.ConverseError as err: return err.as_conversation_result() + await self._async_handle_chat_log(chat_log) + + response = intent.IntentResponse(language=user_input.language) + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + chat_log.content[-1], + ) + raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}") + response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + tools: list[Tool | Callable[..., Any]] | None = None if chat_log.llm_api: tools = [ @@ -499,7 +524,9 @@ class GoogleGenerativeAIConversationEntity( chat = self._genai_client.aio.chats.create( model=model_name, history=messages, config=generateContentConfig ) - chat_request: str | list[Part] = user_input.text + user_message = chat_log.content[-1] + assert isinstance(user_message, conversation.UserContent) + chat_request: str | list[Part] = user_message.content # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -519,7 +546,7 @@ class GoogleGenerativeAIConversationEntity( [ content async for content in chat_log.async_add_delta_content_stream( - user_input.agent_id, + self.entity_id, _transform_stream(chat_response_generator), ) if isinstance(content, conversation.ToolResultContent) @@ -529,20 +556,6 @@ class GoogleGenerativeAIConversationEntity( if not chat_log.unresponded_tool_results: break - response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", - chat_log.content[-1], - ) - raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}") - response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: From a66e9a1a2cc7abdb6b5b24962149e15b1531db01 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:08:59 +0200 Subject: [PATCH 0260/1664] Simplify reolink service actions (#146751) --- homeassistant/components/reolink/services.py | 79 ++++++++++---------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index d170aa32379..352ebb4ef19 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -19,51 +19,54 @@ from .util import get_device_uid_and_ch, raise_translated_error ATTR_RINGTONE = "ringtone" +@raise_translated_error +async def _async_play_chime(service_call: ServiceCall) -> None: + """Play a ringtone.""" + service_data = service_call.data + device_registry = dr.async_get(service_call.hass) + + for device_id in service_data[ATTR_DEVICE_ID]: + config_entry = None + device = device_registry.async_get(device_id) + if device is not None: + for entry_id in device.config_entries: + config_entry = service_call.hass.config_entries.async_get_entry( + entry_id + ) + if config_entry is not None and config_entry.domain == DOMAIN: + break + if ( + config_entry is None + or device is None + or config_entry.state != ConfigEntryState.LOADED + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_entry_ex", + translation_placeholders={"service_name": "play_chime"}, + ) + host: ReolinkHost = config_entry.runtime_data.host + (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) + chime: Chime | None = host.api.chime(chime_id) + if not is_chime or chime is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="service_not_chime", + translation_placeholders={"device_name": str(device.name)}, + ) + + ringtone = service_data[ATTR_RINGTONE] + await chime.play(ChimeToneEnum[ringtone].value) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Set up Reolink services.""" - @raise_translated_error - async def async_play_chime(service_call: ServiceCall) -> None: - """Play a ringtone.""" - service_data = service_call.data - device_registry = dr.async_get(hass) - - for device_id in service_data[ATTR_DEVICE_ID]: - config_entry = None - device = device_registry.async_get(device_id) - if device is not None: - for entry_id in device.config_entries: - config_entry = hass.config_entries.async_get_entry(entry_id) - if config_entry is not None and config_entry.domain == DOMAIN: - break - if ( - config_entry is None - or device is None - or config_entry.state != ConfigEntryState.LOADED - ): - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_entry_ex", - translation_placeholders={"service_name": "play_chime"}, - ) - host: ReolinkHost = config_entry.runtime_data.host - (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) - chime: Chime | None = host.api.chime(chime_id) - if not is_chime or chime is None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="service_not_chime", - translation_placeholders={"device_name": str(device.name)}, - ) - - ringtone = service_data[ATTR_RINGTONE] - await chime.play(ChimeToneEnum[ringtone].value) - hass.services.async_register( DOMAIN, "play_chime", - async_play_chime, + _async_play_chime, schema=vol.Schema( { vol.Required(ATTR_DEVICE_ID): list[str], From 1a5bc2c7e04c965e62d0349106f41d87f46d6927 Mon Sep 17 00:00:00 2001 From: Vasilis Valatsos Date: Fri, 13 Jun 2025 18:47:07 +0200 Subject: [PATCH 0261/1664] Drop HostKeyAlgorithms in aruba (#146619) --- homeassistant/components/aruba/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index c2f0d44a6f8..667f2132fc8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner): def get_aruba_data(self) -> dict[str, dict[str, str]] | None: """Retrieve data from Aruba Access Point and return parsed result.""" - connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" + connect = f"ssh {self.username}@{self.host}" ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8") query = ssh.expect( [ From 434cd95a66de1c14503a1d3cca9357fd7043ad67 Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:47:21 +0200 Subject: [PATCH 0262/1664] Use ConfigEntry.runtime_data to store runtime data in NINA (#146754) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/nina/__init__.py | 12 +++++------- homeassistant/components/nina/binary_sensor.py | 6 +++--- homeassistant/components/nina/coordinator.py | 2 ++ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index b02d6711e74..e074f7ad000 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -11,15 +10,14 @@ from .const import ( CONF_AREA_FILTER, CONF_FILTER_CORONA, CONF_HEADLINE_FILTER, - DOMAIN, NO_MATCH_REGEX, ) -from .coordinator import NINADataUpdateCoordinator +from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator PLATFORMS: list[str] = [Platform.BINARY_SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" if CONF_HEADLINE_FILTER not in entry.data: filter_regex = NO_MATCH_REGEX @@ -41,18 +39,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: NinaConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 3f7d496aca9..be7e5995fbc 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -30,17 +30,17 @@ from .const import ( CONF_REGIONS, DOMAIN, ) -from .coordinator import NINADataUpdateCoordinator +from .coordinator import NinaConfigEntry, NINADataUpdateCoordinator async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: NinaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up entries.""" - coordinator: NINADataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data regions: dict[str, str] = config_entry.data[CONF_REGIONS] message_slots: int = config_entry.data[CONF_MESSAGE_SLOTS] diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py index 3c27729ef09..eb1ad3d6293 100644 --- a/homeassistant/components/nina/coordinator.py +++ b/homeassistant/components/nina/coordinator.py @@ -23,6 +23,8 @@ from .const import ( SCAN_INTERVAL, ) +type NinaConfigEntry = ConfigEntry[NINADataUpdateCoordinator] + @dataclass class NinaWarningData: From 6a1e3b60ee66ed1494a3cf105b2a6c1bf552a09f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 13 Jun 2025 19:49:18 +0300 Subject: [PATCH 0263/1664] Filter speak notify entity for WHA devices in Alexa Devices (#146688) --- homeassistant/components/alexa_devices/notify.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index ff0cd4e59ea..46db294377a 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Final from aioamazondevices.api import AmazonDevice, AmazonEchoApi +from aioamazondevices.const import SPEAKER_GROUP_FAMILY from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.core import HomeAssistant @@ -22,6 +23,7 @@ PARALLEL_UPDATES = 1 class AmazonNotifyEntityDescription(NotifyEntityDescription): """Alexa Devices notify entity description.""" + is_supported: Callable[[AmazonDevice], bool] = lambda _device: True method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]] subkey: str @@ -31,6 +33,7 @@ NOTIFY: Final = ( key="speak", translation_key="speak", subkey="AUDIO_PLAYER", + is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY, method=lambda api, device, message: api.call_alexa_speak(device, message), ), AmazonNotifyEntityDescription( @@ -58,6 +61,7 @@ async def async_setup_entry( for sensor_desc in NOTIFY for serial_num in coordinator.data if sensor_desc.subkey in coordinator.data[serial_num].capabilities + and sensor_desc.is_supported(coordinator.data[serial_num]) ) From 2fdd3d66bc1091b4cdbd5bd66f1136a40bdd9033 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:53:05 +0200 Subject: [PATCH 0264/1664] Update pydantic to 2.11.6 (#146745) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 95c87b6a885..d3c58db1842 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -131,7 +131,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.5 +pydantic==2.11.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index f37dbd3eb1e..ebdbc35720b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,7 +15,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.17.0a2 pre-commit==4.2.0 -pydantic==2.11.5 +pydantic==2.11.6 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index d59c40f7cc5..e8fd6b0f7a8 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.5 +pydantic==2.11.6 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From 524c16fbe13a73e527ce4ad1a4f2786b34a787a2 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 13 Jun 2025 18:59:28 +0200 Subject: [PATCH 0265/1664] Bumb python-homewizard-energy to 9.1.1 (#146723) Co-authored-by: J. Nick Koston --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homewizard/snapshots/test_diagnostics.ambr | 9 +++++++++ 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 5d817fef837..9fd74fa80e4 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==8.3.3"], + "requirements": ["python-homewizard-energy==9.1.1"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e8631e8221..b251e4c940a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2434,7 +2434,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==8.3.3 +python-homewizard-energy==9.1.1 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 094fafb1afd..506bcc9e1fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2010,7 +2010,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==8.3.3 +python-homewizard-energy==9.1.1 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index 2545f674bbd..c8addf72368 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -2,6 +2,7 @@ # name: test_diagnostics[HWE-BAT] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '1.00', @@ -93,6 +94,7 @@ # name: test_diagnostics[HWE-KWH1] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', @@ -184,6 +186,7 @@ # name: test_diagnostics[HWE-KWH3] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', @@ -275,6 +278,7 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '4.19', @@ -402,6 +406,7 @@ # name: test_diagnostics[HWE-SKT-11] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.03', @@ -497,6 +502,7 @@ # name: test_diagnostics[HWE-SKT-21] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '4.07', @@ -592,6 +598,7 @@ # name: test_diagnostics[HWE-WTR] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '2.03', @@ -683,6 +690,7 @@ # name: test_diagnostics[SDM230] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', @@ -774,6 +782,7 @@ # name: test_diagnostics[SDM630] dict({ 'data': dict({ + 'batteries': None, 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '3.06', From d1e2c6243330f037ec29b23bf8430444a22eaca0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jun 2025 13:10:47 -0400 Subject: [PATCH 0266/1664] Remove unnecessary string formatting. (#146762) --- .../google_generative_ai_conversation/conversation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 85183cfbf99..1038377af68 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -391,7 +391,7 @@ class GoogleGenerativeAIConversationEntity( "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", chat_log.content[-1], ) - raise HomeAssistantError(f"{ERROR_GETTING_RESPONSE}") + raise HomeAssistantError(ERROR_GETTING_RESPONSE) response.async_set_speech(chat_log.content[-1].content or "") return conversation.ConversationResult( response=response, From 91bc56b15c7c7aced633f399710dec5d503c23bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Jun 2025 12:12:52 -0500 Subject: [PATCH 0267/1664] Bump aiodns to 3.5.0 (#146758) --- homeassistant/components/dnsip/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/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index e004b386e02..6008fb83e1b 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.4.0"] + "requirements": ["aiodns==3.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d3c58db1842..7e6dea5022e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 -aiodns==3.4.0 +aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/pyproject.toml b/pyproject.toml index 19d8a877f38..284a0d39bfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.13.2" dependencies = [ - "aiodns==3.4.0", + "aiodns==3.5.0", "aiofiles==24.1.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 diff --git a/requirements.txt b/requirements.txt index 087ea13ae87..c96a62b355d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.4.0 +aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp==3.12.12 diff --git a/requirements_all.txt b/requirements_all.txt index b251e4c940a..40f0e21ef80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.4.0 +aiodns==3.5.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 506bcc9e1fb..4b4f177b3e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -211,7 +211,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.4.0 +aiodns==3.5.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 From c3e3a36b4c8deeb59fb4c00ba110ee9f5b69938c Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Thu, 12 Jun 2025 22:32:04 +1000 Subject: [PATCH 0268/1664] Fix palette handling for LIFX Ceiling SKY effect (#146582) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manager.py | 9 ++++++--- tests/components/lifx/test_light.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 887bc3c3527..9fae2628f1d 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any import aiolifx_effects from aiolifx_themes.painter import ThemePainter @@ -31,9 +31,12 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN -from .coordinator import LIFXUpdateCoordinator, Light +from .coordinator import LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk +if TYPE_CHECKING: + from aiolifx.aiolifx import Light + SCAN_INTERVAL = timedelta(seconds=10) SERVICE_EFFECT_COLORLOOP = "effect_colorloop" @@ -426,8 +429,8 @@ class LIFXManager: ) -> None: """Start the firmware-based Sky effect.""" palette = kwargs.get(ATTR_PALETTE) + theme = Theme() if palette is not None: - theme = Theme() for hsbk in palette: theme.add_hsbk(hsbk[0], hsbk[1], hsbk[2], hsbk[3]) diff --git a/tests/components/lifx/test_light.py b/tests/components/lifx/test_light.py index 58843d63f9a..d66908c1b1a 100644 --- a/tests/components/lifx/test_light.py +++ b/tests/components/lifx/test_light.py @@ -843,7 +843,7 @@ async def test_sky_effect(hass: HomeAssistant) -> None: SERVICE_EFFECT_SKY, { ATTR_ENTITY_ID: entity_id, - ATTR_PALETTE: [], + ATTR_PALETTE: None, ATTR_SKY_TYPE: "Clouds", ATTR_CLOUD_SATURATION_MAX: 180, ATTR_CLOUD_SATURATION_MIN: 50, @@ -854,7 +854,7 @@ async def test_sky_effect(hass: HomeAssistant) -> None: bulb.power_level = 65535 bulb.effect = { "effect": "SKY", - "palette": [], + "palette": None, "sky_type": 2, "cloud_saturation_min": 50, "cloud_saturation_max": 180, From e7a88e99f922d027236ba5915aeeec20843c7523 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:18:23 +0200 Subject: [PATCH 0269/1664] Fix fan is_on status in xiaomi_miio (#146592) --- homeassistant/components/xiaomi_miio/fan.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index c69bd150226..de2750f3c81 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -330,6 +330,12 @@ class XiaomiGenericDevice( """Return the percentage based speed of the fan.""" return None + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + # Base FanEntity uses percentage to determine if the device is on. + return self._attr_is_on + async def async_turn_on( self, percentage: int | None = None, From f0fc87e2b68222cee91f58f123eddce282759f5d Mon Sep 17 00:00:00 2001 From: Vasilis Valatsos Date: Fri, 13 Jun 2025 18:47:07 +0200 Subject: [PATCH 0270/1664] Drop HostKeyAlgorithms in aruba (#146619) --- homeassistant/components/aruba/device_tracker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index c2f0d44a6f8..667f2132fc8 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -89,7 +89,7 @@ class ArubaDeviceScanner(DeviceScanner): def get_aruba_data(self) -> dict[str, dict[str, str]] | None: """Retrieve data from Aruba Access Point and return parsed result.""" - connect = f"ssh {self.username}@{self.host} -o HostKeyAlgorithms=ssh-rsa" + connect = f"ssh {self.username}@{self.host}" ssh: pexpect.spawn[str] = pexpect.spawn(connect, encoding="utf-8") query = ssh.expect( [ From b2bb0aeb64d6eae68832872e5111da03ec56481e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 12 Jun 2025 22:52:09 +0200 Subject: [PATCH 0271/1664] Update frontend to 20250531.3 (#146638) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4299d2b7503..efb4891debf 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.2"] + "requirements": ["home-assistant-frontend==20250531.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 57a037f0fb7..64f9d1649a6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==3.49.0 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.2 +home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b70c806c1be..43dbc527e51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.2 +home-assistant-frontend==20250531.3 # homeassistant.components.conversation home-assistant-intents==2025.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39fe3779466..7f467760eaa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,7 +1010,7 @@ hole==0.8.0 holidays==0.74 # homeassistant.components.frontend -home-assistant-frontend==20250531.2 +home-assistant-frontend==20250531.3 # homeassistant.components.conversation home-assistant-intents==2025.6.10 From 52c62b31fd5e1fad2da63dad283fa3edef5fb8db Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 12 Jun 2025 17:24:10 +0300 Subject: [PATCH 0272/1664] Fix cookies with aiohttp >= 3.12.7 for Vodafone Station (#146647) --- homeassistant/components/vodafone_station/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vodafone_station/utils.py b/homeassistant/components/vodafone_station/utils.py index 4f900412faf..faa498afdd6 100644 --- a/homeassistant/components/vodafone_station/utils.py +++ b/homeassistant/components/vodafone_station/utils.py @@ -9,5 +9,5 @@ from homeassistant.helpers import aiohttp_client async def async_client_session(hass: HomeAssistant) -> ClientSession: """Return a new aiohttp session.""" return aiohttp_client.async_create_clientsession( - hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True) + hass, verify_ssl=False, cookie_jar=CookieJar(unsafe=True, quote_cookie=False) ) From 5cd7ea06ad45334e3b1a10b90b19af8864500520 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:58:44 +0200 Subject: [PATCH 0273/1664] Bump wakeonlan to 3.1.0 (#146655) Co-authored-by: J. Nick Koston --- homeassistant/components/samsungtv/entity.py | 4 ++-- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/wake_on_lan/__init__.py | 2 +- homeassistant/components/wake_on_lan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 1918f6ef28c..2927dcf2683 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -76,10 +76,10 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity) def _wake_on_lan(self) -> None: """Wake the device via wake on lan.""" - send_magic_packet(self._mac, ip_address=self._host) + send_magic_packet(self._mac, ip_address=self._host) # type: ignore[arg-type] # If the ip address changed since we last saw the device # broadcast a packet as well - send_magic_packet(self._mac) + send_magic_packet(self._mac) # type: ignore[arg-type] async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 5bb69e7f121..dc8133a1b1f 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -38,7 +38,7 @@ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", - "wakeonlan==2.1.0", + "wakeonlan==3.1.0", "async-upnp-client==0.44.0" ], "ssdp": [ diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index d68d950e641..b2b2bac6480 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -52,7 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) await hass.async_add_executor_job( - partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) + partial(wakeonlan.send_magic_packet, mac_address, **service_kwargs) # type: ignore[arg-type] ) hass.services.async_register( diff --git a/homeassistant/components/wake_on_lan/manifest.json b/homeassistant/components/wake_on_lan/manifest.json index c716a851ae4..34e9ccd5d21 100644 --- a/homeassistant/components/wake_on_lan/manifest.json +++ b/homeassistant/components/wake_on_lan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wake_on_lan", "iot_class": "local_push", - "requirements": ["wakeonlan==2.1.0"] + "requirements": ["wakeonlan==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 43dbc527e51..2e0998a648f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3059,7 +3059,7 @@ vultr==0.1.2 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==2.1.0 +wakeonlan==3.1.0 # homeassistant.components.wallbox wallbox==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f467760eaa..07e4bd250d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2518,7 +2518,7 @@ vultr==0.1.2 # homeassistant.components.samsungtv # homeassistant.components.wake_on_lan -wakeonlan==2.1.0 +wakeonlan==3.1.0 # homeassistant.components.wallbox wallbox==0.9.0 From 7cf3116f5b3321695f721a8da9f4c26c62b028ea Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Thu, 12 Jun 2025 20:29:47 +0300 Subject: [PATCH 0274/1664] Bump hdate to 1.1.2 (#146659) --- homeassistant/components/jewish_calendar/manifest.json | 2 +- homeassistant/components/jewish_calendar/sensor.py | 10 +++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json index 550a6514593..1ab967ecfa4 100644 --- a/homeassistant/components/jewish_calendar/manifest.json +++ b/homeassistant/components/jewish_calendar/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/jewish_calendar", "iot_class": "calculated", "loggers": ["hdate"], - "requirements": ["hdate[astral]==1.1.1"], + "requirements": ["hdate[astral]==1.1.2"], "single_config_entry": true } diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index cb38a3797eb..91c618e1c1c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -73,7 +73,7 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, options_fn=lambda _: [str(p) for p in Parasha], - value_fn=lambda results: str(results.after_tzais_date.upcoming_shabbat.parasha), + value_fn=lambda results: results.after_tzais_date.upcoming_shabbat.parasha, ), JewishCalendarSensorDescription( key="holiday", @@ -98,17 +98,13 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( key="omer_count", translation_key="omer_count", entity_registry_enabled_default=False, - value_fn=lambda results: ( - results.after_shkia_date.omer.total_days - if results.after_shkia_date.omer - else 0 - ), + value_fn=lambda results: results.after_shkia_date.omer.total_days, ), JewishCalendarSensorDescription( key="daf_yomi", translation_key="daf_yomi", entity_registry_enabled_default=False, - value_fn=lambda results: str(results.daytime_date.daf_yomi), + value_fn=lambda results: results.daytime_date.daf_yomi, ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 2e0998a648f..8480beb7c66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1133,7 +1133,7 @@ hass-splunk==0.1.1 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.1 +hdate[astral]==1.1.2 # homeassistant.components.heatmiser heatmiserV3==2.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07e4bd250d7..2704f0614b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ hass-nabucasa==0.101.0 hassil==2.2.3 # homeassistant.components.jewish_calendar -hdate[astral]==1.1.1 +hdate[astral]==1.1.2 # homeassistant.components.here_travel_time here-routing==1.0.1 From e048a3da38d3be49edd3b4c87efcf98ae8c3d131 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:38:42 +0200 Subject: [PATCH 0275/1664] Bump linkplay to v0.2.12 (#146669) --- homeassistant/components/linkplay/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json index d6319c7a506..335f1acf396 100644 --- a/homeassistant/components/linkplay/manifest.json +++ b/homeassistant/components/linkplay/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["linkplay"], - "requirements": ["python-linkplay==0.2.11"], + "requirements": ["python-linkplay==0.2.12"], "zeroconf": ["_linkplay._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8480beb7c66..b3d56a49e8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2452,7 +2452,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.11 +python-linkplay==0.2.12 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2704f0614b0..4732e5467ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.10.2 # homeassistant.components.linkplay -python-linkplay==0.2.11 +python-linkplay==0.2.12 # homeassistant.components.lirc # python-lirc==1.2.3 From c2cf3482556ecc01758ab1f1b5c881c878ecf28c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 13 Jun 2025 19:49:18 +0300 Subject: [PATCH 0276/1664] Filter speak notify entity for WHA devices in Alexa Devices (#146688) --- homeassistant/components/alexa_devices/notify.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index ff0cd4e59ea..46db294377a 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Any, Final from aioamazondevices.api import AmazonDevice, AmazonEchoApi +from aioamazondevices.const import SPEAKER_GROUP_FAMILY from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription from homeassistant.core import HomeAssistant @@ -22,6 +23,7 @@ PARALLEL_UPDATES = 1 class AmazonNotifyEntityDescription(NotifyEntityDescription): """Alexa Devices notify entity description.""" + is_supported: Callable[[AmazonDevice], bool] = lambda _device: True method: Callable[[AmazonEchoApi, AmazonDevice, str], Awaitable[None]] subkey: str @@ -31,6 +33,7 @@ NOTIFY: Final = ( key="speak", translation_key="speak", subkey="AUDIO_PLAYER", + is_supported=lambda _device: _device.device_family != SPEAKER_GROUP_FAMILY, method=lambda api, device, message: api.call_alexa_speak(device, message), ), AmazonNotifyEntityDescription( @@ -58,6 +61,7 @@ async def async_setup_entry( for sensor_desc in NOTIFY for serial_num in coordinator.data if sensor_desc.subkey in coordinator.data[serial_num].capabilities + and sensor_desc.is_supported(coordinator.data[serial_num]) ) From e81c8ce44d9f76656e85d512f2c4fa43a562d65c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 13 Jun 2025 16:53:18 +0300 Subject: [PATCH 0277/1664] Bump aioamazondevices to 3.1.2 (#146690) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/conftest.py | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 2a9e88cfd85..96f17d541fc 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": "bronze", - "requirements": ["aioamazondevices==3.0.6"] + "requirements": ["aioamazondevices==3.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index b3d56a49e8c..bf71c75a83c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.0.6 +aioamazondevices==3.1.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4732e5467ec..61e98df6960 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.0.6 +aioamazondevices==3.1.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 4ce2eb743ea..f1f40eebd27 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -56,6 +56,9 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: do_not_disturb=False, response_style=None, bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={}, ) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( From 4ec711bd633a174f87aca2c59dfb9899e8ef94ba Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 13 Jun 2025 06:58:27 -0700 Subject: [PATCH 0278/1664] Fix opower to work with aiohttp>=3.12.7 by disabling cookie quoting (#146697) Co-authored-by: J. Nick Koston --- homeassistant/components/opower/config_flow.py | 3 ++- homeassistant/components/opower/coordinator.py | 6 ++++-- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 6396ba24a15..4753a77894e 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -10,6 +10,7 @@ from opower import ( CannotConnect, InvalidAuth, Opower, + create_cookie_jar, get_supported_utility_names, select_utility, ) @@ -39,7 +40,7 @@ async def _validate_login( ) -> dict[str, str]: """Validate login data and return any errors.""" api = Opower( - async_create_clientsession(hass), + async_create_clientsession(hass, cookie_jar=create_cookie_jar()), login_data[CONF_UTILITY], login_data[CONF_USERNAME], login_data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index d03c30b7db0..189fa185cd1 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -12,6 +12,7 @@ from opower import ( MeterType, Opower, ReadResolution, + create_cookie_jar, ) from opower.exceptions import ApiException, CannotConnect, InvalidAuth @@ -30,7 +31,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import aiohttp_client, issue_registry as ir +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -62,7 +64,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): update_interval=timedelta(hours=12), ) self.api = Opower( - aiohttp_client.async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=create_cookie_jar()), config_entry.data[CONF_UTILITY], config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 0aa26dbb4b1..4e88c5a68cc 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.12.3"] + "requirements": ["opower==0.12.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index bf71c75a83c..c54b30689ad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1617,7 +1617,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.3 +opower==0.12.4 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 61e98df6960..3a18bc52b4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1370,7 +1370,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.3 +opower==0.12.4 # homeassistant.components.oralb oralb-ble==0.17.6 From cb74b2663f5f19f7741066a9e9a9355989ea8186 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 13 Jun 2025 03:46:01 -0700 Subject: [PATCH 0279/1664] Revert scan interval change in local calendar (#146700) --- homeassistant/components/local_calendar/calendar.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 8534cc1bfbf..639cf5234d1 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -36,11 +36,6 @@ _LOGGER = logging.getLogger(__name__) PRODID = "-//homeassistant.io//local_calendar 1.0//EN" -# The calendar on disk is only changed when this entity is updated, so there -# is no need to poll for changes. The calendar enttiy base class will handle -# refreshing the entity state based on the start or end time of the event. -SCAN_INTERVAL = timedelta(days=1) - async def async_setup_entry( hass: HomeAssistant, From d4ffeedc875afcf2b1270a80eb4ae7e4dd4b7701 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 13 Jun 2025 03:47:56 -0700 Subject: [PATCH 0280/1664] Partial revert of update to remote calendar to fix issue where calendar does not update (#146702) Partial revert --- .../components/remote_calendar/calendar.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py index 2f60918f010..f6918ea9706 100644 --- a/homeassistant/components/remote_calendar/calendar.py +++ b/homeassistant/components/remote_calendar/calendar.py @@ -4,6 +4,7 @@ from datetime import datetime import logging from ical.event import Event +from ical.timeline import Timeline from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant @@ -48,12 +49,18 @@ class RemoteCalendarEntity( super().__init__(coordinator) self._attr_name = entry.data[CONF_CALENDAR_NAME] self._attr_unique_id = entry.entry_id - self._event: CalendarEvent | None = None + self._timeline: Timeline | None = None @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" - return self._event + if self._timeline is None: + return None + now = dt_util.now() + events = self._timeline.active_after(now) + if event := next(events, None): + return _get_calendar_event(event) + return None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -79,15 +86,12 @@ class RemoteCalendarEntity( """ await super().async_update() - def next_timeline_event() -> CalendarEvent | None: + def _get_timeline() -> Timeline | None: """Return the next active event.""" now = dt_util.now() - events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now) - if event := next(events, None): - return _get_calendar_event(event) - return None + return self.coordinator.data.timeline_tz(now.tzinfo) - self._event = await self.hass.async_add_executor_job(next_timeline_event) + self._timeline = await self.hass.async_add_executor_job(_get_timeline) def _get_calendar_event(event: Event) -> CalendarEvent: From e89c3b1e929257b9c96c91f990a330292111a49b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:09:37 +0200 Subject: [PATCH 0281/1664] Ignore lingering pycares shutdown thread (#146733) --- tests/conftest.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d13384055b1..3bacacc6c04 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -382,8 +382,10 @@ def verify_cleanup( # Verify no threads where left behind. threads = frozenset(threading.enumerate()) - threads_before for thread in threads: - assert isinstance(thread, threading._DummyThread) or thread.name.startswith( - "waitpid-" + assert ( + isinstance(thread, threading._DummyThread) + or thread.name.startswith("waitpid-") + or "_run_safe_shutdown_loop" in thread.name ) try: From a017d9415bd4d8ce02e1db5dfc791ff3a0dc336b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Jun 2025 12:12:52 -0500 Subject: [PATCH 0282/1664] Bump aiodns to 3.5.0 (#146758) --- homeassistant/components/dnsip/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/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json index e004b386e02..6008fb83e1b 100644 --- a/homeassistant/components/dnsip/manifest.json +++ b/homeassistant/components/dnsip/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dnsip", "iot_class": "cloud_polling", - "requirements": ["aiodns==3.4.0"] + "requirements": ["aiodns==3.5.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 64f9d1649a6..ac33b8fe9f1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,7 +2,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 -aiodns==3.4.0 +aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 diff --git a/pyproject.toml b/pyproject.toml index 07f19628d0f..cdfd79cc764 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.13.2" dependencies = [ - "aiodns==3.4.0", + "aiodns==3.5.0", "aiofiles==24.1.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 diff --git a/requirements.txt b/requirements.txt index 6dc604d877b..333198a5346 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiodns==3.4.0 +aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp==3.12.12 diff --git a/requirements_all.txt b/requirements_all.txt index c54b30689ad..d8cf0d2841c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.4.0 +aiodns==3.5.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a18bc52b4c..7be1fcb1346 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -211,7 +211,7 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 # homeassistant.components.dnsip -aiodns==3.4.0 +aiodns==3.5.0 # homeassistant.components.duke_energy aiodukeenergy==0.3.0 From df5f2531463754633d1348d9028bde0f335b48fe Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 13 Jun 2025 17:20:24 +0000 Subject: [PATCH 0283/1664] Bump version to 2025.6.1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c006cd9dbed..9d5ed69f8d7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index cdfd79cc764..067b310f371 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.0" +version = "2025.6.1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 761a0877e6135172320b8cc5401fd34f5d782615 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Fri, 13 Jun 2025 19:57:03 +0200 Subject: [PATCH 0284/1664] Fix throttling issue in HomematicIP Cloud (#146683) Co-authored-by: J. Nick Koston --- .../components/homematicip_cloud/hap.py | 55 ++++++------------- .../homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/homematicip_cloud/test_hap.py | 21 ++++++- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index f3681a89110..c42ebff200d 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -128,6 +128,7 @@ class HomematicipHAP: self.config_entry.data.get(HMIPC_AUTHTOKEN), self.config_entry.data.get(HMIPC_NAME), ) + except HmipcConnectionError as err: raise ConfigEntryNotReady from err except Exception as err: # noqa: BLE001 @@ -210,41 +211,13 @@ class HomematicipHAP: for device in self.home.devices: device.fire_update_event() - async def async_connect(self) -> None: - """Start WebSocket connection.""" - tries = 0 - while True: - retry_delay = 2 ** min(tries, 8) + async def async_connect(self, home: AsyncHome) -> None: + """Connect to HomematicIP Cloud Websocket.""" + await home.enable_events() - try: - await self.home.get_current_state_async() - hmip_events = self.home.enable_events() - self.home.set_on_connected_handler(self.ws_connected_handler) - self.home.set_on_disconnected_handler(self.ws_disconnected_handler) - tries = 0 - await hmip_events - except HmipConnectionError: - _LOGGER.error( - ( - "Error connecting to HomematicIP with HAP %s. " - "Retrying in %d seconds" - ), - self.config_entry.unique_id, - retry_delay, - ) - - if self._ws_close_requested: - break - self._ws_close_requested = False - tries += 1 - - try: - self._retry_task = self.hass.async_create_task( - asyncio.sleep(retry_delay) - ) - await self._retry_task - except asyncio.CancelledError: - break + home.set_on_connected_handler(self.ws_connected_handler) + home.set_on_disconnected_handler(self.ws_disconnected_handler) + home.set_on_reconnect_handler(self.ws_reconnected_handler) async def async_reset(self) -> bool: """Close the websocket connection.""" @@ -272,14 +245,22 @@ class HomematicipHAP: async def ws_connected_handler(self) -> None: """Handle websocket connected.""" - _LOGGER.debug("WebSocket connection to HomematicIP established") + _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): await self.get_state() self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: """Handle websocket disconnection.""" - _LOGGER.warning("WebSocket connection to HomematicIP closed") + _LOGGER.warning("Websocket connection to HomematicIP Cloud closed") + self._ws_connection_closed.set() + + async def ws_reconnected_handler(self, reason: str) -> None: + """Handle websocket reconnection.""" + _LOGGER.info( + "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + reason, + ) self._ws_connection_closed.set() async def get_hap( @@ -306,6 +287,6 @@ class HomematicipHAP: home.on_update(self.async_update) home.on_create(self.async_create_entity) - hass.loop.create_task(self.async_connect()) + await self.async_connect(home) return home diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index fc4a1cb831f..163f3c402dc 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.4"] + "requirements": ["homematicip==2.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 40f0e21ef80..7ec909b653f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,7 +1170,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.5 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b4f177b3e0..dd69b70b90d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.5 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index a8aab422eb9..ae094f7dded 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -16,6 +16,7 @@ from homeassistant.components.homematicip_cloud.const import ( ) from homeassistant.components.homematicip_cloud.errors import HmipcConnectionError from homeassistant.components.homematicip_cloud.hap import ( + AsyncHome, HomematicipAuth, HomematicipHAP, ) @@ -251,3 +252,21 @@ async def test_get_state_after_disconnect( assert hap._ws_connection_closed.is_set() await hap.ws_connected_handler() mock_get_state.assert_called_once() + + +async def test_async_connect( + hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home +) -> None: + """Test async_connect.""" + hass.config.components.add(DOMAIN) + hap = HomematicipHAP(hass, hmip_config_entry) + assert hap + + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + + await hap.async_connect(simple_mock_home) + + simple_mock_home.set_on_connected_handler.assert_called_once() + simple_mock_home.set_on_disconnected_handler.assert_called_once() + simple_mock_home.set_on_reconnect_handler.assert_called_once() + simple_mock_home.enable_events.assert_called_once() From 2175754a1fcf827c908a3f4dd886ace47334a30c Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Fri, 13 Jun 2025 19:57:03 +0200 Subject: [PATCH 0285/1664] Fix throttling issue in HomematicIP Cloud (#146683) Co-authored-by: J. Nick Koston --- .../components/homematicip_cloud/hap.py | 55 ++++++------------- .../homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/homematicip_cloud/test_hap.py | 21 ++++++- 5 files changed, 41 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index f3681a89110..c42ebff200d 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -128,6 +128,7 @@ class HomematicipHAP: self.config_entry.data.get(HMIPC_AUTHTOKEN), self.config_entry.data.get(HMIPC_NAME), ) + except HmipcConnectionError as err: raise ConfigEntryNotReady from err except Exception as err: # noqa: BLE001 @@ -210,41 +211,13 @@ class HomematicipHAP: for device in self.home.devices: device.fire_update_event() - async def async_connect(self) -> None: - """Start WebSocket connection.""" - tries = 0 - while True: - retry_delay = 2 ** min(tries, 8) + async def async_connect(self, home: AsyncHome) -> None: + """Connect to HomematicIP Cloud Websocket.""" + await home.enable_events() - try: - await self.home.get_current_state_async() - hmip_events = self.home.enable_events() - self.home.set_on_connected_handler(self.ws_connected_handler) - self.home.set_on_disconnected_handler(self.ws_disconnected_handler) - tries = 0 - await hmip_events - except HmipConnectionError: - _LOGGER.error( - ( - "Error connecting to HomematicIP with HAP %s. " - "Retrying in %d seconds" - ), - self.config_entry.unique_id, - retry_delay, - ) - - if self._ws_close_requested: - break - self._ws_close_requested = False - tries += 1 - - try: - self._retry_task = self.hass.async_create_task( - asyncio.sleep(retry_delay) - ) - await self._retry_task - except asyncio.CancelledError: - break + home.set_on_connected_handler(self.ws_connected_handler) + home.set_on_disconnected_handler(self.ws_disconnected_handler) + home.set_on_reconnect_handler(self.ws_reconnected_handler) async def async_reset(self) -> bool: """Close the websocket connection.""" @@ -272,14 +245,22 @@ class HomematicipHAP: async def ws_connected_handler(self) -> None: """Handle websocket connected.""" - _LOGGER.debug("WebSocket connection to HomematicIP established") + _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): await self.get_state() self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: """Handle websocket disconnection.""" - _LOGGER.warning("WebSocket connection to HomematicIP closed") + _LOGGER.warning("Websocket connection to HomematicIP Cloud closed") + self._ws_connection_closed.set() + + async def ws_reconnected_handler(self, reason: str) -> None: + """Handle websocket reconnection.""" + _LOGGER.info( + "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + reason, + ) self._ws_connection_closed.set() async def get_hap( @@ -306,6 +287,6 @@ class HomematicipHAP: home.on_update(self.async_update) home.on_create(self.async_create_entity) - hass.loop.create_task(self.async_connect()) + await self.async_connect(home) return home diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index fc4a1cb831f..163f3c402dc 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.4"] + "requirements": ["homematicip==2.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index d8cf0d2841c..d0e8b10ce42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,7 +1170,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.5 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7be1fcb1346..861c8097470 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.4 +homematicip==2.0.5 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index c258c85ac93..dec74fedc08 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -16,6 +16,7 @@ from homeassistant.components.homematicip_cloud.const import ( ) from homeassistant.components.homematicip_cloud.errors import HmipcConnectionError from homeassistant.components.homematicip_cloud.hap import ( + AsyncHome, HomematicipAuth, HomematicipHAP, ) @@ -251,3 +252,21 @@ async def test_get_state_after_disconnect( assert hap._ws_connection_closed.is_set() await hap.ws_connected_handler() mock_get_state.assert_called_once() + + +async def test_async_connect( + hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home +) -> None: + """Test async_connect.""" + hass.config.components.add(DOMAIN) + hap = HomematicipHAP(hass, hmip_config_entry) + assert hap + + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + + await hap.async_connect(simple_mock_home) + + simple_mock_home.set_on_connected_handler.assert_called_once() + simple_mock_home.set_on_disconnected_handler.assert_called_once() + simple_mock_home.set_on_reconnect_handler.assert_called_once() + simple_mock_home.enable_events.assert_called_once() From 25d1480f2a4b67e09f6040d58874de58b555af5a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 13 Jun 2025 18:09:05 +0000 Subject: [PATCH 0286/1664] Hotfix ruff warnings --- tests/components/homematicip_cloud/test_hap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index dec74fedc08..6d1e6024b68 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -258,7 +258,7 @@ async def test_async_connect( hass: HomeAssistant, hmip_config_entry: MockConfigEntry, simple_mock_home ) -> None: """Test async_connect.""" - hass.config.components.add(DOMAIN) + hass.config.components.add(HMIPC_DOMAIN) hap = HomematicipHAP(hass, hmip_config_entry) assert hap From 186ed451a9f0f56874b1d89e24eb86e21229e210 Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 13 Jun 2025 16:09:29 -0700 Subject: [PATCH 0287/1664] Bump nextbus client to 2.3.0 (#146780) --- homeassistant/components/nextbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 4b7057f7142..c1da33f2555 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==2.2.0"] + "requirements": ["py-nextbusnext==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7ec909b653f..4d75049746b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1762,7 +1762,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.2.0 +py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd69b70b90d..2b95d114d76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1485,7 +1485,7 @@ py-madvr2==1.6.32 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==2.2.0 +py-nextbusnext==2.3.0 # homeassistant.components.nightscout py-nightscout==1.2.2 From cdb2b407be958aa53e7a3e73bb19931411a2d899 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 14 Jun 2025 01:11:13 +0200 Subject: [PATCH 0288/1664] Add Reolink baby cry sensitivity (#146773) * Add baby cry sensitivity * Adjust tests --- homeassistant/components/reolink/icons.json | 3 +++ homeassistant/components/reolink/number.py | 12 ++++++++++++ homeassistant/components/reolink/strings.json | 3 +++ .../reolink/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 22 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index c79d9b895fa..d998cc79ce8 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -220,6 +220,9 @@ "ai_animal_sensitivity": { "default": "mdi:paw" }, + "cry_sensitivity": { + "default": "mdi:emoticon-cry-outline" + }, "crossline_sensitivity": { "default": "mdi:fence" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index e28b8c97697..6de702a0395 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -272,6 +272,18 @@ NUMBER_ENTITIES = ( value=lambda api, ch: api.ai_sensitivity(ch, "dog_cat"), method=lambda api, ch, value: api.set_ai_sensitivity(ch, int(value), "dog_cat"), ), + ReolinkNumberEntityDescription( + key="cry_sensitivity", + cmd_key="299", + translation_key="cry_sensitivity", + entity_category=EntityCategory.CONFIG, + native_step=1, + native_min_value=1, + native_max_value=5, + supported=lambda api, ch: api.supported(ch, "ai_cry"), + value=lambda api, ch: api.baichuan.cry_sensitivity(ch), + method=lambda api, ch, value: api.baichuan.set_cry_detection(ch, int(value)), + ), ReolinkNumberEntityDescription( key="ai_face_delay", cmd_key="GetAiAlarm", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 45448e2a03c..59d2ce95df4 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -571,6 +571,9 @@ "ai_animal_sensitivity": { "name": "AI animal sensitivity" }, + "cry_sensitivity": { + "name": "Baby cry sensitivity" + }, "crossline_sensitivity": { "name": "AI crossline {zone_name} sensitivity" }, diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 771aeba2a9e..d81b39738e5 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -72,6 +72,10 @@ '0': 1, 'null': 1, }), + '299': dict({ + '0': 1, + 'null': 1, + }), 'DingDongOpt': dict({ '0': 2, 'null': 2, From 3d2dca5f0c5f7cda9458a2232e3ee41542324142 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 14 Jun 2025 03:54:25 +0200 Subject: [PATCH 0289/1664] Adjust scripts for compatibility with Python 3.14 (#146774) --- homeassistant/scripts/__init__.py | 4 +--- tests/scripts/test_check_config.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index f0600b70f48..52d96109bf2 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -46,10 +46,8 @@ def run(args: list[str]) -> int: config_dir = extract_config_dir() - loop = asyncio.get_event_loop() - if not is_virtual_env(): - loop.run_until_complete(async_mount_local_lib_path(config_dir)) + asyncio.run(async_mount_local_lib_path(config_dir)) _pip_kwargs = pip_kwargs(config_dir) diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 3a2007060ae..2bb58cd4d68 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -43,7 +43,7 @@ def mock_is_file(): """Mock is_file.""" # All files exist except for the old entity registry file with patch( - "os.path.isfile", lambda path: not path.endswith("entity_registry.yaml") + "os.path.isfile", lambda path: not str(path).endswith("entity_registry.yaml") ): yield From 56aa8090749783b47748069d6c75e2cf54b6d58f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 14 Jun 2025 03:57:11 +0200 Subject: [PATCH 0290/1664] Simplify google_photos service actions (#146744) --- .../components/google_photos/services.py | 145 +++++++++--------- 1 file changed, 72 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/google_photos/services.py b/homeassistant/components/google_photos/services.py index a74fabb3b77..c30259416e5 100644 --- a/homeassistant/components/google_photos/services.py +++ b/homeassistant/components/google_photos/services.py @@ -78,86 +78,85 @@ def _read_file_contents( return results +async def _async_handle_upload(call: ServiceCall) -> ServiceResponse: + """Generate content from text and optionally images.""" + config_entry: GooglePhotosConfigEntry | None = ( + call.hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) + ) + if not config_entry: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + scopes = config_entry.data["token"]["scope"].split(" ") + if UPLOAD_SCOPE not in scopes: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="missing_upload_permission", + translation_placeholders={"target": DOMAIN}, + ) + coordinator = config_entry.runtime_data + client_api = coordinator.client + upload_tasks = [] + file_results = await call.hass.async_add_executor_job( + _read_file_contents, call.hass, call.data[CONF_FILENAME] + ) + + album = call.data[CONF_ALBUM] + try: + album_id = await coordinator.get_or_create_album(album) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="create_album_error", + translation_placeholders={"message": str(err)}, + ) from err + + for mime_type, content in file_results: + upload_tasks.append(client_api.upload_content(content, mime_type)) + try: + upload_results = await asyncio.gather(*upload_tasks) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="upload_error", + translation_placeholders={"message": str(err)}, + ) from err + try: + upload_result = await client_api.create_media_items( + [ + NewMediaItem(SimpleMediaItem(upload_token=upload_result.upload_token)) + for upload_result in upload_results + ], + album_id=album_id, + ) + except GooglePhotosApiError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"message": str(err)}, + ) from err + if call.return_response: + return { + "media_items": [ + {"media_item_id": item_result.media_item.id} + for item_result in upload_result.new_media_item_results + if item_result.media_item and item_result.media_item.id + ], + "album_id": album_id, + } + return None + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register Google Photos services.""" - async def async_handle_upload(call: ServiceCall) -> ServiceResponse: - """Generate content from text and optionally images.""" - config_entry: GooglePhotosConfigEntry | None = ( - hass.config_entries.async_get_entry(call.data[CONF_CONFIG_ENTRY_ID]) - ) - if not config_entry: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="integration_not_found", - translation_placeholders={"target": DOMAIN}, - ) - scopes = config_entry.data["token"]["scope"].split(" ") - if UPLOAD_SCOPE not in scopes: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="missing_upload_permission", - translation_placeholders={"target": DOMAIN}, - ) - coordinator = config_entry.runtime_data - client_api = coordinator.client - upload_tasks = [] - file_results = await hass.async_add_executor_job( - _read_file_contents, hass, call.data[CONF_FILENAME] - ) - - album = call.data[CONF_ALBUM] - try: - album_id = await coordinator.get_or_create_album(album) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="create_album_error", - translation_placeholders={"message": str(err)}, - ) from err - - for mime_type, content in file_results: - upload_tasks.append(client_api.upload_content(content, mime_type)) - try: - upload_results = await asyncio.gather(*upload_tasks) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="upload_error", - translation_placeholders={"message": str(err)}, - ) from err - try: - upload_result = await client_api.create_media_items( - [ - NewMediaItem( - SimpleMediaItem(upload_token=upload_result.upload_token) - ) - for upload_result in upload_results - ], - album_id=album_id, - ) - except GooglePhotosApiError as err: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="api_error", - translation_placeholders={"message": str(err)}, - ) from err - if call.return_response: - return { - "media_items": [ - {"media_item_id": item_result.media_item.id} - for item_result in upload_result.new_media_item_results - if item_result.media_item and item_result.media_item.id - ], - "album_id": album_id, - } - return None - hass.services.async_register( DOMAIN, UPLOAD_SERVICE, - async_handle_upload, + _async_handle_upload, schema=UPLOAD_SERVICE_SCHEMA, supports_response=SupportsResponse.OPTIONAL, ) From 059c12798de2c7b0f285cb79e60c01f4be231701 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Jun 2025 22:01:39 -0400 Subject: [PATCH 0291/1664] Drop user prompt from LLMContext (#146787) --- homeassistant/components/conversation/chat_log.py | 1 - homeassistant/components/mcp_server/http.py | 1 - homeassistant/helpers/llm.py | 12 ++++++++++-- tests/components/anthropic/test_conversation.py | 2 -- tests/components/mcp/test_init.py | 1 - tests/components/ollama/test_conversation.py | 2 -- tests/helpers/test_llm.py | 12 ++---------- 7 files changed, 12 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index c78f41f3c5c..7580b878d04 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -395,7 +395,6 @@ class ChatLog: llm_context = llm.LLMContext( platform=conversing_domain, context=user_input.context, - user_prompt=user_input.text, language=user_input.language, assistant=DOMAIN, device_id=user_input.device_id, diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py index bc8fdbd56c8..07284b29434 100644 --- a/homeassistant/components/mcp_server/http.py +++ b/homeassistant/components/mcp_server/http.py @@ -88,7 +88,6 @@ class ModelContextProtocolSSEView(HomeAssistantView): context = llm.LLMContext( platform=DOMAIN, context=self.context(request), - user_prompt=None, language="*", assistant=conversation.DOMAIN, device_id=None, diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index adf113e0f30..51b5510495f 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -160,11 +160,19 @@ class LLMContext: """Tool input to be processed.""" platform: str + """Integration that is handling the LLM request.""" + context: Context | None - user_prompt: str | None + """Context of the LLM request.""" + language: str | None + """Language of the LLM request.""" + assistant: str | None + """Assistant domain that is handling the LLM request.""" + device_id: str | None + """Device that is making the request.""" @dataclass(slots=True) @@ -302,7 +310,7 @@ class IntentTool(Tool): platform=llm_context.platform, intent_type=self.name, slots=slots, - text_input=llm_context.user_prompt, + text_input=None, context=llm_context.context, language=llm_context.language, assistant=llm_context.assistant, diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3e01e91976d..6aadcf3eeb4 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -415,7 +415,6 @@ async def test_function_call( llm.LLMContext( platform="anthropic", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, @@ -510,7 +509,6 @@ async def test_function_exception( llm.LLMContext( platform="anthropic", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, diff --git a/tests/components/mcp/test_init.py b/tests/components/mcp/test_init.py index 045fb99e181..00666e71d05 100644 --- a/tests/components/mcp/test_init.py +++ b/tests/components/mcp/test_init.py @@ -58,7 +58,6 @@ def create_llm_context() -> llm.LLMContext: return llm.LLMContext( platform="test_platform", context=Context(), - user_prompt="test_text", language="*", assistant="conversation", device_id=None, diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index 8e54018a14d..e83c2a3495f 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -284,7 +284,6 @@ async def test_function_call( llm.LLMContext( platform="ollama", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, @@ -369,7 +368,6 @@ async def test_function_exception( llm.LLMContext( platform="ollama", context=context, - user_prompt="Please call the test function", language="en", assistant="conversation", device_id=None, diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 1a9225c505b..98dee920bd9 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -36,7 +36,6 @@ def llm_context() -> llm.LLMContext: return llm.LLMContext( platform="", context=None, - user_prompt=None, language=None, assistant=None, device_id=None, @@ -162,7 +161,6 @@ async def test_assist_api( llm_context = llm.LLMContext( platform="test_platform", context=test_context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -237,7 +235,7 @@ async def test_assist_api( "area": {"value": "kitchen"}, "floor": {"value": "ground_floor"}, }, - text_input="test_text", + text_input=None, context=test_context, language="*", assistant="conversation", @@ -296,7 +294,7 @@ async def test_assist_api( "preferred_area_id": {"value": area.id}, "preferred_floor_id": {"value": floor.floor_id}, }, - text_input="test_text", + text_input=None, context=test_context, language="*", assistant="conversation", @@ -412,7 +410,6 @@ async def test_assist_api_prompt( llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -760,7 +757,6 @@ async def test_script_tool( llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -961,7 +957,6 @@ async def test_script_tool_name(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1241,7 +1236,6 @@ async def test_calendar_get_events_tool(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1344,7 +1338,6 @@ async def test_todo_get_items_tool(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, @@ -1451,7 +1444,6 @@ async def test_no_tools_exposed(hass: HomeAssistant) -> None: llm_context = llm.LLMContext( platform="test_platform", context=context, - user_prompt="test_text", language="*", assistant="conversation", device_id=None, From ce52ef64dbe0df68650e593b17636c54a6e73231 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 14 Jun 2025 23:39:27 +1000 Subject: [PATCH 0292/1664] Bump tesla-fleet-api to 1.1.3 (#146793) --- 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 8f5ba1468a5..af7d2ce2b34 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.1.1"] + "requirements": ["tesla-fleet-api==1.1.3"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index 7fc621eeeae..fe6f5416ca3 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.1.1", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.1.3", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 9ad87e9dbbe..38e52679018 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.1.1"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4d75049746b..358b1356c4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2900,7 +2900,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.1 +tesla-fleet-api==1.1.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b95d114d76..f3f7b86774a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2383,7 +2383,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.1 +tesla-fleet-api==1.1.3 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 6204fd5363099c8c546b7cc0fa5e4c2f213c1cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Sat, 14 Jun 2025 16:24:48 +0200 Subject: [PATCH 0293/1664] Add polling to LetPot coordinator (#146823) - Adds polling (update_interval) to the coordinator for the LetPot integration. Push remains the primary update mechanism for all entities, but: - Polling makes entities go unavailable when the device can't be reached, which otherwise won't happen. - Pump changes do not always trigger a status push by the device (not sure why), polling makes the integration catch up to reality. --- homeassistant/components/letpot/coordinator.py | 2 ++ homeassistant/components/letpot/quality_scale.yaml | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index bd787157482..39e49348663 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import timedelta import logging from letpot.deviceclient import LetPotDeviceClient @@ -42,6 +43,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): _LOGGER, config_entry=config_entry, name=f"LetPot {device.serial_number}", + update_interval=timedelta(minutes=10), ) self._info = info self.device = device diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml index 9804a5ec3a4..f5e88bfc369 100644 --- a/homeassistant/components/letpot/quality_scale.yaml +++ b/homeassistant/components/letpot/quality_scale.yaml @@ -5,9 +5,9 @@ rules: comment: | This integration does not provide additional actions. appropriate-polling: - status: exempt + status: done comment: | - This integration only receives push-based updates. + Primarily uses push, but polls with a long interval for availability and missed updates. brands: done common-modules: done config-flow-test-coverage: done @@ -39,7 +39,7 @@ rules: comment: | The integration does not have configuration options. docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: todo parallel-updates: done From 2ac8901a0d04fca2619a59a89fac378bf7cc4a9d Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sat, 14 Jun 2025 17:26:08 +0200 Subject: [PATCH 0294/1664] Improve code quality in async_setup_entry of switches in homematicip_cloud (#146816) improve setup of switches --- .../components/homematicip_cloud/switch.py | 61 ++++++++-------- .../homematicip_cloud/test_switch.py | 70 +++++++++---------- 2 files changed, 65 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 66a40229c7e..ca591adbf5e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -4,13 +4,14 @@ from __future__ import annotations from typing import Any -from homematicip.base.enums import DeviceType +from homematicip.base.enums import DeviceType, FunctionalChannelType from homematicip.device import ( BrandSwitch2, DinRailSwitch, DinRailSwitch4, FullFlushInputSwitch, HeatingSwitch2, + MotionDetectorSwitchOutdoor, MultiIOBox, OpenCollector8Module, PlugableSwitch, @@ -47,18 +48,34 @@ async def async_setup_entry( and getattr(device, "deviceType", None) != DeviceType.BRAND_SWITCH_MEASURING ): entities.append(HomematicipSwitchMeasuring(hap, device)) - elif isinstance(device, WiredSwitch8): + elif isinstance( + device, + ( + WiredSwitch8, + OpenCollector8Module, + BrandSwitch2, + PrintedCircuitBoardSwitch2, + HeatingSwitch2, + MultiIOBox, + MotionDetectorSwitchOutdoor, + DinRailSwitch, + DinRailSwitch4, + ), + ): + channel_indices = [ + ch.index + for ch in device.functionalChannels + if ch.functionalChannelType + in ( + FunctionalChannelType.SWITCH_CHANNEL, + FunctionalChannelType.MULTI_MODE_INPUT_SWITCH_CHANNEL, + ) + ] entities.extend( HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 9) - ) - elif isinstance(device, DinRailSwitch): - entities.append(HomematicipMultiSwitch(hap, device, channel=1)) - elif isinstance(device, DinRailSwitch4): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 5) + for channel in channel_indices ) + elif isinstance( device, ( @@ -68,24 +85,6 @@ async def async_setup_entry( ), ): entities.append(HomematicipSwitch(hap, device)) - elif isinstance(device, OpenCollector8Module): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 9) - ) - elif isinstance( - device, - ( - BrandSwitch2, - PrintedCircuitBoardSwitch2, - HeatingSwitch2, - MultiIOBox, - ), - ): - entities.extend( - HomematicipMultiSwitch(hap, device, channel=channel) - for channel in range(1, 3) - ) async_add_entities(entities) @@ -108,15 +107,15 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity): @property def is_on(self) -> bool: """Return true if switch is on.""" - return self._device.functionalChannels[self._channel].on + return self.functional_channel.on async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self._device.turn_on_async(self._channel) + await self.functional_channel.async_turn_on() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self._device.turn_off_async(self._channel) + await self.functional_channel.async_turn_off() class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity): diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py index 1a728bfecd4..50d527775bd 100644 --- a/tests/components/homematicip_cloud/test_switch.py +++ b/tests/components/homematicip_cloud/test_switch.py @@ -25,14 +25,14 @@ async def test_hmip_switch( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -40,9 +40,9 @@ async def test_hmip_switch( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -64,14 +64,14 @@ async def test_hmip_switch_input( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -79,9 +79,9 @@ async def test_hmip_switch_input( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -103,14 +103,14 @@ async def test_hmip_switch_measuring( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -118,9 +118,9 @@ async def test_hmip_switch_measuring( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) await async_manipulate_test_data(hass, hmip_device, "currentPowerConsumption", 50) ha_state = hass.states.get(entity_id) @@ -191,14 +191,14 @@ async def test_hmip_multi_switch( ) assert ha_state.state == STATE_OFF - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON @@ -206,9 +206,9 @@ async def test_hmip_multi_switch( await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -242,14 +242,14 @@ async def test_hmip_wired_multi_switch( ) assert ha_state.state == STATE_ON - service_call_counter = len(hmip_device.mock_calls) + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) await hass.services.async_call( "switch", "turn_off", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 1 - assert hmip_device.mock_calls[-1][0] == "turn_off_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_off" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF @@ -257,9 +257,9 @@ async def test_hmip_wired_multi_switch( await hass.services.async_call( "switch", "turn_on", {"entity_id": entity_id}, blocking=True ) - assert len(hmip_device.mock_calls) == service_call_counter + 3 - assert hmip_device.mock_calls[-1][0] == "turn_on_async" - assert hmip_device.mock_calls[-1][1] == (1,) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert hmip_device.functionalChannels[1].mock_calls[-1][0] == "async_turn_on" + assert hmip_device.functionalChannels[1].mock_calls[-1][1] == () await async_manipulate_test_data(hass, hmip_device, "on", True) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON From 1d14e1f018b679c96c994a1a91327f98fb7addb2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 11:13:20 -0500 Subject: [PATCH 0295/1664] Bump aiohttp to 3.12.13 (#146830) changelog: https://github.com/aio-libs/aiohttp/compare/v3.12.12...v3.12.13 Likely does not affect us at all but just in case, tagging --- 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 7e6dea5022e..ca1d75cc037 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,7 +7,7 @@ aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 284a0d39bfe..2910ee0221d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.12", + "aiohttp==3.12.13", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index c96a62b355d..b5a949b0a6b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From ed3fb62ffc0bfd9bd8e552560cead35e363a6d44 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Jun 2025 11:49:16 -0500 Subject: [PATCH 0296/1664] Update rokuecp to 0.19.5 (#146788) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 7fe2fb3b686..d5e2e2e5224 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["rokuecp"], - "requirements": ["rokuecp==0.19.3"], + "requirements": ["rokuecp==0.19.5"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index 358b1356c4d..25f7d63de65 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2673,7 +2673,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3f7b86774a..1ec4eb4d978 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2204,7 +2204,7 @@ rflink==0.0.66 ring-doorbell==0.9.13 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 From 3f8f7cd57805475faa7fe256698bb41176f7e8b6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 14 Jun 2025 19:01:41 +0200 Subject: [PATCH 0297/1664] Bump motion blinds to 0.6.28 (#146831) --- homeassistant/components/motion_blinds/cover.py | 1 + homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 165c4c19675..9cff2956a5f 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -62,6 +62,7 @@ TILT_DEVICE_MAP = { BlindType.VerticalBlind: CoverDeviceClass.BLIND, BlindType.VerticalBlindLeft: CoverDeviceClass.BLIND, BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, + BlindType.RollerTiltMotor: CoverDeviceClass.BLIND, } TILT_ONLY_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1a6c9c5f82f..a82da20396f 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.27"] + "requirements": ["motionblinds==0.6.28"] } diff --git a/requirements_all.txt b/requirements_all.txt index 25f7d63de65..5ad1815e56d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1448,7 +1448,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ec4eb4d978..110eb5dd318 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1237,7 +1237,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From 152e5254e2c84ea2e4b63814aae316a8eee951e2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 14 Jun 2025 19:53:51 +0200 Subject: [PATCH 0298/1664] Use Shelly main device area as suggested area for sub-devices (#146810) --- homeassistant/components/shelly/button.py | 8 +++++-- homeassistant/components/shelly/climate.py | 5 +++- .../components/shelly/coordinator.py | 12 +++++++++- homeassistant/components/shelly/entity.py | 24 +++++++++++++++---- homeassistant/components/shelly/event.py | 5 +++- homeassistant/components/shelly/sensor.py | 6 ++++- homeassistant/components/shelly/utils.py | 9 ++++++- 7 files changed, 57 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index eab7514514d..ad03a373dba 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -235,11 +235,15 @@ 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.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) else: self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 26fabe7e8b5..abc387f3efd 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -211,7 +211,10 @@ 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, sensor_block + coordinator.device, + coordinator.mac, + sensor_block, + suggested_area=coordinator.suggested_area, ) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f980ba8f914..cba559a9773 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -31,7 +31,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -114,6 +118,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None + self.suggested_area: str | None = None device_name = device.name if device.initialized else entry.title interval_td = timedelta(seconds=update_interval) # The device has come online at least once. In the case of a sleeping RPC @@ -176,6 +181,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( 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)}", ) + # 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: + area_registry = ar.async_get(self.hass) + if (area := area_registry.async_get_area(area_id)) is not None: + self.suggested_area = area.name self.device_id = device_entry.id async def shutdown(self) -> None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1b0078890af..2c1678d56d9 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -362,7 +362,10 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): 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, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" @@ -405,7 +408,10 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): super().__init__(coordinator) self.key = key self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -521,7 +527,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._last_value = None @@ -630,7 +638,10 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.entity_description = description self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) if block is not None: @@ -698,7 +709,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.entity_description = description self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = self._attr_unique_id = ( f"{coordinator.mac}-{key}-{attribute}" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 677ea1f6138..2eb9ff00964 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -207,7 +207,10 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0ea246c7734..3a6f5f221c5 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -139,7 +139,11 @@ class RpcEmeterPhaseSensor(RpcSensor): super().__init__(coordinator, key, attribute, description) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key, description.emeter_phase + coordinator.device, + coordinator.mac, + 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 cc0f2cf75d5..953fcbace06 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -751,6 +751,7 @@ def get_rpc_device_info( mac: str, key: str | None = None, emeter_phase: str | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for RPC device.""" if key is None: @@ -770,6 +771,7 @@ 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", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -784,6 +786,7 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -805,7 +808,10 @@ def get_blu_trv_device_info( def get_block_device_info( - device: BlockDevice, mac: str, block: Block | None = None + device: BlockDevice, + mac: str, + block: Block | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for Block device.""" if ( @@ -820,6 +826,7 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) From d7b583ae51b6f8f47feac00ba5d38c1ed35655ab Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 14 Jun 2025 23:31:09 +0200 Subject: [PATCH 0299/1664] Update pydantic to 2.11.7 (#146835) --- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ca1d75cc037..29751879dae 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -131,7 +131,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.6 +pydantic==2.11.7 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 diff --git a/requirements_test.txt b/requirements_test.txt index ebdbc35720b..29d2618c69d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -15,7 +15,7 @@ license-expression==30.4.1 mock-open==1.4.0 mypy-dev==1.17.0a2 pre-commit==4.2.0 -pydantic==2.11.6 +pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e8fd6b0f7a8..2e3ecccf5d2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ multidict>=6.0.2 backoff>=2.0 # ensure pydantic version does not float since it might have breaking changes -pydantic==2.11.6 +pydantic==2.11.7 # Required for Python 3.12.4 compatibility (#119223). mashumaro>=3.13.1 From 9f19c4250a97e97403b680e1a323971e6e213a0c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 15 Jun 2025 01:45:28 +0300 Subject: [PATCH 0300/1664] Bump aioamazondevices to 3.1.3 (#146828) --- 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 96f17d541fc..0ea4c32a75e 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": "bronze", - "requirements": ["aioamazondevices==3.1.2"] + "requirements": ["aioamazondevices==3.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ad1815e56d..f2df39a74a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.2 +aioamazondevices==3.1.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 110eb5dd318..de3f60b1edb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.2 +aioamazondevices==3.1.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From ec02f6d0108e8c0716ccd590019ef9012ea0b650 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Jun 2025 01:17:52 -0400 Subject: [PATCH 0301/1664] Extract Google LLM base entity class (#146817) --- .../conversation.py | 474 +---------------- .../entity.py | 475 ++++++++++++++++++ .../conftest.py | 2 +- .../test_conversation.py | 2 +- 4 files changed, 485 insertions(+), 468 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/entity.py diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 1038377af68..726572fc5ae 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -2,63 +2,18 @@ from __future__ import annotations -import codecs -from collections.abc import AsyncGenerator, Callable -from dataclasses import replace -from typing import Any, Literal, cast - -from google.genai.errors import APIError, ClientError -from google.genai.types import ( - AutomaticFunctionCallingConfig, - Content, - FunctionDeclaration, - GenerateContentConfig, - GenerateContentResponse, - GoogleSearch, - HarmCategory, - Part, - SafetySetting, - Schema, - Tool, -) -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - CONF_CHAT_MODEL, - CONF_DANGEROUS_BLOCK_THRESHOLD, - CONF_HARASSMENT_BLOCK_THRESHOLD, - CONF_HATE_BLOCK_THRESHOLD, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_SEXUAL_BLOCK_THRESHOLD, - CONF_TEMPERATURE, - CONF_TOP_K, - CONF_TOP_P, - CONF_USE_GOOGLE_SEARCH_TOOL, - DOMAIN, - LOGGER, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_K, - RECOMMENDED_TOP_P, -) - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -ERROR_GETTING_RESPONSE = ( - "Sorry, I had a problem getting a response from Google Generative AI." -) +from .const import CONF_PROMPT, DOMAIN, LOGGER +from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -71,267 +26,18 @@ async def async_setup_entry( async_add_entities([agent]) -SUPPORTED_SCHEMA_KEYS = { - # Gemini API does not support all of the OpenAPI schema - # SoT: https://ai.google.dev/api/caching#Schema - "type", - "format", - "description", - "nullable", - "enum", - "max_items", - "min_items", - "properties", - "required", - "items", -} - - -def _camel_to_snake(name: str) -> str: - """Convert camel case to snake case.""" - return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") - - -def _format_schema(schema: dict[str, Any]) -> Schema: - """Format the schema to be compatible with Gemini API.""" - if subschemas := schema.get("allOf"): - for subschema in subschemas: # Gemini API does not support allOf keys - if "type" in subschema: # Fallback to first subschema with 'type' field - return _format_schema(subschema) - return _format_schema( - subschemas[0] - ) # Or, if not found, to any of the subschemas - - result = {} - for key, val in schema.items(): - key = _camel_to_snake(key) - if key not in SUPPORTED_SCHEMA_KEYS: - continue - if key == "type": - val = val.upper() - elif key == "format": - # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema - # formats that are not supported are ignored - if schema.get("type") == "string" and val not in ("enum", "date-time"): - continue - if schema.get("type") == "number" and val not in ("float", "double"): - continue - if schema.get("type") == "integer" and val not in ("int32", "int64"): - continue - if schema.get("type") not in ("string", "number", "integer"): - continue - elif key == "items": - val = _format_schema(val) - elif key == "properties": - val = {k: _format_schema(v) for k, v in val.items()} - result[key] = val - - if result.get("enum") and result.get("type") != "STRING": - # enum is only allowed for STRING type. This is safe as long as the schema - # contains vol.Coerce for the respective type, for example: - # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) - result["type"] = "STRING" - result["enum"] = [str(item) for item in result["enum"]] - - if result.get("type") == "OBJECT" and not result.get("properties"): - # An object with undefined properties is not supported by Gemini API. - # Fallback to JSON string. This will probably fail for most tools that want it, - # but we don't have a better fallback strategy so far. - result["properties"] = {"json": {"type": "STRING"}} - result["required"] = [] - return cast(Schema, result) - - -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> Tool: - """Format tool specification.""" - - if tool.parameters.schema: - parameters = _format_schema( - convert(tool.parameters, custom_serializer=custom_serializer) - ) - else: - parameters = None - - return Tool( - function_declarations=[ - FunctionDeclaration( - name=tool.name, - description=tool.description, - parameters=parameters, - ) - ] - ) - - -def _escape_decode(value: Any) -> Any: - """Recursively call codecs.escape_decode on all values.""" - if isinstance(value, str): - return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] - if isinstance(value, list): - return [_escape_decode(item) for item in value] - if isinstance(value, dict): - return {k: _escape_decode(v) for k, v in value.items()} - return value - - -def _create_google_tool_response_parts( - parts: list[conversation.ToolResultContent], -) -> list[Part]: - """Create Google tool response parts.""" - return [ - Part.from_function_response( - name=tool_result.tool_name, response=tool_result.tool_result - ) - for tool_result in parts - ] - - -def _create_google_tool_response_content( - content: list[conversation.ToolResultContent], -) -> Content: - """Create a Google tool response content.""" - return Content( - role="user", - parts=_create_google_tool_response_parts(content), - ) - - -def _convert_content( - content: ( - conversation.UserContent - | conversation.AssistantContent - | conversation.SystemContent - ), -) -> Content: - """Convert HA content to Google content.""" - if content.role != "assistant" or not content.tool_calls: - role = "model" if content.role == "assistant" else content.role - return Content( - role=role, - parts=[ - Part.from_text(text=content.content if content.content else ""), - ], - ) - - # Handle the Assistant content with tool calls. - assert type(content) is conversation.AssistantContent - parts: list[Part] = [] - - if content.content: - parts.append(Part.from_text(text=content.content)) - - if content.tool_calls: - parts.extend( - [ - Part.from_function_call( - name=tool_call.tool_name, - args=_escape_decode(tool_call.tool_args), - ) - for tool_call in content.tool_calls - ] - ) - - return Content(role="model", parts=parts) - - -async def _transform_stream( - result: AsyncGenerator[GenerateContentResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - new_message = True - try: - async for response in result: - LOGGER.debug("Received response chunk: %s", response) - chunk: conversation.AssistantContentDeltaDict = {} - - if new_message: - chunk["role"] = "assistant" - new_message = False - - # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. - if response.prompt_feedback or not response.candidates: - reason = ( - response.prompt_feedback.block_reason_message - if response.prompt_feedback - else "unknown" - ) - raise HomeAssistantError( - f"The message got blocked due to content violations, reason: {reason}" - ) - - candidate = response.candidates[0] - - if ( - candidate.finish_reason is not None - and candidate.finish_reason != "STOP" - ): - # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason - LOGGER.error( - "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", - candidate.finish_reason, - ) - raise HomeAssistantError( - f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" - ) - - response_parts = ( - candidate.content.parts - if candidate.content is not None and candidate.content.parts is not None - else [] - ) - - content = "".join([part.text for part in response_parts if part.text]) - tool_calls = [] - for part in response_parts: - if not part.function_call: - continue - tool_call = part.function_call - tool_name = tool_call.name if tool_call.name else "" - tool_args = _escape_decode(tool_call.args) - tool_calls.append( - llm.ToolInput(tool_name=tool_name, tool_args=tool_args) - ) - - if tool_calls: - chunk["tool_calls"] = tool_calls - - chunk["content"] = content - yield chunk - except ( - APIError, - ValueError, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) - if isinstance(err, APIError): - message = err.message - else: - message = type(err).__name__ - error = f"{ERROR_GETTING_RESPONSE}: {message}" - raise HomeAssistantError(error) from err - - class GoogleGenerativeAIConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + GoogleGenerativeAILLMBaseEntity, ): """Google Generative AI conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True def __init__(self, entry: ConfigEntry) -> None: """Initialize the agent.""" - self.entry = entry - self._genai_client = entry.runtime_data - self._attr_unique_id = entry.entry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry) if self.entry.options.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -358,13 +64,6 @@ class GoogleGenerativeAIConversationEntity( conversation.async_unset_agent(self.hass, self.entry) await super().async_will_remove_from_hass() - def _fix_tool_name(self, tool_name: str) -> str: - """Fix tool name if needed.""" - # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool - # name. This makes sure when it incorrectly changes the name, that we change it - # back for HA to call. - return tool_name if tool_name != "HasListAddItem" else "HassListAddItem" - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -399,163 +98,6 @@ class GoogleGenerativeAIConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.entry.options - - tools: list[Tool | Callable[..., Any]] | 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 - ] - - # Using search grounding allows the model to retrieve information from the web, - # however, it may interfere with how the model decides to use some tools, or entities - # for example weather entity may be disregarded if the model chooses to Google it. - if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: - tools = tools or [] - tools.append(Tool(google_search=GoogleSearch())) - - model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - # Avoid INVALID_ARGUMENT Developer instruction is not enabled for - supports_system_instruction = ( - "gemma" not in model_name - and "gemini-2.0-flash-preview-image-generation" not in model_name - ) - - prompt_content = cast( - conversation.SystemContent, - chat_log.content[0], - ) - - if prompt_content.content: - prompt = prompt_content.content - else: - raise HomeAssistantError("Invalid prompt content") - - messages: list[Content] = [] - - # Google groups tool results, we do not. Group them before sending. - tool_results: list[conversation.ToolResultContent] = [] - - for chat_content in chat_log.content[1:-1]: - if chat_content.role == "tool_result": - tool_results.append(chat_content) - continue - - if ( - not isinstance(chat_content, conversation.ToolResultContent) - and chat_content.content == "" - ): - # Skipping is not possible since the number of function calls need to match the number of function responses - # and skipping one would mean removing the other and hence this would prevent a proper chat log - chat_content = replace(chat_content, content=" ") - - if tool_results: - messages.append(_create_google_tool_response_content(tool_results)) - tool_results.clear() - - messages.append(_convert_content(chat_content)) - - # The SDK requires the first message to be a user message - # This is not the case if user used `start_conversation` - # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 - if messages and messages[0].role != "user": - messages.insert( - 0, - Content(role="user", parts=[Part.from_text(text=" ")]), - ) - - if tool_results: - messages.append(_create_google_tool_response_content(tool_results)) - generateContentConfig = GenerateContentConfig( - temperature=self.entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), - top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - max_output_tokens=self.entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - safety_settings=[ - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=self.entry.options.get( - CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=self.entry.options.get( - CONF_HARASSMENT_BLOCK_THRESHOLD, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=self.entry.options.get( - CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=self.entry.options.get( - CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - ], - tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, - automatic_function_calling=AutomaticFunctionCallingConfig( - disable=True, maximum_remote_calls=None - ), - ) - - if not supports_system_instruction: - messages = [ - Content(role="user", parts=[Part.from_text(text=prompt)]), - Content(role="model", parts=[Part.from_text(text="Ok")]), - *messages, - ] - chat = self._genai_client.aio.chats.create( - model=model_name, history=messages, config=generateContentConfig - ) - user_message = chat_log.content[-1] - assert isinstance(user_message, conversation.UserContent) - chat_request: str | list[Part] = user_message.content - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - chat_response_generator = await chat.send_message_stream( - message=chat_request - ) - except ( - APIError, - ClientError, - ValueError, - ) as err: - LOGGER.error("Error sending message: %s %s", type(err), err) - error = ERROR_GETTING_RESPONSE - raise HomeAssistantError(error) from err - - chat_request = _create_google_tool_response_parts( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_response_generator), - ) - if isinstance(content, conversation.ToolResultContent) - ] - ) - - if not chat_log.unresponded_tool_results: - break - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py new file mode 100644 index 00000000000..7eef3dbacff --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -0,0 +1,475 @@ +"""Conversation support for the Google Generative AI Conversation integration.""" + +from __future__ import annotations + +import codecs +from collections.abc import AsyncGenerator, Callable +from dataclasses import replace +from typing import Any, cast + +from google.genai.errors import APIError, ClientError +from google.genai.types import ( + AutomaticFunctionCallingConfig, + Content, + FunctionDeclaration, + GenerateContentConfig, + GenerateContentResponse, + GoogleSearch, + HarmCategory, + Part, + SafetySetting, + Schema, + Tool, +) +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from .const import ( + CONF_CHAT_MODEL, + CONF_DANGEROUS_BLOCK_THRESHOLD, + CONF_HARASSMENT_BLOCK_THRESHOLD, + CONF_HATE_BLOCK_THRESHOLD, + CONF_MAX_TOKENS, + CONF_SEXUAL_BLOCK_THRESHOLD, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + CONF_USE_GOOGLE_SEARCH_TOOL, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +ERROR_GETTING_RESPONSE = ( + "Sorry, I had a problem getting a response from Google Generative AI." +) + + +SUPPORTED_SCHEMA_KEYS = { + # Gemini API does not support all of the OpenAPI schema + # SoT: https://ai.google.dev/api/caching#Schema + "type", + "format", + "description", + "nullable", + "enum", + "max_items", + "min_items", + "properties", + "required", + "items", +} + + +def _camel_to_snake(name: str) -> str: + """Convert camel case to snake case.""" + return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_") + + +def _format_schema(schema: dict[str, Any]) -> Schema: + """Format the schema to be compatible with Gemini API.""" + if subschemas := schema.get("allOf"): + for subschema in subschemas: # Gemini API does not support allOf keys + if "type" in subschema: # Fallback to first subschema with 'type' field + return _format_schema(subschema) + return _format_schema( + subschemas[0] + ) # Or, if not found, to any of the subschemas + + result = {} + for key, val in schema.items(): + key = _camel_to_snake(key) + if key not in SUPPORTED_SCHEMA_KEYS: + continue + if key == "type": + val = val.upper() + elif key == "format": + # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema + # formats that are not supported are ignored + if schema.get("type") == "string" and val not in ("enum", "date-time"): + continue + if schema.get("type") == "number" and val not in ("float", "double"): + continue + if schema.get("type") == "integer" and val not in ("int32", "int64"): + continue + if schema.get("type") not in ("string", "number", "integer"): + continue + elif key == "items": + val = _format_schema(val) + elif key == "properties": + val = {k: _format_schema(v) for k, v in val.items()} + result[key] = val + + if result.get("enum") and result.get("type") != "STRING": + # enum is only allowed for STRING type. This is safe as long as the schema + # contains vol.Coerce for the respective type, for example: + # vol.All(vol.Coerce(int), vol.In([1, 2, 3])) + result["type"] = "STRING" + result["enum"] = [str(item) for item in result["enum"]] + + if result.get("type") == "OBJECT" and not result.get("properties"): + # An object with undefined properties is not supported by Gemini API. + # Fallback to JSON string. This will probably fail for most tools that want it, + # but we don't have a better fallback strategy so far. + result["properties"] = {"json": {"type": "STRING"}} + result["required"] = [] + return cast(Schema, result) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> Tool: + """Format tool specification.""" + + if tool.parameters.schema: + parameters = _format_schema( + convert(tool.parameters, custom_serializer=custom_serializer) + ) + else: + parameters = None + + return Tool( + function_declarations=[ + FunctionDeclaration( + name=tool.name, + description=tool.description, + parameters=parameters, + ) + ] + ) + + +def _escape_decode(value: Any) -> Any: + """Recursively call codecs.escape_decode on all values.""" + if isinstance(value, str): + return codecs.escape_decode(bytes(value, "utf-8"))[0].decode("utf-8") # type: ignore[attr-defined] + if isinstance(value, list): + return [_escape_decode(item) for item in value] + if isinstance(value, dict): + return {k: _escape_decode(v) for k, v in value.items()} + return value + + +def _create_google_tool_response_parts( + parts: list[conversation.ToolResultContent], +) -> list[Part]: + """Create Google tool response parts.""" + return [ + Part.from_function_response( + name=tool_result.tool_name, response=tool_result.tool_result + ) + for tool_result in parts + ] + + +def _create_google_tool_response_content( + content: list[conversation.ToolResultContent], +) -> Content: + """Create a Google tool response content.""" + return Content( + role="user", + parts=_create_google_tool_response_parts(content), + ) + + +def _convert_content( + content: ( + conversation.UserContent + | conversation.AssistantContent + | conversation.SystemContent + ), +) -> Content: + """Convert HA content to Google content.""" + if content.role != "assistant" or not content.tool_calls: + role = "model" if content.role == "assistant" else content.role + return Content( + role=role, + parts=[ + Part.from_text(text=content.content if content.content else ""), + ], + ) + + # Handle the Assistant content with tool calls. + assert type(content) is conversation.AssistantContent + parts: list[Part] = [] + + if content.content: + parts.append(Part.from_text(text=content.content)) + + if content.tool_calls: + parts.extend( + [ + Part.from_function_call( + name=tool_call.tool_name, + args=_escape_decode(tool_call.tool_args), + ) + for tool_call in content.tool_calls + ] + ) + + return Content(role="model", parts=parts) + + +async def _transform_stream( + result: AsyncGenerator[GenerateContentResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + new_message = True + try: + async for response in result: + LOGGER.debug("Received response chunk: %s", response) + chunk: conversation.AssistantContentDeltaDict = {} + + if new_message: + chunk["role"] = "assistant" + new_message = False + + # According to the API docs, this would mean no candidate is returned, so we can safely throw an error here. + if response.prompt_feedback or not response.candidates: + reason = ( + response.prompt_feedback.block_reason_message + if response.prompt_feedback + else "unknown" + ) + raise HomeAssistantError( + f"The message got blocked due to content violations, reason: {reason}" + ) + + candidate = response.candidates[0] + + if ( + candidate.finish_reason is not None + and candidate.finish_reason != "STOP" + ): + # The message ended due to a content error as explained in: https://ai.google.dev/api/generate-content#FinishReason + LOGGER.error( + "Error in Google Generative AI response: %s, see: https://ai.google.dev/api/generate-content#FinishReason", + candidate.finish_reason, + ) + raise HomeAssistantError( + f"{ERROR_GETTING_RESPONSE} Reason: {candidate.finish_reason}" + ) + + response_parts = ( + candidate.content.parts + if candidate.content is not None and candidate.content.parts is not None + else [] + ) + + content = "".join([part.text for part in response_parts if part.text]) + tool_calls = [] + for part in response_parts: + if not part.function_call: + continue + tool_call = part.function_call + tool_name = tool_call.name if tool_call.name else "" + tool_args = _escape_decode(tool_call.args) + tool_calls.append( + llm.ToolInput(tool_name=tool_name, tool_args=tool_args) + ) + + if tool_calls: + chunk["tool_calls"] = tool_calls + + chunk["content"] = content + yield chunk + except ( + APIError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + if isinstance(err, APIError): + message = err.message + else: + message = type(err).__name__ + error = f"{ERROR_GETTING_RESPONSE}: {message}" + raise HomeAssistantError(error) from err + + +class GoogleGenerativeAILLMBaseEntity(Entity): + """Google Generative AI base entity.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.entry = entry + self._genai_client = entry.runtime_data + self._attr_unique_id = entry.entry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.entry.options + + tools: list[Tool | Callable[..., Any]] | 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 + ] + + # Using search grounding allows the model to retrieve information from the web, + # however, it may interfere with how the model decides to use some tools, or entities + # for example weather entity may be disregarded if the model chooses to Google it. + if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True: + tools = tools or [] + tools.append(Tool(google_search=GoogleSearch())) + + model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Avoid INVALID_ARGUMENT Developer instruction is not enabled for + supports_system_instruction = ( + "gemma" not in model_name + and "gemini-2.0-flash-preview-image-generation" not in model_name + ) + + prompt_content = cast( + conversation.SystemContent, + chat_log.content[0], + ) + + if prompt_content.content: + prompt = prompt_content.content + else: + raise HomeAssistantError("Invalid prompt content") + + messages: list[Content] = [] + + # Google groups tool results, we do not. Group them before sending. + tool_results: list[conversation.ToolResultContent] = [] + + for chat_content in chat_log.content[1:-1]: + if chat_content.role == "tool_result": + tool_results.append(chat_content) + continue + + if ( + not isinstance(chat_content, conversation.ToolResultContent) + and chat_content.content == "" + ): + # Skipping is not possible since the number of function calls need to match the number of function responses + # and skipping one would mean removing the other and hence this would prevent a proper chat log + chat_content = replace(chat_content, content=" ") + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + tool_results.clear() + + messages.append(_convert_content(chat_content)) + + # The SDK requires the first message to be a user message + # This is not the case if user used `start_conversation` + # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537 + if messages and messages[0].role != "user": + messages.insert( + 0, + Content(role="user", parts=[Part.from_text(text=" ")]), + ) + + if tool_results: + messages.append(_create_google_tool_response_content(tool_results)) + generateContentConfig = GenerateContentConfig( + temperature=self.entry.options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + max_output_tokens=self.entry.options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + safety_settings=[ + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=self.entry.options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=self.entry.options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=self.entry.options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=self.entry.options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + ], + tools=tools or None, + system_instruction=prompt if supports_system_instruction else None, + automatic_function_calling=AutomaticFunctionCallingConfig( + disable=True, maximum_remote_calls=None + ), + ) + + if not supports_system_instruction: + messages = [ + Content(role="user", parts=[Part.from_text(text=prompt)]), + Content(role="model", parts=[Part.from_text(text="Ok")]), + *messages, + ] + chat = self._genai_client.aio.chats.create( + model=model_name, history=messages, config=generateContentConfig + ) + user_message = chat_log.content[-1] + assert isinstance(user_message, conversation.UserContent) + chat_request: str | list[Part] = user_message.content + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + chat_response_generator = await chat.send_message_stream( + message=chat_request + ) + except ( + APIError, + ClientError, + ValueError, + ) as err: + LOGGER.error("Error sending message: %s %s", type(err), err) + error = ERROR_GETTING_RESPONSE + raise HomeAssistantError(error) from err + + chat_request = _create_google_tool_response_parts( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_response_generator), + ) + if isinstance(content, conversation.ToolResultContent) + ] + ) + + if not chat_log.unresponded_tool_results: + break diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 6ec147da2ab..0d222c78472 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.google_generative_ai_conversation.conversation import ( +from homeassistant.components.google_generative_ai_conversation.entity import ( CONF_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.config_entries import ConfigEntry diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 2d1a46393fd..a55a86b67c9 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components import conversation from homeassistant.components.conversation import UserContent -from homeassistant.components.google_generative_ai_conversation.conversation import ( +from homeassistant.components.google_generative_ai_conversation.entity import ( ERROR_GETTING_RESPONSE, _escape_decode, _format_schema, From c988d1ce362803943899b0e746d2c5c81688f310 Mon Sep 17 00:00:00 2001 From: Markus Lanthaler Date: Sun, 15 Jun 2025 07:21:04 +0200 Subject: [PATCH 0302/1664] Add support for Gemini's new TTS capabilities (#145872) * Add support for Gemini TTS * Add tests * Use wave library and update a few comments --- .../__init__.py | 5 +- .../const.py | 2 + .../google_generative_ai_conversation/tts.py | 216 +++++++++ .../test_tts.py | 413 ++++++++++++++++++ 4 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/tts.py create mode 100644 tests/components/google_generative_ai_conversation/test_tts.py diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 79d092a60c3..7e9ca550275 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -45,7 +45,10 @@ CONF_IMAGE_FILENAME = "image_filename" CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = ( + Platform.CONVERSATION, + Platform.TTS, +) type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 239b3ff763e..831e7d8f508 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,9 +6,11 @@ DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" +ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" +RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py new file mode 100644 index 00000000000..160048e4897 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -0,0 +1,216 @@ +"""Text to speech support for Google Generative AI.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import logging +from typing import Any +import wave + +from google.genai import types + +from homeassistant.components.tts import ( + ATTR_VOICE, + TextToSpeechEntity, + TtsAudioType, + Voice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up TTS entity.""" + tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) + async_add_entities([tts_entity]) + + +class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): + """Google Generative AI text-to-speech entity.""" + + _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + # See https://ai.google.dev/gemini-api/docs/speech-generation#languages + _attr_supported_languages = [ + "ar-EG", + "bn-BD", + "de-DE", + "en-IN", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "mr-IN", + "nl-NL", + "pl-PL", + "pt-BR", + "ro-RO", + "ru-RU", + "ta-IN", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "vi-VN", + ] + _attr_default_language = "en-US" + # See https://ai.google.dev/gemini-api/docs/speech-generation#voices + _supported_voices = [ + Voice(voice.split(" ", 1)[0].lower(), voice) + for voice in ( + "Zephyr (Bright)", + "Puck (Upbeat)", + "Charon (Informative)", + "Kore (Firm)", + "Fenrir (Excitable)", + "Leda (Youthful)", + "Orus (Firm)", + "Aoede (Breezy)", + "Callirrhoe (Easy-going)", + "Autonoe (Bright)", + "Enceladus (Breathy)", + "Iapetus (Clear)", + "Umbriel (Easy-going)", + "Algieba (Smooth)", + "Despina (Smooth)", + "Erinome (Clear)", + "Algenib (Gravelly)", + "Rasalgethi (Informative)", + "Laomedeia (Upbeat)", + "Achernar (Soft)", + "Alnilam (Firm)", + "Schedar (Even)", + "Gacrux (Mature)", + "Pulcherrima (Forward)", + "Achird (Friendly)", + "Zubenelgenubi (Casual)", + "Vindemiatrix (Gentle)", + "Sadachbia (Lively)", + "Sadaltager (Knowledgeable)", + "Sulafat (Warm)", + ) + ] + + def __init__(self, entry: ConfigEntry) -> None: + """Initialize Google Generative AI Conversation speech entity.""" + self.entry = entry + self._attr_name = "Google Generative AI TTS" + self._attr_unique_id = f"{entry.entry_id}_tts" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + self._genai_client = entry.runtime_data + self._default_voice_id = self._supported_voices[0].voice_id + + @callback + def async_get_supported_voices(self, language: str) -> list[Voice] | None: + """Return a list of supported voices for a language.""" + return self._supported_voices + + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: + """Load tts audio file from the engine.""" + try: + response = self._genai_client.models.generate_content( + model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options.get( + ATTR_VOICE, self._default_voice_id + ) + ) + ) + ), + ), + ) + + data = response.candidates[0].content.parts[0].inline_data.data + mime_type = response.candidates[0].content.parts[0].inline_data.mime_type + except Exception as exc: + _LOGGER.warning( + "Error during processing of TTS request %s", exc, exc_info=True + ) + raise HomeAssistantError(exc) from exc + return "wav", self._convert_to_wav(data, mime_type) + + def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = self._parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + _LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py new file mode 100644 index 00000000000..5ea056307b5 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -0,0 +1,413 @@ +"""Tests for the Google Generative AI Conversation TTS entity.""" + +from __future__ import annotations + +from collections.abc import Generator +from http import HTTPStatus +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import tts +from homeassistant.components.google_generative_ai_conversation.tts import ( + ATTR_MODEL, + DOMAIN, + RECOMMENDED_TTS_MODEL, +) +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core_config import async_process_ha_core_config +from homeassistant.setup import async_setup_component + +from . import API_ERROR_500 + +from tests.common import MockConfigEntry, async_mock_service +from tests.components.tts.common import retrieve_media +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir: Path) -> None: + """Mock the TTS cache dir with empty dir.""" + + +@pytest.fixture +async def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Mock media player calls.""" + return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant) -> None: + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai_client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.models.generate_content.return_value = types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) + ) + ), + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client + + +@pytest.fixture(name="setup") +async def setup_fixture( + hass: HomeAssistant, + config: dict[str, Any], + request: pytest.FixtureRequest, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + if request.param == "mock_setup": + await mock_setup(hass, config) + if request.param == "mock_config_entry_setup": + await mock_config_entry_setup(hass, config) + else: + raise RuntimeError("Invalid setup fixture") + + await hass.async_block_till_done() + + +@pytest.fixture(name="config") +def config_fixture() -> dict[str, Any]: + """Return config.""" + return { + CONF_API_KEY: "bla", + } + + +async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock setup.""" + assert await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} + ) + + +async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Mock config entry setup.""" + default_config = {tts.CONF_LANG: "en-US"} + config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + + client_mock = Mock() + client_mock.models.get = None + client_mock.models.generate_content.return_value = types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) + ) + ), + ) + ) + config_entry.runtime_data = client_mock + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") + model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=model_id, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "de-DE", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "it-IT", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_lang_config( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with languages in the config.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_error( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with HTTP response 500.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.INTERNAL_SERVER_ERROR + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") + ) + ), + ), + ) + + +@pytest.mark.parametrize( + ("setup", "tts_service", "service_data"), + [ + ( + "mock_config_entry_setup", + "speak", + { + ATTR_ENTITY_ID: "tts.google_generative_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + ), + ], + indirect=["setup"], +) +async def test_tts_service_speak_without_options( + setup: AsyncMock, + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + calls: list[ServiceCall], + tts_service: str, + service_data: dict[str, Any], +) -> None: + """Test service call with HTTP response 200.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) + tts_entity._genai_client.models.generate_content.reset_mock() + + await hass.services.async_call( + tts.DOMAIN, + tts_service, + service_data, + blocking=True, + ) + + assert len(calls) == 1 + assert ( + await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + == HTTPStatus.OK + ) + + tts_entity._genai_client.models.generate_content.assert_called_once_with( + model=RECOMMENDED_TTS_MODEL, + contents="There is a person at the front door.", + config=types.GenerateContentConfig( + response_modalities=["AUDIO"], + speech_config=types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + ) + ), + ), + ) From 29ce17abf44460b785488795bb10bde7746c7d0c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 15 Jun 2025 10:17:01 +0200 Subject: [PATCH 0303/1664] Update eq3btsmart to 2.1.0 (#146335) * Update eq3btsmart to 2.1.0 * Update import names * Update register callbacks * Updated data model * Update Thermostat set value methods * Update Thermostat init * Thermostat status and device_data are always given * Minor compatibility fixes --------- Co-authored-by: Lennard Beers --- .../components/eq3btsmart/__init__.py | 8 +- .../components/eq3btsmart/binary_sensor.py | 4 - .../components/eq3btsmart/climate.py | 73 +++++++------------ homeassistant/components/eq3btsmart/const.py | 19 ++--- homeassistant/components/eq3btsmart/entity.py | 43 +++++++++-- .../components/eq3btsmart/manifest.json | 2 +- homeassistant/components/eq3btsmart/number.py | 42 +++++------ .../components/eq3btsmart/schemas.py | 4 +- homeassistant/components/eq3btsmart/sensor.py | 8 +- homeassistant/components/eq3btsmart/switch.py | 32 ++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 4 - 13 files changed, 124 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py index 4493f944db3..b4be3cf5ee9 100644 --- a/homeassistant/components/eq3btsmart/__init__.py +++ b/homeassistant/components/eq3btsmart/__init__.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING from eq3btsmart import Thermostat from eq3btsmart.exceptions import Eq3Exception -from eq3btsmart.thermostat_config import ThermostatConfig from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -53,12 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: Eq3ConfigEntry) -> bool: f"[{eq3_config.mac_address}] Device could not be found" ) - thermostat = Thermostat( - thermostat_config=ThermostatConfig( - mac_address=mac_address, - ), - ble_device=device, - ) + thermostat = Thermostat(mac_address=device) # type: ignore[arg-type] entry.runtime_data = Eq3ConfigEntryData( eq3_config=eq3_config, thermostat=thermostat diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py index 55b1f4d6ced..8cec495f017 100644 --- a/homeassistant/components/eq3btsmart/binary_sensor.py +++ b/homeassistant/components/eq3btsmart/binary_sensor.py @@ -2,7 +2,6 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING from eq3btsmart.models import Status @@ -80,7 +79,4 @@ class Eq3BinarySensorEntity(Eq3Entity, BinarySensorEntity): def is_on(self) -> bool: """Return the state of the binary sensor.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py index 738efa99187..c11328c7ec3 100644 --- a/homeassistant/components/eq3btsmart/climate.py +++ b/homeassistant/components/eq3btsmart/climate.py @@ -1,9 +1,16 @@ """Platform for eQ-3 climate entities.""" +from datetime import timedelta import logging from typing import Any -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_OFF_TEMP, Eq3Preset, OperationMode +from eq3btsmart.const import ( + EQ3_DEFAULT_AWAY_TEMP, + EQ3_MAX_TEMP, + EQ3_OFF_TEMP, + Eq3OperationMode, + Eq3Preset, +) from eq3btsmart.exceptions import Eq3Exception from homeassistant.components.climate import ( @@ -20,9 +27,11 @@ from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util from . import Eq3ConfigEntry from .const import ( + DEFAULT_AWAY_HOURS, EQ_TO_HA_HVAC, HA_TO_EQ_HVAC, CurrentTemperatureSelector, @@ -57,8 +66,8 @@ class Eq3Climate(Eq3Entity, ClimateEntity): | ClimateEntityFeature.TURN_ON ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_min_temp = EQ3BT_OFF_TEMP - _attr_max_temp = EQ3BT_MAX_TEMP + _attr_min_temp = EQ3_OFF_TEMP + _attr_max_temp = EQ3_MAX_TEMP _attr_precision = PRECISION_HALVES _attr_hvac_modes = list(HA_TO_EQ_HVAC.keys()) _attr_preset_modes = list(Preset) @@ -70,38 +79,21 @@ class Eq3Climate(Eq3Entity, ClimateEntity): _target_temperature: float | None = None @callback - def _async_on_updated(self) -> None: - """Handle updated data from the thermostat.""" - - if self._thermostat.status is not None: - self._async_on_status_updated() - - if self._thermostat.device_data is not None: - self._async_on_device_updated() - - super()._async_on_updated() - - @callback - def _async_on_status_updated(self) -> None: + def _async_on_status_updated(self, data: Any) -> None: """Handle updated status from the thermostat.""" - if self._thermostat.status is None: - return - - self._target_temperature = self._thermostat.status.target_temperature.value + self._target_temperature = self._thermostat.status.target_temperature self._attr_hvac_mode = EQ_TO_HA_HVAC[self._thermostat.status.operation_mode] self._attr_current_temperature = self._get_current_temperature() self._attr_target_temperature = self._get_target_temperature() self._attr_preset_mode = self._get_current_preset_mode() self._attr_hvac_action = self._get_current_hvac_action() + super()._async_on_status_updated(data) @callback - def _async_on_device_updated(self) -> None: + def _async_on_device_updated(self, data: Any) -> None: """Handle updated device data from the thermostat.""" - if self._thermostat.device_data is None: - return - device_registry = dr.async_get(self.hass) if device := device_registry.async_get_device( connections={(CONNECTION_BLUETOOTH, self._eq3_config.mac_address)}, @@ -109,8 +101,9 @@ class Eq3Climate(Eq3Entity, ClimateEntity): device_registry.async_update_device( device.id, sw_version=str(self._thermostat.device_data.firmware_version), - serial_number=self._thermostat.device_data.device_serial.value, + serial_number=self._thermostat.device_data.device_serial, ) + super()._async_on_device_updated(data) def _get_current_temperature(self) -> float | None: """Return the current temperature.""" @@ -119,17 +112,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case CurrentTemperatureSelector.NOTHING: return None case CurrentTemperatureSelector.VALVE: - if self._thermostat.status is None: - return None - return float(self._thermostat.status.valve_temperature) case CurrentTemperatureSelector.UI: return self._target_temperature case CurrentTemperatureSelector.DEVICE: - if self._thermostat.status is None: - return None - - return float(self._thermostat.status.target_temperature.value) + return float(self._thermostat.status.target_temperature) case CurrentTemperatureSelector.ENTITY: state = self.hass.states.get(self._eq3_config.external_temp_sensor) if state is not None: @@ -147,16 +134,12 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case TargetTemperatureSelector.TARGET: return self._target_temperature case TargetTemperatureSelector.LAST_REPORTED: - if self._thermostat.status is None: - return None - - return float(self._thermostat.status.target_temperature.value) + return float(self._thermostat.status.target_temperature) def _get_current_preset_mode(self) -> str: """Return the current preset mode.""" - if (status := self._thermostat.status) is None: - return PRESET_NONE + status = self._thermostat.status if status.is_window_open: return Preset.WINDOW_OPEN if status.is_boost: @@ -165,7 +148,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): return Preset.LOW_BATTERY if status.is_away: return Preset.AWAY - if status.operation_mode is OperationMode.ON: + if status.operation_mode is Eq3OperationMode.ON: return Preset.OPEN if status.presets is None: return PRESET_NONE @@ -179,10 +162,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): def _get_current_hvac_action(self) -> HVACAction: """Return the current hvac action.""" - if ( - self._thermostat.status is None - or self._thermostat.status.operation_mode is OperationMode.OFF - ): + if self._thermostat.status.operation_mode is Eq3OperationMode.OFF: return HVACAction.OFF if self._thermostat.status.valve == 0: return HVACAction.IDLE @@ -227,7 +207,7 @@ class Eq3Climate(Eq3Entity, ClimateEntity): """Set new target hvac mode.""" if hvac_mode is HVACMode.OFF: - await self.async_set_temperature(temperature=EQ3BT_OFF_TEMP) + await self.async_set_temperature(temperature=EQ3_OFF_TEMP) try: await self._thermostat.async_set_mode(HA_TO_EQ_HVAC[hvac_mode]) @@ -241,10 +221,11 @@ class Eq3Climate(Eq3Entity, ClimateEntity): case Preset.BOOST: await self._thermostat.async_set_boost(True) case Preset.AWAY: - await self._thermostat.async_set_away(True) + away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + await self._thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP) case Preset.ECO: await self._thermostat.async_set_preset(Eq3Preset.ECO) case Preset.COMFORT: await self._thermostat.async_set_preset(Eq3Preset.COMFORT) case Preset.OPEN: - await self._thermostat.async_set_mode(OperationMode.ON) + await self._thermostat.async_set_mode(Eq3OperationMode.ON) diff --git a/homeassistant/components/eq3btsmart/const.py b/homeassistant/components/eq3btsmart/const.py index a5f7ea2ff95..33698d2d076 100644 --- a/homeassistant/components/eq3btsmart/const.py +++ b/homeassistant/components/eq3btsmart/const.py @@ -2,7 +2,7 @@ from enum import Enum -from eq3btsmart.const import OperationMode +from eq3btsmart.const import Eq3OperationMode from homeassistant.components.climate import ( PRESET_AWAY, @@ -34,17 +34,17 @@ ENTITY_KEY_AWAY_UNTIL = "away_until" GET_DEVICE_TIMEOUT = 5 # seconds -EQ_TO_HA_HVAC: dict[OperationMode, HVACMode] = { - OperationMode.OFF: HVACMode.OFF, - OperationMode.ON: HVACMode.HEAT, - OperationMode.AUTO: HVACMode.AUTO, - OperationMode.MANUAL: HVACMode.HEAT, +EQ_TO_HA_HVAC: dict[Eq3OperationMode, HVACMode] = { + Eq3OperationMode.OFF: HVACMode.OFF, + Eq3OperationMode.ON: HVACMode.HEAT, + Eq3OperationMode.AUTO: HVACMode.AUTO, + Eq3OperationMode.MANUAL: HVACMode.HEAT, } HA_TO_EQ_HVAC = { - HVACMode.OFF: OperationMode.OFF, - HVACMode.AUTO: OperationMode.AUTO, - HVACMode.HEAT: OperationMode.MANUAL, + HVACMode.OFF: Eq3OperationMode.OFF, + HVACMode.AUTO: Eq3OperationMode.AUTO, + HVACMode.HEAT: Eq3OperationMode.MANUAL, } @@ -81,6 +81,7 @@ class TargetTemperatureSelector(str, Enum): DEFAULT_CURRENT_TEMP_SELECTOR = CurrentTemperatureSelector.DEVICE DEFAULT_TARGET_TEMP_SELECTOR = TargetTemperatureSelector.TARGET DEFAULT_SCAN_INTERVAL = 10 # seconds +DEFAULT_AWAY_HOURS = 30 * 24 SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" diff --git a/homeassistant/components/eq3btsmart/entity.py b/homeassistant/components/eq3btsmart/entity.py index e68545c08c7..e8dbb934289 100644 --- a/homeassistant/components/eq3btsmart/entity.py +++ b/homeassistant/components/eq3btsmart/entity.py @@ -1,5 +1,10 @@ """Base class for all eQ-3 entities.""" +from typing import Any + +from eq3btsmart import Eq3Exception +from eq3btsmart.const import Eq3Event + from homeassistant.core import callback from homeassistant.helpers.device_registry import ( CONNECTION_BLUETOOTH, @@ -45,7 +50,15 @@ class Eq3Entity(Entity): async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" - self._thermostat.register_update_callback(self._async_on_updated) + self._thermostat.register_callback( + Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated + ) + self._thermostat.register_callback( + Eq3Event.STATUS_RECEIVED, self._async_on_status_updated + ) + self._thermostat.register_callback( + Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated + ) self.async_on_remove( async_dispatcher_connect( @@ -65,10 +78,25 @@ class Eq3Entity(Entity): async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" - self._thermostat.unregister_update_callback(self._async_on_updated) + self._thermostat.unregister_callback( + Eq3Event.DEVICE_DATA_RECEIVED, self._async_on_device_updated + ) + self._thermostat.unregister_callback( + Eq3Event.STATUS_RECEIVED, self._async_on_status_updated + ) + self._thermostat.unregister_callback( + Eq3Event.SCHEDULE_RECEIVED, self._async_on_status_updated + ) - def _async_on_updated(self) -> None: - """Handle updated data from the thermostat.""" + @callback + def _async_on_status_updated(self, data: Any) -> None: + """Handle updated status from the thermostat.""" + + self.async_write_ha_state() + + @callback + def _async_on_device_updated(self, data: Any) -> None: + """Handle updated device data from the thermostat.""" self.async_write_ha_state() @@ -90,4 +118,9 @@ class Eq3Entity(Entity): def available(self) -> bool: """Whether the entity is available.""" - return self._thermostat.status is not None and self._attr_available + try: + _ = self._thermostat.status + except Eq3Exception: + return False + + return self._attr_available diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 889401ffc3e..62128077f2f 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.16.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"] } diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py index c3cbd8eae31..c9601a4437e 100644 --- a/homeassistant/components/eq3btsmart/number.py +++ b/homeassistant/components/eq3btsmart/number.py @@ -1,17 +1,12 @@ """Platform for eq3 number entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass from typing import TYPE_CHECKING from eq3btsmart import Thermostat -from eq3btsmart.const import ( - EQ3BT_MAX_OFFSET, - EQ3BT_MAX_TEMP, - EQ3BT_MIN_OFFSET, - EQ3BT_MIN_TEMP, -) -from eq3btsmart.models import Presets +from eq3btsmart.const import EQ3_MAX_OFFSET, EQ3_MAX_TEMP, EQ3_MIN_OFFSET, EQ3_MIN_TEMP +from eq3btsmart.models import Presets, Status from homeassistant.components.number import ( NumberDeviceClass, @@ -42,7 +37,7 @@ class Eq3NumberEntityDescription(NumberEntityDescription): value_func: Callable[[Presets], float] value_set_func: Callable[ [Thermostat], - Callable[[float], Awaitable[None]], + Callable[[float], Coroutine[None, None, Status]], ] mode: NumberMode = NumberMode.BOX entity_category: EntityCategory | None = EntityCategory.CONFIG @@ -51,44 +46,44 @@ class Eq3NumberEntityDescription(NumberEntityDescription): NUMBER_ENTITY_DESCRIPTIONS = [ Eq3NumberEntityDescription( key=ENTITY_KEY_COMFORT, - value_func=lambda presets: presets.comfort_temperature.value, + value_func=lambda presets: presets.comfort_temperature, value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature, translation_key=ENTITY_KEY_COMFORT, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_ECO, - value_func=lambda presets: presets.eco_temperature.value, + value_func=lambda presets: presets.eco_temperature, value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature, translation_key=ENTITY_KEY_ECO, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, - value_func=lambda presets: presets.window_open_temperature.value, + value_func=lambda presets: presets.window_open_temperature, value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature, translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE, - native_min_value=EQ3BT_MIN_TEMP, - native_max_value=EQ3BT_MAX_TEMP, + native_min_value=EQ3_MIN_TEMP, + native_max_value=EQ3_MAX_TEMP, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, ), Eq3NumberEntityDescription( key=ENTITY_KEY_OFFSET, - value_func=lambda presets: presets.offset_temperature.value, + value_func=lambda presets: presets.offset_temperature, value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset, translation_key=ENTITY_KEY_OFFSET, - native_min_value=EQ3BT_MIN_OFFSET, - native_max_value=EQ3BT_MAX_OFFSET, + native_min_value=EQ3_MIN_OFFSET, + native_max_value=EQ3_MAX_OFFSET, native_step=EQ3BT_STEP, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=NumberDeviceClass.TEMPERATURE, @@ -96,7 +91,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [ Eq3NumberEntityDescription( key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration, - value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60, + value_func=lambda presets: presets.window_open_time.total_seconds() / 60, translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT, native_min_value=0, native_max_value=60, @@ -137,7 +132,6 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity): """Return the state of the entity.""" if TYPE_CHECKING: - assert self._thermostat.status is not None assert self._thermostat.status.presets is not None return self.entity_description.value_func(self._thermostat.status.presets) @@ -152,7 +146,7 @@ class Eq3NumberEntity(Eq3Entity, NumberEntity): """Return whether the entity is available.""" return ( - self._thermostat.status is not None + super().available and self._thermostat.status.presets is not None and self._attr_available ) diff --git a/homeassistant/components/eq3btsmart/schemas.py b/homeassistant/components/eq3btsmart/schemas.py index 643bb4a02a6..daeed5a05e3 100644 --- a/homeassistant/components/eq3btsmart/schemas.py +++ b/homeassistant/components/eq3btsmart/schemas.py @@ -1,12 +1,12 @@ """Voluptuous schemas for eq3btsmart.""" -from eq3btsmart.const import EQ3BT_MAX_TEMP, EQ3BT_MIN_TEMP +from eq3btsmart.const import EQ3_MAX_TEMP, EQ3_MIN_TEMP import voluptuous as vol from homeassistant.const import CONF_MAC from homeassistant.helpers import config_validation as cv -SCHEMA_TEMPERATURE = vol.Range(min=EQ3BT_MIN_TEMP, max=EQ3BT_MAX_TEMP) +SCHEMA_TEMPERATURE = vol.Range(min=EQ3_MIN_TEMP, max=EQ3_MAX_TEMP) SCHEMA_DEVICE = vol.Schema({vol.Required(CONF_MAC): cv.string}) SCHEMA_MAC = vol.Schema( { diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py index aab3cbf1925..0f61ef22452 100644 --- a/homeassistant/components/eq3btsmart/sensor.py +++ b/homeassistant/components/eq3btsmart/sensor.py @@ -3,7 +3,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import TYPE_CHECKING from eq3btsmart.models import Status @@ -40,9 +39,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [ Eq3SensorEntityDescription( key=ENTITY_KEY_AWAY_UNTIL, translation_key=ENTITY_KEY_AWAY_UNTIL, - value_func=lambda status: ( - status.away_until.value if status.away_until else None - ), + value_func=lambda status: (status.away_until if status.away_until else None), device_class=SensorDeviceClass.DATE, ), ] @@ -78,7 +75,4 @@ class Eq3SensorEntity(Eq3Entity, SensorEntity): def native_value(self) -> int | datetime | None: """Return the value reported by the sensor.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py index 61da133cb71..0d5521fee32 100644 --- a/homeassistant/components/eq3btsmart/switch.py +++ b/homeassistant/components/eq3btsmart/switch.py @@ -1,26 +1,45 @@ """Platform for eq3 switch entities.""" -from collections.abc import Awaitable, Callable +from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from datetime import timedelta +from functools import partial +from typing import Any from eq3btsmart import Thermostat +from eq3btsmart.const import EQ3_DEFAULT_AWAY_TEMP, Eq3OperationMode from eq3btsmart.models import Status from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.util.dt as dt_util from . import Eq3ConfigEntry -from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK +from .const import ( + DEFAULT_AWAY_HOURS, + ENTITY_KEY_AWAY, + ENTITY_KEY_BOOST, + ENTITY_KEY_LOCK, +) from .entity import Eq3Entity +async def async_set_away(thermostat: Thermostat, enable: bool) -> Status: + """Backport old async_set_away behavior.""" + + if not enable: + return await thermostat.async_set_mode(Eq3OperationMode.AUTO) + + away_until = dt_util.now() + timedelta(hours=DEFAULT_AWAY_HOURS) + return await thermostat.async_set_away(away_until, EQ3_DEFAULT_AWAY_TEMP) + + @dataclass(frozen=True, kw_only=True) class Eq3SwitchEntityDescription(SwitchEntityDescription): """Entity description for eq3 switch entities.""" - toggle_func: Callable[[Thermostat], Callable[[bool], Awaitable[None]]] + toggle_func: Callable[[Thermostat], Callable[[bool], Coroutine[None, None, Status]]] value_func: Callable[[Status], bool] @@ -40,7 +59,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [ Eq3SwitchEntityDescription( key=ENTITY_KEY_AWAY, translation_key=ENTITY_KEY_AWAY, - toggle_func=lambda thermostat: thermostat.async_set_away, + toggle_func=lambda thermostat: partial(async_set_away, thermostat), value_func=lambda status: status.is_away, ), ] @@ -88,7 +107,4 @@ class Eq3SwitchEntity(Eq3Entity, SwitchEntity): def is_on(self) -> bool: """Return the state of the switch.""" - if TYPE_CHECKING: - assert self._thermostat.status is not None - return self.entity_description.value_func(self._thermostat.status) diff --git a/requirements_all.txt b/requirements_all.txt index f2df39a74a2..59e02cd3e6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -893,7 +893,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.4.1 +eq3btsmart==2.1.0 # homeassistant.components.esphome esphome-dashboard-api==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de3f60b1edb..ddf736619f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -772,7 +772,7 @@ epion==0.0.3 epson-projector==0.5.1 # homeassistant.components.eq3btsmart -eq3btsmart==1.4.1 +eq3btsmart==2.1.0 # homeassistant.components.esphome esphome-dashboard-api==1.3.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b8265e4e58d..b1aff0dc1fd 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -331,10 +331,6 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # https://github.com/hbldh/bleak/pull/1718 (not yet released) "homeassistant": {"bleak"} }, - "eq3btsmart": { - # https://github.com/EuleMitKeule/eq3btsmart/releases/tag/2.0.0 - "homeassistant": {"eq3btsmart"} - }, "python_script": { # Security audits are needed for each Python version "homeassistant": {"restrictedpython"} From 8c7ba11493ff6ecb95561f324bdb2506da263afe Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 15 Jun 2025 10:23:17 +0200 Subject: [PATCH 0304/1664] Fix telegram_bot RuntimeWarning in tests (#146781) --- .../components/telegram_bot/test_config_flow.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 47b6d99b9ce..0287ccc5dfa 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -1,6 +1,6 @@ """Config flow tests for the Telegram Bot integration.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from telegram import ChatFullInfo, User from telegram.constants import AccentColor @@ -305,10 +305,19 @@ async def test_reauth_flow( # test: valid - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.webhooks.PushBot", + ) as mock_pushbot, ): + mock_pushbot.return_value.start_application = AsyncMock() + mock_pushbot.return_value.register_webhook = AsyncMock() + mock_pushbot.return_value.shutdown = AsyncMock() + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_API_KEY: "new mock api key"}, From 1361d10cd78b0bd807a08bcd427ecdbdee5af03a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 15 Jun 2025 18:30:19 +0300 Subject: [PATCH 0305/1664] Bump aioamazondevices to 3.1.4 (#146883) --- 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 0ea4c32a75e..7a7713f861b 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": "bronze", - "requirements": ["aioamazondevices==3.1.3"] + "requirements": ["aioamazondevices==3.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 59e02cd3e6c..c57a96de02d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.3 +aioamazondevices==3.1.4 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ddf736619f5..b00187ec5cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.3 +aioamazondevices==3.1.4 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From fdf4ed2aa5a9ccb80f6501236cc45863ddda1cdd Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 15 Jun 2025 18:17:52 +0200 Subject: [PATCH 0306/1664] Homee add button_state to event entities (#146860) * use entityDescription * Add new event and adapt tests * change translation * use references in strings --- homeassistant/components/homee/event.py | 78 ++++++++--- homeassistant/components/homee/strings.json | 26 +++- tests/components/homee/fixtures/events.json | 42 ++++++ .../homee/snapshots/test_event.ambr | 122 ++++++++++++++++++ tests/components/homee/test_event.py | 51 +++++--- 5 files changed, 280 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/homee/event.py b/homeassistant/components/homee/event.py index 047d9e2e122..73c315e8695 100644 --- a/homeassistant/components/homee/event.py +++ b/homeassistant/components/homee/event.py @@ -1,9 +1,13 @@ """The homee event platform.""" -from pyHomee.const import AttributeType +from pyHomee.const import AttributeType, NodeProfile from pyHomee.model import HomeeAttribute -from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.components.event import ( + EventDeviceClass, + EventEntity, + EventEntityDescription, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -13,6 +17,38 @@ from .entity import HomeeEntity PARALLEL_UPDATES = 0 +REMOTE_PROFILES = [ + NodeProfile.REMOTE, + NodeProfile.TWO_BUTTON_REMOTE, + NodeProfile.THREE_BUTTON_REMOTE, + NodeProfile.FOUR_BUTTON_REMOTE, +] + +EVENT_DESCRIPTIONS: dict[AttributeType, EventEntityDescription] = { + AttributeType.BUTTON_STATE: EventEntityDescription( + key="button_state", + device_class=EventDeviceClass.BUTTON, + event_types=["upper", "lower", "released"], + ), + AttributeType.UP_DOWN_REMOTE: EventEntityDescription( + key="up_down_remote", + device_class=EventDeviceClass.BUTTON, + event_types=[ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ], + ), +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: HomeeConfigEntry, @@ -21,30 +57,31 @@ async def async_setup_entry( """Add event entities for homee.""" async_add_entities( - HomeeEvent(attribute, config_entry) + HomeeEvent(attribute, config_entry, EVENT_DESCRIPTIONS[attribute.type]) for node in config_entry.runtime_data.nodes for attribute in node.attributes - if attribute.type == AttributeType.UP_DOWN_REMOTE + if attribute.type in EVENT_DESCRIPTIONS + and node.profile in REMOTE_PROFILES + and not attribute.editable ) class HomeeEvent(HomeeEntity, EventEntity): """Representation of a homee event.""" - _attr_translation_key = "up_down_remote" - _attr_event_types = [ - "released", - "up", - "down", - "stop", - "up_long", - "down_long", - "stop_long", - "c_button", - "b_button", - "a_button", - ] - _attr_device_class = EventDeviceClass.BUTTON + def __init__( + self, + attribute: HomeeAttribute, + entry: HomeeConfigEntry, + description: EventEntityDescription, + ) -> None: + """Initialize the homee event entity.""" + super().__init__(attribute, entry) + self.entity_description = description + self._attr_translation_key = description.key + if attribute.instance > 0: + self._attr_translation_key = f"{self._attr_translation_key}_instance" + self._attr_translation_placeholders = {"instance": str(attribute.instance)} async def async_added_to_hass(self) -> None: """Add the homee event entity to home assistant.""" @@ -56,6 +93,5 @@ class HomeeEvent(HomeeEntity, EventEntity): @callback def _event_triggered(self, event: HomeeAttribute) -> None: """Handle a homee event.""" - if event.type == AttributeType.UP_DOWN_REMOTE: - self._trigger_event(self.event_types[int(event.current_value)]) - self.schedule_update_ha_state() + self._trigger_event(self.event_types[int(event.current_value)]) + self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index e2e4c6659d6..b5849f8b1a6 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -160,12 +160,36 @@ } }, "event": { + "button_state": { + "name": "Switch", + "state_attributes": { + "event_type": { + "state": { + "upper": "Upper button", + "lower": "Lower button", + "released": "Released" + } + } + } + }, + "button_state_instance": { + "name": "Switch {instance}", + "state_attributes": { + "event_type": { + "state": { + "upper": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]", + "lower": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]", + "released": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]" + } + } + } + }, "up_down_remote": { "name": "Up/down remote", "state_attributes": { "event_type": { "state": { - "release": "Released", + "release": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]", "up": "Up", "down": "Down", "stop": "Stop", diff --git a/tests/components/homee/fixtures/events.json b/tests/components/homee/fixtures/events.json index 351d35ec497..dc541bca597 100644 --- a/tests/components/homee/fixtures/events.json +++ b/tests/components/homee/fixtures/events.json @@ -41,6 +41,48 @@ "options": { "observed_by": [145] } + }, + { + "id": 2, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 3, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 0.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 40, + "state": 1, + "last_changed": 1749885830, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" + }, + { + "id": 3, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 3, + "current_value": 2.0, + "target_value": 2.0, + "last_value": 2.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 0, + "type": 40, + "state": 1, + "last_changed": 1749885830, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "" } ] } diff --git a/tests/components/homee/snapshots/test_event.ambr b/tests/components/homee/snapshots/test_event.ambr index b3f544bcc4e..40b9a99fcc4 100644 --- a/tests/components/homee/snapshots/test_event.ambr +++ b/tests/components/homee/snapshots/test_event.ambr @@ -1,4 +1,126 @@ # serializer version: 1 +# name: test_event_snapshot[event.remote_control_switch_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_state_instance', + 'unique_id': '00055511EECC-1-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + 'friendly_name': 'Remote Control Switch 1', + }), + 'context': , + 'entity_id': 'event.remote_control_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.remote_control_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'button_state_instance', + 'unique_id': '00055511EECC-1-3', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_snapshot[event.remote_control_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'upper', + 'lower', + 'released', + ]), + 'friendly_name': 'Remote Control Switch 2', + }), + 'context': , + 'entity_id': 'event.remote_control_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_event_snapshot[event.remote_control_up_down_remote-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_event.py b/tests/components/homee/test_event.py index 0ffa7cd8530..176f1e9a053 100644 --- a/tests/components/homee/test_event.py +++ b/tests/components/homee/test_event.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.event import ATTR_EVENT_TYPE @@ -14,38 +15,54 @@ from . import build_mock_node, setup_integration from tests.common import MockConfigEntry, snapshot_platform -async def test_event_fires( +@pytest.mark.parametrize( + ("entity_id", "attribute_id", "expected_event_types"), + [ + ( + "event.remote_control_up_down_remote", + 1, + [ + "released", + "up", + "down", + "stop", + "up_long", + "down_long", + "stop_long", + "c_button", + "b_button", + "a_button", + ], + ), + ( + "event.remote_control_switch_2", + 3, + ["upper", "lower", "released"], + ), + ], +) +async def test_event_triggers( hass: HomeAssistant, mock_homee: MagicMock, mock_config_entry: MockConfigEntry, + entity_id: str, + attribute_id: int, + expected_event_types: list[str], ) -> None: """Test that the correct event fires when the attribute changes.""" - - EVENT_TYPES = [ - "released", - "up", - "down", - "stop", - "up_long", - "down_long", - "stop_long", - "c_button", - "b_button", - "a_button", - ] mock_homee.nodes = [build_mock_node("events.json")] mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) # Simulate the event triggers. - attribute = mock_homee.nodes[0].attributes[0] - for i, event_type in enumerate(EVENT_TYPES): + attribute = mock_homee.nodes[0].attributes[attribute_id - 1] + for i, event_type in enumerate(expected_event_types): attribute.current_value = i attribute.add_on_changed_listener.call_args_list[1][0][0](attribute) await hass.async_block_till_done() # Check if the event was fired - state = hass.states.get("event.remote_control_up_down_remote") + state = hass.states.get(entity_id) assert state.attributes[ATTR_EVENT_TYPE] == event_type From 6b669ce40c9b1182e145072aa90015352bdd1c08 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 15 Jun 2025 19:32:13 +0200 Subject: [PATCH 0307/1664] Bump pypck to 0.8.8 (#146841) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 9575c01515b..30584bc33f6 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.7", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.8", "lcn-frontend==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index c57a96de02d..17f14d1f72f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2236,7 +2236,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.7 +pypck==0.8.8 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b00187ec5cc..3746cad7aef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1857,7 +1857,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.7 +pypck==0.8.8 # homeassistant.components.pglab pypglab==0.0.5 From 7a2d99a45048fb21c04560d8e4e4824c82f0ffa9 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 15 Jun 2025 12:41:07 -0600 Subject: [PATCH 0308/1664] Bump pylitterbot to 2024.2.0 (#146901) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index f7563296711..81f987f8c1f 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.0.0"] + "requirements": ["pylitterbot==2024.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 17f14d1f72f..2eb8b3d1fc0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2114,7 +2114,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.0.0 +pylitterbot==2024.2.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3746cad7aef..59b56af0e81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1756,7 +1756,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.0.0 +pylitterbot==2024.2.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 From 5f5869ffc68591bd008b32ddc193a9622e8c70a7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 15 Jun 2025 20:53:32 +0200 Subject: [PATCH 0309/1664] Bump reolink-aio to 0.14.1 (#146903) --- 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 917ef9e73f7..04996689bf7 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.0"] + "requirements": ["reolink-aio==0.14.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2eb8b3d1fc0..4174ca124d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2652,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.0 +reolink-aio==0.14.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59b56af0e81..12efb0dd738 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.0 +reolink-aio==0.14.1 # homeassistant.components.rflink rflink==0.0.66 From fa21269f0da8edc58445c0007b8591f002003964 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Jun 2025 17:41:15 -0400 Subject: [PATCH 0310/1664] Simplify ChatLog dependencies (#146351) --- .../components/anthropic/conversation.py | 6 +- .../components/conversation/chat_log.py | 48 ++++++++------ .../components/conversation/models.py | 14 +++- .../conversation.py | 6 +- .../components/ollama/conversation.py | 6 +- .../openai_conversation/conversation.py | 6 +- .../assist_pipeline/test_pipeline.py | 6 +- .../components/conversation/test_chat_log.py | 64 ++++++++----------- 8 files changed, 86 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 846249b1caf..f17294fe0e7 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -366,11 +366,11 @@ class AnthropicConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 7580b878d04..6322bdb4435 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -14,12 +14,11 @@ import voluptuous as vol from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.helpers import chat_session, intent, llm, template +from homeassistant.helpers import chat_session, frame, intent, llm, template from homeassistant.util.hass_dict import HassKey from homeassistant.util.json import JsonObjectType from . import trace -from .const import DOMAIN from .models import ConversationInput, ConversationResult DATA_CHAT_LOGS: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_logs") @@ -359,7 +358,7 @@ class ChatLog: self, llm_context: llm.LLMContext, prompt: str, - language: str, + language: str | None, user_name: str | None = None, ) -> str: try: @@ -373,7 +372,7 @@ class ChatLog: ) except TemplateError as err: LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=language) + intent_response = intent.IntentResponse(language=language or "") intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, "Sorry, I had a problem with my template", @@ -392,14 +391,25 @@ class ChatLog: user_llm_prompt: str | None = None, ) -> None: """Set the LLM system prompt.""" - llm_context = llm.LLMContext( - platform=conversing_domain, - context=user_input.context, - language=user_input.language, - assistant=DOMAIN, - device_id=user_input.device_id, + frame.report_usage( + "ChatLog.async_update_llm_data", + breaks_in_ha_version="2026.1", + ) + return await self.async_provide_llm_data( + llm_context=user_input.as_llm_context(conversing_domain), + user_llm_hass_api=user_llm_hass_api, + user_llm_prompt=user_llm_prompt, + user_extra_system_prompt=user_input.extra_system_prompt, ) + async def async_provide_llm_data( + self, + llm_context: llm.LLMContext, + user_llm_hass_api: str | list[str] | None = None, + user_llm_prompt: str | None = None, + user_extra_system_prompt: str | None = None, + ) -> None: + """Set the LLM system prompt.""" llm_api: llm.APIInstance | None = None if user_llm_hass_api: @@ -413,10 +423,12 @@ class ChatLog: LOGGER.error( "Error getting LLM API %s for %s: %s", user_llm_hass_api, - conversing_domain, + llm_context.platform, err, ) - intent_response = intent.IntentResponse(language=user_input.language) + intent_response = intent.IntentResponse( + language=llm_context.language or "" + ) intent_response.async_set_error( intent.IntentResponseErrorCode.UNKNOWN, "Error preparing LLM API", @@ -430,10 +442,10 @@ class ChatLog: user_name: str | None = None if ( - user_input.context - and user_input.context.user_id + llm_context.context + and llm_context.context.user_id and ( - user := await self.hass.auth.async_get_user(user_input.context.user_id) + user := await self.hass.auth.async_get_user(llm_context.context.user_id) ) ): user_name = user.name @@ -443,7 +455,7 @@ class ChatLog: await self._async_expand_prompt_template( llm_context, (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT), - user_input.language, + llm_context.language, user_name, ) ) @@ -455,14 +467,14 @@ class ChatLog: await self._async_expand_prompt_template( llm_context, llm.BASE_PROMPT, - user_input.language, + llm_context.language, user_name, ) ) if extra_system_prompt := ( # Take new system prompt if one was given - user_input.extra_system_prompt or self.extra_system_prompt + user_extra_system_prompt or self.extra_system_prompt ): prompt_parts.append(extra_system_prompt) diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py index 00097f5b4d3..dac1fb862ec 100644 --- a/homeassistant/components/conversation/models.py +++ b/homeassistant/components/conversation/models.py @@ -7,7 +7,9 @@ from dataclasses import dataclass from typing import Any, Literal from homeassistant.core import Context -from homeassistant.helpers import intent +from homeassistant.helpers import intent, llm + +from .const import DOMAIN @dataclass(frozen=True) @@ -56,6 +58,16 @@ class ConversationInput: "extra_system_prompt": self.extra_system_prompt, } + def as_llm_context(self, conversing_domain: str) -> llm.LLMContext: + """Return input as an LLM context.""" + return llm.LLMContext( + platform=conversing_domain, + context=self.context, + language=self.language, + assistant=DOMAIN, + device_id=self.device_id, + ) + @dataclass(slots=True) class ConversationResult: diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 726572fc5ae..00199f5fe1f 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -73,11 +73,11 @@ class GoogleGenerativeAIConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 4b4f79d4eed..e304a39f061 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -219,11 +219,11 @@ class OllamaConversationEntity( settings = {**self.entry.data, **self.entry.options} try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), settings.get(CONF_LLM_HASS_API), settings.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index a129400194b..8fea4613ce0 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -279,11 +279,11 @@ class OpenAIConversationEntity( options = self.entry.options try: - await chat_log.async_update_llm_data( - DOMAIN, - user_input, + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), options.get(CONF_PROMPT), + user_input.extra_system_prompt, ) except conversation.ConverseError as err: return err.as_conversation_result() diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index abdcb55054c..9ea3802d9f6 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1779,11 +1779,11 @@ async def test_chat_log_tts_streaming( conversation_input, ) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=conversation_input, + await chat_log.async_provide_llm_data( + conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, + user_extra_system_prompt=conversation_input.extra_system_prompt, ) async for _content in chat_log.async_add_delta_content_stream( agent_id, stream_llm_response() diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index c9e72ae5a03..0e2a384f1da 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -106,9 +106,8 @@ async def test_llm_api( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -128,9 +127,8 @@ async def test_unknown_llm_api( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, pytest.raises(ConverseError) as exc_info, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="unknown-api", user_llm_prompt=None, ) @@ -170,9 +168,8 @@ async def test_multiple_llm_apis( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=["assist", "my-api"], user_llm_prompt=None, ) @@ -192,9 +189,8 @@ async def test_template_error( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, pytest.raises(ConverseError) as exc_info, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt="{{ invalid_syntax", ) @@ -217,9 +213,8 @@ async def test_template_variables( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, patch("homeassistant.auth.AuthManager.async_get_user", return_value=mock_user), ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=( "The instance name is {{ ha_name }}. " @@ -249,11 +244,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) chat_log.async_add_assistant_content_without_tools( AssistantContent( @@ -273,11 +268,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) assert chat_log.extra_system_prompt == extra_system_prompt @@ -290,11 +285,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) chat_log.async_add_assistant_content_without_tools( AssistantContent( @@ -314,11 +309,11 @@ async def test_extra_systen_prompt( chat_session.async_get_chat_session(hass, conversation_id) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api=None, user_llm_prompt=None, + user_extra_system_prompt=mock_conversation_input.extra_system_prompt, ) assert chat_log.extra_system_prompt == extra_system_prompt2 @@ -357,9 +352,8 @@ async def test_tool_call( chat_session.async_get_chat_session(hass) as session, async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -434,9 +428,8 @@ async def test_tool_call_exception( async_get_chat_log(hass, session, mock_conversation_input) as chat_log, ): mock_get_tools.return_value = [mock_tool] - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) @@ -595,9 +588,8 @@ async def test_add_delta_content_stream( ) as chat_log, ): mock_get_tools.return_value = [mock_tool] - await chat_log.async_update_llm_data( - conversing_domain="test", - user_input=mock_conversation_input, + await chat_log.async_provide_llm_data( + mock_conversation_input.as_llm_context("test"), user_llm_hass_api="assist", user_llm_prompt=None, ) From 8498928e47da8e5fce6b10d452239cf965db6e37 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Jun 2025 23:00:27 -0400 Subject: [PATCH 0311/1664] Move Google Gen AI fixture to allow reuse (#146921) --- .../conftest.py | 22 ++++++++++++++++++- .../test_conversation.py | 20 ----------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 0d222c78472..f499f18bc15 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -1,6 +1,7 @@ """Tests helpers.""" -from unittest.mock import Mock, patch +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -77,3 +78,22 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_send_message_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(stream): + for value in stream: + yield value + + with patch( + "google.genai.chats.AsyncChat.send_message_stream", + AsyncMock(), + ) as mock_send_message_stream: + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) + + yield mock_send_message_stream diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index a55a86b67c9..92aa6f08d42 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -1,6 +1,5 @@ """Tests for the Google Generative AI Conversation integration conversation platform.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch from freezegun import freeze_time @@ -41,25 +40,6 @@ def mock_ulid_tools(): yield -@pytest.fixture -def mock_send_message_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(stream): - for value in stream: - yield value - - with patch( - "google.genai.chats.AsyncChat.send_message_stream", - AsyncMock(), - ) as mock_send_message_stream: - mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( - mock_send_message_stream.return_value.pop(0) - ) - - yield mock_send_message_stream - - @pytest.mark.parametrize( ("error"), [ From 85aa7bef1e7b97885f3329aa315c92fd11cc2782 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Mon, 16 Jun 2025 02:43:31 -0400 Subject: [PATCH 0312/1664] Add sensor categorizations for APCUPSD (#146863) * Add sensor categorizations * Fix snapshot problem * Fix snapshot problem --- homeassistant/components/apcupsd/sensor.py | 45 ++++++++++++++++ .../apcupsd/snapshots/test_sensor.ambr | 52 +++++++++---------- 2 files changed, 71 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index a3faf6b0268..5076b537467 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( PERCENTAGE, + EntityCategory, UnitOfApparentPower, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -35,6 +36,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "alarmdel": SensorEntityDescription( key="alarmdel", translation_key="alarm_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "ambtemp": SensorEntityDescription( key="ambtemp", @@ -47,15 +49,18 @@ SENSORS: dict[str, SensorEntityDescription] = { key="apc", translation_key="apc_status", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "apcmodel": SensorEntityDescription( key="apcmodel", translation_key="apc_model", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "badbatts": SensorEntityDescription( key="badbatts", translation_key="bad_batteries", + entity_category=EntityCategory.DIAGNOSTIC, ), "battdate": SensorEntityDescription( key="battdate", @@ -82,6 +87,7 @@ SENSORS: dict[str, SensorEntityDescription] = { key="cable", translation_key="cable_type", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "cumonbatt": SensorEntityDescription( key="cumonbatt", @@ -94,52 +100,63 @@ SENSORS: dict[str, SensorEntityDescription] = { key="date", translation_key="date", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "dipsw": SensorEntityDescription( key="dipsw", translation_key="dip_switch_settings", + entity_category=EntityCategory.DIAGNOSTIC, ), "dlowbatt": SensorEntityDescription( key="dlowbatt", translation_key="low_battery_signal", + entity_category=EntityCategory.DIAGNOSTIC, ), "driver": SensorEntityDescription( key="driver", translation_key="driver", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "dshutd": SensorEntityDescription( key="dshutd", translation_key="shutdown_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "dwake": SensorEntityDescription( key="dwake", translation_key="wake_delay", + entity_category=EntityCategory.DIAGNOSTIC, ), "end apc": SensorEntityDescription( key="end apc", translation_key="date_and_time", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "extbatts": SensorEntityDescription( key="extbatts", translation_key="external_batteries", + entity_category=EntityCategory.DIAGNOSTIC, ), "firmware": SensorEntityDescription( key="firmware", translation_key="firmware_version", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "hitrans": SensorEntityDescription( key="hitrans", translation_key="transfer_high", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "hostname": SensorEntityDescription( key="hostname", translation_key="hostname", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "humidity": SensorEntityDescription( key="humidity", @@ -163,10 +180,12 @@ SENSORS: dict[str, SensorEntityDescription] = { key="lastxfer", translation_key="last_transfer", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "linefail": SensorEntityDescription( key="linefail", translation_key="line_failure", + entity_category=EntityCategory.DIAGNOSTIC, ), "linefreq": SensorEntityDescription( key="linefreq", @@ -198,15 +217,18 @@ SENSORS: dict[str, SensorEntityDescription] = { translation_key="transfer_low", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "mandate": SensorEntityDescription( key="mandate", translation_key="manufacture_date", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "masterupd": SensorEntityDescription( key="masterupd", translation_key="master_update", + entity_category=EntityCategory.DIAGNOSTIC, ), "maxlinev": SensorEntityDescription( key="maxlinev", @@ -217,11 +239,13 @@ SENSORS: dict[str, SensorEntityDescription] = { "maxtime": SensorEntityDescription( key="maxtime", translation_key="max_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "mbattchg": SensorEntityDescription( key="mbattchg", translation_key="max_battery_charge", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "minlinev": SensorEntityDescription( key="minlinev", @@ -232,41 +256,48 @@ SENSORS: dict[str, SensorEntityDescription] = { "mintimel": SensorEntityDescription( key="mintimel", translation_key="min_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "model": SensorEntityDescription( key="model", translation_key="model", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "nombattv": SensorEntityDescription( key="nombattv", translation_key="battery_nominal_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nominv": SensorEntityDescription( key="nominv", translation_key="nominal_input_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nomoutv": SensorEntityDescription( key="nomoutv", translation_key="nominal_output_voltage", native_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "nompower": SensorEntityDescription( key="nompower", translation_key="nominal_output_power", native_unit_of_measurement=UnitOfPower.WATT, device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, ), "nomapnt": SensorEntityDescription( key="nomapnt", translation_key="nominal_apparent_power", native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, device_class=SensorDeviceClass.APPARENT_POWER, + entity_category=EntityCategory.DIAGNOSTIC, ), "numxfers": SensorEntityDescription( key="numxfers", @@ -291,21 +322,25 @@ SENSORS: dict[str, SensorEntityDescription] = { key="reg1", translation_key="register_1_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "reg2": SensorEntityDescription( key="reg2", translation_key="register_2_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "reg3": SensorEntityDescription( key="reg3", translation_key="register_3_fault", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "retpct": SensorEntityDescription( key="retpct", translation_key="restore_capacity", native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, ), "selftest": SensorEntityDescription( key="selftest", @@ -315,20 +350,24 @@ SENSORS: dict[str, SensorEntityDescription] = { key="sense", translation_key="sensitivity", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "serialno": SensorEntityDescription( key="serialno", translation_key="serial_number", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "starttime": SensorEntityDescription( key="starttime", translation_key="startup_time", + entity_category=EntityCategory.DIAGNOSTIC, ), "statflag": SensorEntityDescription( key="statflag", translation_key="online_status", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "status": SensorEntityDescription( key="status", @@ -337,6 +376,7 @@ SENSORS: dict[str, SensorEntityDescription] = { "stesti": SensorEntityDescription( key="stesti", translation_key="self_test_interval", + entity_category=EntityCategory.DIAGNOSTIC, ), "timeleft": SensorEntityDescription( key="timeleft", @@ -360,23 +400,28 @@ SENSORS: dict[str, SensorEntityDescription] = { key="upsname", translation_key="ups_name", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "version": SensorEntityDescription( key="version", translation_key="version", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), "xoffbat": SensorEntityDescription( key="xoffbat", translation_key="transfer_from_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), "xoffbatt": SensorEntityDescription( key="xoffbatt", translation_key="transfer_from_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), "xonbatt": SensorEntityDescription( key="xonbatt", translation_key="transfer_to_battery", + entity_category=EntityCategory.DIAGNOSTIC, ), } diff --git a/tests/components/apcupsd/snapshots/test_sensor.ambr b/tests/components/apcupsd/snapshots/test_sensor.ambr index 9c0b2de4fdc..2e991d7cfa6 100644 --- a/tests/components/apcupsd/snapshots/test_sensor.ambr +++ b/tests/components/apcupsd/snapshots/test_sensor.ambr @@ -11,7 +11,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_alarm_delay', 'has_entity_name': True, 'hidden_by': None, @@ -113,7 +113,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_nominal_voltage', 'has_entity_name': True, 'hidden_by': None, @@ -214,7 +214,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_shutdown', 'has_entity_name': True, 'hidden_by': None, @@ -263,7 +263,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_battery_timeout', 'has_entity_name': True, 'hidden_by': None, @@ -368,7 +368,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_cable_type', 'has_entity_name': True, 'hidden_by': None, @@ -416,7 +416,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_daemon_version', 'has_entity_name': True, 'hidden_by': None, @@ -464,7 +464,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_date_and_time', 'has_entity_name': True, 'hidden_by': None, @@ -512,7 +512,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_driver', 'has_entity_name': True, 'hidden_by': None, @@ -560,7 +560,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_firmware_version', 'has_entity_name': True, 'hidden_by': None, @@ -768,7 +768,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_last_transfer', 'has_entity_name': True, 'hidden_by': None, @@ -916,7 +916,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_model', 'has_entity_name': True, 'hidden_by': None, @@ -964,7 +964,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_name', 'has_entity_name': True, 'hidden_by': None, @@ -1012,7 +1012,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_apparent_power', 'has_entity_name': True, 'hidden_by': None, @@ -1065,7 +1065,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_input_voltage', 'has_entity_name': True, 'hidden_by': None, @@ -1118,7 +1118,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_nominal_output_power', 'has_entity_name': True, 'hidden_by': None, @@ -1227,7 +1227,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_self_test_interval', 'has_entity_name': True, 'hidden_by': None, @@ -1324,7 +1324,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_sensitivity', 'has_entity_name': True, 'hidden_by': None, @@ -1372,7 +1372,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_serial_number', 'has_entity_name': True, 'hidden_by': None, @@ -1420,7 +1420,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_shutdown_time', 'has_entity_name': True, 'hidden_by': None, @@ -1517,7 +1517,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_data', 'has_entity_name': True, 'hidden_by': None, @@ -1565,7 +1565,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_date', 'has_entity_name': True, 'hidden_by': None, @@ -1613,7 +1613,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_status_flag', 'has_entity_name': True, 'hidden_by': None, @@ -1880,7 +1880,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_from_battery', 'has_entity_name': True, 'hidden_by': None, @@ -1928,7 +1928,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_high', 'has_entity_name': True, 'hidden_by': None, @@ -1981,7 +1981,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_low', 'has_entity_name': True, 'hidden_by': None, @@ -2034,7 +2034,7 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, + 'entity_category': , 'entity_id': 'sensor.myups_transfer_to_battery', 'has_entity_name': True, 'hidden_by': None, From ddfe17d0a43fb096978d604fda9b1d19b73b508e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 16 Jun 2025 18:12:34 +1000 Subject: [PATCH 0313/1664] Bump tesla-fleet-api to match Protobuf compatibility (#146918) Bump for v1.2.0 --- 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 af7d2ce2b34..4c92e0bd222 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.1.3"] + "requirements": ["tesla-fleet-api==1.2.0"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index fe6f5416ca3..f58783e04a4 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.1.3", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.0", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index 38e52679018..c0cbc2ea431 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.1.3"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4174ca124d5..262aef8e61e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2900,7 +2900,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.3 +tesla-fleet-api==1.2.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12efb0dd738..3a8f8ce8f81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2383,7 +2383,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.1.3 +tesla-fleet-api==1.2.0 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From 5ea026d3698a859fd1e9f2c5bc65a6881a870f70 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 16 Jun 2025 10:29:00 +0200 Subject: [PATCH 0314/1664] Bump bthome-ble to 3.13.1 (#146871) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 4130606ff5c..0bbdfae50e4 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.12.4"] + "requirements": ["bthome-ble==3.13.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 262aef8e61e..c7f42393eb2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,7 +683,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a8f8ce8f81..5f755dbe6e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -607,7 +607,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.buienradar buienradar==1.0.6 From e354a850c978b6d58093c74b241f09733386278c Mon Sep 17 00:00:00 2001 From: mbo18 Date: Mon, 16 Jun 2025 10:36:20 +0200 Subject: [PATCH 0315/1664] Bump python-rflink to 0.0.67 (#146908) * update python-rflink * remove from FORBIDDEN_PACKAGE_EXCEPTIONS --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index f5f372d2d33..206b31ab86f 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -6,5 +6,5 @@ "iot_class": "assumed_state", "loggers": ["rflink"], "quality_scale": "legacy", - "requirements": ["rflink==0.0.66"] + "requirements": ["rflink==0.0.67"] } diff --git a/requirements_all.txt b/requirements_all.txt index c7f42393eb2..b75a49e1eb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2658,7 +2658,7 @@ reolink-aio==0.14.1 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.66 +rflink==0.0.67 # homeassistant.components.ring ring-doorbell==0.9.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5f755dbe6e9..9ce8ed16916 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2198,7 +2198,7 @@ renson-endura-delta==1.7.2 reolink-aio==0.14.1 # homeassistant.components.rflink -rflink==0.0.66 +rflink==0.0.67 # homeassistant.components.ring ring-doorbell==0.9.13 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b1aff0dc1fd..1d4518812b3 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -277,11 +277,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # gpiozero > colorzero > setuptools "colorzero": {"setuptools"} }, - "rflink": { - # https://github.com/aequitas/python-rflink/issues/78 - # rflink > pyserial-asyncio - "rflink": {"pyserial-asyncio", "async-timeout"} - }, "ring": {"ring-doorbell": {"async-timeout"}}, "rmvtransport": {"pyrmvtransport": {"async-timeout"}}, "roborock": {"python-roborock": {"async-timeout"}}, From 8d4f5d78ff3ea704e19cc45144316def24ccf333 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Jun 2025 10:42:10 +0200 Subject: [PATCH 0316/1664] Bump dawidd6/action-download-artifact from 10 to 11 (#146928) Bumps [dawidd6/action-download-artifact](https://github.com/dawidd6/action-download-artifact) from 10 to 11. - [Release notes](https://github.com/dawidd6/action-download-artifact/releases) - [Commits](https://github.com/dawidd6/action-download-artifact/compare/v10...v11) --- updated-dependencies: - dependency-name: dawidd6/action-download-artifact dependency-version: '11' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 136f1b83d06..dc97e627ea4 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -94,7 +94,7 @@ jobs: - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v10 + uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/frontend @@ -105,7 +105,7 @@ jobs: - name: Download nightly wheels of intents if: needs.init.outputs.channel == 'dev' - uses: dawidd6/action-download-artifact@v10 + uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} repo: home-assistant/intents-package From e8667dfbe0c23c6d61ba481b9cb80e8426a20a6f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:11:57 +0200 Subject: [PATCH 0317/1664] Bump nessclient to 1.2.0 (#146937) --- homeassistant/components/ness_alarm/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json index 3d97e3290e0..79227e8564b 100644 --- a/homeassistant/components/ness_alarm/manifest.json +++ b/homeassistant/components/ness_alarm/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["nessclient"], "quality_scale": "legacy", - "requirements": ["nessclient==1.1.2"] + "requirements": ["nessclient==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b75a49e1eb1..09fe3cb9347 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1484,7 +1484,7 @@ nad-receiver==0.3.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.1.2 +nessclient==1.2.0 # homeassistant.components.netdata netdata==1.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ce8ed16916..89c745d1e16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1270,7 +1270,7 @@ myuplink==0.7.0 ndms2-client==0.1.2 # homeassistant.components.ness_alarm -nessclient==1.1.2 +nessclient==1.2.0 # homeassistant.components.nmap_tracker netmap==0.7.0.2 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 1d4518812b3..e0888a96b3e 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -235,11 +235,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # python-mystrom > setuptools "python-mystrom": {"setuptools"} }, - "ness_alarm": { - # https://github.com/nickw444/nessclient/issues/73 - # nessclient > pyserial-asyncio - "nessclient": {"pyserial-asyncio"} - }, "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "nx584": { From b563f9078a05c3f91722cd6a4cb825b5ef2b7b86 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 16 Jun 2025 21:29:17 +1000 Subject: [PATCH 0318/1664] Significantly improve Tesla Fleet config flow (#146794) * Improved config flow * Tests * Improvements * Dashboard url & tests * Apply suggestions from code review Co-authored-by: Norbert Rittel * revert oauth change * fully restore oauth file * remove CONF_DOMAIN * Add pick_implementation back in * Use try else * Improve translation * use CONF_DOMAIN --------- Co-authored-by: Norbert Rittel --- .../components/tesla_fleet/config_flow.py | 188 +++++- homeassistant/components/tesla_fleet/const.py | 1 + .../components/tesla_fleet/strings.json | 23 +- .../tesla_fleet/test_config_flow.py | 533 +++++++++++++++++- 4 files changed, 715 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index feeb5e74ca6..ac55a380abb 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -4,14 +4,30 @@ from __future__ import annotations from collections.abc import Mapping import logging -from typing import Any +import re +from typing import Any, cast import jwt +from tesla_fleet_api import TeslaFleetApi +from tesla_fleet_api.const import SERVERS +from tesla_fleet_api.exceptions import ( + InvalidResponse, + PreconditionFailed, + TeslaFleetError, +) +import voluptuous as vol from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + QrCodeSelector, + QrCodeSelectorConfig, + QrErrorCorrectionLevel, +) -from .const import DOMAIN, LOGGER +from .const import CONF_DOMAIN, DOMAIN, LOGGER +from .oauth import TeslaUserImplementation class OAuth2FlowHandler( @@ -21,36 +37,173 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + def __init__(self) -> None: + """Initialize config flow.""" + super().__init__() + self.domain: str | None = None + self.registration_status: dict[str, bool] = {} + self.tesla_apis: dict[str, TeslaFleetApi] = {} + self.failed_regions: list[str] = [] + self.data: dict[str, Any] = {} + self.uid: str | None = None + self.api: TeslaFleetApi | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" return LOGGER - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle a flow start.""" - return await super().async_step_user() - async def async_oauth_create_entry( self, data: dict[str, Any], ) -> ConfigFlowResult: - """Handle the initial step.""" - + """Handle OAuth completion and proceed to domain registration.""" token = jwt.decode( data["token"]["access_token"], options={"verify_signature": False} ) - uid = token["sub"] - await self.async_set_unique_id(uid) + self.data = data + self.uid = token["sub"] + server = SERVERS[token["ou_code"].lower()] + + await self.async_set_unique_id(self.uid) if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch") return self.async_update_reload_and_abort( self._get_reauth_entry(), data=data ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=uid, data=data) + + # OAuth done, setup a Partner API connection + implementation = cast(TeslaUserImplementation, self.flow_impl) + + session = async_get_clientsession(self.hass) + self.api = TeslaFleetApi( + session=session, + server=server, + partner_scope=True, + charging_scope=False, + energy_scope=False, + user_scope=False, + vehicle_scope=False, + ) + await self.api.get_private_key(self.hass.config.path("tesla_fleet.key")) + await self.api.partner_login( + implementation.client_id, implementation.client_secret + ) + + return await self.async_step_domain_input() + + async def async_step_domain_input( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> ConfigFlowResult: + """Handle domain input step.""" + + errors = errors or {} + + if user_input is not None: + domain = user_input[CONF_DOMAIN].strip().lower() + + # Validate domain format + if not self._is_valid_domain(domain): + errors[CONF_DOMAIN] = "invalid_domain" + else: + self.domain = domain + return await self.async_step_domain_registration() + + return self.async_show_form( + step_id="domain_input", + description_placeholders={ + "dashboard": "https://developer.tesla.com/en_AU/dashboard/" + }, + data_schema=vol.Schema( + { + vol.Required(CONF_DOMAIN): str, + } + ), + errors=errors, + ) + + async def async_step_domain_registration( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle domain registration for both regions.""" + + assert self.api + assert self.api.private_key + assert self.domain + + errors = {} + description_placeholders = { + "public_key_url": f"https://{self.domain}/.well-known/appspecific/com.tesla.3p.public-key.pem", + "pem": self.api.public_pem, + } + + try: + register_response = await self.api.partner.register(self.domain) + except PreconditionFailed: + return await self.async_step_domain_input( + errors={CONF_DOMAIN: "precondition_failed"} + ) + except InvalidResponse: + errors["base"] = "invalid_response" + except TeslaFleetError as e: + errors["base"] = "unknown_error" + description_placeholders["error"] = e.message + else: + # Get public key from response + registered_public_key = register_response.get("response", {}).get( + "public_key" + ) + + if not registered_public_key: + errors["base"] = "public_key_not_found" + elif ( + registered_public_key.lower() + != self.api.public_uncompressed_point.lower() + ): + errors["base"] = "public_key_mismatch" + else: + return await self.async_step_registration_complete() + + return self.async_show_form( + step_id="domain_registration", + description_placeholders=description_placeholders, + errors=errors, + ) + + async def async_step_registration_complete( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show completion and virtual key installation.""" + if user_input is not None and self.uid and self.data: + return self.async_create_entry(title=self.uid, data=self.data) + + if not self.domain: + return await self.async_step_domain_input() + + virtual_key_url = f"https://www.tesla.com/_ak/{self.domain}" + data_schema = vol.Schema({}).extend( + { + vol.Optional("qr_code"): QrCodeSelector( + config=QrCodeSelectorConfig( + data=virtual_key_url, + scale=6, + error_correction_level=QrErrorCorrectionLevel.QUARTILE, + ) + ), + } + ) + + return self.async_show_form( + step_id="registration_complete", + data_schema=data_schema, + description_placeholders={ + "virtual_key_url": virtual_key_url, + }, + ) async def async_step_reauth( self, entry_data: Mapping[str, Any] @@ -67,4 +220,11 @@ class OAuth2FlowHandler( step_id="reauth_confirm", description_placeholders={"name": "Tesla Fleet"}, ) - return await self.async_step_user() + # For reauth, skip domain registration and go straight to OAuth + return await super().async_step_user() + + def _is_valid_domain(self, domain: str) -> bool: + """Validate domain format.""" + # Basic domain validation regex + domain_pattern = re.compile(r"^(?:[a-zA-Z0-9]+\.)+[a-zA-Z0-9-]+$") + return bool(domain_pattern.match(domain)) diff --git a/homeassistant/components/tesla_fleet/const.py b/homeassistant/components/tesla_fleet/const.py index 5d2dc84c49e..d73234b1fdd 100644 --- a/homeassistant/components/tesla_fleet/const.py +++ b/homeassistant/components/tesla_fleet/const.py @@ -9,6 +9,7 @@ from tesla_fleet_api.const import Scope DOMAIN = "tesla_fleet" +CONF_DOMAIN = "domain" CONF_REFRESH_TOKEN = "refresh_token" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index 276858bb3dd..a9b1cfc4845 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -4,6 +4,7 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "already_configured": "Configuration updated for profile.", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", @@ -13,7 +14,12 @@ "reauth_account_mismatch": "The reauthentication account does not match the original account" }, "error": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "invalid_domain": "Invalid domain format. Please enter a valid domain name.", + "public_key_not_found": "Public key not found.", + "public_key_mismatch": "The public key hosted at your domain does not match the expected key. Please ensure the correct public key is hosted at the specified location.", + "precondition_failed": "The domain does not match the application's allowed origins.", + "invalid_response": "The registration was rejected by Tesla", + "unknown_error": "An unknown error occurred: {error}" }, "step": { "pick_implementation": { @@ -25,6 +31,21 @@ "implementation": "[%key:common::config_flow::description::implementation%]" } }, + "domain_input": { + "title": "Tesla Fleet domain registration", + "description": "Enter the domain that will host your public key. This is typically the domain of the origin you specified during registration at {dashboard}.", + "data": { + "domain": "Domain" + } + }, + "domain_registration": { + "title": "Registering public key", + "description": "You must host the public key at:\n\n{public_key_url}\n\n```\n{pem}\n```" + }, + "registration_complete": { + "title": "Command signing", + "description": "To enable command signing, you must open the Tesla app, select your vehicle, and then visit the following URL to set up a virtual key. You must repeat this process for each vehicle.\n\n{virtual_key_url}" + }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "The {name} integration needs to re-authenticate your account" diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index 6cb8c60ac0c..4a8142a2d85 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -1,16 +1,23 @@ """Test the Tesla Fleet config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from urllib.parse import parse_qs, urlparse import pytest +from tesla_fleet_api.exceptions import ( + InvalidResponse, + PreconditionFailed, + TeslaFleetError, +) from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) +from homeassistant.components.tesla_fleet.config_flow import OAuth2FlowHandler from homeassistant.components.tesla_fleet.const import ( AUTHORIZE_URL, + CONF_DOMAIN, DOMAIN, SCOPES, TOKEN_URL, @@ -64,15 +71,30 @@ async def create_credential(hass: HomeAssistant) -> None: ) +@pytest.fixture +def mock_private_key(): + """Mock private key for testing.""" + private_key = Mock() + public_key = Mock() + private_key.public_key.return_value = public_key + public_key.public_bytes.side_effect = [ + b"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n-----END PUBLIC KEY-----", + bytes.fromhex( + "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + ), + ] + return private_key + + @pytest.mark.usefixtures("current_request_with_host") -async def test_full_flow_user_cred( +async def test_full_flow_with_domain_registration( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, access_token: str, + mock_private_key, ) -> None: - """Check full flow.""" - + """Test full flow with domain registration.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -95,7 +117,7 @@ async def test_full_flow_user_cred( assert parsed_query["redirect_uri"][0] == REDIRECT assert parsed_query["state"][0] == state assert parsed_query["scope"][0] == " ".join(SCOPES) - assert "code_challenge" not in parsed_query # Ensure not a PKCE flow + assert "code_challenge" not in parsed_query client = await hass_client_no_auth() resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") @@ -112,21 +134,416 @@ async def test_full_flow_user_cred( "expires_in": 60, }, ) - with patch( - "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True - ) as mock_setup: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert len(mock_setup.mock_calls) == 1 + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + patch( + "homeassistant.components.tesla_fleet.async_setup_entry", return_value=True + ), + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + mock_api.partner.register.return_value = { + "response": { + "public_key": "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + } + } + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + # Enter domain - this should automatically register and go to registration_complete + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + + # Complete flow - provide user input to complete registration + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == UNIQUE_ID - assert "result" in result assert result["result"].unique_id == UNIQUE_ID - assert "token" in result["result"].data - assert result["result"].data["token"]["access_token"] == access_token - assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_input_invalid_domain( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain input with invalid domain.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + # Enter invalid domain + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "invalid-domain"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + assert result["errors"] == {CONF_DOMAIN: "invalid_domain"} + + # Enter valid domain - this should automatically register and go to registration_complete + mock_api.public_uncompressed_point = "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + mock_api.partner.register.return_value = { + "response": { + "public_key": "0404112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff112233445566778899aabbccddeeff1122" + } + } + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (InvalidResponse, "invalid_response"), + (TeslaFleetError("Custom error"), "unknown_error"), + ], +) +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_errors( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, + side_effect, + expected_error, +) -> None: + """Test domain registration with errors that stay on domain_registration step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.side_effect = side_effect + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + with patch( + "homeassistant.helpers.translation.async_get_translations", return_value={} + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": expected_error} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_precondition_failed( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with PreconditionFailed redirects to domain_input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.side_effect = PreconditionFailed + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should go to domain_registration and then fail back to domain_input + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + assert result["errors"] == {CONF_DOMAIN: "precondition_failed"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_public_key_not_found( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with missing public key.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "test_point" + mock_api.partner.register.return_value = {"response": {}} + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": "public_key_not_found"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_domain_registration_public_key_mismatch( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, + mock_private_key, +) -> None: + """Test domain registration with public key mismatch.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with ( + patch( + "homeassistant.components.tesla_fleet.config_flow.TeslaFleetApi" + ) as mock_api_class, + ): + mock_api = AsyncMock() + mock_api.private_key = mock_private_key + mock_api.get_private_key = AsyncMock() + mock_api.partner_login = AsyncMock() + mock_api.public_uncompressed_point = "expected_key" + mock_api.partner.register.return_value = { + "response": {"public_key": "different_key"} + } + mock_api_class.return_value = mock_api + + # Complete OAuth + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + # Enter domain - this should fail and stay on domain_registration + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_DOMAIN: "example.com"} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_registration" + assert result["errors"] == {"base": "public_key_mismatch"} + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_registration_complete_no_domain( + hass: HomeAssistant, +) -> None: + """Test registration complete step without domain.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = None + + result = await flow_instance.async_step_registration_complete({}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "domain_input" + + +async def test_registration_complete_with_domain_and_user_input( + hass: HomeAssistant, +) -> None: + """Test registration complete step with domain and user input.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = "example.com" + flow_instance.uid = UNIQUE_ID + flow_instance.data = {"token": {"access_token": "test"}} + + result = await flow_instance.async_step_registration_complete({"complete": True}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == UNIQUE_ID + + +async def test_registration_complete_with_domain_no_user_input( + hass: HomeAssistant, +) -> None: + """Test registration complete step with domain but no user input.""" + + flow_instance = OAuth2FlowHandler() + flow_instance.hass = hass + flow_instance.domain = "example.com" + + result = await flow_instance.async_step_registration_complete(None) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "registration_complete" + assert ( + result["description_placeholders"]["virtual_key_url"] + == "https://www.tesla.com/_ak/example.com" + ) @pytest.mark.usefixtures("current_request_with_host") @@ -225,3 +642,89 @@ async def test_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_account_mismatch" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_unique_id_abort( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token: str, +) -> None: + """Test duplicate unique ID aborts flow.""" + # Create existing entry + existing_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + version=1, + data={}, + ) + existing_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT, + }, + ) + + client = await hass_client_no_auth() + await client.get(f"/auth/external/callback?code=abcd&state={state}") + + aioclient_mock.post( + TOKEN_URL, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + # Complete OAuth - should abort due to duplicate unique_id + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_confirm_form(hass: HomeAssistant) -> None: + """Test reauth confirm form display.""" + old_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=UNIQUE_ID, + version=1, + data={}, + ) + old_entry.add_to_hass(hass) + + result = await old_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {"name": "Tesla Fleet"} + + +@pytest.mark.parametrize( + ("domain", "expected_valid"), + [ + ("example.com", True), + ("test.example.com", True), + ("sub.domain.example.org", True), + ("https://example.com", False), + ("invalid-domain", False), + ("", False), + ("example", False), + ("example.", False), + (".example.com", False), + ("exam ple.com", False), + ], +) +def test_is_valid_domain(domain: str, expected_valid: bool) -> None: + """Test domain validation.""" + + assert OAuth2FlowHandler()._is_valid_domain(domain) == expected_valid From d5262231a1ec898e88e1933259f90bb5ec92a8b2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:37:39 +0200 Subject: [PATCH 0319/1664] Bump pymysensors to 0.25.0 (#146941) --- homeassistant/components/mysensors/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json index b272a610516..a4b802f001c 100644 --- a/homeassistant/components/mysensors/manifest.json +++ b/homeassistant/components/mysensors/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mysensors", "iot_class": "local_push", "loggers": ["mysensors"], - "requirements": ["pymysensors==0.24.0"] + "requirements": ["pymysensors==0.25.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 09fe3cb9347..2cdd6a505e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2156,7 +2156,7 @@ pymonoprice==0.4 pymsteams==0.1.12 # homeassistant.components.mysensors -pymysensors==0.24.0 +pymysensors==0.25.0 # homeassistant.components.iron_os pynecil==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89c745d1e16..817fb80c591 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1789,7 +1789,7 @@ pymodbus==3.9.2 pymonoprice==0.4 # homeassistant.components.mysensors -pymysensors==0.24.0 +pymysensors==0.25.0 # homeassistant.components.iron_os pynecil==4.1.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index e0888a96b3e..0f669132b53 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -225,11 +225,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # pymonoprice > pyserial-asyncio "pymonoprice": {"pyserial-asyncio"} }, - "mysensors": { - # https://github.com/theolind/pymysensors/issues/818 - # pymysensors > pyserial-asyncio - "pymysensors": {"pyserial-asyncio"} - }, "mystrom": { # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 # python-mystrom > setuptools From 33978ce59eb541c188fab09a5e8696ff364f1660 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:46:38 +0200 Subject: [PATCH 0320/1664] Bump pyosoenergyapi to 1.1.5 (#146942) --- homeassistant/components/osoenergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/osoenergy/manifest.json b/homeassistant/components/osoenergy/manifest.json index c7b81177a2b..6129aa379f7 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.4"] + "requirements": ["pyosoenergyapi==1.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2cdd6a505e8..731c9b8c298 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2210,7 +2210,7 @@ pyopnsense==0.4.0 pyoppleio-legacy==1.0.8 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.4 +pyosoenergyapi==1.1.5 # homeassistant.components.opentherm_gw pyotgw==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 817fb80c591..1b3d1067184 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1834,7 +1834,7 @@ pyopenweathermap==0.2.2 pyopnsense==0.4.0 # homeassistant.components.osoenergy -pyosoenergyapi==1.1.4 +pyosoenergyapi==1.1.5 # homeassistant.components.opentherm_gw pyotgw==2.2.2 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 0f669132b53..7860cb0400d 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -250,11 +250,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # opower > arrow > types-python-dateutil "arrow": {"types-python-dateutil"} }, - "osoenergy": { - # https://github.com/osohotwateriot/apyosohotwaterapi/pull/4 - # pyosoenergyapi > unasync > setuptools - "unasync": {"setuptools"} - }, "ovo_energy": { # https://github.com/timmo001/ovoenergy/issues/132 # ovoenergy > incremental > setuptools From 4a9cbc79f207814f81685d5ad63421d52cdfe586 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:56:03 +0200 Subject: [PATCH 0321/1664] Bump pysml to 0.1.5 (#146935) --- homeassistant/components/edl21/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index faa471e44b1..28b61c4c0e1 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.12"] + "requirements": ["pysml==0.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 731c9b8c298..48c80ee883f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2350,7 +2350,7 @@ pysmarty2==0.10.2 pysmhi==1.0.2 # homeassistant.components.edl21 -pysml==0.0.12 +pysml==0.1.5 # homeassistant.components.smlight pysmlight==0.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b3d1067184..d8af45c573f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1950,7 +1950,7 @@ pysmarty2==0.10.2 pysmhi==1.0.2 # homeassistant.components.edl21 -pysml==0.0.12 +pysml==0.1.5 # homeassistant.components.smlight pysmlight==0.2.6 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 7860cb0400d..48066ff6bf0 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -109,11 +109,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { "devialet": {"async-upnp-client": {"async-timeout"}}, "dlna_dmr": {"async-upnp-client": {"async-timeout"}}, "dlna_dms": {"async-upnp-client": {"async-timeout"}}, - "edl21": { - # https://github.com/mtdcr/pysml/issues/21 - # pysml > pyserial-asyncio - "pysml": {"pyserial-asyncio", "async-timeout"}, - }, "efergy": { # https://github.com/tkdrob/pyefergy/issues/46 # pyefergy > codecov From 3283965b45229aa76c1f33672905865dd2cb0bf0 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:11:35 +0200 Subject: [PATCH 0322/1664] Re-enable v2 API support for HomeWizard P1 Meter (#146927) --- .../components/homewizard/__init__.py | 7 ++--- tests/components/homewizard/test_init.py | 31 ------------------- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 3831146aed8..6c9530db72c 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -23,9 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - api: HomeWizardEnergy - is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False - - if (token := entry.data.get(CONF_TOKEN)) and is_battery: + if token := entry.data.get(CONF_TOKEN): api = HomeWizardEnergyV2( entry.data[CONF_IP_ADDRESS], token=token, @@ -37,8 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) - clientsession=async_get_clientsession(hass), ) - if is_battery: - await async_check_v2_support_and_create_issue(hass, entry) + await async_check_v2_support_and_create_issue(hass, entry) coordinator = HWEnergyDeviceUpdateCoordinator(hass, entry, api) try: diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index 9139ef80d12..be811355e1d 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -10,7 +10,6 @@ import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, async_fire_time_changed @@ -58,36 +57,6 @@ async def test_load_unload_v2( assert mock_config_entry_v2.state is ConfigEntryState.NOT_LOADED -async def test_load_unload_v2_as_v1( - hass: HomeAssistant, - mock_homewizardenergy: MagicMock, -) -> None: - """Test loading and unloading of integration with v2 config, but without using it.""" - - # Simulate v2 config but as a P1 Meter - mock_config_entry = MockConfigEntry( - title="Device", - domain=DOMAIN, - data={ - CONF_IP_ADDRESS: "127.0.0.1", - CONF_TOKEN: "00112233445566778899ABCDEFABCDEF", - }, - unique_id="HWE-P1_5c2fafabcdef", - ) - - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert len(mock_homewizardenergy.combined.mock_calls) == 1 - - 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_load_failed_host_unavailable( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From e47e2c92fe24806087c2b25ab9cd6cf985180080 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 16 Jun 2025 14:11:48 +0200 Subject: [PATCH 0323/1664] Change `PARALLEL_UPDATES` to `0` for read-only NextDNS platforms (#146939) Change PARALLEL_UPDATES to 0 for read-only platforms --- homeassistant/components/nextdns/binary_sensor.py | 2 +- homeassistant/components/nextdns/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index ed244146efc..a06b56e1732 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .coordinator import NextDnsUpdateCoordinator -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 0a4a8eaad8f..14b5fffbc8d 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -35,7 +35,7 @@ from .const import ( ) from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator -PARALLEL_UPDATES = 1 +PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) From 61b00892c3fadd5ade1cacfac594543c7d0db95a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 16 Jun 2025 14:17:36 +0200 Subject: [PATCH 0324/1664] Add debug log for update in onedrive (#146907) --- homeassistant/components/onedrive/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 3eb7d762712..ff05b19f84d 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -66,6 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): translation_domain=DOMAIN, translation_key="authentication_failed" ) from err except OneDriveException as err: + _LOGGER.debug("Failed to fetch drive data: %s") raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err From c335b5b37cfc080fad578c779824d5b51c015996 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Mon, 16 Jun 2025 14:31:22 +0200 Subject: [PATCH 0325/1664] Add verify ssl option to paperless-ngx integration (#146802) * add verify ssl config option * Refactoring * Use .get() with default value instead of migration * Reconfigure fix * minor changes --- .../components/paperless_ngx/__init__.py | 4 ++-- .../components/paperless_ngx/config_flow.py | 23 ++++++++++++------- .../components/paperless_ngx/strings.json | 12 ++++++---- tests/components/paperless_ngx/const.py | 4 +++- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/paperless_ngx/__init__.py b/homeassistant/components/paperless_ngx/__init__.py index 4d60f47e1e8..0fea90b7ea3 100644 --- a/homeassistant/components/paperless_ngx/__init__.py +++ b/homeassistant/components/paperless_ngx/__init__.py @@ -9,7 +9,7 @@ from pypaperless.exceptions import ( PaperlessInvalidTokenError, ) -from homeassistant.const import CONF_API_KEY, CONF_URL, Platform +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -69,7 +69,7 @@ async def _get_paperless_api( api = Paperless( entry.data[CONF_URL], entry.data[CONF_API_KEY], - session=async_get_clientsession(hass), + session=async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True)), ) try: diff --git a/homeassistant/components/paperless_ngx/config_flow.py b/homeassistant/components/paperless_ngx/config_flow.py index c0c1dc4ce19..9a8ea05d168 100644 --- a/homeassistant/components/paperless_ngx/config_flow.py +++ b/homeassistant/components/paperless_ngx/config_flow.py @@ -16,7 +16,7 @@ from pypaperless.exceptions import ( import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -25,6 +25,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, + vol.Required(CONF_VERIFY_SSL, default=True): bool, } ) @@ -78,15 +79,19 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): if not errors: return self.async_update_reload_and_abort(entry, data=user_input) + if user_input is not None: + suggested_values = user_input + else: + suggested_values = { + CONF_URL: entry.data[CONF_URL], + CONF_VERIFY_SSL: entry.data.get(CONF_VERIFY_SSL, True), + } + return self.async_show_form( step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, - suggested_values={ - CONF_URL: user_input[CONF_URL] - if user_input is not None - else entry.data[CONF_URL], - }, + suggested_values=suggested_values, ), errors=errors, ) @@ -122,13 +127,15 @@ class PaperlessConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _validate_input(self, user_input: dict[str, str]) -> dict[str, str]: + async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]: errors: dict[str, str] = {} client = Paperless( user_input[CONF_URL], user_input[CONF_API_KEY], - session=async_get_clientsession(self.hass), + session=async_get_clientsession( + self.hass, user_input.get(CONF_VERIFY_SSL, True) + ), ) try: diff --git a/homeassistant/components/paperless_ngx/strings.json b/homeassistant/components/paperless_ngx/strings.json index 1347dc83e98..aa3f7ada943 100644 --- a/homeassistant/components/paperless_ngx/strings.json +++ b/homeassistant/components/paperless_ngx/strings.json @@ -4,11 +4,13 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "url": "URL to connect to the Paperless-ngx instance", - "api_key": "API key to connect to the Paperless-ngx API" + "api_key": "API key to connect to the Paperless-ngx API", + "verify_ssl": "Verify the SSL certificate of the Paperless-ngx instance. Disable this option if you’re using a self-signed certificate." }, "title": "Add Paperless-ngx instance" }, @@ -24,11 +26,13 @@ "reconfigure": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" }, "data_description": { "url": "[%key:component::paperless_ngx::config::step::user::data_description::url%]", - "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]" + "api_key": "[%key:component::paperless_ngx::config::step::user::data_description::api_key%]", + "verify_ssl": "[%key:component::paperless_ngx::config::step::user::data_description::verify_ssl%]" }, "title": "Reconfigure Paperless-ngx instance" } diff --git a/tests/components/paperless_ngx/const.py b/tests/components/paperless_ngx/const.py index addfd54a001..36f62b507dd 100644 --- a/tests/components/paperless_ngx/const.py +++ b/tests/components/paperless_ngx/const.py @@ -1,15 +1,17 @@ """Constants for the Paperless NGX integration tests.""" -from homeassistant.const import CONF_API_KEY, CONF_URL +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL USER_INPUT_ONE = { CONF_URL: "https://192.168.69.16:8000", CONF_API_KEY: "12345678", + CONF_VERIFY_SSL: True, } USER_INPUT_TWO = { CONF_URL: "https://paperless.example.de", CONF_API_KEY: "87654321", + CONF_VERIFY_SSL: True, } USER_INPUT_REAUTH = {CONF_API_KEY: "192837465"} From 25c408484c278cac35c371aa3940478cfe496a86 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 16 Jun 2025 06:35:56 -0600 Subject: [PATCH 0326/1664] Set goalzero total run time sensor device class to duration (#146897) --- homeassistant/components/goalzero/sensor.py | 1 + tests/components/goalzero/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 7b5f8955947..67441930f7a 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -109,6 +109,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="timestamp", translation_key="timestamp", + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 6421f0c526c..0ac829d07b5 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -97,7 +97,7 @@ async def test_sensors( assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_total_run_time") assert state.state == "1720984" - assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DURATION assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.SECONDS assert state.attributes.get(ATTR_STATE_CLASS) is None state = hass.states.get(f"sensor.{DEFAULT_NAME}_wi_fi_ssid") From d657964729fff9dda9685e300d18797a3a0a6eae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:37:38 +0200 Subject: [PATCH 0327/1664] Simplify habitica service actions (#146746) --- homeassistant/components/habitica/services.py | 1075 +++++++++-------- 1 file changed, 540 insertions(+), 535 deletions(-) diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py index c5207ae4ec0..38833f26932 100644 --- a/homeassistant/components/habitica/services.py +++ b/homeassistant/components/habitica/services.py @@ -250,56 +250,203 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry: return entry -@callback -def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 - """Set up services for Habitica integration.""" +async def _cast_skill(call: ServiceCall) -> ServiceResponse: + """Skill action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data - async def cast_skill(call: ServiceCall) -> ServiceResponse: - """Skill action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data + skill = SKILL_MAP[call.data[ATTR_SKILL]] + cost = COST_MAP[call.data[ATTR_SKILL]] - skill = SKILL_MAP[call.data[ATTR_SKILL]] - cost = COST_MAP[call.data[ATTR_SKILL]] + try: + task_id = next( + task.id + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e - try: - task_id = next( - task.id - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - ) - except StopIteration as e: + try: + response = await coordinator.habitica.cast_skill(skill, task_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_enough_mana", + translation_placeholders={ + "cost": cost, + "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + }, + ) from e + except NotFoundError as e: + # could also be task not found, but the task is looked up + # before the request, so most likely wrong skill selected + # or the skill hasn't been unlocked yet. + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="skill_not_found", + translation_placeholders={"skill": call.data[ATTR_SKILL]}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + await coordinator.async_request_refresh() + return asdict(response.data) + + +async def _manage_quests(call: ServiceCall) -> ServiceResponse: + """Accept, reject, start, leave or cancel quests.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + FUNC_MAP = { + SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, + SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, + SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, + SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, + SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, + SERVICE_START_QUEST: coordinator.habitica.start_quest, + } + + func = FUNC_MAP[call.service] + + try: + response = await func() + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_action_unallowed" + ) from e + except NotFoundError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, translation_key="quest_not_found" + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) + + +async def _score_task(call: ServiceCall) -> ServiceResponse: + """Score a task action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + direction = ( + Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP + ) + try: + task_id, task_value = next( + (task.id, task.value) + for task in coordinator.data.tasks + if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="task_not_found", + translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, + ) from e + + if TYPE_CHECKING: + assert task_id + try: + response = await coordinator.habitica.update_score(task_id, direction) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + if task_value is not None: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e - - try: - response = await coordinator.habitica.cast_skill(skill, task_id) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_mana", + translation_key="not_enough_gold", translation_placeholders={ - "cost": cost, - "mana": f"{int(coordinator.data.user.stats.mp or 0)} MP", + "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", + "cost": f"{task_value:.2f} GP", }, ) from e + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + await coordinator.async_request_refresh() + return asdict(response.data) + + +async def _transformation(call: ServiceCall) -> ServiceResponse: + """User a transformation item on a player character.""" + + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + + item = ITEMID_MAP[call.data[ATTR_ITEM]] + # check if target is self + if call.data[ATTR_TARGET] in ( + str(coordinator.data.user.id), + coordinator.data.user.profile.name, + coordinator.data.user.auth.local.username, + ): + target_id = coordinator.data.user.id + else: + # check if target is a party member + try: + party = await coordinator.habitica.get_group_members(public_fields=True) except NotFoundError as e: - # could also be task not found, but the task is looked up - # before the request, so most likely wrong skill selected - # or the skill hasn't been unlocked yet. raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="skill_not_found", - translation_placeholders={"skill": call.data[ATTR_SKILL]}, + translation_key="party_not_found", ) from e except HabiticaException as e: raise HomeAssistantError( @@ -313,86 +460,125 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="service_call_exception", translation_placeholders={"reason": str(e)}, ) from e - else: - await coordinator.async_request_refresh() - return asdict(response.data) + try: + target_id = next( + member.id + for member in party.data + if member.id + and call.data[ATTR_TARGET].lower() + in ( + str(member.id), + str(member.auth.local.username).lower(), + str(member.profile.name).lower(), + ) + ) + except StopIteration as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="target_not_found", + translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, + ) from e + try: + response = await coordinator.habitica.cast_skill(item, target_id) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except NotAuthorizedError as e: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="item_not_found", + translation_placeholders={"item": call.data[ATTR_ITEM]}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return asdict(response.data) - async def manage_quests(call: ServiceCall) -> ServiceResponse: - """Accept, reject, start, leave or cancel quests.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - FUNC_MAP = { - SERVICE_ABORT_QUEST: coordinator.habitica.abort_quest, - SERVICE_ACCEPT_QUEST: coordinator.habitica.accept_quest, - SERVICE_CANCEL_QUEST: coordinator.habitica.cancel_quest, - SERVICE_LEAVE_QUEST: coordinator.habitica.leave_quest, - SERVICE_REJECT_QUEST: coordinator.habitica.reject_quest, - SERVICE_START_QUEST: coordinator.habitica.start_quest, +async def _get_tasks(call: ServiceCall) -> ServiceResponse: + """Get tasks action.""" + + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + response: list[TaskData] = coordinator.data.tasks + + if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: + response = [task for task in response if task.Type in types] + + if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: + response = [task for task in response if task.priority in priority] + + if tasks := call.data.get(ATTR_TASK): + response = [ + task + for task in response + if str(task.id) in tasks or task.alias in tasks or task.text in tasks + ] + + if tags := call.data.get(ATTR_TAG): + tag_ids = { + tag.id + for tag in coordinator.data.user.tags + if (tag.name and tag.name.lower()) + in (tag.lower() for tag in tags) # Case-insensitive matching + and tag.id } - func = FUNC_MAP[call.service] + response = [ + task + for task in response + if any(tag_id in task.tags for tag_id in tag_ids if task.tags) + ] + if keyword := call.data.get(ATTR_KEYWORD): + keyword = keyword.lower() + response = [ + task + for task in response + if (task.text and keyword in task.text.lower()) + or (task.notes and keyword in task.notes.lower()) + or any(keyword in item.text.lower() for item in task.checklist) + ] + result: dict[str, Any] = { + "tasks": [task.to_dict(omit_none=False) for task in response] + } + return result + + +async def _create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 + """Create or update task action.""" + entry = get_config_entry(call.hass, call.data[ATTR_CONFIG_ENTRY]) + coordinator = entry.runtime_data + await coordinator.async_refresh() + is_update = call.service in ( + SERVICE_UPDATE_HABIT, + SERVICE_UPDATE_REWARD, + SERVICE_UPDATE_TODO, + SERVICE_UPDATE_DAILY, + ) + task_type = SERVICE_TASK_TYPE_MAP[call.service] + current_task = None + + if is_update: try: - response = await func() - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_action_unallowed" - ) from e - except NotFoundError as e: - raise ServiceValidationError( - translation_domain=DOMAIN, translation_key="quest_not_found" - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return asdict(response.data) - - for service in ( - SERVICE_ABORT_QUEST, - SERVICE_ACCEPT_QUEST, - SERVICE_CANCEL_QUEST, - SERVICE_LEAVE_QUEST, - SERVICE_REJECT_QUEST, - SERVICE_START_QUEST, - ): - hass.services.async_register( - DOMAIN, - service, - manage_quests, - schema=SERVICE_MANAGE_QUEST_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - - async def score_task(call: ServiceCall) -> ServiceResponse: - """Score a task action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - - direction = ( - Direction.DOWN if call.data.get(ATTR_DIRECTION) == "down" else Direction.UP - ) - try: - task_id, task_value = next( - (task.id, task.value) + current_task = next( + task for task in coordinator.data.tasks if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) + and task.Type is task_type ) except StopIteration as e: raise ServiceValidationError( @@ -401,69 +587,48 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, ) from e - if TYPE_CHECKING: - assert task_id - try: - response = await coordinator.habitica.update_score(task_id, direction) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: - if task_value is not None: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="not_enough_gold", - translation_placeholders={ - "gold": f"{(coordinator.data.user.stats.gp or 0):.2f} GP", - "cost": f"{task_value:.2f} GP", - }, - ) from e - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": e.error.message}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - await coordinator.async_request_refresh() - return asdict(response.data) + data = Task() - async def transformation(call: ServiceCall) -> ServiceResponse: - """User a transformation item on a player character.""" + if not is_update: + data["type"] = task_type - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data + if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): + data["text"] = text + + if (notes := call.data.get(ATTR_NOTES)) is not None: + data["notes"] = notes + + tags = cast(list[str], call.data.get(ATTR_TAG)) + remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) + + if tags or remove_tags: + update_tags = set(current_task.tags) if current_task else set() + user_tags = { + tag.name.lower(): tag.id + for tag in coordinator.data.user.tags + if tag.id and tag.name + } + + if tags: + # Creates new tag if it doesn't exist + async def create_tag(tag_name: str) -> UUID: + tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id + if TYPE_CHECKING: + assert tag_id + return tag_id - item = ITEMID_MAP[call.data[ATTR_ITEM]] - # check if target is self - if call.data[ATTR_TARGET] in ( - str(coordinator.data.user.id), - coordinator.data.user.profile.name, - coordinator.data.user.auth.local.username, - ): - target_id = coordinator.data.user.id - else: - # check if target is a party member try: - party = await coordinator.habitica.get_group_members(public_fields=True) - except NotFoundError as e: - raise ServiceValidationError( + update_tags.update( + { + user_tags.get(tag_name.lower()) or (await create_tag(tag_name)) + for tag_name in tags + } + ) + except TooManyRequestsError as e: + raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="party_not_found", + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, ) from e except HabiticaException as e: raise HomeAssistantError( @@ -477,378 +642,218 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 translation_key="service_call_exception", translation_placeholders={"reason": str(e)}, ) from e - try: - target_id = next( - member.id - for member in party.data - if member.id - and call.data[ATTR_TARGET].lower() - in ( - str(member.id), - str(member.auth.local.username).lower(), - str(member.profile.name).lower(), - ) + + if remove_tags: + update_tags.difference_update( + { + user_tags[tag_name.lower()] + for tag_name in remove_tags + if tag_name.lower() in user_tags + } + ) + + data["tags"] = list(update_tags) + + if (alias := call.data.get(ATTR_ALIAS)) is not None: + data["alias"] = alias + + if (cost := call.data.get(ATTR_COST)) is not None: + data["value"] = cost + + if priority := call.data.get(ATTR_PRIORITY): + data["priority"] = TaskPriority[priority] + + if frequency := call.data.get(ATTR_FREQUENCY): + data["frequency"] = frequency + else: + frequency = current_task.frequency if current_task else Frequency.WEEKLY + + if up_down := call.data.get(ATTR_UP_DOWN): + data["up"] = "up" in up_down + data["down"] = "down" in up_down + + if counter_up := call.data.get(ATTR_COUNTER_UP): + data["counterUp"] = counter_up + + if counter_down := call.data.get(ATTR_COUNTER_DOWN): + data["counterDown"] = counter_down + + if due_date := call.data.get(ATTR_DATE): + data["date"] = datetime.combine(due_date, time()) + + if call.data.get(ATTR_CLEAR_DATE): + data["date"] = None + + checklist = current_task.checklist if current_task else [] + + if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): + checklist.extend( + Checklist(completed=False, id=uuid4(), text=item) + for item in add_checklist_item + if not any(i.text == item for i in checklist) + ) + if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): + checklist = [ + item for item in checklist if item.text not in remove_checklist_item + ] + + if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in score_checklist_item: + item.completed = True + + if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): + for item in checklist: + if item.text in unscore_checklist_item: + item.completed = False + if ( + add_checklist_item + or remove_checklist_item + or score_checklist_item + or unscore_checklist_item + ): + data["checklist"] = checklist + + reminders = current_task.reminders if current_task else [] + + if add_reminders := call.data.get(ATTR_REMINDER): + if task_type is TaskType.TODO: + existing_reminder_datetimes = { + r.time.replace(tzinfo=None) for r in reminders + } + + reminders.extend( + Reminders(id=uuid4(), time=r) + for r in add_reminders + if r not in existing_reminder_datetimes + ) + if task_type is TaskType.DAILY: + existing_reminder_times = { + r.time.time().replace(microsecond=0, second=0) for r in reminders + } + + reminders.extend( + Reminders( + id=uuid4(), + time=datetime.combine(date.today(), r, tzinfo=UTC), ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="target_not_found", - translation_placeholders={"target": f"'{call.data[ATTR_TARGET]}'"}, - ) from e - try: - response = await coordinator.habitica.cast_skill(item, target_id) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except NotAuthorizedError as e: + for r in add_reminders + if r not in existing_reminder_times + ) + + if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): + if task_type is TaskType.TODO: + reminders = list( + filter( + lambda r: r.time.replace(tzinfo=None) not in remove_reminder, + reminders, + ) + ) + if task_type is TaskType.DAILY: + reminders = list( + filter( + lambda r: r.time.time().replace(second=0, microsecond=0) + not in remove_reminder, + reminders, + ) + ) + + if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): + reminders = [] + + if add_reminders or remove_reminder or clear_reminders: + data["reminders"] = reminders + + if start_date := call.data.get(ATTR_START_DATE): + data["startDate"] = datetime.combine(start_date, time()) + else: + start_date = ( + current_task.startDate + if current_task and current_task.startDate + else dt_util.start_of_local_day() + ) + if repeat := call.data.get(ATTR_REPEAT): + if frequency is Frequency.WEEKLY: + data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) + else: raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="item_not_found", - translation_placeholders={"item": call.data[ATTR_ITEM]}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( + translation_key="frequency_not_weekly", + ) + if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): + if frequency is not Frequency.MONTHLY: + raise ServiceValidationError( translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e + translation_key="frequency_not_monthly", + ) + + if repeat_monthly == "day_of_week": + weekday = start_date.weekday() + data["weeksOfMonth"] = [(start_date.day - 1) // 7] + data["repeat"] = Repeat( + **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} + ) + data["daysOfMonth"] = [] + else: - return asdict(response.data) + data["daysOfMonth"] = [start_date.day] + data["weeksOfMonth"] = [] - async def get_tasks(call: ServiceCall) -> ServiceResponse: - """Get tasks action.""" + if interval := call.data.get(ATTR_INTERVAL): + data["everyX"] = interval - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - response: list[TaskData] = coordinator.data.tasks - - if types := {TaskType[x] for x in call.data.get(ATTR_TYPE, [])}: - response = [task for task in response if task.Type in types] - - if priority := {TaskPriority[x] for x in call.data.get(ATTR_PRIORITY, [])}: - response = [task for task in response if task.priority in priority] - - if tasks := call.data.get(ATTR_TASK): - response = [ - task - for task in response - if str(task.id) in tasks or task.alias in tasks or task.text in tasks - ] - - if tags := call.data.get(ATTR_TAG): - tag_ids = { - tag.id - for tag in coordinator.data.user.tags - if (tag.name and tag.name.lower()) - in (tag.lower() for tag in tags) # Case-insensitive matching - and tag.id - } - - response = [ - task - for task in response - if any(tag_id in task.tags for tag_id in tag_ids if task.tags) - ] - if keyword := call.data.get(ATTR_KEYWORD): - keyword = keyword.lower() - response = [ - task - for task in response - if (task.text and keyword in task.text.lower()) - or (task.notes and keyword in task.notes.lower()) - or any(keyword in item.text.lower() for item in task.checklist) - ] - result: dict[str, Any] = { - "tasks": [task.to_dict(omit_none=False) for task in response] - } - - return result - - async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901 - """Create or update task action.""" - entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - coordinator = entry.runtime_data - await coordinator.async_refresh() - is_update = call.service in ( - SERVICE_UPDATE_HABIT, - SERVICE_UPDATE_REWARD, - SERVICE_UPDATE_TODO, - SERVICE_UPDATE_DAILY, - ) - task_type = SERVICE_TASK_TYPE_MAP[call.service] - current_task = None + if streak := call.data.get(ATTR_STREAK): + data["streak"] = streak + try: if is_update: - try: - current_task = next( - task - for task in coordinator.data.tasks - if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text) - and task.Type is task_type - ) - except StopIteration as e: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="task_not_found", - translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"}, - ) from e - - data = Task() - - if not is_update: - data["type"] = task_type - - if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)): - data["text"] = text - - if (notes := call.data.get(ATTR_NOTES)) is not None: - data["notes"] = notes - - tags = cast(list[str], call.data.get(ATTR_TAG)) - remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG)) - - if tags or remove_tags: - update_tags = set(current_task.tags) if current_task else set() - user_tags = { - tag.name.lower(): tag.id - for tag in coordinator.data.user.tags - if tag.id and tag.name - } - - if tags: - # Creates new tag if it doesn't exist - async def create_tag(tag_name: str) -> UUID: - tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id - if TYPE_CHECKING: - assert tag_id - return tag_id - - try: - update_tags.update( - { - user_tags.get(tag_name.lower()) - or (await create_tag(tag_name)) - for tag_name in tags - } - ) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - - if remove_tags: - update_tags.difference_update( - { - user_tags[tag_name.lower()] - for tag_name in remove_tags - if tag_name.lower() in user_tags - } - ) - - data["tags"] = list(update_tags) - - if (alias := call.data.get(ATTR_ALIAS)) is not None: - data["alias"] = alias - - if (cost := call.data.get(ATTR_COST)) is not None: - data["value"] = cost - - if priority := call.data.get(ATTR_PRIORITY): - data["priority"] = TaskPriority[priority] - - if frequency := call.data.get(ATTR_FREQUENCY): - data["frequency"] = frequency + if TYPE_CHECKING: + assert current_task + assert current_task.id + response = await coordinator.habitica.update_task(current_task.id, data) else: - frequency = current_task.frequency if current_task else Frequency.WEEKLY + response = await coordinator.habitica.create_task(data) + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e.error.message)}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + else: + return response.data.to_dict(omit_none=True) - if up_down := call.data.get(ATTR_UP_DOWN): - data["up"] = "up" in up_down - data["down"] = "down" in up_down - if counter_up := call.data.get(ATTR_COUNTER_UP): - data["counterUp"] = counter_up +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Habitica integration.""" - if counter_down := call.data.get(ATTR_COUNTER_DOWN): - data["counterDown"] = counter_down - - if due_date := call.data.get(ATTR_DATE): - data["date"] = datetime.combine(due_date, time()) - - if call.data.get(ATTR_CLEAR_DATE): - data["date"] = None - - checklist = current_task.checklist if current_task else [] - - if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM): - checklist.extend( - Checklist(completed=False, id=uuid4(), text=item) - for item in add_checklist_item - if not any(i.text == item for i in checklist) - ) - if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM): - checklist = [ - item for item in checklist if item.text not in remove_checklist_item - ] - - if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM): - for item in checklist: - if item.text in score_checklist_item: - item.completed = True - - if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM): - for item in checklist: - if item.text in unscore_checklist_item: - item.completed = False - if ( - add_checklist_item - or remove_checklist_item - or score_checklist_item - or unscore_checklist_item - ): - data["checklist"] = checklist - - reminders = current_task.reminders if current_task else [] - - if add_reminders := call.data.get(ATTR_REMINDER): - if task_type is TaskType.TODO: - existing_reminder_datetimes = { - r.time.replace(tzinfo=None) for r in reminders - } - - reminders.extend( - Reminders(id=uuid4(), time=r) - for r in add_reminders - if r not in existing_reminder_datetimes - ) - if task_type is TaskType.DAILY: - existing_reminder_times = { - r.time.time().replace(microsecond=0, second=0) for r in reminders - } - - reminders.extend( - Reminders( - id=uuid4(), - time=datetime.combine(date.today(), r, tzinfo=UTC), - ) - for r in add_reminders - if r not in existing_reminder_times - ) - - if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER): - if task_type is TaskType.TODO: - reminders = list( - filter( - lambda r: r.time.replace(tzinfo=None) not in remove_reminder, - reminders, - ) - ) - if task_type is TaskType.DAILY: - reminders = list( - filter( - lambda r: r.time.time().replace(second=0, microsecond=0) - not in remove_reminder, - reminders, - ) - ) - - if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER): - reminders = [] - - if add_reminders or remove_reminder or clear_reminders: - data["reminders"] = reminders - - if start_date := call.data.get(ATTR_START_DATE): - data["startDate"] = datetime.combine(start_date, time()) - else: - start_date = ( - current_task.startDate - if current_task and current_task.startDate - else dt_util.start_of_local_day() - ) - if repeat := call.data.get(ATTR_REPEAT): - if frequency is Frequency.WEEKLY: - data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS}) - else: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="frequency_not_weekly", - ) - if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY): - if frequency is not Frequency.MONTHLY: - raise ServiceValidationError( - translation_domain=DOMAIN, - translation_key="frequency_not_monthly", - ) - - if repeat_monthly == "day_of_week": - weekday = start_date.weekday() - data["weeksOfMonth"] = [(start_date.day - 1) // 7] - data["repeat"] = Repeat( - **{day: i == weekday for i, day in enumerate(WEEK_DAYS)} - ) - data["daysOfMonth"] = [] - - else: - data["daysOfMonth"] = [start_date.day] - data["weeksOfMonth"] = [] - - if interval := call.data.get(ATTR_INTERVAL): - data["everyX"] = interval - - if streak := call.data.get(ATTR_STREAK): - data["streak"] = streak - - try: - if is_update: - if TYPE_CHECKING: - assert current_task - assert current_task.id - response = await coordinator.habitica.update_task(current_task.id, data) - else: - response = await coordinator.habitica.create_task(data) - except TooManyRequestsError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="setup_rate_limit_exception", - translation_placeholders={"retry_after": str(e.retry_after)}, - ) from e - except HabiticaException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e.error.message)}, - ) from e - except ClientError as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="service_call_exception", - translation_placeholders={"reason": str(e)}, - ) from e - else: - return response.data.to_dict(omit_none=True) + for service in ( + SERVICE_ABORT_QUEST, + SERVICE_ACCEPT_QUEST, + SERVICE_CANCEL_QUEST, + SERVICE_LEAVE_QUEST, + SERVICE_REJECT_QUEST, + SERVICE_START_QUEST, + ): + hass.services.async_register( + DOMAIN, + service, + _manage_quests, + schema=SERVICE_MANAGE_QUEST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) for service in ( SERVICE_UPDATE_DAILY, @@ -859,7 +864,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, service, - create_or_update_task, + _create_or_update_task, schema=SERVICE_UPDATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -872,7 +877,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, service, - create_or_update_task, + _create_or_update_task, schema=SERVICE_CREATE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -880,7 +885,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_CAST_SKILL, - cast_skill, + _cast_skill, schema=SERVICE_CAST_SKILL_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -888,14 +893,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_SCORE_HABIT, - score_task, + _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_SCORE_REWARD, - score_task, + _score_task, schema=SERVICE_SCORE_TASK_SCHEMA, supports_response=SupportsResponse.ONLY, ) @@ -903,14 +908,14 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 hass.services.async_register( DOMAIN, SERVICE_TRANSFORMATION, - transformation, + _transformation, schema=SERVICE_TRANSFORMATION_SCHEMA, supports_response=SupportsResponse.ONLY, ) hass.services.async_register( DOMAIN, SERVICE_GET_TASKS, - get_tasks, + _get_tasks, schema=SERVICE_GET_TASKS_SCHEMA, supports_response=SupportsResponse.ONLY, ) From 38973fe64a37d6928fa2a62e5b698c4defa6ba3a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 16 Jun 2025 14:40:19 +0200 Subject: [PATCH 0328/1664] Add Reolink privacy mask switch (#146906) --- homeassistant/components/reolink/icons.json | 6 ++++++ homeassistant/components/reolink/strings.json | 3 +++ homeassistant/components/reolink/switch.py | 9 +++++++++ tests/components/reolink/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 22 insertions(+) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index d998cc79ce8..cf3079e51e8 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -491,6 +491,12 @@ "state": { "on": "mdi:eye-off" } + }, + "privacy_mask": { + "default": "mdi:eye", + "state": { + "on": "mdi:eye-off" + } } } }, diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 59d2ce95df4..e7a970ec1c8 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -960,6 +960,9 @@ }, "privacy_mode": { "name": "Privacy mode" + }, + "privacy_mask": { + "name": "Privacy mask" } } } diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index d9f192a3faa..47b14f7f4ad 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -216,6 +216,15 @@ SWITCH_ENTITIES = ( value=lambda api, ch: api.baichuan.privacy_mode(ch), method=lambda api, ch, value: api.baichuan.set_privacy_mode(ch, value), ), + ReolinkSwitchEntityDescription( + key="privacy_mask", + cmd_key="GetMask", + translation_key="privacy_mask", + entity_category=EntityCategory.CONFIG, + supported=lambda api, ch: api.supported(ch, "privacy_mask"), + value=lambda api, ch: api.privacy_mask_enabled(ch), + method=lambda api, ch, value: api.set_privacy_mask(ch, enable=value), + ), ReolinkSwitchEntityDescription( key="hardwired_chime_enabled", cmd_key="483", diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index d81b39738e5..a6d7f14a149 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -148,6 +148,10 @@ '0': 1, 'null': 1, }), + 'GetMask': dict({ + '0': 1, + 'null': 1, + }), 'GetMdAlarm': dict({ '0': 1, 'null': 1, From add9f4c5ab167a97a31d83ce4975d51a6b03f303 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 14:48:44 +0200 Subject: [PATCH 0329/1664] Move Meater coordinator to module (#146946) * Move Meater coordinator to module * Fix tests --- homeassistant/components/meater/__init__.py | 68 +---------------- .../components/meater/coordinator.py | 75 +++++++++++++++++++ homeassistant/components/meater/sensor.py | 14 +--- tests/components/meater/test_config_flow.py | 4 +- 4 files changed, 86 insertions(+), 75 deletions(-) create mode 100644 homeassistant/components/meater/coordinator.py diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 50eff40c0e8..3d4f45206b6 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -1,85 +1,25 @@ """The Meater Temperature Probe integration.""" -import asyncio -from datetime import timedelta -import logging - -from meater import ( - AuthenticationError, - MeaterApi, - ServiceUnavailableError, - TooManyRequestsError, -) -from meater.MeaterApi import MeaterProbe - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN +from .coordinator import MeaterCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Meater Temperature Probe from a config entry.""" - # Store an API object to access - session = async_get_clientsession(hass) - meater_api = MeaterApi(session) - # Add the credentials - try: - _LOGGER.debug("Authenticating with the Meater API") - await meater_api.authenticate( - entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD] - ) - except (ServiceUnavailableError, TooManyRequestsError) as err: - raise ConfigEntryNotReady from err - except AuthenticationError as err: - raise ConfigEntryAuthFailed( - f"Unable to authenticate with the Meater API: {err}" - ) from err - - async def async_update_data() -> dict[str, MeaterProbe]: - """Fetch data from API endpoint.""" - try: - # Note: TimeoutError and aiohttp.ClientError are already - # handled by the data update coordinator. - async with asyncio.timeout(10): - devices: list[MeaterProbe] = await meater_api.get_all_devices() - except AuthenticationError as err: - raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err - except TooManyRequestsError as err: - raise UpdateFailed( - "Too many requests have been made to the API, rate limiting is in place" - ) from err - - return {device.id: device for device in devices} - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - # Name of the data. For logging purposes. - name="meater_api", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=30), - ) + coordinator = MeaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN].setdefault("known_probes", set()) - hass.data[DOMAIN][entry.entry_id] = { - "api": meater_api, - "coordinator": coordinator, - } + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py new file mode 100644 index 00000000000..7fb0c69d4eb --- /dev/null +++ b/homeassistant/components/meater/coordinator.py @@ -0,0 +1,75 @@ +"""Meater Coordinator.""" + +import asyncio +from datetime import timedelta +import logging + +from meater.MeaterApi import ( + AuthenticationError, + MeaterApi, + MeaterProbe, + ServiceUnavailableError, + TooManyRequestsError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): + """Meater Coordinator.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize the Meater Coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"Meater {entry.title}", + update_interval=timedelta(seconds=30), + ) + session = async_get_clientsession(hass) + self.client = MeaterApi(session) + + async def _async_setup(self) -> None: + """Set up the Meater Coordinator.""" + try: + _LOGGER.debug("Authenticating with the Meater API") + await self.client.authenticate( + self.config_entry.data[CONF_USERNAME], + self.config_entry.data[CONF_PASSWORD], + ) + except (ServiceUnavailableError, TooManyRequestsError) as err: + raise UpdateFailed from err + except AuthenticationError as err: + raise ConfigEntryAuthFailed( + f"Unable to authenticate with the Meater API: {err}" + ) from err + + async def _async_update_data(self) -> dict[str, MeaterProbe]: + """Fetch data from API endpoint.""" + try: + # Note: TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + async with asyncio.timeout(10): + devices: list[MeaterProbe] = await self.client.get_all_devices() + except AuthenticationError as err: + raise ConfigEntryAuthFailed("The API call wasn't authenticated") from err + except TooManyRequestsError as err: + raise UpdateFailed( + "Too many requests have been made to the API, rate limiting is in place" + ) from err + + return {device.id: device for device in devices} diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 00fc28b8718..cf1c72de85e 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -19,12 +19,10 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from . import MeaterCoordinator from .const import DOMAIN @@ -141,9 +139,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" - coordinator: DataUpdateCoordinator[dict[str, MeaterProbe]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinator"] + coordinator: MeaterCoordinator = hass.data[DOMAIN][entry.entry_id] @callback def async_update_data(): @@ -176,9 +172,7 @@ async def async_setup_entry( coordinator.async_add_listener(async_update_data) -class MeaterProbeTemperature( - SensorEntity, CoordinatorEntity[DataUpdateCoordinator[dict[str, MeaterProbe]]] -): +class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]): """Meater Temperature Sensor Entity.""" entity_description: MeaterSensorEntityDescription diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index 9049cf4ac9a..bb4055c7fae 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -23,7 +23,9 @@ def mock_client(): @pytest.fixture def mock_meater(mock_client): """Mock the meater library.""" - with patch("homeassistant.components.meater.MeaterApi.authenticate") as mock_: + with patch( + "homeassistant.components.meater.coordinator.MeaterApi.authenticate" + ) as mock_: mock_.side_effect = mock_client yield mock_ From f5355c833e4e9afc471b80b6e345633b3368136d Mon Sep 17 00:00:00 2001 From: "Etienne C." <59794011+etiennec78@users.noreply.github.com> Date: Mon, 16 Jun 2025 15:14:43 +0200 Subject: [PATCH 0330/1664] Add duration device class in Here Travel Time sensors (#146804) --- homeassistant/components/here_travel_time/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index bbaabb56d46..71184797a2e 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -55,6 +55,7 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( @@ -62,6 +63,7 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] icon=ICONS.get(travel_mode, ICON_CAR), key=ATTR_DURATION_IN_TRAFFIC, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( From 6e9224779937bc43a361399df7ba5713a0860a11 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 16 Jun 2025 15:15:17 +0200 Subject: [PATCH 0331/1664] Fix missing key for ecosmart in older Wallbox models (#146847) * fix 146839, missing key * added tests for this issue * added tests for this issue * added tests for this issue, formatting * Prevent loading select on missing key * Prevent loading select on missing key - formatting fixed * Update homeassistant/components/wallbox/coordinator.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/wallbox/const.py | 1 + .../components/wallbox/coordinator.py | 21 ++++++++++------ homeassistant/components/wallbox/select.py | 16 ++++++------ tests/components/wallbox/__init__.py | 25 +++++++++++++++++++ tests/components/wallbox/test_init.py | 13 ++++++++++ 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index d978e1ec7c9..5aa659a0527 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -74,3 +74,4 @@ class EcoSmartMode(StrEnum): OFF = "off" ECO_MODE = "eco_mode" FULL_SOLAR = "full_solar" + DISABLED = "disabled" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 60f062e57cc..8276ee14eaf 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -166,13 +166,20 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) # Set current solar charging mode - eco_smart_enabled = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_STATUS_KEY - ] - eco_smart_mode = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_MODE_KEY - ] - if eco_smart_enabled is False: + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) + + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF elif eco_smart_mode == 0: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 7ad7a135bc8..0048aa35c7c 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -63,15 +63,15 @@ async def async_setup_entry( ) -> None: """Create wallbox select entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities( - WallboxSelect(coordinator, description) - for ent in coordinator.data - if ( - (description := SELECT_TYPES.get(ent)) - and description.supported_fn(coordinator) + if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED: + async_add_entities( + WallboxSelect(coordinator, description) + for ent in coordinator.data + if ( + (description := SELECT_TYPES.get(ent)) + and description.supported_fn(coordinator) + ) ) - ) class WallboxSelect(WallboxEntity, SelectEntity): diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index d347777f7e8..83e39d2f602 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -216,6 +216,31 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response_no_power_boost, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_select( hass: HomeAssistant, entry: MockConfigEntry, response ) -> None: diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index b4b5a199243..6d6a5cd1417 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -13,6 +13,7 @@ from . import ( authorisation_response, setup_integration, setup_integration_connection_error, + setup_integration_no_eco_mode, setup_integration_read_only, test_response, ) @@ -138,3 +139,15 @@ async def test_wallbox_refresh_failed_read_only( assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_setup_load_entry_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox Unload.""" + + await setup_integration_no_eco_mode(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED From d4686a3ccebbd6763a5d6f320cfb137023b77f74 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 16 Jun 2025 15:28:25 +0200 Subject: [PATCH 0332/1664] Add config flow data description for NextDNS (#146938) * Add config flow data description * Better wording --- homeassistant/components/nextdns/strings.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 38944a0711e..76d37691f77 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -4,16 +4,25 @@ "user": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "API Key for your NextDNS account" } }, "profiles": { "data": { "profile": "Profile" + }, + "data_description": { + "profile": "NextDNS configuration profile you want to integrate" } }, "reauth_confirm": { "data": { "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" } } }, From 664441eaec2151942e711a55fe1bb35735648f05 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 15:40:43 +0200 Subject: [PATCH 0333/1664] Improve Meater config flow tests (#146951) --- tests/components/meater/conftest.py | 49 +++++ tests/components/meater/test_config_flow.py | 192 +++++++++----------- 2 files changed, 135 insertions(+), 106 deletions(-) create mode 100644 tests/components/meater/conftest.py diff --git a/tests/components/meater/conftest.py b/tests/components/meater/conftest.py new file mode 100644 index 00000000000..8a84da859f7 --- /dev/null +++ b/tests/components/meater/conftest.py @@ -0,0 +1,49 @@ +"""Meater tests configuration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.meater.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.meater.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_meater_client() -> Generator[AsyncMock]: + """Mock a Meater client.""" + with ( + patch( + "homeassistant.components.meater.coordinator.MeaterApi", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.meater.config_flow.MeaterApi", + new=mock_client, + ), + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Meater", + data={CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + unique_id="user@host.com", + ) diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index bb4055c7fae..c6704f2f3f7 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -1,12 +1,12 @@ """Define tests for the Meater config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from meater import AuthenticationError, ServiceUnavailableError import pytest -from homeassistant import config_entries from homeassistant.components.meater import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -14,134 +14,114 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -@pytest.fixture -def mock_client(): - """Define a fixture for authentication coroutine.""" - return AsyncMock(return_value=None) - - -@pytest.fixture -def mock_meater(mock_client): - """Mock the meater library.""" - with patch( - "homeassistant.components.meater.coordinator.MeaterApi.authenticate" - ) as mock_: - mock_.side_effect = mock_client - yield mock_ - - -async def test_duplicate_error(hass: HomeAssistant) -> None: - """Test that errors are shown when duplicates are added.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( - hass - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=Exception)]) -async def test_unknown_auth_error(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "unknown_auth_error"} - - -@pytest.mark.parametrize("mock_client", [AsyncMock(side_effect=AuthenticationError)]) -async def test_invalid_credentials(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "invalid_auth"} - - -@pytest.mark.parametrize( - "mock_client", [AsyncMock(side_effect=ServiceUnavailableError)] -) -async def test_service_unavailable(hass: HomeAssistant, mock_meater) -> None: - """Test that an invalid API/App Key throws an error.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf - ) - assert result["errors"] == {"base": "service_unavailable_error"} - - -async def test_user_flow(hass: HomeAssistant, mock_meater) -> None: +async def test_user_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_meater_client: AsyncMock +) -> None: """Test that the user flow works.""" - conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER}, data=None + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - with patch( - "homeassistant.components.meater.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"], conf) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123", } + assert result["result"].unique_id == "user@host.com" assert len(mock_setup_entry.mock_calls) == 1 - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "password123", - } - -async def test_reauth_flow(hass: HomeAssistant, mock_meater) -> None: - """Test that the reauth flow works.""" - data = { - CONF_USERNAME: "user@host.com", - CONF_PASSWORD: "password123", - } - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id="user@host.com", - data=data, +@pytest.mark.parametrize( + ("exception", "error"), + [ + (AuthenticationError, "invalid_auth"), + (ServiceUnavailableError, "service_unavailable_error"), + (Exception, "unknown_auth_error"), + ], +) +async def test_user_flow_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_meater_client: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test that an invalid API/App Key throws an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} ) - mock_config.add_to_hass(hass) - result = await mock_config.start_reauth_flow(hass) + mock_meater_client.authenticate.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_meater_client.authenticate.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_meater_client: AsyncMock, +) -> None: + """Test that errors are shown when duplicates are added.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_meater_client: AsyncMock, +) -> None: + """Test that the reauth flow works.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result["errors"] is None + assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"password": "passwordabc"}, + {CONF_PASSWORD: "passwordabc"}, ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - assert config_entry.data == { + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data == { CONF_USERNAME: "user@host.com", CONF_PASSWORD: "passwordabc", } From cce878213f869392c6ca1d7bd0f30be7c8dd9d7e Mon Sep 17 00:00:00 2001 From: Aviad Levy Date: Mon, 16 Jun 2025 16:48:59 +0300 Subject: [PATCH 0334/1664] Add Telegram Bot message reactions (#146354) --- .../components/telegram_bot/__init__.py | 19 +++++++ homeassistant/components/telegram_bot/bot.py | 33 +++++++++++ .../components/telegram_bot/const.py | 3 + .../components/telegram_bot/icons.json | 3 + .../components/telegram_bot/services.yaml | 26 +++++++++ .../components/telegram_bot/strings.json | 26 +++++++++ .../telegram_bot/test_telegram_bot.py | 55 +++++++++++++++++-- 7 files changed, 161 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index f9472c50cae..554ddd8fc4e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -46,6 +46,7 @@ from .const import ( ATTR_DISABLE_WEB_PREV, ATTR_FILE, ATTR_IS_ANONYMOUS, + ATTR_IS_BIG, ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, ATTR_MESSAGE, @@ -58,6 +59,7 @@ from .const import ( ATTR_PARSER, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_REACTION, ATTR_RESIZE_KEYBOARD, ATTR_SHOW_ALERT, ATTR_STICKER_ID, @@ -94,6 +96,7 @@ from .const import ( SERVICE_SEND_STICKER, SERVICE_SEND_VIDEO, SERVICE_SEND_VOICE, + SERVICE_SET_MESSAGE_REACTION, ) _LOGGER = logging.getLogger(__name__) @@ -250,6 +253,19 @@ SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema( } ) +SERVICE_SCHEMA_SET_MESSAGE_REACTION = vol.Schema( + { + vol.Optional(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(ATTR_MESSAGEID): vol.Any( + cv.positive_int, vol.All(cv.string, "last") + ), + vol.Required(ATTR_CHAT_ID): vol.Coerce(int), + vol.Required(ATTR_REACTION): cv.string, + vol.Optional(ATTR_IS_BIG, default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, +) + SERVICE_MAP = { SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE, SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE, @@ -266,6 +282,7 @@ SERVICE_MAP = { SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE, SERVICE_LEAVE_CHAT: SERVICE_SCHEMA_LEAVE_CHAT, + SERVICE_SET_MESSAGE_REACTION: SERVICE_SCHEMA_SET_MESSAGE_REACTION, } @@ -378,6 +395,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: messages = await notify_service.leave_chat( context=service.context, **kwargs ) + elif msgtype == SERVICE_SET_MESSAGE_REACTION: + await notify_service.set_message_reaction(context=service.context, **kwargs) else: await notify_service.edit_message( msgtype, context=service.context, **kwargs diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 7749c7f1183..534923b3568 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -786,6 +786,39 @@ class TelegramNotificationService: self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context ) + async def set_message_reaction( + self, + chat_id: int, + reaction: str, + is_big: bool = False, + context: Context | None = None, + **kwargs, + ) -> None: + """Set the bot's reaction for a given message.""" + chat_id = self._get_target_chat_ids(chat_id)[0] + message_id, _ = self._get_msg_ids(kwargs, chat_id) + params = self._get_msg_kwargs(kwargs) + + _LOGGER.debug( + "Set reaction to message %s in chat ID %s to %s with params: %s", + message_id, + chat_id, + reaction, + params, + ) + + await self._send_msg( + self.bot.set_message_reaction, + "Error setting message reaction", + params[ATTR_MESSAGE_TAG], + chat_id, + message_id, + reaction=reaction, + is_big=is_big, + read_timeout=params[ATTR_TIMEOUT], + context=context, + ) + def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> Bot: """Initialize telegram bot with proxy support.""" diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index ca79fc868cf..4abdbaf9738 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -43,6 +43,7 @@ SERVICE_SEND_VOICE = "send_voice" SERVICE_SEND_DOCUMENT = "send_document" SERVICE_SEND_LOCATION = "send_location" SERVICE_SEND_POLL = "send_poll" +SERVICE_SET_MESSAGE_REACTION = "set_message_reaction" SERVICE_EDIT_MESSAGE = "edit_message" SERVICE_EDIT_CAPTION = "edit_caption" SERVICE_EDIT_REPLYMARKUP = "edit_replymarkup" @@ -87,6 +88,8 @@ ATTR_MSG = "message" ATTR_MSGID = "id" ATTR_PARSER = "parse_mode" ATTR_PASSWORD = "password" +ATTR_REACTION = "reaction" +ATTR_IS_BIG = "is_big" ATTR_REPLY_TO_MSGID = "reply_to_message_id" ATTR_REPLYMARKUP = "reply_markup" ATTR_SHOW_ALERT = "show_alert" diff --git a/homeassistant/components/telegram_bot/icons.json b/homeassistant/components/telegram_bot/icons.json index 8deecfb9c27..3a53e2b4118 100644 --- a/homeassistant/components/telegram_bot/icons.json +++ b/homeassistant/components/telegram_bot/icons.json @@ -44,6 +44,9 @@ }, "leave_chat": { "service": "mdi:exit-run" + }, + "set_message_reaction": { + "service": "mdi:emoticon-happy" } } } diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 1577d76b527..d5fc0e134d5 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -787,3 +787,29 @@ leave_chat: example: 12345 selector: text: + +set_message_reaction: + fields: + config_entry_id: + selector: + config_entry: + integration: telegram_bot + message_id: + required: true + example: 54321 + selector: + text: + chat_id: + required: true + example: 12345 + selector: + text: + reaction: + required: true + example: 👍 + selector: + text: + is_big: + required: false + selector: + boolean: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index d772edf1945..9fcc0740970 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -857,6 +857,32 @@ "description": "Chat ID of the group from which the bot should be removed." } } + }, + "set_message_reaction": { + "name": "Set message reaction", + "description": "Sets the bot's reaction for a given message.", + "fields": { + "config_entry_id": { + "name": "[%key:component::telegram_bot::services::send_message::fields::config_entry_id::name%]", + "description": "The config entry representing the Telegram bot to set the message reaction." + }, + "message_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::message_id::name%]", + "description": "ID of the message to react to." + }, + "chat_id": { + "name": "[%key:component::telegram_bot::services::edit_message::fields::chat_id::name%]", + "description": "ID of the chat containing the message." + }, + "reaction": { + "name": "Reaction", + "description": "Emoji reaction to use." + }, + "is_big": { + "name": "Large animation", + "description": "Whether the reaction animation should be large." + } + } } }, "exceptions": { diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index d276d72c8a6..24b6deb27b5 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -30,6 +30,7 @@ from homeassistant.components.telegram_bot import ( ATTR_OPTIONS, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_SHOW_ALERT, ATTR_STICKER_ID, ATTR_TARGET, ATTR_URL, @@ -752,20 +753,27 @@ async def test_answer_callback_query( await hass.async_block_till_done() with patch( - "homeassistant.components.telegram_bot.bot.TelegramNotificationService.answer_callback_query" + "homeassistant.components.telegram_bot.bot.Bot.answer_callback_query" ) as mock: await hass.services.async_call( DOMAIN, SERVICE_ANSWER_CALLBACK_QUERY, { ATTR_MESSAGE: "mock message", - ATTR_CALLBACK_QUERY_ID: 12345, + ATTR_CALLBACK_QUERY_ID: 123456, + ATTR_SHOW_ALERT: True, }, blocking=True, ) await hass.async_block_till_done() mock.assert_called_once() + mock.assert_called_with( + 123456, + text="mock message", + show_alert=True, + read_timeout=None, + ) async def test_leave_chat( @@ -779,20 +787,23 @@ async def test_leave_chat( await hass.async_block_till_done() with patch( - "homeassistant.components.telegram_bot.bot.TelegramNotificationService.leave_chat", + "homeassistant.components.telegram_bot.bot.Bot.leave_chat", AsyncMock(return_value=True), ) as mock: await hass.services.async_call( DOMAIN, SERVICE_LEAVE_CHAT, { - ATTR_CHAT_ID: 12345, + ATTR_CHAT_ID: 123456, }, blocking=True, ) await hass.async_block_till_done() mock.assert_called_once() + mock.assert_called_with( + 123456, + ) async def test_send_video( @@ -974,3 +985,39 @@ async def test_send_video( await hass.async_block_till_done() assert mock_get.call_count > 0 assert response["chats"][0]["message_id"] == 12345 + + +async def test_set_message_reaction( + hass: HomeAssistant, + mock_broadcast_config_entry: MockConfigEntry, + mock_external_calls: None, +) -> None: + """Test set message reaction.""" + mock_broadcast_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.set_message_reaction", + AsyncMock(return_value=True), + ) as mock: + await hass.services.async_call( + DOMAIN, + "set_message_reaction", + { + ATTR_CHAT_ID: 123456, + ATTR_MESSAGEID: 54321, + "reaction": "👍", + "is_big": True, + }, + blocking=True, + ) + + await hass.async_block_till_done() + mock.assert_called_once_with( + 123456, + 54321, + reaction="👍", + is_big=True, + read_timeout=None, + ) From 421251308fce398608ab46156384090d27f8bb34 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 16:19:35 +0200 Subject: [PATCH 0335/1664] Add Meater sensor tests (#146952) --- tests/components/meater/__init__.py | 12 + tests/components/meater/conftest.py | 35 +- tests/components/meater/const.py | 3 + .../meater/snapshots/test_init.ambr | 34 ++ .../meater/snapshots/test_sensor.ambr | 411 ++++++++++++++++++ tests/components/meater/test_init.py | 34 ++ tests/components/meater/test_sensor.py | 36 ++ 7 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 tests/components/meater/const.py create mode 100644 tests/components/meater/snapshots/test_init.ambr create mode 100644 tests/components/meater/snapshots/test_sensor.ambr create mode 100644 tests/components/meater/test_init.py create mode 100644 tests/components/meater/test_sensor.py diff --git a/tests/components/meater/__init__.py b/tests/components/meater/__init__.py index ef96dafe88c..48d576ce79b 100644 --- a/tests/components/meater/__init__.py +++ b/tests/components/meater/__init__.py @@ -1 +1,13 @@ """Tests for the Meater 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/meater/conftest.py b/tests/components/meater/conftest.py index 8a84da859f7..ccaa48437f3 100644 --- a/tests/components/meater/conftest.py +++ b/tests/components/meater/conftest.py @@ -1,13 +1,17 @@ """Meater tests configuration.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from datetime import datetime +from unittest.mock import AsyncMock, Mock, patch +from meater.MeaterApi import MeaterCook, MeaterProbe import pytest from homeassistant.components.meater.const import DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from .const import PROBE_ID + from tests.common import MockConfigEntry @@ -22,7 +26,7 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_meater_client() -> Generator[AsyncMock]: +def mock_meater_client(mock_probe: Mock) -> Generator[AsyncMock]: """Mock a Meater client.""" with ( patch( @@ -35,6 +39,7 @@ def mock_meater_client() -> Generator[AsyncMock]: ), ): client = mock_client.return_value + client.get_all_devices.return_value = [mock_probe] yield client @@ -47,3 +52,29 @@ def mock_config_entry() -> MockConfigEntry: data={CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"}, unique_id="user@host.com", ) + + +@pytest.fixture +def mock_cook() -> Mock: + """Mock a cook.""" + mock = Mock(spec=MeaterCook) + mock.id = "123123" + mock.name = "Whole chicken" + mock.state = "Started" + mock.target_temperature = 25.0 + mock.peak_temperature = 27.0 + mock.time_remaining = 32 + mock.time_elapsed = 32 + return mock + + +@pytest.fixture +def mock_probe(mock_cook: Mock) -> Mock: + """Mock a probe.""" + mock = Mock(spec=MeaterProbe) + mock.id = PROBE_ID + mock.internal_temperature = 26.0 + mock.ambient_temperature = 28.0 + mock.cook = mock_cook + mock.time_updated = datetime.fromisoformat("2025-06-16T13:53:51+00:00") + return mock diff --git a/tests/components/meater/const.py b/tests/components/meater/const.py new file mode 100644 index 00000000000..52ba9ac3feb --- /dev/null +++ b/tests/components/meater/const.py @@ -0,0 +1,3 @@ +"""Constants for the Meater tests.""" + +PROBE_ID = "40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58" diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr new file mode 100644 index 00000000000..582fd68efb1 --- /dev/null +++ b/tests/components/meater/snapshots/test_init.ambr @@ -0,0 +1,34 @@ +# serializer version: 1 +# name: test_device_info + 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( + 'meater', + '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Apption Labs', + 'model': 'Meater Probe', + 'model_id': None, + 'name': 'Meater Probe 40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + '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/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..954dbf8b138 --- /dev/null +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -0,0 +1,411 @@ +# serializer version: 1 +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'has_entity_name': False, + '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': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ambient', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-ambient', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_name', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Whole chicken', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', + 'has_entity_name': False, + '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': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_peak_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_state', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Started', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', + 'has_entity_name': False, + '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': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_target_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_elapsed', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-20T23:59:58+00:00', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_remaining', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:01:02+00:00', + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'has_entity_name': False, + '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': None, + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'internal', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-internal', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py new file mode 100644 index 00000000000..52fb73ffdd8 --- /dev/null +++ b/tests/components/meater/test_init.py @@ -0,0 +1,34 @@ +"""Tests for the Meater integration.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.meater.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import setup_integration +from .const import PROBE_ID + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_device_info( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device registry integration.""" + await setup_integration(hass, mock_config_entry) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) + assert device_entry is not None + assert device_entry == snapshot diff --git a/tests/components/meater/test_sensor.py b/tests/components/meater/test_sensor.py new file mode 100644 index 00000000000..7a39538b914 --- /dev/null +++ b/tests/components/meater/test_sensor.py @@ -0,0 +1,36 @@ +"""Tests for the Meater sensors.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +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 . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.mark.freeze_time("2023-10-21") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the sensor entities.""" + with patch("homeassistant.components.meater.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 4add783108294222994ad48964a53e919ef1bc9b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 16 Jun 2025 16:58:47 +0200 Subject: [PATCH 0336/1664] Use entity base class for NextDNS entities (#146934) * Add entity module * Add NextDnsEntityDescription class * Remove NextDnsEntityDescription * Create DeviceInfo in entity module * Use property --- .../components/nextdns/binary_sensor.py | 31 ++++------------- homeassistant/components/nextdns/button.py | 23 +++---------- .../components/nextdns/coordinator.py | 9 ----- homeassistant/components/nextdns/entity.py | 31 +++++++++++++++++ homeassistant/components/nextdns/sensor.py | 33 +++++-------------- homeassistant/components/nextdns/switch.py | 12 ++----- 6 files changed, 54 insertions(+), 85 deletions(-) create mode 100644 homeassistant/components/nextdns/entity.py diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index a06b56e1732..5107fcd00d6 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -13,12 +13,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry -from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 0 @@ -61,30 +60,14 @@ async def async_setup_entry( ) -class NextDnsBinarySensor( - CoordinatorEntity[NextDnsUpdateCoordinator[ConnectionStatus]], BinarySensorEntity -): +class NextDnsBinarySensor(NextDnsEntity, BinarySensorEntity): """Define an NextDNS binary sensor.""" - _attr_has_entity_name = True entity_description: NextDnsBinarySensorEntityDescription - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[ConnectionStatus], - description: NextDnsBinarySensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self._attr_is_on = description.state(coordinator.data, coordinator.profile_id) - self.entity_description = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._attr_is_on = self.entity_description.state( + @property + def is_on(self) -> bool: + """Return True if the binary sensor is on.""" + return self.entity_description.state( self.coordinator.data, self.coordinator.profile_id ) - self.async_write_ha_state() diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py index 2adccaa304f..5c78d794120 100644 --- a/homeassistant/components/nextdns/button.py +++ b/homeassistant/components/nextdns/button.py @@ -4,21 +4,21 @@ from __future__ import annotations from aiohttp import ClientError from aiohttp.client_exceptions import ClientConnectorError -from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError +from nextdns import ApiError, InvalidApiKeyError from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import DOMAIN -from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 1 + CLEAR_LOGS_BUTTON = ButtonEntityDescription( key="clear_logs", translation_key="clear_logs", @@ -37,24 +37,9 @@ async def async_setup_entry( async_add_entities([NextDnsButton(coordinator, CLEAR_LOGS_BUTTON)]) -class NextDnsButton( - CoordinatorEntity[NextDnsUpdateCoordinator[AnalyticsStatus]], ButtonEntity -): +class NextDnsButton(NextDnsEntity, ButtonEntity): """Define an NextDNS button.""" - _attr_has_entity_name = True - - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[AnalyticsStatus], - description: ButtonEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self.entity_description = description - async def async_press(self) -> None: """Trigger cleaning logs.""" try: diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 41f6ff43a2a..3bc5dfe60d1 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -24,7 +24,6 @@ from tenacity import RetryError from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed if TYPE_CHECKING: @@ -53,14 +52,6 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): """Initialize.""" self.nextdns = nextdns self.profile_id = profile_id - self.profile_name = nextdns.get_profile_name(profile_id) - self.device_info = DeviceInfo( - configuration_url=f"https://my.nextdns.io/{profile_id}/setup", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, str(profile_id))}, - manufacturer="NextDNS Inc.", - name=self.profile_name, - ) super().__init__( hass, diff --git a/homeassistant/components/nextdns/entity.py b/homeassistant/components/nextdns/entity.py new file mode 100644 index 00000000000..26e0a5dd9ef --- /dev/null +++ b/homeassistant/components/nextdns/entity.py @@ -0,0 +1,31 @@ +"""Define NextDNS entities.""" + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator + + +class NextDnsEntity(CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]]): + """Define NextDNS entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + configuration_url=f"https://my.nextdns.io/{coordinator.profile_id}/setup", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, str(coordinator.profile_id))}, + manufacturer="NextDNS Inc.", + name=coordinator.nextdns.get_profile_name(coordinator.profile_id), + ) + self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + self.entity_description = description diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 14b5fffbc8d..b03f262cbeb 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -20,10 +20,9 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import PERCENTAGE, EntityCategory -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import ( @@ -33,7 +32,8 @@ from .const import ( ATTR_PROTOCOLS, ATTR_STATUS, ) -from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator +from .coordinator import CoordinatorDataT +from .entity import NextDnsEntity PARALLEL_UPDATES = 0 @@ -297,27 +297,12 @@ async def async_setup_entry( ) -class NextDnsSensor( - CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]], SensorEntity -): +class NextDnsSensor(NextDnsEntity, SensorEntity): """Define an NextDNS sensor.""" - _attr_has_entity_name = True + entity_description: NextDnsSensorEntityDescription - def __init__( - self, - coordinator: NextDnsUpdateCoordinator[CoordinatorDataT], - description: NextDnsSensorEntityDescription, - ) -> None: - """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" - self._attr_native_value = description.value(coordinator.data) - self.entity_description: NextDnsSensorEntityDescription = description - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self._attr_native_value = self.entity_description.value(self.coordinator.data) - self.async_write_ha_state() + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index 8bdca76b955..872f7430b3d 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import NextDnsConfigEntry from .const import DOMAIN from .coordinator import NextDnsUpdateCoordinator +from .entity import NextDnsEntity PARALLEL_UPDATES = 1 @@ -536,12 +536,9 @@ async def async_setup_entry( ) -class NextDnsSwitch( - CoordinatorEntity[NextDnsUpdateCoordinator[Settings]], SwitchEntity -): +class NextDnsSwitch(NextDnsEntity, SwitchEntity): """Define an NextDNS switch.""" - _attr_has_entity_name = True entity_description: NextDnsSwitchEntityDescription def __init__( @@ -550,11 +547,8 @@ class NextDnsSwitch( description: NextDnsSwitchEntityDescription, ) -> None: """Initialize.""" - super().__init__(coordinator) - self._attr_device_info = coordinator.device_info - self._attr_unique_id = f"{coordinator.profile_id}_{description.key}" + super().__init__(coordinator, description) self._attr_is_on = description.state(coordinator.data) - self.entity_description = description @callback def _handle_coordinator_update(self) -> None: From dffaf49ecaaad1912ddd78b9f3f0ba599a2196b6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 17:18:21 +0200 Subject: [PATCH 0337/1664] Use runtime data in Meater (#146961) --- homeassistant/components/meater/__init__.py | 17 ++++++----------- homeassistant/components/meater/coordinator.py | 6 ++++-- homeassistant/components/meater/sensor.py | 6 +++--- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 3d4f45206b6..0a9fa77f902 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -1,33 +1,28 @@ """The Meater Temperature Probe integration.""" -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import DOMAIN -from .coordinator import MeaterCoordinator +from .coordinator import MeaterConfigEntry, MeaterCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Set up Meater Temperature Probe from a config entry.""" coordinator = MeaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN].setdefault("known_probes", set()) + hass.data.setdefault(DOMAIN, {}).setdefault("known_probes", set()) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py index 7fb0c69d4eb..042a3c87b0c 100644 --- a/homeassistant/components/meater/coordinator.py +++ b/homeassistant/components/meater/coordinator.py @@ -21,16 +21,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda _LOGGER = logging.getLogger(__name__) +type MeaterConfigEntry = ConfigEntry[MeaterCoordinator] + class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): """Meater Coordinator.""" - config_entry: ConfigEntry + config_entry: MeaterConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: MeaterConfigEntry, ) -> None: """Initialize the Meater Coordinator.""" super().__init__( diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index cf1c72de85e..f7a746c923f 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -24,6 +23,7 @@ from homeassistant.util import dt as dt_util from . import MeaterCoordinator from .const import DOMAIN +from .coordinator import MeaterConfigEntry @dataclass(frozen=True, kw_only=True) @@ -135,11 +135,11 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MeaterConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the entry.""" - coordinator: MeaterCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data @callback def async_update_data(): From 9ae0cfc7e591a2f3a8d7a35793fa320e0c250ae0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 18:23:20 +0200 Subject: [PATCH 0338/1664] Create entities directly on setup in Meater (#146953) * Don't wait an update when adding devices in Meater * Fix --- homeassistant/components/meater/sensor.py | 1 + tests/components/meater/snapshots/test_sensor.ambr | 4 ++-- tests/components/meater/test_init.py | 8 +------- tests/components/meater/test_sensor.py | 9 +-------- 4 files changed, 5 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index f7a746c923f..ee6056e0ddc 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -170,6 +170,7 @@ async def async_setup_entry( # Add a subscriber to the coordinator to discover new temperature probes coordinator.async_add_listener(async_update_data) + async_update_data() class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]): diff --git a/tests/components/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr index 954dbf8b138..268f972b716 100644 --- a/tests/components/meater/snapshots/test_sensor.ambr +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -303,7 +303,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2023-10-20T23:59:58+00:00', + 'state': '2023-10-20T23:59:28+00:00', }) # --- # name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-entry] @@ -351,7 +351,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2023-10-21T00:01:02+00:00', + 'state': '2023-10-21T00:00:32+00:00', }) # --- # name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-entry] diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py index 52fb73ffdd8..52f6b29d488 100644 --- a/tests/components/meater/test_init.py +++ b/tests/components/meater/test_init.py @@ -1,9 +1,7 @@ """Tests for the Meater integration.""" -from datetime import timedelta from unittest.mock import AsyncMock -from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion from homeassistant.components.meater.const import DOMAIN @@ -13,7 +11,7 @@ from homeassistant.helpers import device_registry as dr from . import setup_integration from .const import PROBE_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry async def test_device_info( @@ -22,13 +20,9 @@ async def test_device_info( mock_meater_client: AsyncMock, mock_config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, - freezer: FrozenDateTimeFactory, ) -> None: """Test device registry integration.""" await setup_integration(hass, mock_config_entry) - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) assert device_entry is not None assert device_entry == snapshot diff --git a/tests/components/meater/test_sensor.py b/tests/components/meater/test_sensor.py index 7a39538b914..8ddd5fbb590 100644 --- a/tests/components/meater/test_sensor.py +++ b/tests/components/meater/test_sensor.py @@ -1,9 +1,7 @@ """Tests for the Meater sensors.""" -from datetime import timedelta from unittest.mock import AsyncMock, patch -from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +11,7 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration -from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.freeze_time("2023-10-21") @@ -23,14 +21,9 @@ async def test_entities( entity_registry: er.EntityRegistry, mock_meater_client: AsyncMock, mock_config_entry: MockConfigEntry, - freezer: FrozenDateTimeFactory, ) -> None: """Test the sensor entities.""" with patch("homeassistant.components.meater.PLATFORMS", [Platform.SENSOR]): await setup_integration(hass, mock_config_entry) - freezer.tick(timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From ad64139b8e20b27b17ab97868f9ae8f017e7305f Mon Sep 17 00:00:00 2001 From: mswilson Date: Mon, 16 Jun 2025 10:31:49 -0700 Subject: [PATCH 0339/1664] Add switch for Samsung ice bites (and rename ice maker) (#146925) * Add switch for ice bites (and rename ice maker) Fixes: home-assistant/home-assistant.io#37826 * Fix tests * Fix --------- Co-authored-by: Joostlek --- .../components/smartthings/strings.json | 5 +- .../components/smartthings/switch.py | 1 + .../device_status/da_ref_normal_01011.json | 362 +++++++++++------- .../fixtures/devices/da_ref_normal_01011.json | 45 ++- .../smartthings/snapshots/test_button.ambr | 48 +++ .../smartthings/snapshots/test_number.ambr | 20 +- .../smartthings/snapshots/test_sensor.ambr | 16 +- .../smartthings/snapshots/test_switch.ambr | 168 +++++++- 8 files changed, 483 insertions(+), 182 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index b322b73062b..038894a3d5b 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -605,7 +605,10 @@ "name": "Wrinkle prevent" }, "ice_maker": { - "name": "Ice maker" + "name": "Ice cubes" + }, + "ice_maker_2": { + "name": "Ice bites" }, "sabbath_mode": { "name": "Sabbath mode" diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 56096dc6ab5..1f75e1976f6 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -95,6 +95,7 @@ CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescriptio status_attribute=Attribute.SWITCH, component_translation_key={ "icemaker": "ice_maker", + "icemaker-02": "ice_maker_2", }, ), Capability.SAMSUNG_CE_SABBATH_MODE: SmartThingsSwitchEntityDescription( diff --git a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json index 350a0ee14bb..dbb4519ca61 100644 --- a/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json +++ b/tests/components/smartthings/fixtures/device_status/da_ref_normal_01011.json @@ -105,12 +105,14 @@ "icemaker": { "custom.disabledCapabilities": { "disabledCapabilities": { - "value": null + "value": [], + "timestamp": "2024-12-19T19:47:51.861Z" } }, "switch": { "switch": { - "value": null + "value": "on", + "timestamp": "2025-06-16T07:20:04.493Z" } } }, @@ -134,13 +136,13 @@ "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { "value": ["samsungce.freezerConvertMode", "custom.fridgeMode"], - "timestamp": "2024-12-01T18:22:20.155Z" + "timestamp": "2024-12-19T19:47:55.421Z" } }, "samsungce.temperatureSetting": { @@ -229,19 +231,19 @@ "contactSensor": { "contact": { "value": "closed", - "timestamp": "2025-03-30T18:36:45.151Z" + "timestamp": "2025-06-16T15:59:26.313Z" } }, "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { "disabledCapabilities": { "value": ["custom.fridgeMode", "samsungce.temperatureSetting"], - "timestamp": "2024-12-01T18:22:22.081Z" + "timestamp": "2024-12-19T19:47:56.956Z" } }, "samsungce.temperatureSetting": { @@ -257,37 +259,37 @@ "value": null }, "temperature": { - "value": 6, - "unit": "C", - "timestamp": "2025-03-30T17:41:42.863Z" + "value": 36, + "unit": "F", + "timestamp": "2025-06-07T07:52:37.532Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { - "value": 1, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 34, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "maximumSetpoint": { - "value": 7, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 44, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" } }, "thermostatCoolingSetpoint": { "coolingSetpointRange": { "value": { - "minimum": 1, - "maximum": 7, + "minimum": 34, + "maximum": 44, "step": 1 }, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "coolingSetpoint": { - "value": 6, - "unit": "C", - "timestamp": "2025-03-30T17:33:48.530Z" + "value": 36, + "unit": "F", + "timestamp": "2025-06-07T07:48:40.490Z" } } }, @@ -306,13 +308,13 @@ "contactSensor": { "contact": { "value": "closed", - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-06-16T15:01:16.141Z" } }, "samsungce.unavailableCapabilities": { "unavailableCommands": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "custom.disabledCapabilities": { @@ -322,7 +324,7 @@ "samsungce.temperatureSetting", "samsungce.freezerConvertMode" ], - "timestamp": "2024-12-01T18:22:22.081Z" + "timestamp": "2024-12-19T19:47:56.956Z" } }, "samsungce.temperatureSetting": { @@ -338,26 +340,27 @@ "value": null }, "temperature": { - "value": -17, - "unit": "C", - "timestamp": "2025-03-30T17:35:48.599Z" + "value": -8, + "unit": "F", + "timestamp": "2025-06-07T07:50:37.311Z" } }, "custom.thermostatSetpointControl": { "minimumSetpoint": { - "value": -23, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": -8, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "maximumSetpoint": { - "value": -15, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 5, + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" } }, "samsungce.freezerConvertMode": { "supportedFreezerConvertModes": { - "value": null + "value": [], + "timestamp": "2025-05-25T02:26:23.578Z" }, "freezerConvertMode": { "value": null @@ -366,17 +369,17 @@ "thermostatCoolingSetpoint": { "coolingSetpointRange": { "value": { - "minimum": -23, - "maximum": -15, + "minimum": -8, + "maximum": 5, "step": 1 }, - "unit": "C", - "timestamp": "2024-12-01T18:22:19.337Z" + "unit": "F", + "timestamp": "2025-05-25T02:26:23.832Z" }, "coolingSetpoint": { - "value": -17, - "unit": "C", - "timestamp": "2025-03-30T17:32:34.710Z" + "value": -8, + "unit": "F", + "timestamp": "2025-06-07T07:48:42.385Z" } } }, @@ -411,7 +414,8 @@ }, "samsungce.deviceIdentification": { "micomAssayCode": { - "value": null + "value": "00176141", + "timestamp": "2025-06-13T04:49:15.194Z" }, "modelName": { "value": null @@ -423,23 +427,26 @@ "value": null }, "modelClassificationCode": { - "value": null + "value": "0000083C031813294103010041030000", + "timestamp": "2025-06-13T04:49:15.194Z" }, "description": { - "value": null + "value": "TP1X_REF_21K", + "timestamp": "2025-06-13T04:49:15.194Z" }, "releaseYear": { - "value": null + "value": 24, + "timestamp": "2025-06-13T04:49:14.072Z" }, "binaryId": { "value": "TP1X_REF_21K", - "timestamp": "2025-03-23T21:53:15.900Z" + "timestamp": "2025-06-16T07:20:04.493Z" } }, "samsungce.quickControl": { "version": { "value": "1.0", - "timestamp": "2025-02-12T21:52:01.494Z" + "timestamp": "2025-05-25T02:26:25.302Z" } }, "custom.fridgeMode": { @@ -461,66 +468,65 @@ "value": null }, "mnfv": { - "value": "A-RFWW-TP1-22-REV1_20241030", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "A-RFWW-TP1-24-T4-COM_20250216", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnhw": { "value": "Realtek", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "di": { - "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "5ff1ef72-56ce-6559-4bd3-be42c31f3395", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnsl": { "value": "http://www.samsung.com", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "dmv": { - "value": "1.2.1", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "n": { "value": "Samsung-Refrigerator", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnmo": { - "value": "TP1X_REF_21K|00156941|00050126001611304100000030010000", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "TP1X_REF_21K|00176141|0000083C031813294103010041030000", + "timestamp": "2025-04-12T15:30:22.827Z" }, "vid": { "value": "DA-REF-NORMAL-01011", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnmn": { "value": "Samsung Electronics", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnml": { "value": "http://www.samsung.com", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnpv": { - "value": "DAWIT 2.0", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "SYSTEM 2.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "mnos": { - "value": "TizenRT 3.1", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "TizenRT 4.0", + "timestamp": "2025-04-12T15:30:22.827Z" }, "pi": { - "value": "5758b2ec-563e-f39b-ec39-208e54aabf60", - "timestamp": "2025-02-12T21:51:58.927Z" + "value": "5ff1ef72-56ce-6559-4bd3-be42c31f3395", + "timestamp": "2025-04-12T15:30:22.827Z" }, "icv": { "value": "core.1.1.0", - "timestamp": "2025-02-12T21:51:58.927Z" + "timestamp": "2025-04-12T15:30:22.827Z" } }, "samsungce.fridgeVacationMode": { "vacationMode": { - "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": null } }, "custom.disabledCapabilities": { @@ -530,56 +536,56 @@ "thermostatCoolingSetpoint", "custom.fridgeMode", "custom.deodorFilter", - "custom.waterFilter", "custom.dustFilter", "samsungce.viewInside", "samsungce.fridgeWelcomeLighting", - "samsungce.sabbathMode" + "sec.smartthingsHub", + "samsungce.fridgeVacationMode" ], - "timestamp": "2025-02-12T21:52:01.494Z" + "timestamp": "2025-03-31T03:05:25.793Z" } }, "samsungce.driverVersion": { "versionNumber": { - "value": 24090102, - "timestamp": "2024-12-01T18:22:19.337Z" + "value": 25040101, + "timestamp": "2025-06-13T04:49:16.828Z" } }, "sec.diagnosticsInformation": { "logType": { "value": ["errCode", "dump"], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "endpoint": { "value": "SSM", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "minVersion": { "value": "3.0", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "signinPermission": { "value": null }, "setupId": { - "value": "RB0", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": "RRD", + "timestamp": "2025-05-25T02:26:23.664Z" }, "protocolType": { "value": "ble_ocf", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "tsId": { "value": "DA01", - "timestamp": "2025-02-12T21:52:00.460Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "mnId": { "value": "0AJT", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "dumpType": { "value": "file", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" } }, "temperatureMeasurement": { @@ -598,11 +604,11 @@ "value": { "state": "disabled" }, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.815Z" }, "reportStatePeriod": { "value": "enabled", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.815Z" } }, "thermostatCoolingSetpoint": { @@ -616,8 +622,6 @@ "custom.disabledComponents": { "disabledComponents": { "value": [ - "icemaker", - "icemaker-02", "icemaker-03", "pantry-01", "pantry-02", @@ -626,7 +630,7 @@ "cvroom", "onedoor" ], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "demandResponseLoadControl": { @@ -637,31 +641,33 @@ "duration": 0, "override": false }, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.225Z" } }, "samsungce.sabbathMode": { "supportedActions": { - "value": null + "value": ["on", "off"], + "timestamp": "2025-05-25T02:26:23.696Z" }, "status": { - "value": null + "value": "off", + "timestamp": "2025-05-25T02:26:23.696Z" } }, "powerConsumptionReport": { "powerConsumption": { "value": { - "energy": 66571, - "deltaEnergy": 19, - "power": 61, - "powerEnergy": 18.91178222020467, + "energy": 229226, + "deltaEnergy": 10, + "power": 17, + "powerEnergy": 14.351180554098551, "persistedEnergy": 0, "energySaved": 0, "persistedSavedEnergy": 0, - "start": "2025-03-30T18:21:37Z", - "end": "2025-03-30T18:38:18Z" + "start": "2025-06-16T16:30:09Z", + "end": "2025-06-16T16:45:48Z" }, - "timestamp": "2025-03-30T18:38:18.219Z" + "timestamp": "2025-06-16T16:45:48.369Z" } }, "refresh": {}, @@ -673,44 +679,63 @@ "sec.wifiConfiguration": { "autoReconnection": { "value": true, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "minVersion": { "value": "1.0", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "supportedWiFiFreq": { "value": ["2.4G"], - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "supportedAuthType": { "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], - "timestamp": "2024-12-01T18:22:19.331Z" + "timestamp": "2025-05-25T02:26:25.567Z" }, "protocolType": { - "value": ["helper_hotspot"], - "timestamp": "2024-12-01T18:22:19.331Z" + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-05-25T02:26:25.567Z" } }, "samsungce.selfCheck": { "result": { "value": "passed", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "supportedActions": { "value": ["start"], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "progress": { "value": null }, "errors": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" }, "status": { "value": "ready", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.843Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "250216", + "description": "WiFi Module" + }, + { + "id": "1", + "swType": "Firmware", + "versionNumber": "24120326, 24030400, 24061400, FFFFFFFF", + "description": "Micom" + } + ], + "timestamp": "2025-05-25T02:26:23.664Z" } }, "custom.dustFilter": { @@ -735,15 +760,16 @@ }, "refrigeration": { "defrost": { - "value": null + "value": "off", + "timestamp": "2025-05-25T02:26:22.999Z" }, "rapidCooling": { "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.827Z" }, "rapidFreezing": { "value": "off", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T06:58:12.005Z" } }, "custom.deodorFilter": { @@ -769,88 +795,134 @@ "samsungce.powerCool": { "activated": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.827Z" } }, "custom.energyType": { "energyType": { "value": "2.0", - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingSupport": { "value": true, - "timestamp": "2025-03-06T23:10:37.429Z" + "timestamp": "2025-05-23T06:02:34.025Z" }, "drMaxDuration": { "value": 99999999, "unit": "min", - "timestamp": "2024-12-01T18:22:20.756Z" + "timestamp": "2024-12-19T19:47:54.446Z" }, "energySavingLevel": { "value": 1, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingInfo": { "value": null }, "supportedEnergySavingLevels": { - "value": [1, 2], - "timestamp": "2024-12-01T18:22:19.337Z" + "value": [1], + "timestamp": "2024-12-19T19:47:51.861Z" }, "energySavingOperation": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.225Z" }, "notificationTemplateID": { "value": null }, "energySavingOperationSupport": { "value": true, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2024-12-19T19:47:51.861Z" } }, "samsungce.softwareUpdate": { "targetModule": { "value": {}, - "timestamp": "2024-12-01T18:55:10.062Z" + "timestamp": "2025-05-25T02:26:23.686Z" }, "otnDUID": { - "value": "MTCB2ZD4B6BT4", - "timestamp": "2024-12-01T18:22:19.337Z" + "value": "XTCB2ZD4CVZDG", + "timestamp": "2025-05-25T02:26:23.664Z" }, "lastUpdatedDate": { "value": null }, "availableModules": { "value": [], - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "newVersionAvailable": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T02:26:23.664Z" }, "operatingState": { "value": "none", - "timestamp": "2024-12-01T18:28:40.492Z" + "timestamp": "2025-05-25T02:26:23.686Z" }, "progress": { "value": 0, "unit": "%", - "timestamp": "2024-12-01T18:43:42.645Z" + "timestamp": "2025-05-25T02:26:23.686Z" } }, "samsungce.powerFreeze": { "activated": { "value": false, - "timestamp": "2024-12-01T18:22:19.337Z" + "timestamp": "2025-05-25T06:58:12.005Z" + } + }, + "sec.smartthingsHub": { + "threadHardwareAvailability": { + "value": null + }, + "availability": { + "value": null + }, + "deviceId": { + "value": null + }, + "zigbeeHardwareAvailability": { + "value": null + }, + "version": { + "value": null + }, + "threadRequiresExternalHardware": { + "value": null + }, + "zigbeeRequiresExternalHardware": { + "value": null + }, + "eui": { + "value": null + }, + "lastOnboardingResult": { + "value": null + }, + "zwaveHardwareAvailability": { + "value": null + }, + "zwaveRequiresExternalHardware": { + "value": null + }, + "state": { + "value": null + }, + "onboardingProgress": { + "value": null + }, + "lastOnboardingErrorCode": { + "value": null } }, "custom.waterFilter": { "waterFilterUsageStep": { - "value": null + "value": 1, + "timestamp": "2025-05-25T02:26:23.401Z" }, "waterFilterResetType": { - "value": null + "value": ["replaceable"], + "timestamp": "2025-05-25T02:26:23.401Z" }, "waterFilterCapacity": { "value": null @@ -859,10 +931,12 @@ "value": null }, "waterFilterUsage": { - "value": null + "value": 97, + "timestamp": "2025-06-16T13:02:17.608Z" }, "waterFilterStatus": { - "value": null + "value": "normal", + "timestamp": "2025-05-25T02:26:23.401Z" } } }, @@ -872,10 +946,18 @@ "value": null }, "fridgeMode": { - "value": null + "value": "CV_TTYPE_RF9000A_FRUIT_VEGGIES", + "timestamp": "2025-05-25T02:26:23.578Z" }, "supportedFridgeModes": { - "value": null + "value": [ + "CV_TTYPE_RF9000A_FREEZE", + "CV_TTYPE_RF9000A_SOFTFREEZE", + "CV_TTYPE_RF9000A_MEAT_FISH", + "CV_TTYPE_RF9000A_FRUIT_VEGGIES", + "CV_TTYPE_RF9000A_BEVERAGE" + ], + "timestamp": "2025-05-25T02:26:23.578Z" } }, "contactSensor": { @@ -908,12 +990,14 @@ "icemaker-02": { "custom.disabledCapabilities": { "disabledCapabilities": { - "value": null + "value": [], + "timestamp": "2024-12-19T19:47:51.861Z" } }, "switch": { "switch": { - "value": null + "value": "on", + "timestamp": "2025-06-16T14:00:28.428Z" } } }, diff --git a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json index 9be5db0bda9..2cde305ca3d 100644 --- a/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json +++ b/tests/components/smartthings/fixtures/devices/da_ref_normal_01011.json @@ -128,6 +128,10 @@ "id": "samsungce.quickControl", "version": 1 }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, { "id": "sec.diagnosticsInformation", "version": 1 @@ -135,6 +139,11 @@ { "id": "sec.wifiConfiguration", "version": 1 + }, + { + "id": "sec.smartthingsHub", + "version": 1, + "ephemeral": true } ], "categories": [ @@ -142,7 +151,8 @@ "name": "Refrigerator", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "freezer", @@ -190,7 +200,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "cooler", @@ -234,7 +245,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "cvroom", @@ -266,7 +278,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "onedoor", @@ -314,7 +327,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker", @@ -334,7 +348,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker-02", @@ -354,7 +369,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "icemaker-03", @@ -374,7 +390,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "scale-10", @@ -402,7 +419,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "scale-11", @@ -422,7 +440,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "pantry-01", @@ -454,7 +473,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false }, { "id": "pantry-02", @@ -486,7 +506,8 @@ "name": "Other", "categoryType": "manufacturer" } - ] + ], + "optional": false } ], "createTime": "2024-12-01T18:22:14.880Z", diff --git a/tests/components/smartthings/snapshots/test_button.ambr b/tests/components/smartthings/snapshots/test_button.ambr index ad8e0ff276b..a49aad2f897 100644 --- a/tests/components/smartthings/snapshots/test_button.ambr +++ b/tests/components/smartthings/snapshots/test_button.ambr @@ -239,3 +239,51 @@ 'state': 'unknown', }) # --- +# name: test_all_entities[da_ref_normal_01011][button.frigo_reset_water_filter-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.frigo_reset_water_filter', + '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': 'Reset water filter', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_water_filter', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_custom.waterFilter_resetWaterFilter', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][button.frigo_reset_water_filter-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Reset water filter', + }), + 'context': , + 'entity_id': 'button.frigo_reset_water_filter', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/smartthings/snapshots/test_number.ambr b/tests/components/smartthings/snapshots/test_number.ambr index e02b2ecc9b4..b9af2605f1d 100644 --- a/tests/components/smartthings/snapshots/test_number.ambr +++ b/tests/components/smartthings/snapshots/test_number.ambr @@ -298,8 +298,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': -15, - 'min': -23, + 'max': -15.0, + 'min': -23.0, 'mode': , 'step': 1, }), @@ -337,8 +337,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Frigo Freezer temperature', - 'max': -15, - 'min': -23, + 'max': -15.0, + 'min': -23.0, 'mode': , 'step': 1, 'unit_of_measurement': , @@ -348,7 +348,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-22.0', }) # --- # name: test_all_entities[da_ref_normal_01011][number.frigo_fridge_temperature-entry] @@ -357,8 +357,8 @@ }), 'area_id': None, 'capabilities': dict({ - 'max': 7, - 'min': 1, + 'max': 7.0, + 'min': 1.0, 'mode': , 'step': 1, }), @@ -396,8 +396,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', 'friendly_name': 'Frigo Fridge temperature', - 'max': 7, - 'min': 1, + 'max': 7.0, + 'min': 1.0, 'mode': , 'step': 1, 'unit_of_measurement': , @@ -407,7 +407,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '2.0', }) # --- # name: test_all_entities[da_wm_wm_000001][number.washer_rinse_cycles-entry] diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index e85ec4620e9..40180b88bca 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5569,7 +5569,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '66.571', + 'state': '229.226', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_difference-entry] @@ -5625,7 +5625,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.019', + 'state': '0.01', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy_saved-entry] @@ -5737,7 +5737,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '-17', + 'state': '-22.2222222222222', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_fridge_temperature-entry] @@ -5793,7 +5793,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '6', + 'state': '2.22222222222222', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power-entry] @@ -5841,8 +5841,8 @@ 'attributes': ReadOnlyDict({ 'device_class': 'power', 'friendly_name': 'Frigo Power', - 'power_consumption_end': '2025-03-30T18:38:18Z', - 'power_consumption_start': '2025-03-30T18:21:37Z', + 'power_consumption_end': '2025-06-16T16:45:48Z', + 'power_consumption_start': '2025-06-16T16:30:09Z', 'state_class': , 'unit_of_measurement': , }), @@ -5851,7 +5851,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '61', + 'state': '17', }) # --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_power_energy-entry] @@ -5907,7 +5907,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0189117822202047', + 'state': '0.0143511805540986', }) # --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index 1323230e7ea..a182c3bf2a2 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-entry] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_cubes-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +60,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_ice_cubes', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -72,7 +72,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice maker', + 'original_name': 'Ice cubes', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -82,13 +82,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_maker-state] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_cubes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice maker', + 'friendly_name': 'Refrigerator Ice cubes', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_ice_cubes', 'last_changed': , 'last_reported': , 'last_updated': , @@ -239,7 +239,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-entry] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_cubes-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -252,7 +252,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_ice_cubes', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -264,7 +264,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice maker', + 'original_name': 'Ice cubes', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -274,13 +274,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_maker-state] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_cubes-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice maker', + 'friendly_name': 'Refrigerator Ice cubes', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_maker', + 'entity_id': 'switch.refrigerator_ice_cubes', 'last_changed': , 'last_reported': , 'last_updated': , @@ -383,6 +383,102 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-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': None, + 'entity_id': 'switch.frigo_ice_bites', + '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': 'Ice bites', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker_2', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker-02_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Ice bites', + }), + 'context': , + 'entity_id': 'switch.frigo_ice_bites', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_cubes-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': None, + 'entity_id': 'switch.frigo_ice_cubes', + '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': 'Ice cubes', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_cubes-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Ice cubes', + }), + 'context': , + 'entity_id': 'switch.frigo_ice_cubes', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -479,6 +575,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_sabbath_mode-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.frigo_sabbath_mode', + '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': 'Sabbath mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sabbath_mode', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_samsungce.sabbathMode_status_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_sabbath_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Sabbath mode', + }), + 'context': , + 'entity_id': 'switch.frigo_sabbath_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From cb21bb6542c2e9a4a7b200680dc417249ca2faec Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 20:13:34 +0200 Subject: [PATCH 0340/1664] Make Meater cook state an enum (#146958) --- homeassistant/components/meater/sensor.py | 18 +++++++++-- homeassistant/components/meater/strings.json | 13 +++++++- .../meater/snapshots/test_sensor.ambr | 30 +++++++++++++++++-- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index ee6056e0ddc..61833babd47 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -25,6 +25,18 @@ from . import MeaterCoordinator from .const import DOMAIN from .coordinator import MeaterConfigEntry +COOK_STATES = { + "Not Started": "not_started", + "Configured": "configured", + "Started": "started", + "Ready For Resting": "ready_for_resting", + "Resting": "resting", + "Slightly Underdone": "slightly_underdone", + "Finished": "finished", + "Slightly Overdone": "slightly_overdone", + "OVERCOOK!": "overcooked", +} + @dataclass(frozen=True, kw_only=True) class MeaterSensorEntityDescription(SensorEntityDescription): @@ -80,13 +92,13 @@ SENSOR_TYPES = ( available=lambda probe: probe is not None and probe.cook is not None, value=lambda probe: probe.cook.name if probe.cook else None, ), - # One of Not Started, Configured, Started, Ready For Resting, Resting, - # Slightly Underdone, Finished, Slightly Overdone, OVERCOOK!. Not translated. MeaterSensorEntityDescription( key="cook_state", translation_key="cook_state", available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.state if probe.cook else None, + device_class=SensorDeviceClass.ENUM, + options=list(COOK_STATES.values()), + value=lambda probe: COOK_STATES.get(probe.cook.state) if probe.cook else None, ), # Target temperature MeaterSensorEntityDescription( diff --git a/homeassistant/components/meater/strings.json b/homeassistant/components/meater/strings.json index 20dd2919026..a578f895a8c 100644 --- a/homeassistant/components/meater/strings.json +++ b/homeassistant/components/meater/strings.json @@ -40,7 +40,18 @@ "name": "Cooking" }, "cook_state": { - "name": "Cook state" + "name": "Cook state", + "state": { + "not_started": "Not started", + "configured": "Configured", + "started": "Started", + "ready_for_resting": "Ready for resting", + "resting": "Resting", + "slightly_underdone": "Slightly underdone", + "finished": "Finished", + "slightly_overdone": "Slightly overdone", + "overcooked": "Overcooked" + } }, "cook_target_temp": { "name": "Target temperature" diff --git a/tests/components/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr index 268f972b716..aaec1db296a 100644 --- a/tests/components/meater/snapshots/test_sensor.ambr +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -161,7 +161,19 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'options': list([ + 'not_started', + 'configured', + 'started', + 'ready_for_resting', + 'resting', + 'slightly_underdone', + 'finished', + 'slightly_overdone', + 'overcooked', + ]), + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -179,7 +191,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': None, 'platform': 'meater', @@ -194,13 +206,25 @@ # name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'options': list([ + 'not_started', + 'configured', + 'started', + 'ready_for_resting', + 'resting', + 'slightly_underdone', + 'finished', + 'slightly_overdone', + 'overcooked', + ]), }), 'context': , 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'Started', + 'state': 'started', }) # --- # name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-entry] From 589577a04c253ae17cb7225483143ee49a38f615 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 20:17:30 +0200 Subject: [PATCH 0341/1664] Add diagnostics support to Meater (#146967) --- .../components/meater/diagnostics.py | 55 +++++++++++++++++++ .../meater/snapshots/test_diagnostics.ambr | 20 +++++++ tests/components/meater/test_diagnostics.py | 28 ++++++++++ 3 files changed, 103 insertions(+) create mode 100644 homeassistant/components/meater/diagnostics.py create mode 100644 tests/components/meater/snapshots/test_diagnostics.ambr create mode 100644 tests/components/meater/test_diagnostics.py diff --git a/homeassistant/components/meater/diagnostics.py b/homeassistant/components/meater/diagnostics.py new file mode 100644 index 00000000000..247457d0bc8 --- /dev/null +++ b/homeassistant/components/meater/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for the Meater integration.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import MeaterConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: MeaterConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = config_entry.runtime_data + + return { + identifier: { + "id": probe.id, + "internal_temperature": probe.internal_temperature, + "ambient_temperature": probe.ambient_temperature, + "time_updated": probe.time_updated.isoformat(), + "cook": ( + { + "id": probe.cook.id, + "name": probe.cook.name, + "state": probe.cook.state, + "target_temperature": ( + probe.cook.target_temperature + if hasattr(probe.cook, "target_temperature") + else None + ), + "peak_temperature": ( + probe.cook.peak_temperature + if hasattr(probe.cook, "peak_temperature") + else None + ), + "time_remaining": ( + probe.cook.time_remaining + if hasattr(probe.cook, "time_remaining") + else None + ), + "time_elapsed": ( + probe.cook.time_elapsed + if hasattr(probe.cook, "time_elapsed") + else None + ), + } + if probe.cook + else None + ), + } + for identifier, probe in coordinator.data.items() + } diff --git a/tests/components/meater/snapshots/test_diagnostics.ambr b/tests/components/meater/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..ced779eb114 --- /dev/null +++ b/tests/components/meater/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58': dict({ + 'ambient_temperature': 28.0, + 'cook': dict({ + 'id': '123123', + 'name': 'Whole chicken', + 'peak_temperature': 27.0, + 'state': 'Started', + 'target_temperature': 25.0, + 'time_elapsed': 32, + 'time_remaining': 32, + }), + 'id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'internal_temperature': 26.0, + 'time_updated': '2025-06-16T13:53:51+00:00', + }), + }) +# --- diff --git a/tests/components/meater/test_diagnostics.py b/tests/components/meater/test_diagnostics.py new file mode 100644 index 00000000000..9d78828a92f --- /dev/null +++ b/tests/components/meater/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Meater diagnostics.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await setup_integration(hass, mock_config_entry) + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 6f3ceb83c2af06620d87199ec7ddaa1cab6730b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 16 Jun 2025 20:14:02 +0100 Subject: [PATCH 0342/1664] Use non-autospec mock for Reolink's button tests (#146969) --- tests/components/reolink/conftest.py | 197 +++++++++++++----------- tests/components/reolink/test_button.py | 24 ++- 2 files changed, 116 insertions(+), 105 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index c94dd8d7d37..d96931aaf26 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -62,6 +62,100 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +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.check_new_firmware = AsyncMock(return_value=False) + host_mock.unsubscribe = AsyncMock(return_value=True) + host_mock.logout = AsyncMock(return_value=True) + host_mock.reboot = AsyncMock() + host_mock.set_ptz_command = AsyncMock() + host_mock.is_nvr = True + host_mock.is_hub = False + host_mock.mac_address = TEST_MAC + host_mock.uid = TEST_UID + host_mock.onvif_enabled = True + host_mock.rtmp_enabled = True + host_mock.rtsp_enabled = True + host_mock.nvr_name = TEST_NVR_NAME + host_mock.port = TEST_PORT + host_mock.use_https = TEST_USE_HTTPS + host_mock.is_admin = True + host_mock.user_level = "admin" + host_mock.protocol = "rtsp" + host_mock.channels = [0] + host_mock.stream_channels = [0] + host_mock.new_devices = False + host_mock.sw_version_update_required = False + host_mock.hardware_version = "IPC_00000" + host_mock.sw_version = "v1.0.0.0.0.0000" + host_mock.sw_upload_progress.return_value = 100 + host_mock.manufacturer = "Reolink" + host_mock.model = TEST_HOST_MODEL + host_mock.supported.return_value = True + host_mock.item_number.return_value = TEST_ITEM_NUMBER + host_mock.camera_model.return_value = TEST_CAM_MODEL + host_mock.camera_name.return_value = TEST_NVR_NAME + host_mock.camera_hardware_version.return_value = "IPC_00001" + host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" + host_mock.camera_sw_version_update_required.return_value = False + host_mock.camera_uid.return_value = TEST_UID_CAM + host_mock.camera_online.return_value = True + host_mock.channel_for_uid.return_value = 0 + host_mock.get_encoding.return_value = "h264" + host_mock.firmware_update_available.return_value = False + host_mock.session_active = True + host_mock.timeout = 60 + host_mock.renewtimer.return_value = 600 + host_mock.wifi_connection = False + host_mock.wifi_signal = None + host_mock.whiteled_mode_list.return_value = [] + host_mock.zoom_range.return_value = { + "zoom": {"pos": {"min": 0, "max": 100}}, + "focus": {"pos": {"min": 0, "max": 100}}, + } + host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} + host_mock.checked_api_versions = {"GetEvents": 1} + host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} + host_mock.get_raw_host_data.return_value = ( + "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" + ) + + # enums + host_mock.whiteled_mode.return_value = 1 + host_mock.whiteled_mode_list.return_value = ["off", "auto"] + host_mock.doorbell_led.return_value = "Off" + host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] + host_mock.auto_track_method.return_value = 3 + host_mock.daynight_state.return_value = "Black&White" + host_mock.hub_alarm_tone_id.return_value = 1 + host_mock.hub_visitor_tone_id.return_value = 1 + host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] + host_mock.recording_packing_time = "60 Minutes" + + # Baichuan + host_mock.baichuan_only = False + # Disable tcp push by default for tests + host_mock.baichuan.port = TEST_BC_PORT + host_mock.baichuan.events_active = False + host_mock.baichuan.unsubscribe_events = AsyncMock() + host_mock.baichuan.check_subscribe_events = 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" + host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + host_mock.baichuan.active_scene = "off" + host_mock.baichuan.scene_names = ["off", "home"] + host_mock.baichuan.abilities = { + 0: {"chnID": 0, "aitype": 34615}, + "Host": {"pushAlarm": 7}, + } + 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 + host_mock.baichuan.smart_ai_name.return_value = "zone1" + + @pytest.fixture(scope="module") def reolink_connect_class() -> Generator[MagicMock]: """Mock reolink connection and return both the host_mock and host_mock_class.""" @@ -71,97 +165,8 @@ def reolink_connect_class() -> Generator[MagicMock]: ) as host_mock_class, ): host_mock = host_mock_class.return_value - host_mock.get_host_data.return_value = None - host_mock.get_states.return_value = None - host_mock.supported.return_value = True - host_mock.check_new_firmware.return_value = False - host_mock.unsubscribe.return_value = True - host_mock.logout.return_value = True - host_mock.is_nvr = True - host_mock.is_hub = False - host_mock.mac_address = TEST_MAC - host_mock.uid = TEST_UID - host_mock.onvif_enabled = True - host_mock.rtmp_enabled = True - host_mock.rtsp_enabled = True - host_mock.nvr_name = TEST_NVR_NAME - host_mock.port = TEST_PORT - host_mock.use_https = TEST_USE_HTTPS - host_mock.is_admin = True - host_mock.user_level = "admin" - host_mock.protocol = "rtsp" - host_mock.channels = [0] - host_mock.stream_channels = [0] - host_mock.new_devices = False - host_mock.sw_version_update_required = False - host_mock.hardware_version = "IPC_00000" - host_mock.sw_version = "v1.0.0.0.0.0000" - host_mock.sw_upload_progress.return_value = 100 - host_mock.manufacturer = "Reolink" - host_mock.model = TEST_HOST_MODEL - host_mock.item_number.return_value = TEST_ITEM_NUMBER - host_mock.camera_model.return_value = TEST_CAM_MODEL - host_mock.camera_name.return_value = TEST_NVR_NAME - host_mock.camera_hardware_version.return_value = "IPC_00001" - host_mock.camera_sw_version.return_value = "v1.1.0.0.0.0000" - host_mock.camera_sw_version_update_required.return_value = False - host_mock.camera_uid.return_value = TEST_UID_CAM - host_mock.camera_online.return_value = True - host_mock.channel_for_uid.return_value = 0 - host_mock.get_encoding.return_value = "h264" - host_mock.firmware_update_available.return_value = False - host_mock.session_active = True - host_mock.timeout = 60 - host_mock.renewtimer.return_value = 600 - host_mock.wifi_connection = False - host_mock.wifi_signal = None - host_mock.whiteled_mode_list.return_value = [] - host_mock.zoom_range.return_value = { - "zoom": {"pos": {"min": 0, "max": 100}}, - "focus": {"pos": {"min": 0, "max": 100}}, - } - host_mock.capabilities = {"Host": ["RTSP"], "0": ["motion_detection"]} - host_mock.checked_api_versions = {"GetEvents": 1} - host_mock.abilities = {"abilityChn": [{"aiTrack": {"permit": 0, "ver": 0}}]} - host_mock.get_raw_host_data.return_value = ( - "{'host':'TEST_RESPONSE','channel':'TEST_RESPONSE'}" - ) - - reolink_connect.chime_list = [] - - # enums - host_mock.whiteled_mode.return_value = 1 - host_mock.whiteled_mode_list.return_value = ["off", "auto"] - host_mock.doorbell_led.return_value = "Off" - host_mock.doorbell_led_list.return_value = ["stayoff", "auto"] - host_mock.auto_track_method.return_value = 3 - host_mock.daynight_state.return_value = "Black&White" - host_mock.hub_alarm_tone_id.return_value = 1 - host_mock.hub_visitor_tone_id.return_value = 1 - host_mock.recording_packing_time_list = ["30 Minutes", "60 Minutes"] - host_mock.recording_packing_time = "60 Minutes" - - # Baichuan host_mock.baichuan = create_autospec(Baichuan) - host_mock.baichuan_only = False - # Disable tcp push by default for tests - host_mock.baichuan.port = TEST_BC_PORT - host_mock.baichuan.events_active = False - 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" - host_mock.baichuan.subscribe_events.side_effect = ReolinkError("Test error") - host_mock.baichuan.active_scene = "off" - host_mock.baichuan.scene_names = ["off", "home"] - host_mock.baichuan.abilities = { - 0: {"chnID": 0, "aitype": 34615}, - "Host": {"pushAlarm": 7}, - } - 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 - host_mock.baichuan.smart_ai_name.return_value = "zone1" - + _init_host_mock(host_mock) yield host_mock_class @@ -173,6 +178,18 @@ def reolink_connect( return reolink_connect_class.return_value +@pytest.fixture +def reolink_host() -> 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 + + @pytest.fixture def reolink_platforms() -> Generator[None]: """Mock reolink entry setup.""" diff --git a/tests/components/reolink/test_button.py b/tests/components/reolink/test_button.py index 126fbb6b29a..ee51d0f0b99 100644 --- a/tests/components/reolink/test_button.py +++ b/tests/components/reolink/test_button.py @@ -21,7 +21,7 @@ from tests.common import MockConfigEntry async def test_button( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test button entity with ptz up.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -37,9 +37,9 @@ async def test_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_ptz_command.assert_called_once() + reolink_host.set_ptz_command.assert_called_once() - reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + reolink_host.set_ptz_command.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( BUTTON_DOMAIN, @@ -48,13 +48,11 @@ async def test_button( blocking=True, ) - reolink_connect.set_ptz_command.reset_mock(side_effect=True) - async def test_ptz_move_service( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ptz_move entity service using PTZ button entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -70,9 +68,9 @@ async def test_ptz_move_service( {ATTR_ENTITY_ID: entity_id, ATTR_SPEED: 5}, blocking=True, ) - reolink_connect.set_ptz_command.assert_called_with(0, command="Up", speed=5) + reolink_host.set_ptz_command.assert_called_with(0, command="Up", speed=5) - reolink_connect.set_ptz_command.side_effect = ReolinkError("Test error") + reolink_host.set_ptz_command.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -81,14 +79,12 @@ async def test_ptz_move_service( blocking=True, ) - reolink_connect.set_ptz_command.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_host_button( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host button entity with reboot.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BUTTON]): @@ -104,9 +100,9 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.reboot.assert_called_once() + reolink_host.reboot.assert_called_once() - reolink_connect.reboot.side_effect = ReolinkError("Test error") + reolink_host.reboot.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( BUTTON_DOMAIN, @@ -114,5 +110,3 @@ async def test_host_button( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - - reolink_connect.reboot.reset_mock(side_effect=True) From ef9b46dce5691b9ce80cd8934e67aa810dc6ee5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Mon, 16 Jun 2025 21:30:06 +0200 Subject: [PATCH 0343/1664] Record current IQS state for Home Connect (#131703) * Home Connect quality scale * Update current iqs * Docs rules done * parallel-updates rule * Complete appropriate-polling's comment * Apply suggestions Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../home_connect/quality_scale.yaml | 71 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/home_connect/quality_scale.yaml diff --git a/homeassistant/components/home_connect/quality_scale.yaml b/homeassistant/components/home_connect/quality_scale.yaml new file mode 100644 index 00000000000..b89af885f38 --- /dev/null +++ b/homeassistant/components/home_connect/quality_scale.yaml @@ -0,0 +1,71 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: + status: done + comment: | + Full polling is performed at the configuration entry setup and + device polling is performed when a CONNECTED or a PAIRED event is received. + If many CONNECTED or PAIRED events are received for a device within a short time span, + the integration will stop polling for that device and will create a repair issue. + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + 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: done + 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: 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: + status: done + comment: | + Event entities are disabled by default to prevent user confusion regarding + which events are supported by its appliance. + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: exempt + comment: | + This integration doesn't have settings in its configuration flow. + repair-issues: done + stale-devices: 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 73cd0bc37d9..f4283f14ec3 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -480,7 +480,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "hko", "hlk_sw16", "holiday", - "home_connect", "homekit", "homekit_controller", "homematic", From c5d93e545675c6a4353d597f845802bfda6e951d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 16 Jun 2025 21:37:19 +0200 Subject: [PATCH 0344/1664] Fix translation key in NextDNS integration (#146976) * Fix translation key * Better wording --- homeassistant/components/nextdns/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 76d37691f77..b1602f8985e 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -6,15 +6,15 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "API Key for your NextDNS account" + "api_key": "The API Key for your NextDNS account" } }, "profiles": { "data": { - "profile": "Profile" + "profile_name": "Profile" }, "data_description": { - "profile": "NextDNS configuration profile you want to integrate" + "profile_name": "The NextDNS configuration profile you want to integrate" } }, "reauth_confirm": { From ad3dac0373c4b52ff570577294ffb7a4c9b4b4a0 Mon Sep 17 00:00:00 2001 From: "Etienne C." <59794011+etiennec78@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:20:01 +0200 Subject: [PATCH 0345/1664] Removed rounding of durations in Here Travel Time sensors (#146838) * Removed rounding of durations * Set duration sensors unit to seconds * Updated Here Travel Time tests * Update homeassistant/components/here_travel_time/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/here_travel_time/sensor.py Co-authored-by: Joost Lekkerkerker * Updated Here Travel Time tests --------- Co-authored-by: Joost Lekkerkerker --- .../here_travel_time/coordinator.py | 14 ++++++------ .../components/here_travel_time/sensor.py | 6 +++-- .../here_travel_time/test_sensor.py | 22 ++++++++++--------- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 447a45f5d2b..2c678316939 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -133,8 +133,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] def _parse_routing_response(self, response: dict[str, Any]) -> HERETravelTimeData: """Parse the routing response dict to a HERETravelTimeData.""" distance: float = 0.0 - duration: float = 0.0 - duration_in_traffic: float = 0.0 + duration: int = 0 + duration_in_traffic: int = 0 for section in response["routes"][0]["sections"]: distance += DistanceConverter.convert( @@ -167,8 +167,8 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] destination_name = names[0]["value"] return HERETravelTimeData( attribution=None, - duration=round(duration / 60), - duration_in_traffic=round(duration_in_traffic / 60), + duration=duration, + duration_in_traffic=duration_in_traffic, distance=distance, origin=f"{mapped_origin_lat},{mapped_origin_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}", @@ -271,13 +271,13 @@ class HERETransitDataUpdateCoordinator( UnitOfLength.METERS, UnitOfLength.KILOMETERS, ) - duration: float = sum( + duration: int = sum( section["travelSummary"]["duration"] for section in sections ) return HERETravelTimeData( attribution=attribution, - duration=round(duration / 60), - duration_in_traffic=round(duration / 60), + duration=duration, + duration_in_traffic=duration, distance=distance, origin=f"{mapped_origin_lat},{mapped_origin_lon}", destination=f"{mapped_destination_lat},{mapped_destination_lon}", diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 71184797a2e..da93c6e301e 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -56,7 +56,8 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] key=ATTR_DURATION, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( translation_key="duration_in_traffic", @@ -64,7 +65,8 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...] key=ATTR_DURATION_IN_TRAFFIC, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DURATION, - native_unit_of_measurement=UnitOfTime.MINUTES, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( translation_key="distance", diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 0231ac6428f..22042f863bc 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -150,10 +150,10 @@ async def test_sensor( duration = hass.states.get("sensor.test_duration") assert duration.attributes.get("unit_of_measurement") == UnitOfTime.MINUTES assert duration.attributes.get(ATTR_ICON) == icon - assert duration.state == "26" + assert duration.state == "26.1833333333333" assert float(hass.states.get("sensor.test_distance").state) == pytest.approx(13.682) - assert hass.states.get("sensor.test_duration_in_traffic").state == "30" + assert hass.states.get("sensor.test_duration_in_traffic").state == "29.6" assert hass.states.get("sensor.test_origin").state == "22nd St NW" assert ( hass.states.get("sensor.test_origin").attributes.get(ATTR_LATITUDE) @@ -501,13 +501,13 @@ async def test_restore_state(hass: HomeAssistant) -> None: "1234", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ), { "native_value": 1234, - "native_unit_of_measurement": UnitOfTime.MINUTES, + "native_unit_of_measurement": UnitOfTime.SECONDS, "icon": "mdi:car", "last_reset": last_reset, }, @@ -518,13 +518,13 @@ async def test_restore_state(hass: HomeAssistant) -> None: "5678", attributes={ ATTR_LAST_RESET: last_reset, - ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.MINUTES, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTime.SECONDS, ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, }, ), { "native_value": 5678, - "native_unit_of_measurement": UnitOfTime.MINUTES, + "native_unit_of_measurement": UnitOfTime.SECONDS, "icon": "mdi:car", "last_reset": last_reset, }, @@ -596,12 +596,12 @@ async def test_restore_state(hass: HomeAssistant) -> None: # restore from cache state = hass.states.get("sensor.test_duration") - assert state.state == "1234" + assert state.state == "20.5666666666667" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.test_duration_in_traffic") - assert state.state == "5678" + assert state.state == "94.6333333333333" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.MINUTES assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT @@ -799,10 +799,12 @@ async def test_multiple_sections( await hass.async_block_till_done() duration = hass.states.get("sensor.test_duration") - assert duration.state == "18" + assert duration.state == "18.4833333333333" assert float(hass.states.get("sensor.test_distance").state) == pytest.approx(3.583) - assert hass.states.get("sensor.test_duration_in_traffic").state == "18" + assert ( + hass.states.get("sensor.test_duration_in_traffic").state == "18.4833333333333" + ) assert hass.states.get("sensor.test_origin").state == "Chemin de Halage" assert ( hass.states.get("sensor.test_origin").attributes.get(ATTR_LATITUDE) From bab34b844b8df6c3a9216d315d435edbebf52fe4 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:46:11 +0200 Subject: [PATCH 0346/1664] Fix blocking open in Minecraft Server (#146820) Fix blocking open by dnspython --- .../components/minecraft_server/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index d8f60380a6c..e74b78446e5 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from typing import Any +import dns.asyncresolver import dns.rdata import dns.rdataclass import dns.rdatatype @@ -22,20 +23,23 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def load_dnspython_rdata_classes() -> None: - """Load dnspython rdata classes used by mcstatus.""" +def prevent_dnspython_blocking_operations() -> None: + """Prevent dnspython blocking operations by pre-loading required data.""" + + # Blocking import: https://github.com/rthalley/dnspython/issues/1083 for rdtype in dns.rdatatype.RdataType: if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] + # Blocking open: https://github.com/rthalley/dnspython/issues/1200 + dns.asyncresolver.get_default_resolver() + async def async_setup_entry( hass: HomeAssistant, entry: MinecraftServerConfigEntry ) -> bool: """Set up Minecraft Server from a config entry.""" - - # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) - await hass.async_add_executor_job(load_dnspython_rdata_classes) + await hass.async_add_executor_job(prevent_dnspython_blocking_operations) # Create coordinator instance and store it. coordinator = MinecraftServerCoordinator(hass, entry) From 1bc6ea98ce5bb24a0d84f6f0ccbb0ff4f968fc22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 16 Jun 2025 22:46:27 +0200 Subject: [PATCH 0347/1664] Set Matter SolarPower tagList in fixture (#146837) Update solar_power.json Set tagList to [{"0":null,"1":15,"2":2,"3":"Solar"}] --- tests/components/matter/fixtures/nodes/solar_power.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/components/matter/fixtures/nodes/solar_power.json b/tests/components/matter/fixtures/nodes/solar_power.json index 4b7c4af5b43..1147ff202ca 100644 --- a/tests/components/matter/fixtures/nodes/solar_power.json +++ b/tests/components/matter/fixtures/nodes/solar_power.json @@ -243,7 +243,14 @@ "1/29/1": [3, 29, 47, 144, 145, 156], "1/29/2": [], "1/29/3": [], - "1/29/4": [], + "1/29/4": [ + { + "0": null, + "1": 15, + "2": 2, + "3": "Solar" + } + ], "1/29/65532": 0, "1/29/65533": 2, "1/29/65528": [], From 6533562f4eb2ff66609a6bdba057f48dd4215ee5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:51:54 +0200 Subject: [PATCH 0348/1664] Rename Xiaomi Miio integration to Xiaomi Home (#146555) Co-authored-by: Norbert Rittel --- .../components/xiaomi_miio/manifest.json | 2 +- .../components/xiaomi_miio/strings.json | 26 +++++++++---------- homeassistant/generated/integrations.json | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json index abda8703e02..129acf53740 100644 --- a/homeassistant/components/xiaomi_miio/manifest.json +++ b/homeassistant/components/xiaomi_miio/manifest.json @@ -1,6 +1,6 @@ { "domain": "xiaomi_miio", - "name": "Xiaomi Miio", + "name": "Xiaomi Home", "codeowners": ["@rytilahti", "@syssi", "@starkillerOG"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/xiaomi_miio", diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json index a5af3d8bd1f..fef185daf41 100644 --- a/homeassistant/components/xiaomi_miio/strings.json +++ b/homeassistant/components/xiaomi_miio/strings.json @@ -5,37 +5,37 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "incomplete_info": "Incomplete information to set up device, no host or token supplied.", - "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Miio.", + "not_xiaomi_miio": "Device is not (yet) supported by Xiaomi Home integration.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "wrong_token": "Checksum error, wrong token", "unknown_device": "The device model is not known, not able to set up the device using config flow.", - "cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.", - "cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country", - "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials." + "cloud_no_devices": "No devices found in this Xiaomi Home account.", + "cloud_credentials_incomplete": "Credentials incomplete, please fill in username, password and server region", + "cloud_login_error": "Could not log in to Xiaomi Home, check the credentials." }, "flow_title": "{name}", "step": { "reauth_confirm": { - "description": "The Xiaomi Miio integration needs to re-authenticate your account in order to update the tokens or add missing cloud credentials.", + "description": "The Xiaomi Home integration needs to re-authenticate your account in order to update the tokens or add missing credentials.", "title": "[%key:common::config_flow::title::reauth%]" }, "cloud": { "data": { - "cloud_username": "Cloud username", - "cloud_password": "Cloud password", - "cloud_country": "Cloud server country", + "cloud_username": "[%key:common::config_flow::data::username%]", + "cloud_password": "[%key:common::config_flow::data::password%]", + "cloud_country": "Server region", "manual": "Configure manually (not recommended)" }, - "description": "Log in to the Xiaomi Miio cloud, see https://www.openhab.org/addons/bindings/miio/#country-servers for the cloud server to use." + "description": "Log in to Xiaomi Home, see https://www.openhab.org/addons/bindings/miio/#country-servers for the server region to use." }, "select": { "data": { - "select_device": "Miio device" + "select_device": "[%key:common::config_flow::data::device%]" }, - "description": "Select the Xiaomi Miio device to set up." + "description": "Select the Xiaomi Home device to set up." }, "manual": { "data": { @@ -58,7 +58,7 @@ "step": { "init": { "data": { - "cloud_subdevices": "Use cloud to get connected subdevices" + "cloud_subdevices": "Use Xiaomi Home service to get connected subdevices" } } } @@ -331,7 +331,7 @@ "fields": { "entity_id": { "name": "Entity ID", - "description": "Name of the Xiaomi Miio entity." + "description": "Name of the Xiaomi Home entity." } } }, diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ca527d117f1..3795bd838ea 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7475,7 +7475,7 @@ "integration_type": "hub", "config_flow": true, "iot_class": "local_polling", - "name": "Xiaomi Miio" + "name": "Xiaomi Home" }, "xiaomi_tv": { "integration_type": "hub", From 36381e6753f49080f905b4eec7c47f761610602e Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:52:23 +0200 Subject: [PATCH 0349/1664] Bump aioautomower to 2025.6.0 (#146979) --- 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 705975bb966..29a4fafb8c0 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==2025.5.1"] + "requirements": ["aioautomower==2025.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 48c80ee883f..dc37042a81c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -201,7 +201,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.5.1 +aioautomower==2025.6.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8af45c573f..59e2c218933 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aioaseko==1.0.0 aioasuswrt==1.4.0 # homeassistant.components.husqvarna_automower -aioautomower==2025.5.1 +aioautomower==2025.6.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 From e02267ad89ef8560a7cd4475bfed8449decd54bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 16 Jun 2025 21:55:16 +0100 Subject: [PATCH 0350/1664] Improve bootstrap file logging test (#146670) --- tests/test_bootstrap.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2af7ef4dc07..9e1f246b551 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -85,6 +85,17 @@ async def test_async_enable_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test to ensure logging is migrated to the queue handlers.""" + config_log_file_pattern = get_test_config_dir("home-assistant.log*") + arg_log_file_pattern = "test.log*" + + # Ensure we start with a clean slate + for f in glob.glob(arg_log_file_pattern): + os.remove(f) + for f in glob.glob(config_log_file_pattern): + os.remove(f) + assert len(glob.glob(config_log_file_pattern)) == 0 + assert len(glob.glob(arg_log_file_pattern)) == 0 + with ( patch("logging.getLogger"), patch( @@ -97,6 +108,8 @@ async def test_async_enable_logging( ): await bootstrap.async_enable_logging(hass) mock_async_activate_log_queue_handler.assert_called_once() + assert len(glob.glob(config_log_file_pattern)) > 0 + mock_async_activate_log_queue_handler.reset_mock() await bootstrap.async_enable_logging( hass, @@ -104,13 +117,15 @@ async def test_async_enable_logging( log_file="test.log", ) mock_async_activate_log_queue_handler.assert_called_once() - for f in glob.glob("test.log*"): - os.remove(f) - for f in glob.glob("testing_config/home-assistant.log*"): - os.remove(f) + assert len(glob.glob(arg_log_file_pattern)) > 0 assert "Error rolling over log file" in caplog.text + for f in glob.glob(arg_log_file_pattern): + os.remove(f) + for f in glob.glob(config_log_file_pattern): + os.remove(f) + async def test_load_hassio(hass: HomeAssistant) -> None: """Test that we load the hassio integration when using Supervisor.""" From c446cce2cc4dc1fe7db0914bd26dc4cb51f2bf32 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 23:44:14 +0200 Subject: [PATCH 0351/1664] Bump pySmartThings to 3.2.5 (#146983) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 481048c3bdb..7c3fc47e512 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.4"] + "requirements": ["pysmartthings==3.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc37042a81c..f21b455f0a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2341,7 +2341,7 @@ pysmappee==0.2.29 pysmarlaapi==0.8.2 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59e2c218933..58983df2583 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1941,7 +1941,7 @@ pysmappee==0.2.29 pysmarlaapi==0.8.2 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 From b0c2a4728834b9d137c5ef7a42546f297ba511e4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 17 Jun 2025 10:32:58 +0200 Subject: [PATCH 0352/1664] Remove deprecated support feature values in vacuum (#146982) --- homeassistant/components/vacuum/__init__.py | 17 ++------- tests/components/vacuum/test_init.py | 39 --------------------- 2 files changed, 2 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 3b1eee8509c..83c68fb61b6 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -312,7 +312,7 @@ class StateVacuumEntity( @property def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" - if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: + if VacuumEntityFeature.FAN_SPEED in self.supported_features: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} return None @@ -330,7 +330,7 @@ class StateVacuumEntity( def state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if VacuumEntityFeature.BATTERY in supported_features: data[ATTR_BATTERY_LEVEL] = self.battery_level @@ -369,19 +369,6 @@ class StateVacuumEntity( """Flag vacuum cleaner features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> VacuumEntityFeature: - """Return the supported features as VacuumEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = VacuumEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" raise NotImplementedError diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index b4fab54e98d..b3e5d17c728 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -31,7 +31,6 @@ from .common import async_start from tests.common import ( MockConfigEntry, MockEntity, - MockEntityPlatform, MockModule, help_test_all, import_and_test_deprecated_constant_enum, @@ -263,44 +262,6 @@ async def test_send_command(hass: HomeAssistant, config_flow_fixture: None) -> N assert "test" in strings -async def test_supported_features_compat(hass: HomeAssistant) -> None: - """Test StateVacuumEntity using deprecated feature constants features.""" - - features = ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE - ) - - class _LegacyConstantsStateVacuum(StateVacuumEntity): - _attr_supported_features = int(features) - _attr_fan_speed_list = ["silent", "normal", "pet hair"] - - entity = _LegacyConstantsStateVacuum() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert isinstance(entity.supported_features, int) - assert entity.supported_features == int(features) - assert entity.supported_features_compat is ( - VacuumEntityFeature.BATTERY - | VacuumEntityFeature.FAN_SPEED - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.PAUSE - ) - assert entity.state_attributes == { - "battery_level": None, - "battery_icon": "mdi:battery-unknown", - "fan_speed": None, - } - assert entity.capability_attributes == { - "fan_speed_list": ["silent", "normal", "pet hair"] - } - assert entity._deprecated_supported_features_reported - - async def test_vacuum_not_log_deprecated_state_warning( hass: HomeAssistant, mock_vacuum_entity: MockVacuum, From 308c89af4a9563ad72daa166cd9781858b3282db Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 17 Jun 2025 10:33:41 +0200 Subject: [PATCH 0353/1664] Remove deprecated support feature values in media_player (#146986) --- .../components/media_player/__init__.py | 53 +++++++------------ tests/components/media_player/test_init.py | 28 ++-------- 2 files changed, 22 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 0979852ecce..d0c6bcabfcf 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -814,19 +814,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag media player features that are supported.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> MediaPlayerEntityFeature: - """Return the supported features as MediaPlayerEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = MediaPlayerEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - def turn_on(self) -> None: """Turn the media player on.""" raise NotImplementedError @@ -966,87 +953,85 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property def support_play(self) -> bool: """Boolean if play is supported.""" - return MediaPlayerEntityFeature.PLAY in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY in self.supported_features @final @property def support_pause(self) -> bool: """Boolean if pause is supported.""" - return MediaPlayerEntityFeature.PAUSE in self.supported_features_compat + return MediaPlayerEntityFeature.PAUSE in self.supported_features @final @property def support_stop(self) -> bool: """Boolean if stop is supported.""" - return MediaPlayerEntityFeature.STOP in self.supported_features_compat + return MediaPlayerEntityFeature.STOP in self.supported_features @final @property def support_seek(self) -> bool: """Boolean if seek is supported.""" - return MediaPlayerEntityFeature.SEEK in self.supported_features_compat + return MediaPlayerEntityFeature.SEEK in self.supported_features @final @property def support_volume_set(self) -> bool: """Boolean if setting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_SET in self.supported_features @final @property def support_volume_mute(self) -> bool: """Boolean if muting volume is supported.""" - return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features_compat + return MediaPlayerEntityFeature.VOLUME_MUTE in self.supported_features @final @property def support_previous_track(self) -> bool: """Boolean if previous track command supported.""" - return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.PREVIOUS_TRACK in self.supported_features @final @property def support_next_track(self) -> bool: """Boolean if next track command supported.""" - return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features_compat + return MediaPlayerEntityFeature.NEXT_TRACK in self.supported_features @final @property def support_play_media(self) -> bool: """Boolean if play media command supported.""" - return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features_compat + return MediaPlayerEntityFeature.PLAY_MEDIA in self.supported_features @final @property def support_select_source(self) -> bool: """Boolean if select source command supported.""" - return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features_compat + return MediaPlayerEntityFeature.SELECT_SOURCE in self.supported_features @final @property def support_select_sound_mode(self) -> bool: """Boolean if select sound mode command supported.""" - return ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features_compat - ) + return MediaPlayerEntityFeature.SELECT_SOUND_MODE in self.supported_features @final @property def support_clear_playlist(self) -> bool: """Boolean if clear playlist command supported.""" - return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features_compat + return MediaPlayerEntityFeature.CLEAR_PLAYLIST in self.supported_features @final @property def support_shuffle_set(self) -> bool: """Boolean if shuffle is supported.""" - return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features_compat + return MediaPlayerEntityFeature.SHUFFLE_SET in self.supported_features @final @property def support_grouping(self) -> bool: """Boolean if player grouping is supported.""" - return MediaPlayerEntityFeature.GROUPING in self.supported_features_compat + return MediaPlayerEntityFeature.GROUPING in self.supported_features async def async_toggle(self) -> None: """Toggle the power on the media player.""" @@ -1074,7 +1059,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level < 1 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( min(1, self.volume_level + self.volume_step) @@ -1092,7 +1077,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if ( self.volume_level is not None and self.volume_level > 0 - and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features_compat + and MediaPlayerEntityFeature.VOLUME_SET in self.supported_features ): await self.async_set_volume_level( max(0, self.volume_level - self.volume_step) @@ -1135,7 +1120,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = {} - supported_features = self.supported_features_compat + supported_features = self.supported_features if ( source_list := self.source_list @@ -1364,7 +1349,7 @@ async def websocket_browse_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media" @@ -1447,7 +1432,7 @@ async def websocket_search_media( connection.send_error(msg["id"], "entity_not_found", "Entity not found") return - if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat: + if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features: connection.send_message( websocket_api.error_message( msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media" diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 090ea9f27e2..2e270eb3b2e 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -152,7 +152,9 @@ def test_support_properties(hass: HomeAssistant, property_suffix: str) -> None: entity4 = MediaPlayerEntity() entity4.hass = hass entity4.platform = MockEntityPlatform(hass) - entity4._attr_supported_features = all_features - feature + entity4._attr_supported_features = media_player.MediaPlayerEntityFeature( + all_features - feature + ) assert getattr(entity1, f"support_{property_suffix}") is False assert getattr(entity2, f"support_{property_suffix}") is True @@ -652,27 +654,3 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockMediaPlayerEntity(MediaPlayerEntity): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockMediaPlayerEntity() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "MockMediaPlayerEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "MediaPlayerEntityFeature.PAUSE" in caplog.text - caplog.clear() - assert entity.supported_features_compat is MediaPlayerEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text From 0926b16095459b166c250402d491ca10088a9a6e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 17 Jun 2025 10:46:08 +0200 Subject: [PATCH 0354/1664] Remove deprecated support feature values in cover (#146987) --- homeassistant/components/cover/__init__.py | 4 --- tests/components/cover/test_init.py | 29 +--------------------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 85069b425e3..a77c8bf8ba3 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -300,10 +300,6 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" if (features := self._attr_supported_features) is not None: - if type(features) is int: - new_features = CoverEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features return features supported_features = ( diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py index f1997066638..e43b64b16a7 100644 --- a/tests/components/cover/test_init.py +++ b/tests/components/cover/test_init.py @@ -2,8 +2,6 @@ from enum import Enum -import pytest - from homeassistant.components import cover from homeassistant.components.cover import CoverState from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TOGGLE @@ -13,11 +11,7 @@ from homeassistant.setup import async_setup_component from .common import MockCover -from tests.common import ( - MockEntityPlatform, - help_test_all, - setup_test_component_platform, -) +from tests.common import help_test_all, setup_test_component_platform async def test_services( @@ -159,24 +153,3 @@ def _create_tuples(enum: type[Enum], constant_prefix: str) -> list[tuple[Enum, s def test_all() -> None: """Test module.__all__ is correctly set.""" help_test_all(cover) - - -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockCoverEntity(cover.CoverEntity): - _attr_supported_features = 1 - - entity = MockCoverEntity() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "MockCoverEntity" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CoverEntityFeature.OPEN" in caplog.text - caplog.clear() - assert entity.supported_features is cover.CoverEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text From 40a00fb79086d9616a78c71b1576333c7d7db686 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 17 Jun 2025 11:23:03 +0200 Subject: [PATCH 0355/1664] Address late review for NextDNS integration (#146980) key instead of Key --- homeassistant/components/nextdns/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index b1602f8985e..8d7bd6a215f 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -6,7 +6,7 @@ "api_key": "[%key:common::config_flow::data::api_key%]" }, "data_description": { - "api_key": "The API Key for your NextDNS account" + "api_key": "The API key for your NextDNS account" } }, "profiles": { From adc4e9fdc10c6717417423efb7a5e9815ca3b7b7 Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Tue, 17 Jun 2025 11:23:50 +0200 Subject: [PATCH 0356/1664] Bump pysmarlaapi version to 0.9.0 (#146629) Bump pysmarlaapi version Fix default values of entities --- homeassistant/components/smarla/__init__.py | 3 ++- homeassistant/components/smarla/manifest.json | 2 +- homeassistant/components/smarla/number.py | 5 +++-- homeassistant/components/smarla/switch.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smarla/snapshots/test_number.ambr | 2 +- tests/components/smarla/test_number.py | 4 ++-- 8 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index c55b1067735..2de3fcfa242 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -22,12 +22,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - federwiege = Federwiege(hass.loop, connection) federwiege.register() - federwiege.connect() entry.runtime_data = federwiege await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + federwiege.connect() + return True diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 5e572c78536..8f7786bdf72 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.8.2"] + "requirements": ["pysmarlaapi==0.9.0"] } diff --git a/homeassistant/components/smarla/number.py b/homeassistant/components/smarla/number.py index d2421962b07..c1a236e4557 100644 --- a/homeassistant/components/smarla/number.py +++ b/homeassistant/components/smarla/number.py @@ -53,9 +53,10 @@ class SmarlaNumber(SmarlaBaseEntity, NumberEntity): _property: Property[int] @property - def native_value(self) -> float: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" - return self._property.get() + v = self._property.get() + return float(v) if v is not None else None def set_native_value(self, value: float) -> None: """Update to the smarla device.""" diff --git a/homeassistant/components/smarla/switch.py b/homeassistant/components/smarla/switch.py index d68f3428a77..f9b56fdea7e 100644 --- a/homeassistant/components/smarla/switch.py +++ b/homeassistant/components/smarla/switch.py @@ -52,7 +52,7 @@ class SmarlaSwitch(SmarlaBaseEntity, SwitchEntity): _property: Property[bool] @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return the entity value to represent the entity state.""" return self._property.get() diff --git a/requirements_all.txt b/requirements_all.txt index f21b455f0a1..44499fb9d33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2338,7 +2338,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.8.2 +pysmarlaapi==0.9.0 # homeassistant.components.smartthings pysmartthings==3.2.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58983df2583..82238467816 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1938,7 +1938,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.8.2 +pysmarlaapi==0.9.0 # homeassistant.components.smartthings pysmartthings==3.2.5 diff --git a/tests/components/smarla/snapshots/test_number.ambr b/tests/components/smarla/snapshots/test_number.ambr index 3232795c277..50312e09920 100644 --- a/tests/components/smarla/snapshots/test_number.ambr +++ b/tests/components/smarla/snapshots/test_number.ambr @@ -53,6 +53,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '1.0', }) # --- diff --git a/tests/components/smarla/test_number.py b/tests/components/smarla/test_number.py index 642b39f33fb..3589829e56c 100644 --- a/tests/components/smarla/test_number.py +++ b/tests/components/smarla/test_number.py @@ -93,11 +93,11 @@ async def test_number_state_update( entity_id = entity_info["entity_id"] - assert hass.states.get(entity_id).state == "1" + assert hass.states.get(entity_id).state == "1.0" mock_number_property.get.return_value = 100 await update_property_listeners(mock_number_property) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == "100" + assert hass.states.get(entity_id).state == "100.0" From ef319c966d6767a98ec8398a6ed65d1f9e9d7a48 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 17 Jun 2025 14:11:55 +0200 Subject: [PATCH 0357/1664] Bump nextcord to 3.1.0 (#147020) --- homeassistant/components/discord/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json index 5f1ba2a13ef..c795c7ed2ed 100644 --- a/homeassistant/components/discord/manifest.json +++ b/homeassistant/components/discord/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_push", "loggers": ["discord"], - "requirements": ["nextcord==2.6.0"] + "requirements": ["nextcord==3.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 44499fb9d33..92790f35cb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1505,7 +1505,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82238467816..fdd8e785e79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1285,7 +1285,7 @@ nexia==2.10.0 nextcloudmonitor==1.5.1 # homeassistant.components.discord -nextcord==2.6.0 +nextcord==3.1.0 # homeassistant.components.nextdns nextdns==4.0.0 From 058f860be756dca882cfdb18472963016aab3d33 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Jun 2025 14:24:31 +0200 Subject: [PATCH 0358/1664] Fix incorrect use of zip in service.async_get_all_descriptions (#147013) * Fix incorrect use of zip in service.async_get_all_descriptions * Fix lint errors in test --- homeassistant/helpers/service.py | 10 +++--- tests/helpers/test_service.py | 54 ++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f157e82bc53..6e1988fe4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -682,9 +682,12 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_services_files( hass: HomeAssistant, integrations: Iterable[Integration] -) -> list[JSON_TYPE]: +) -> dict[str, JSON_TYPE]: """Load service files for multiple integrations.""" - return [_load_services_file(hass, integration) for integration in integrations] + return { + integration.domain: _load_services_file(hass, integration) + for integration in integrations + } @callback @@ -744,10 +747,9 @@ async def async_get_all_descriptions( _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) if integrations: - contents = await hass.async_add_executor_job( + loaded = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(domains_with_missing_services, contents, strict=False)) # Load translations for all service domains translations = await translation.async_get_translations( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 38e7e1ae452..6b464faa110 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -16,6 +16,7 @@ from homeassistant import exceptions from homeassistant.auth.permissions import PolicyPermissions import homeassistant.components # noqa: F401 from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group +from homeassistant.components.input_button import DOMAIN as DOMAIN_INPUT_BUTTON from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH @@ -42,7 +43,12 @@ from homeassistant.helpers import ( entity_registry as er, service, ) -from homeassistant.loader import async_get_integration +from homeassistant.helpers.translation import async_get_translations +from homeassistant.loader import ( + Integration, + async_get_integration, + async_get_integrations, +) from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -1092,38 +1098,66 @@ async def test_async_get_all_descriptions_failing_integration( """Test async_get_all_descriptions when async_get_integrations returns an exception.""" group_config = {DOMAIN_GROUP: {}} await async_setup_component(hass, DOMAIN_GROUP, group_config) - descriptions = await service.async_get_all_descriptions(hass) - - assert len(descriptions) == 1 - - assert "description" in descriptions["group"]["reload"] - assert "fields" in descriptions["group"]["reload"] logger_config = {DOMAIN_LOGGER: {}} await async_setup_component(hass, DOMAIN_LOGGER, logger_config) + + input_button_config = {DOMAIN_INPUT_BUTTON: {}} + await async_setup_component(hass, DOMAIN_INPUT_BUTTON, input_button_config) + + async def wrap_get_integrations( + hass: HomeAssistant, domains: Iterable[str] + ) -> dict[str, Integration | Exception]: + integrations = await async_get_integrations(hass, domains) + integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml") + return integrations + + async def wrap_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, str]: + translations = await async_get_translations( + hass, language, category, integrations, config_flow + ) + return { + key: value + for key, value in translations.items() + if not key.startswith("component.logger.services.") + } + with ( patch( "homeassistant.helpers.service.async_get_integrations", - return_value={"logger": ImportError}, + wraps=wrap_get_integrations, ), patch( "homeassistant.helpers.service.translation.async_get_translations", - return_value={}, + wrap_get_translations, ), ): descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 + assert len(descriptions) == 3 assert "Failed to load integration: logger" in caplog.text # Services are empty defaults if the load fails but should # not raise + assert descriptions[DOMAIN_GROUP]["remove"]["description"] + assert descriptions[DOMAIN_GROUP]["remove"]["fields"] + assert descriptions[DOMAIN_LOGGER]["set_level"] == { "description": "", "fields": {}, "name": "", } + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["description"] + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {} + assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"] + hass.services.async_register(DOMAIN_LOGGER, "new_service", lambda x: None, None) service.async_set_service_schema( hass, DOMAIN_LOGGER, "new_service", {"description": "new service"} From 5c455304a57b9b79c93d0b7bdf37dfc585c07625 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 17 Jun 2025 14:39:22 +0200 Subject: [PATCH 0359/1664] Disable Z-Wave indidator CC entities by default (#147018) * Update discovery tests * Disable Z-Wave indidator CC entities by default --- .../components/zwave_js/discovery.py | 4 + tests/components/zwave_js/test_discovery.py | 115 ++++++++++-------- 2 files changed, 68 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b46735e4040..924778a9e5b 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -896,6 +896,7 @@ DISCOVERY_SCHEMAS = [ writeable=False, ), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # generic text sensors ZWaveDiscoverySchema( @@ -932,6 +933,7 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -957,6 +959,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # button for Indicator CC ZWaveDiscoverySchema( @@ -980,6 +983,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # binary switch # barrier operator signaling states diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 7ef5f0e480f..02296262d1f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,10 +1,12 @@ """Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.number import ( @@ -12,7 +14,6 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -26,12 +27,13 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_aeon_smart_switch_6_state( @@ -222,17 +224,24 @@ async def test_merten_507801_disabled_enitites( async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, - client, - switch_zooz_zen72, - integration, + client: MagicMock, + switch_zooz_zen72: Node, + integration: MockConfigEntry, ) -> None: """Test that Zooz ZEN72 Indicators are discovered as number entities.""" - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + client.async_send_command.reset_mock() state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN @@ -246,7 +255,7 @@ async def test_zooz_zen72( }, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -260,16 +269,18 @@ async def test_zooz_zen72( client.async_send_command.reset_mock() entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is None + assert hass.states.get(entity_id) is not None await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -285,53 +296,55 @@ async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - indicator_test, - integration, + client: MagicMock, + indicator_test: Node, + integration: MockConfigEntry, ) -> None: """Test that Indicators are discovered properly. This test covers indicators that we don't already have device fixtures for. """ - device = device_registry.async_get_device( - identifiers={get_device_id(client.driver, indicator_test)} + binary_sensor_entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + sensor_entity_id = "sensor.this_is_a_fake_device_sensor" + switch_entity_id = "switch.this_is_a_fake_device_switch" + + for entity_id in ( + binary_sensor_entity_id, + sensor_entity_id, + ): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + entity_id = switch_entity_id + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - assert device - entities = er.async_entries_for_device(entity_registry, device.id) + await hass.async_block_till_done() + client.async_send_command.reset_mock() - def len_domain(domain): - return len([entity for entity in entities if entity.domain == domain]) - - assert len_domain(NUMBER_DOMAIN) == 0 - assert len_domain(BUTTON_DOMAIN) == 1 # only ping - assert len_domain(BINARY_SENSOR_DOMAIN) == 1 - assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen - assert len_domain(SWITCH_DOMAIN) == 1 - - entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = binary_sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - client.async_send_command.reset_mock() - - entity_id = "sensor.this_is_a_fake_device_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == "0.0" - client.async_send_command.reset_mock() - - entity_id = "switch.this_is_a_fake_device_switch" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_id = switch_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -342,7 +355,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id @@ -362,7 +375,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id From 79cc3bffc680fc1a95a48b6d5c7f22a51d71a6ec Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 17 Jun 2025 07:40:56 -0500 Subject: [PATCH 0360/1664] Bump aiorussound to 4.6.0 (#147023) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index e16e589e648..30b9205f439 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.5.2"], + "requirements": ["aiorussound==4.6.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 92790f35cb0..65a4b441cb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdd8e785e79..60c7ae60829 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -351,7 +351,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.5.2 +aiorussound==4.6.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 3b611b9b03758398e5c7574b9cdaa1ec0655a208 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 17 Jun 2025 08:39:18 -0500 Subject: [PATCH 0361/1664] Add TTS response timeout for idle state (#146984) * Add TTS response timeout for idle state * Consider time spent sending TTS audio in timeout --- .../components/wyoming/assist_satellite.py | 88 +++++++++----- tests/components/wyoming/test_satellite.py | 107 ++++++++++++++++++ 2 files changed, 168 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 88939f0ba77..75c227f8537 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import AsyncGenerator import io import logging +import time from typing import Any, Final import wave @@ -36,6 +37,7 @@ from homeassistant.components.assist_satellite import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.ulid import ulid_now from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH from .data import WyomingService @@ -53,6 +55,7 @@ _PING_SEND_DELAY: Final = 2 _PIPELINE_FINISH_TIMEOUT: Final = 1 _TTS_SAMPLE_RATE: Final = 22050 _ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples +_TTS_TIMEOUT_EXTRA: Final = 1.0 # Wyoming stage -> Assist stage _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = { @@ -125,6 +128,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None self._played_event_received: asyncio.Event | None = None + # Randomly set on each pipeline loop run. + # Used to ensure TTS timeout is acted on correctly. + self._run_loop_id: str | None = None + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -511,6 +518,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): wake_word_phrase: str | None = None run_pipeline: RunPipeline | None = None send_ping = True + self._run_loop_id = ulid_now() # Read events and check for pipeline end in parallel pipeline_ended_task = self.config_entry.async_create_background_task( @@ -698,38 +706,52 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): f"Cannot stream audio format to satellite: {tts_result.extension}" ) - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + # Track the total duration of TTS audio for response timeout + total_seconds = 0.0 + start_time = time.monotonic() - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + try: + data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) - timestamp = 0 - await self._client.write_event( - AudioStart( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - timestamp=timestamp, - ).event() - ) + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + sample_rate = wav_file.getframerate() + sample_width = wav_file.getsampwidth() + sample_channels = wav_file.getnchannels() + _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) - # Stream audio chunks - while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): - chunk = AudioChunk( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - audio=audio_bytes, - timestamp=timestamp, + timestamp = 0 + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() ) - await self._client.write_event(chunk.event()) - timestamp += chunk.seconds - await self._client.write_event(AudioStop(timestamp=timestamp).event()) - _LOGGER.debug("TTS streaming complete") + # Stream audio chunks + while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): + chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=audio_bytes, + timestamp=timestamp, + ) + await self._client.write_event(chunk.event()) + timestamp += chunk.seconds + total_seconds += chunk.seconds + + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") + finally: + send_duration = time.monotonic() - start_time + timeout_seconds = max(0, total_seconds - send_duration + _TTS_TIMEOUT_EXTRA) + self.config_entry.async_create_background_task( + self.hass, + self._tts_timeout(timeout_seconds, self._run_loop_id), + name="wyoming TTS timeout", + ) async def _stt_stream(self) -> AsyncGenerator[bytes]: """Yield audio chunks from a queue.""" @@ -744,6 +766,18 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): yield chunk + async def _tts_timeout( + self, timeout_seconds: float, run_loop_id: str | None + ) -> None: + """Force state change to IDLE in case TTS played event isn't received.""" + await asyncio.sleep(timeout_seconds + _TTS_TIMEOUT_EXTRA) + + if run_loop_id != self._run_loop_id: + # On a different pipeline run now + return + + self.tts_response_finished() + @callback def _handle_timer( self, event_type: intent.TimerEventType, timer: intent.TimerInfo diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index 800870f4604..dec5d6cbebd 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1365,3 +1365,110 @@ async def test_announce( # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_tts_timeout( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test entity state goes back to IDLE on a timeout.""" + events = [ + Info(satellite=SATELLITE_INFO.satellite).event(), + RunPipeline(start_stage=PipelineStage.TTS, end_stage=PipelineStage.TTS).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + + response_finished = asyncio.Event() + + def tts_response_finished(self): + response_finished.set() + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ), + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + patch( + "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite.tts_response_finished", + tts_response_finished, + ), + patch( + "homeassistant.components.wyoming.assist_satellite._TTS_TIMEOUT_EXTRA", + 0, + ), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + satellite_entry = next( + ( + maybe_entry + for maybe_entry in er.async_entries_for_device( + entity_registry, device.device_id + ) + if maybe_entry.domain == assist_satellite.DOMAIN + ), + None, + ) + assert satellite_entry is not None + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + # Reset so we can check the pipeline is automatically restarted below + run_pipeline_called.clear() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + async with asyncio.timeout(1): + # tts_response_finished should be called on timeout + await response_finished.wait() + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From 22a06a6c2e64acfa75077701c82d721dc8bc7c7b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 17 Jun 2025 07:06:51 -0700 Subject: [PATCH 0362/1664] Bump ical to 10.0.4 (#147005) * Bump ical to 10.0.4 * Bump ical to 10.0.4 in google --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fecd245869a..1acfa3a2ad1 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.0"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index e0b08313d63..3bf00f30624 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.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index c8e80e4f91b..134cea5293b 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.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 052b409dfe7..6ba1dea55ed 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.0"] + "requirements": ["ical==10.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 65a4b441cb4..7eddc5762a2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1203,7 +1203,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 60c7ae60829..4e9155baeec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 From ed9503324d9d255e6fb077f1614fb6d55800f389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 17 Jun 2025 17:18:48 +0100 Subject: [PATCH 0363/1664] Fix flaky Reolink webhook test (#147036) --- tests/components/reolink/test_host.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index c777e4064f0..3c2f434ccc7 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -118,6 +118,7 @@ async def test_webhook_callback( reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") + await hass.async_block_till_done() signal_all.assert_called_once() assert hass.states.get(entity_id).state == STATE_ON @@ -129,6 +130,7 @@ async def test_webhook_callback( signal_all.reset_mock() reolink_connect.get_motion_state_all_ch.return_value = False await client.post(f"/api/webhook/{webhook_id}") + await hass.async_block_till_done() signal_all.assert_not_called() assert hass.states.get(entity_id).state == STATE_ON @@ -137,6 +139,7 @@ async def test_webhook_callback( reolink_connect.motion_detected.return_value = False reolink_connect.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") + await hass.async_block_till_done() signal_ch.assert_called_once() assert hass.states.get(entity_id).state == STATE_OFF @@ -144,6 +147,7 @@ async def test_webhook_callback( signal_ch.reset_mock() reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") await client.post(f"/api/webhook/{webhook_id}", data="test_data") + await hass.async_block_till_done() signal_ch.assert_not_called() # test failure to read date from webhook post From e69b38ab2cd14378a434574dda7a52394d45adf7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Jun 2025 19:57:52 +0200 Subject: [PATCH 0364/1664] Fix log in onedrive (#147029) --- homeassistant/components/onedrive/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index ff05b19f84d..07a8dbd203b 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -66,7 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): translation_domain=DOMAIN, translation_key="authentication_failed" ) from err except OneDriveException as err: - _LOGGER.debug("Failed to fetch drive data: %s") + _LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True) raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err From 3bc68941e679a3fe60c18a77f7614355c27aadef Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 17 Jun 2025 20:43:16 +0200 Subject: [PATCH 0365/1664] Remove not used constant in climate (#147041) --- homeassistant/components/climate/__init__.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 59749cd58ee..790579d6a73 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -105,11 +105,6 @@ DEFAULT_MAX_HUMIDITY = 99 CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH] -# Can be removed in 2025.1 after deprecation period of the new feature flags -CHECK_TURN_ON_OFF_FEATURE_FLAG = ( - ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF -) - SET_TEMPERATURE_SCHEMA = vol.All( cv.has_at_least_one_key( ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW From 8e82e3aa3a6198d506b3d4455936a7e875242f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 17 Jun 2025 20:48:09 +0200 Subject: [PATCH 0366/1664] Bump aiohomeconnect to 0.18.0 (#147044) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index d4b37552fb7..34a3b756119 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -21,6 +21,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.17.1"], + "requirements": ["aiohomeconnect==0.18.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7eddc5762a2..fa2ead403ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.0 # homeassistant.components.homekit_controller aiohomekit==3.2.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e9155baeec..e3dc57d3d60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.0 # homeassistant.components.homekit_controller aiohomekit==3.2.15 From fc6844b3c90e6adca44057a787893625fc725381 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 17 Jun 2025 20:49:52 +0200 Subject: [PATCH 0367/1664] Add _attr_has_entity_name to devolo Home Network device tracker platform (#146978) * Add _attr_has_entity_name to devolo Home Network device tracker platform * Set name * Fix tests --- .../devolo_home_network/device_tracker.py | 2 ++ .../snapshots/test_device_tracker.ambr | 3 ++- .../devolo_home_network/test_device_tracker.py | 13 ++++--------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index 15ff0e5ac2a..ad3d3e1cffa 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -87,6 +87,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module ): """Representation of a devolo device tracker.""" + _attr_has_entity_name = True _attr_translation_key = "device_tracker" def __init__( @@ -99,6 +100,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module super().__init__(coordinator) self._device = device self._attr_mac_address = mac + self._attr_name = mac @property def extra_state_attributes(self) -> dict[str, str]: diff --git a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr index 950aff87752..9011439c42b 100644 --- a/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr +++ b/tests/components/devolo_home_network/snapshots/test_device_tracker.ambr @@ -3,12 +3,13 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'band': '5 GHz', + 'friendly_name': 'AA:BB:CC:DD:EE:FF', 'mac': 'AA:BB:CC:DD:EE:FF', 'source_type': , 'wifi': 'Main', }), 'context': , - 'entity_id': 'device_tracker.devolo_home_network_1234567890_aa_bb_cc_dd_ee_ff', + 'entity_id': 'device_tracker.aa_bb_cc_dd_ee_ff', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 2af6a1e3759..cb92b8bc3d9 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -17,13 +17,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import configure_integration -from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS +from .const import CONNECTED_STATIONS, NO_CONNECTED_STATIONS from .mock import MockDevice from tests.common import async_fire_time_changed STATION = CONNECTED_STATIONS[0] -SERIAL = DISCOVERY_INFO.properties["SN"] @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -35,9 +34,7 @@ async def test_device_tracker( snapshot: SnapshotAssertion, ) -> None: """Test device tracker states.""" - state_key = ( - f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" - ) + state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" entry = configure_integration(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -77,14 +74,12 @@ async def test_restoring_clients( entity_registry: er.EntityRegistry, ) -> None: """Test restoring existing device_tracker entities.""" - state_key = ( - f"{PLATFORM}.{DOMAIN}_{SERIAL}_{STATION.mac_address.lower().replace(':', '_')}" - ) + state_key = f"{PLATFORM}.{STATION.mac_address.lower().replace(':', '_')}" entry = configure_integration(hass) entity_registry.async_get_or_create( PLATFORM, DOMAIN, - f"{SERIAL}_{STATION.mac_address}", + f"{STATION.mac_address}", config_entry=entry, ) From ce1678719a102f0a16f6f5240064b8c764525d92 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Jun 2025 20:59:41 +0200 Subject: [PATCH 0368/1664] Bump pylamarzocco to 2.0.9 (#147046) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 46a29427264..7fdafc4dda1 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.8"] + "requirements": ["pylamarzocco==2.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa2ead403ff..bb498ac523a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2096,7 +2096,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e3dc57d3d60..06b9e35e8d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1738,7 +1738,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 From 81257f9d579ba3c1ef86b09e2ad435e93f8b346e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:06:53 -0400 Subject: [PATCH 0369/1664] Bump ZHA to 0.0.60 (#147045) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4a5ec7be1dc..4908298847b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.59"], + "requirements": ["zha==0.0.60"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index bb498ac523a..5a5547d5860 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3180,7 +3180,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06b9e35e8d4..46974b17ba0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2621,7 +2621,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zwave_js zwave-js-server-python==0.63.0 From 5e31b5ac4f852798ffabdc2ec8fe320ea75ba71a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Jun 2025 21:25:27 +0200 Subject: [PATCH 0370/1664] Handle missing widget in lamarzocco (#147047) --- homeassistant/components/lamarzocco/number.py | 6 +++++- homeassistant/components/lamarzocco/sensor.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 980a08c09ae..f8cb8b1d6fe 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,6 +58,10 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", @@ -221,7 +225,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): entity_description: LaMarzoccoNumberEntityDescription @property - def native_value(self) -> float: + def native_value(self) -> float | int: """Return the current value.""" return self.entity_description.native_value_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 29f1c6209ec..c76f51c3488 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,6 +57,10 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", From ffd940e07c5fd43ba9e3a82070bc11f6a483ef0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 17 Jun 2025 22:42:40 +0200 Subject: [PATCH 0371/1664] Set quality scale at Home Connect manifest (#147050) --- homeassistant/components/home_connect/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 34a3b756119..5e296ba18ac 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -21,6 +21,7 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], + "quality_scale": "platinum", "requirements": ["aiohomeconnect==0.18.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index f4283f14ec3..52e5f935117 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1527,7 +1527,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "hko", "hlk_sw16", "holiday", - "home_connect", "homekit", "homekit_controller", "homematic", From be53ad544934192764de833f4230ebd8cbaa4fda Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 18 Jun 2025 07:29:04 +0200 Subject: [PATCH 0372/1664] Disable Z-Wave idle notification button (#147026) * Update test * Disable Z-Wave idle notification button * Update tests --- .../components/zwave_js/discovery.py | 1 + .../zwave_js/snapshots/test_diagnostics.ambr | 8 ++-- tests/components/zwave_js/test_button.py | 41 ++++++++++++++++--- tests/components/zwave_js/test_init.py | 12 +----- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 924778a9e5b..4e9a3321beb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1188,6 +1188,7 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), allow_multi=True, + entity_registry_enabled_default=False, ), # event # stateful = False diff --git a/tests/components/zwave_js/snapshots/test_diagnostics.ambr b/tests/components/zwave_js/snapshots/test_diagnostics.ambr index dc0dbba59b5..40ed3bbf836 100644 --- a/tests/components/zwave_js/snapshots/test_diagnostics.ambr +++ b/tests/components/zwave_js/snapshots/test_diagnostics.ambr @@ -97,8 +97,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_cover_status', @@ -120,8 +120,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_motion_sensor_status', diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 0282a268b54..422888cab23 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -1,13 +1,21 @@ """Test the Z-Wave JS button entities.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest +from zwave_js_server.model.node import Node from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ATTR_ENTITY_ID, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -71,11 +79,32 @@ async def test_ping_entity( async def test_notification_idle_button( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test Notification idle button.""" node = multisensor_6 - state = hass.states.get("button.multisensor_6_idle_home_security_cover_status") + entity_id = "button.multisensor_6_idle_home_security_cover_status" + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity( + entity_id, + disabled_by=None, + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) assert state assert state.state == "unknown" assert ( @@ -88,13 +117,13 @@ async def test_notification_idle_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - assert len(client.async_send_command_no_wait.call_args_list) == 1 - args = client.async_send_command_no_wait.call_args_list[0][0][0] + assert client.async_send_command_no_wait.call_count == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.manually_idle_notification_value" assert args["nodeId"] == node.node_id assert args["valueId"] == { diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index ef74373ad9e..fa82b051e59 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1812,7 +1812,8 @@ async def test_disabled_node_status_entity_on_node_replaced( assert state.state == STATE_UNAVAILABLE -async def test_disabled_entity_on_value_removed( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remove_entity_on_value_removed( hass: HomeAssistant, zp3111: Node, client: MagicMock, @@ -1823,15 +1824,6 @@ async def test_disabled_entity_on_value_removed( "button.4_in_1_sensor_idle_home_security_cover_status" ) - # must reload the integration when enabling an entity - await hass.config_entries.async_unload(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.NOT_LOADED - integration.add_to_hass(hass) - await hass.config_entries.async_setup(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.LOADED - state = hass.states.get(idle_cover_status_button_entity) assert state assert state.state != STATE_UNAVAILABLE From b8cd3f3635e7d2f1e3ccd60a02364f8b23eab524 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 18 Jun 2025 09:11:01 +0200 Subject: [PATCH 0373/1664] Bump holidays lib to 0.75 (#147043) --- 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 5a5f1daf967..c76d6638730 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.74", "babel==2.15.0"] + "requirements": ["holidays==0.75", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 9091dd131dd..f9fae38f1f5 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.74"] + "requirements": ["holidays==0.75"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a5547d5860..01b36bc3354 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend home-assistant-frontend==20250531.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 46974b17ba0..09c4731dfa2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend home-assistant-frontend==20250531.3 From 3449dae7a27715ceaff76e84edd29df05b9feebd Mon Sep 17 00:00:00 2001 From: msw Date: Wed, 18 Jun 2025 00:14:45 -0700 Subject: [PATCH 0374/1664] Capitalize "Ice Bites" and switch to "Cubed ice" (#147060) (#147061) --- .../components/smartthings/strings.json | 4 +- .../smartthings/snapshots/test_switch.ambr | 124 +++++++++--------- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 038894a3d5b..5a1d111b617 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -605,10 +605,10 @@ "name": "Wrinkle prevent" }, "ice_maker": { - "name": "Ice cubes" + "name": "Cubed ice" }, "ice_maker_2": { - "name": "Ice bites" + "name": "Ice Bites" }, "sabbath_mode": { "name": "Sabbath mode" diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index a182c3bf2a2..d0ea3dbcdad 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -47,7 +47,7 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_cubes-entry] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -60,7 +60,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_cubes', + 'entity_id': 'switch.refrigerator_cubed_ice', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -72,7 +72,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice cubes', + 'original_name': 'Cubed ice', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -82,13 +82,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_ice_cubes-state] +# name: test_all_entities[da_ref_normal_000001][switch.refrigerator_cubed_ice-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice cubes', + 'friendly_name': 'Refrigerator Cubed ice', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_cubes', + 'entity_id': 'switch.refrigerator_cubed_ice', 'last_changed': , 'last_reported': , 'last_updated': , @@ -239,7 +239,7 @@ 'state': 'off', }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_cubes-entry] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -252,7 +252,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.refrigerator_ice_cubes', + 'entity_id': 'switch.refrigerator_cubed_ice', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -264,7 +264,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice cubes', + 'original_name': 'Cubed ice', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -274,13 +274,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_ice_cubes-state] +# name: test_all_entities[da_ref_normal_01001][switch.refrigerator_cubed_ice-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Refrigerator Ice cubes', + 'friendly_name': 'Refrigerator Cubed ice', }), 'context': , - 'entity_id': 'switch.refrigerator_ice_cubes', + 'entity_id': 'switch.refrigerator_cubed_ice', 'last_changed': , 'last_reported': , 'last_updated': , @@ -383,6 +383,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-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': None, + 'entity_id': 'switch.frigo_cubed_ice', + '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': 'Cubed ice', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ice_maker', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][switch.frigo_cubed_ice-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Cubed ice', + }), + 'context': , + 'entity_id': 'switch.frigo_cubed_ice', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -408,7 +456,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Ice bites', + 'original_name': 'Ice Bites', 'platform': 'smartthings', 'previous_unique_id': None, 'suggested_object_id': None, @@ -421,7 +469,7 @@ # name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_bites-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Frigo Ice bites', + 'friendly_name': 'Frigo Ice Bites', }), 'context': , 'entity_id': 'switch.frigo_ice_bites', @@ -431,54 +479,6 @@ 'state': 'on', }) # --- -# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_cubes-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': None, - 'entity_id': 'switch.frigo_ice_cubes', - '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': 'Ice cubes', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'ice_maker', - 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_icemaker_switch_switch_switch', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ref_normal_01011][switch.frigo_ice_cubes-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Frigo Ice cubes', - }), - 'context': , - 'entity_id': 'switch.frigo_ice_cubes', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_all_entities[da_ref_normal_01011][switch.frigo_power_cool-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From ba2aac46145fc01827c444a9a917b73d055a2461 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 18 Jun 2025 09:15:27 +0200 Subject: [PATCH 0375/1664] Bump aiowebdav2 to 0.4.6 (#147054) --- homeassistant/components/webdav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json index 63d093745d1..9e9e1c8866e 100644 --- a/homeassistant/components/webdav/manifest.json +++ b/homeassistant/components/webdav/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aiowebdav2"], "quality_scale": "bronze", - "requirements": ["aiowebdav2==0.4.5"] + "requirements": ["aiowebdav2==0.4.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 01b36bc3354..925a6172cf8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -429,7 +429,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.5 +aiowebdav2==0.4.6 # homeassistant.components.webostv aiowebostv==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09c4731dfa2..e2007ddb1ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -411,7 +411,7 @@ aiowaqi==3.1.0 aiowatttime==0.1.1 # homeassistant.components.webdav -aiowebdav2==0.4.5 +aiowebdav2==0.4.6 # homeassistant.components.webostv aiowebostv==0.7.3 From 07110e288dfd44e9fceb795f283e5ab8b8d32438 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 18 Jun 2025 09:16:08 +0200 Subject: [PATCH 0376/1664] If no Reolink HTTP api available, do not set configuration_url (#146684) * If no http api available, do not set configuration_url * Add tests --- homeassistant/components/reolink/entity.py | 12 ++++++++++-- tests/components/reolink/test_init.py | 13 +++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 2e0f1ac9e6a..467472fef9c 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -75,7 +75,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] ) http_s = "https" if self._host.api.use_https else "http" - self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" + if self._host.api.baichuan_only: + self._conf_url = None + else: + self._conf_url = f"{http_s}://{self._host.api.host}:{self._host.api.port}" self._dev_id = self._host.unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, @@ -184,6 +187,11 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): if mac := self._host.api.baichuan.mac_address(dev_ch): connections.add((CONNECTION_NETWORK_MAC, mac)) + if self._conf_url is None: + conf_url = None + else: + conf_url = f"{self._conf_url}/?ch={dev_ch}" + self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._dev_id)}, connections=connections, @@ -195,7 +203,7 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity): hw_version=self._host.api.camera_hardware_version(dev_ch), sw_version=self._host.api.camera_sw_version(dev_ch), serial_number=self._host.api.camera_uid(dev_ch), - configuration_url=f"{self._conf_url}/?ch={dev_ch}", + configuration_url=conf_url, ) @property diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 86c4ed861a1..482928560b9 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1186,6 +1186,19 @@ async def test_camera_wake_callback( assert hass.states.get(entity_id).state == STATE_OFF +async def test_baichaun_only( + hass: HomeAssistant, + reolink_connect: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test initializing a baichuan only device.""" + reolink_connect.baichuan_only = True + + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + async def test_remove( hass: HomeAssistant, reolink_connect: MagicMock, From 43d8a151ab2a9e981da48396dd76e4665025d89e Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 18 Jun 2025 03:21:21 -0400 Subject: [PATCH 0377/1664] Remove internals from Sonos test_init.py (#147063) * fix: test init * fix: revert * fix: revert * fix: revert * fix: revert * fix: simplify --- tests/components/sonos/conftest.py | 11 ++- tests/components/sonos/test_init.py | 111 ++++++++++++++-------------- 2 files changed, 64 insertions(+), 58 deletions(-) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 4994d36f1bf..d121d5a4a12 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -85,6 +85,15 @@ class SonosMockService: self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address)) +class SonosMockRenderingService(SonosMockService): + """Mock rendering service.""" + + def __init__(self, return_value: dict[str, str], ip_address="192.168.42.2") -> None: + """Initialize the instance.""" + super().__init__("RenderingControl", ip_address) + self.GetVolume = Mock(return_value=30) + + class SonosMockAlarmClock(SonosMockService): """Mock a Sonos AlarmClock Service used in callbacks.""" @@ -239,7 +248,7 @@ class SoCoMockFactory: mock_soco.avTransport.GetPositionInfo = Mock( return_value=self.current_track_info ) - mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address) + mock_soco.renderingControl = SonosMockRenderingService(ip_address) mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address) mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index c6be606eb20..1bc8baff752 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -3,15 +3,15 @@ import asyncio from datetime import timedelta import logging -from unittest.mock import Mock, patch +from unittest.mock import Mock, PropertyMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import config_entries from homeassistant.components import sonos -from homeassistant.components.sonos import SonosDiscoveryManager from homeassistant.components.sonos.const import ( - DATA_SONOS_DISCOVERY_MANAGER, + DISCOVERY_INTERVAL, SONOS_SPEAKER_ACTIVITY, ) from homeassistant.components.sonos.exception import SonosUpdateError @@ -87,76 +87,73 @@ async def test_not_configuring_sonos_not_creates_entry(hass: HomeAssistant) -> N async def test_async_poll_manual_hosts_warnings( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + soco_factory: SoCoMockFactory, + freezer: FrozenDateTimeFactory, ) -> None: """Test that host warnings are not logged repeatedly.""" - await async_setup_component( - hass, - sonos.DOMAIN, - {"sonos": {"media_player": {"interface_addr": "127.0.0.1"}}}, - ) - await hass.async_block_till_done() - manager: SonosDiscoveryManager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] - manager.hosts.add("10.10.10.10") + + soco = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Bedroom") with ( caplog.at_level(logging.DEBUG), - patch.object(manager, "_async_handle_discovery_message"), - patch( - "homeassistant.components.sonos.async_call_later" - ) as mock_async_call_later, - patch("homeassistant.components.sonos.async_dispatcher_send"), - patch( - "homeassistant.components.sonos.sync_get_visible_zones", - side_effect=[ - OSError(), - OSError(), - [], - [], - OSError(), - ], - ), + patch.object( + type(soco), "visible_zones", new_callable=PropertyMock + ) as mock_visible_zones, ): # First call fails, it should be logged as a WARNING message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 1 + await _setup_hass(hass) + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["WARNING"] # Second call fails again, it should be logged as a DEBUG message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "DEBUG" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 2 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["DEBUG"] - # Third call succeeds, it should log an info message + # Third call succeeds, logs message indicating reconnect + mock_visible_zones.return_value = {soco} + mock_visible_zones.side_effect = None caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Connection reestablished to Sonos device" in record.message - assert mock_async_call_later.call_count == 3 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert [ + rec.levelname + for rec in caplog.records + if "Connection reestablished to Sonos device" in rec.message + ] == ["WARNING"] - # Fourth call succeeds again, no need to log + # Fourth call succeeds, it should log nothing caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 0 - assert mock_async_call_later.call_count == 4 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert "Connection reestablished to Sonos device" not in caplog.text - # Fifth call fail again again, should be logged as a WARNING message + # Fifth call fails again again, should be logged as a WARNING message + mock_visible_zones.side_effect = OSError() caplog.clear() - await manager.async_poll_manual_hosts() - assert len(caplog.messages) == 1 - record = caplog.records[0] - assert record.levelname == "WARNING" - assert "Could not get visible Sonos devices from" in record.message - assert mock_async_call_later.call_count == 5 + freezer.tick(DISCOVERY_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert [ + rec.levelname + for rec in caplog.records + if "Could not get visible Sonos devices from" in rec.message + ] == ["WARNING"] class _MockSoCoOsError(MockSoCo): From 3fad76dfa120685be2294258030733d6a212ce07 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 18 Jun 2025 09:22:37 +0200 Subject: [PATCH 0378/1664] Use missed typed ConfigEntry in devolo Home Control (#147049) --- homeassistant/components/devolo_home_control/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index b8dc948913f..331bb5df94a 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -91,7 +91,9 @@ async def async_unload_entry( async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry + hass: HomeAssistant, + config_entry: DevoloHomeControlConfigEntry, + device_entry: DeviceEntry, ) -> bool: """Remove a config entry from a device.""" return True From 75d6b885cfa8483c337433c4b81613816c399983 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 18 Jun 2025 09:23:37 +0200 Subject: [PATCH 0379/1664] Fix typo in state name references of `homee` (#146905) Fix typo in state references Replace wrong semicolons with colon. --- homeassistant/components/homee/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index b5849f8b1a6..8b10b3ebb8a 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -177,9 +177,9 @@ "state_attributes": { "event_type": { "state": { - "upper": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]", - "lower": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]", - "released": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]" + "upper": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::upper%]", + "lower": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::lower%]", + "released": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]" } } } @@ -189,7 +189,7 @@ "state_attributes": { "event_type": { "state": { - "release": "[%key;component::homee::entity::event::button_state::state_attributes::event_type::state::released%]", + "release": "[%key:component::homee::entity::event::button_state::state_attributes::event_type::state::released%]", "up": "Up", "down": "Down", "stop": "Stop", From 596951ea9fbb9dd80759a6d55898d0aec6728080 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 18 Jun 2025 09:24:09 +0200 Subject: [PATCH 0380/1664] Cleanup devolo Home Control tests (#147051) --- .../devolo_home_control/test_binary_sensor.py | 5 --- .../devolo_home_control/test_config_flow.py | 38 ------------------- .../devolo_home_control/test_diagnostics.py | 2 - .../devolo_home_control/test_init.py | 2 - .../devolo_home_control/test_siren.py | 5 --- 5 files changed, 52 deletions(-) diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index fd28ce2fdf6..b2a58ef5038 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -19,7 +18,6 @@ from .mocks import ( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_binary_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -58,7 +56,6 @@ async def test_binary_sensor( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_remote_control( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -99,7 +96,6 @@ async def test_remote_control( ) -@pytest.mark.usefixtures("mock_zeroconf") async def test_disabled(hass: HomeAssistant) -> None: """Test setup of a disabled device.""" entry = configure_integration(hass) @@ -113,7 +109,6 @@ async def test_disabled(hass: HomeAssistant) -> None: assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door") is None -@pytest.mark.usefixtures("mock_zeroconf") async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py index aab3e69b38f..9367d746d2e 100644 --- a/tests/components/devolo_home_control/test_config_flow.py +++ b/tests/components/devolo_home_control/test_config_flow.py @@ -66,44 +66,6 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_advanced_options(hass: HomeAssistant) -> None: - """Test if we get the advanced options if user has enabled it.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.devolo_home_control.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - patch( - "homeassistant.components.devolo_home_control.Mydevolo.uuid", - return_value="123456", - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "devolo Home Control" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_form_zeroconf(hass: HomeAssistant) -> None: """Test that the zeroconf confirmation form is served.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/devolo_home_control/test_diagnostics.py b/tests/components/devolo_home_control/test_diagnostics.py index dfadc4d1c4b..558ed6394fa 100644 --- a/tests/components/devolo_home_control/test_diagnostics.py +++ b/tests/components/devolo_home_control/test_diagnostics.py @@ -1,7 +1,5 @@ """Tests for the devolo Home Control diagnostics.""" -from __future__ import annotations - from unittest.mock import patch from syrupy.assertion import SnapshotAssertion diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index da007303688..fb97447264d 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -19,7 +19,6 @@ from .mocks import HomeControlMock, HomeControlMockBinarySensor from tests.typing import WebSocketGenerator -@pytest.mark.usefixtures("mock_zeroconf") async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry.""" entry = configure_integration(hass) @@ -44,7 +43,6 @@ async def test_setup_entry_maintenance(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -@pytest.mark.usefixtures("mock_zeroconf") async def test_setup_gateway_offline(hass: HomeAssistant) -> None: """Test setup entry fails on gateway offline.""" entry = configure_integration(hass) diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py index 71f4dfdd34d..7c943e05cef 100644 --- a/tests/components/devolo_home_control/test_siren.py +++ b/tests/components/devolo_home_control/test_siren.py @@ -2,7 +2,6 @@ from unittest.mock import patch -import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN @@ -14,7 +13,6 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSiren -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -45,7 +43,6 @@ async def test_siren( assert hass.states.get(f"{SIREN_DOMAIN}.test").state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren_switching( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -98,7 +95,6 @@ async def test_siren_switching( property_set.assert_called_once_with(0) -@pytest.mark.usefixtures("mock_zeroconf") async def test_siren_change_default_tone( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: @@ -130,7 +126,6 @@ async def test_siren_change_default_tone( property_set.assert_called_once_with(2) -@pytest.mark.usefixtures("mock_zeroconf") async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) From fec65f40fc2edcb0f5eb649cdfdc365960f9639f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 18 Jun 2025 11:20:51 +0300 Subject: [PATCH 0381/1664] Bump aioamazondevices to 3.1.12 (#147055) * Bump aioamazondevices to 3.1.10 * bump to 3.1.12 --- 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 7a7713f861b..aeecb5bc96c 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": "bronze", - "requirements": ["aioamazondevices==3.1.4"] + "requirements": ["aioamazondevices==3.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 925a6172cf8..10386058750 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.4 +aioamazondevices==3.1.12 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e2007ddb1ad..d7efe2904dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.4 +aioamazondevices==3.1.12 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 5487bfe1d9d01288ce810382855832187ca25361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 18 Jun 2025 14:47:01 +0100 Subject: [PATCH 0382/1664] Bump hass-nabucasa from 0.101.0 to 0.102.0 (#147087) --- 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 faee244a074..0d1aca60c8f 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.101.0"], + "requirements": ["hass-nabucasa==0.102.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 29751879dae..df0c6ef7452 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.3 diff --git a/pyproject.toml b/pyproject.toml index 2910ee0221d..c4cfe7a8593 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.101.0", + "hass-nabucasa==0.102.0", # hassil is indirectly imported from onboarding via the import chain # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its diff --git a/requirements.txt b/requirements.txt index b5a949b0a6b..bf963ecc52d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 10386058750..b69e1e69ccd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7efe2904dd..8254ce9f261 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.101.0 +hass-nabucasa==0.102.0 # homeassistant.components.conversation hassil==2.2.3 From d01758cea852fcee43f8f5438da24616006554d5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 18 Jun 2025 15:48:38 +0200 Subject: [PATCH 0383/1664] Ensure mqtt sensor has a valid native unit of measurement (#146722) --- homeassistant/components/mqtt/sensor.py | 43 ++-------------------- homeassistant/components/mqtt/strings.json | 4 -- tests/components/mqtt/test_sensor.py | 38 ++----------------- 3 files changed, 7 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 46d475fcee8..783a0b30b14 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -35,7 +35,6 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.service_info.mqtt import ReceivePayloadType from homeassistant.helpers.typing import ConfigType, VolSchemaType from homeassistant.util import dt as dt_util @@ -48,7 +47,6 @@ from .const import ( CONF_OPTIONS, CONF_STATE_TOPIC, CONF_SUGGESTED_DISPLAY_PRECISION, - DOMAIN, PAYLOAD_NONE, ) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper @@ -138,12 +136,9 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT device_class in DEVICE_CLASS_UNITS and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] ): - _LOGGER.warning( - "The unit of measurement `%s` is not valid " - "together with device class `%s`. " - "this will stop working in HA Core 2025.7.0", - unit_of_measurement, - device_class, + raise vol.Invalid( + f"The unit of measurement `{unit_of_measurement}` is not valid " + f"together with device class `{device_class}`", ) return config @@ -194,40 +189,8 @@ class MqttSensor(MqttEntity, RestoreSensor): None ) - @callback - def async_check_uom(self) -> None: - """Check if the unit of measurement is valid with the device class.""" - if ( - self._discovery_data is not None - or self.device_class is None - or self.native_unit_of_measurement is None - ): - return - if ( - self.device_class in DEVICE_CLASS_UNITS - and self.native_unit_of_measurement - not in DEVICE_CLASS_UNITS[self.device_class] - ): - async_create_issue( - self.hass, - DOMAIN, - self.entity_id, - issue_domain=sensor.DOMAIN, - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM, - translation_placeholders={ - "uom": self.native_unit_of_measurement, - "device_class": self.device_class.value, - "entity_id": self.entity_id, - }, - translation_key="invalid_unit_of_measurement", - breaks_in_ha_version="2025.7.0", - ) - async def mqtt_async_added_to_hass(self) -> None: """Restore state for entities with expire_after set.""" - self.async_check_uom() last_state: State | None last_sensor_data: SensorExtraStoredData | None if ( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9bc6df1b633..16652c498f3 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,10 +3,6 @@ "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." - }, - "invalid_unit_of_measurement": { - "title": "Sensor with invalid unit of measurement", - "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index ea1b7e186e2..997c014cd13 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -898,42 +898,12 @@ async def test_invalid_unit_of_measurement( "The unit of measurement `ppm` is not valid together with device class `energy`" in caplog.text ) - # A repair issue was logged + # A repair issue was logged for the failing YAML config assert len(events) == 1 - assert events[0].data["issue_id"] == "sensor.test" - # Assert the sensor works - async_fire_mqtt_message(hass, "test-topic", "100") - await hass.async_block_till_done() + assert events[0].data["domain"] == mqtt.DOMAIN + # Assert the sensor is not created state = hass.states.get("sensor.test") - assert state is not None - assert state.state == "100" - - caplog.clear() - - discovery_payload = { - "name": "bla", - "state_topic": "test-topic2", - "device_class": "temperature", - "unit_of_measurement": "C", - } - # Now discover an other invalid sensor - async_fire_mqtt_message( - hass, "homeassistant/sensor/bla/config", json.dumps(discovery_payload) - ) - await hass.async_block_till_done() - assert ( - "The unit of measurement `C` is not valid together with device class `temperature`" - in caplog.text - ) - # Assert the sensor works - async_fire_mqtt_message(hass, "test-topic2", "21") - await hass.async_block_till_done() - state = hass.states.get("sensor.bla") - assert state is not None - assert state.state == "21" - - # No new issue was registered for the discovered entity - assert len(events) == 1 + assert state is None @pytest.mark.parametrize( From bcb87cf812d4762467a38297aad583076e974e31 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 18 Jun 2025 10:49:46 -0400 Subject: [PATCH 0384/1664] Support variables, icon, and picture for all compatible template platforms (#145893) * Fix template entity variables in blueprints * add picture and icon tests * add variable test for all platforms * apply comments * Update all test names --- .../template/alarm_control_panel.py | 16 +- homeassistant/components/template/button.py | 24 +- homeassistant/components/template/cover.py | 16 +- homeassistant/components/template/fan.py | 16 +- homeassistant/components/template/image.py | 7 +- homeassistant/components/template/light.py | 66 +++--- homeassistant/components/template/lock.py | 13 +- homeassistant/components/template/number.py | 34 +-- homeassistant/components/template/select.py | 28 +-- homeassistant/components/template/switch.py | 28 +-- .../components/template/template_entity.py | 26 ++- homeassistant/components/template/vacuum.py | 21 +- homeassistant/components/template/weather.py | 43 ++-- tests/components/template/test_blueprint.py | 64 ++++++ tests/components/template/test_button.py | 46 ++++ tests/components/template/test_number.py | 177 ++++++--------- tests/components/template/test_select.py | 207 +++++++----------- tests/components/template/test_weather.py | 132 ++++++++++- ...st_alarm_control_panel_with_variables.yaml | 16 ++ .../test_binary_sensor_with_variables.yaml | 16 ++ .../template/test_cover_with_variables.yaml | 18 ++ .../template/test_fan_with_variables.yaml | 18 ++ .../template/test_image_with_variables.yaml | 16 ++ .../template/test_light_with_variables.yaml | 18 ++ .../template/test_lock_with_variables.yaml | 18 ++ .../template/test_number_with_variables.yaml | 18 ++ .../template/test_select_with_variables.yaml | 18 ++ .../template/test_sensor_with_variables.yaml | 16 ++ .../template/test_switch_with_variables.yaml | 18 ++ .../template/test_vacuum_with_variables.yaml | 17 ++ .../template/test_weather_with_variables.yaml | 18 ++ 31 files changed, 754 insertions(+), 435 deletions(-) create mode 100644 tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_cover_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_fan_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_image_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_light_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_lock_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_number_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_select_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_sensor_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_switch_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml create mode 100644 tests/testing_config/blueprints/template/test_weather_with_variables.yaml diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 725a73338fa..29c71973f42 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -41,13 +41,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -105,15 +104,10 @@ ALARM_CONTROL_PANEL_SCHEMA = vol.All( CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name ): cv.enum(TemplateCodeFormat), vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) @@ -419,9 +413,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 4ee8844d6e7..07aa41b3811 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -26,29 +26,19 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +BUTTON_SCHEMA = vol.Schema( + { + vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) CONFIG_BUTTON_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0b2009e83e3..68645c718b2 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -37,14 +37,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -100,21 +99,16 @@ COVER_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_NAME, default=DEFAULT_NAME): cv.template, vol.Optional(CONF_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_POSITION): cv.template, vol.Optional(CONF_STATE): cv.template, vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_TILT): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema), cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) @@ -463,9 +457,7 @@ class CoverTemplate(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateCover.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c353fca48df..4837ded9029 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -37,14 +37,13 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -85,12 +84,10 @@ FAN_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, - vol.Optional(CONF_NAME): 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_PICTURE): 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, @@ -99,11 +96,8 @@ FAN_SCHEMA = vol.All( vol.Optional(CONF_SET_PRESET_MODE_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SPEED_COUNT): vol.Coerce(int), vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) LEGACY_FAN_SCHEMA = vol.All( @@ -488,9 +482,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateFan.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5afbca55cbb..d286a2f6b4d 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -29,7 +29,10 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .template_entity import TemplateEntity, make_template_entity_common_schema +from .template_entity import ( + TemplateEntity, + make_template_entity_common_modern_attributes_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -43,7 +46,7 @@ IMAGE_SCHEMA = vol.Schema( vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, } -).extend(make_template_entity_common_schema(DEFAULT_NAME).schema) +).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) IMAGE_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index c852ee1808d..fa393c76ab4 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -49,14 +49,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -124,38 +123,31 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = ( - vol.Schema( - { - vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, - vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, - vol.Inclusive(CONF_EFFECT, "effect"): cv.template, - vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_HS): cv.template, - vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_LEVEL): cv.template, - vol.Optional(CONF_MAX_MIREDS): cv.template, - vol.Optional(CONF_MIN_MIREDS): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, - vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGB): cv.template, - vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBW): cv.template, - vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_RGBWW): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template, - vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TEMPERATURE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +LIGHT_SCHEMA = vol.Schema( + { + vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, + vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, + vol.Inclusive(CONF_EFFECT, "effect"): cv.template, + vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_HS): cv.template, + vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_LEVEL): cv.template, + vol.Optional(CONF_MAX_MIREDS): cv.template, + vol.Optional(CONF_MIN_MIREDS): cv.template, + vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGB): cv.template, + vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBW): cv.template, + vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_RGBWW): cv.template, + vol.Optional(CONF_STATE): cv.template, + 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, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LEGACY_LIGHT_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), @@ -955,9 +947,7 @@ class LightTemplate(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateLight.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 25eac8c35e4..8ed8a004e92 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -31,10 +31,9 @@ from .const import CONF_PICTURE, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) @@ -57,17 +56,13 @@ LOCK_SCHEMA = vol.All( { vol.Optional(CONF_CODE_FORMAT): cv.template, vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_NAME): cv.template, 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.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) @@ -313,9 +308,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=DEFAULT_NAME, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 3ecf1db565a..4d9eaff0b2d 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -35,11 +35,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -49,23 +45,17 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, - vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +NUMBER_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, + vol.Required(CONF_STEP): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) NUMBER_CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.template, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 74d88ee96c4..8c05e8e2592 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -32,11 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN -from .template_entity import ( - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, - TEMPLATE_ENTITY_ICON_SCHEMA, - TemplateEntity, -) +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -47,20 +43,14 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_UNIQUE_ID): cv.string, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +SELECT_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, + vol.Required(ATTR_OPTIONS): cv.template, + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) SELECT_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 0f6d45f46ca..677686ea8d8 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -40,13 +40,12 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -60,20 +59,13 @@ LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { DEFAULT_NAME = "Template Switch" -SWITCH_SCHEMA = ( - vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_PICTURE): cv.template, - } - ) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema) -) +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_STATE): cv.template, + vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + } +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) LEGACY_SWITCH_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), @@ -228,7 +220,7 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config=config, unique_id=unique_id) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index f879c60ed9e..3157a60347e 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -94,16 +94,24 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( ) -def make_template_entity_common_schema(default_name: str) -> vol.Schema: +def make_template_entity_common_modern_schema( + default_name: str, +) -> vol.Schema: """Return a schema with default name.""" - return ( - vol.Schema( - { - vol.Optional(CONF_AVAILABILITY): cv.template, - } - ) - .extend(make_template_entity_base_schema(default_name).schema) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) + return vol.Schema( + { + vol.Optional(CONF_AVAILABILITY): cv.template, + vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, + } + ).extend(make_template_entity_base_schema(default_name).schema) + + +def make_template_entity_common_modern_attributes_schema( + default_name: str, +) -> vol.Schema: + """Return a schema with default name.""" + return make_template_entity_common_modern_schema(default_name).extend( + TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index f50751012b3..79e00e7e1c0 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -38,16 +38,14 @@ from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN +from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, - TEMPLATE_ENTITY_ICON_SCHEMA, TemplateEntity, + make_template_entity_common_modern_attributes_schema, rewrite_common_legacy_to_modern_conf, ) @@ -60,6 +58,8 @@ CONF_FAN_SPEED_LIST = "fan_speeds" CONF_FAN_SPEED = "fan_speed" CONF_FAN_SPEED_TEMPLATE = "fan_speed_template" +DEFAULT_NAME = "Template Vacuum" + ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}" _VALID_STATES = [ VacuumActivity.CLEANING, @@ -80,13 +80,9 @@ VACUUM_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list, vol.Optional(CONF_FAN_SPEED): cv.template, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_PICTURE): cv.template, vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA, @@ -95,10 +91,7 @@ VACUUM_SCHEMA = vol.All( vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA, vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA, } - ) - .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) - .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema), + ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) ) LEGACY_VACUUM_SCHEMA = vol.All( @@ -353,9 +346,7 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__( - self, hass, config=config, fallback_name=None, unique_id=unique_id - ) + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) AbstractTemplateVacuum.__init__(self, config) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 86bab6f5ad1..ee834e757a3 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -32,7 +32,6 @@ from homeassistant.components.weather import ( WeatherEntityFeature, ) from homeassistant.const import ( - CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID, STATE_UNAVAILABLE, @@ -53,7 +52,11 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf +from .template_entity import ( + TemplateEntity, + make_template_entity_common_modern_schema, + rewrite_common_legacy_to_modern_conf, +) from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -104,33 +107,33 @@ CONF_CLOUD_COVERAGE_TEMPLATE = "cloud_coverage_template" CONF_DEW_POINT_TEMPLATE = "dew_point_template" CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" +DEFAULT_NAME = "Template Weather" + WEATHER_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_CONDITION_TEMPLATE): cv.template, - vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, - vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, - vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, - vol.Optional(CONF_OZONE_TEMPLATE): cv.template, - vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), - vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), - vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), - vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, - vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, - vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, - vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), } -) +).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) diff --git a/tests/components/template/test_blueprint.py b/tests/components/template/test_blueprint.py index 312c04b670c..fd45c3b008b 100644 --- a/tests/components/template/test_blueprint.py +++ b/tests/components/template/test_blueprint.py @@ -16,6 +16,22 @@ from homeassistant.components.blueprint import ( DomainBlueprints, ) from homeassistant.components.template import DOMAIN, SERVICE_RELOAD +from homeassistant.components.template.config import ( + DOMAIN_ALARM_CONTROL_PANEL, + DOMAIN_BINARY_SENSOR, + DOMAIN_COVER, + DOMAIN_FAN, + DOMAIN_IMAGE, + DOMAIN_LIGHT, + DOMAIN_LOCK, + DOMAIN_NUMBER, + DOMAIN_SELECT, + DOMAIN_SENSOR, + DOMAIN_SWITCH, + DOMAIN_VACUUM, + DOMAIN_WEATHER, +) +from homeassistant.const import STATE_ON from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -459,3 +475,51 @@ async def test_no_blueprint(hass: HomeAssistant) -> None: template.helpers.blueprint_in_template(hass, "binary_sensor.test_entity") is None ) + + +@pytest.mark.parametrize( + ("domain", "set_state", "expected"), + [ + (DOMAIN_ALARM_CONTROL_PANEL, STATE_ON, "armed_home"), + (DOMAIN_BINARY_SENSOR, STATE_ON, STATE_ON), + (DOMAIN_COVER, STATE_ON, "open"), + (DOMAIN_FAN, STATE_ON, STATE_ON), + (DOMAIN_IMAGE, "test.jpg", "2025-06-13T00:00:00+00:00"), + (DOMAIN_LIGHT, STATE_ON, STATE_ON), + (DOMAIN_LOCK, STATE_ON, "locked"), + (DOMAIN_NUMBER, "1", "1.0"), + (DOMAIN_SELECT, "option1", "option1"), + (DOMAIN_SENSOR, "foo", "foo"), + (DOMAIN_SWITCH, STATE_ON, STATE_ON), + (DOMAIN_VACUUM, "cleaning", "cleaning"), + (DOMAIN_WEATHER, "sunny", "sunny"), + ], +) +@pytest.mark.freeze_time("2025-06-13 00:00:00+00:00") +async def test_variables_for_entity( + hass: HomeAssistant, domain: str, set_state: str, expected: str +) -> None: + """Test regular template entities via blueprint with variables defined.""" + hass.states.async_set("sensor.test_state", set_state) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "use_blueprint": { + "path": f"test_{domain}_with_variables.yaml", + "input": {"sensor": "sensor.test_state"}, + }, + "name": "Test", + }, + ] + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state is not None + assert state.state == expected diff --git a/tests/components/template/test_button.py b/tests/components/template/test_button.py index 31239dbaf92..77d316ce89d 100644 --- a/tests/components/template/test_button.py +++ b/tests/components/template/test_button.py @@ -11,7 +11,10 @@ from homeassistant import setup from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.template import DOMAIN from homeassistant.components.template.button import DEFAULT_NAME +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( + ATTR_ENTITY_PICTURE, + ATTR_ICON, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_FRIENDLY_NAME, @@ -247,6 +250,49 @@ async def test_name_template(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + ("field", "attribute", "test_template", "expected"), + [ + (CONF_ICON, ATTR_ICON, "mdi:test{{ 1 + 1 }}", "mdi:test2"), + (CONF_PICTURE, ATTR_ENTITY_PICTURE, "test{{ 1 + 1 }}.jpg", "test2.jpg"), + ], +) +async def test_templated_optional_config( + hass: HomeAssistant, + field: str, + attribute: str, + test_template: str, + expected: str, +) -> None: + """Test optional config templates.""" + with assert_setup_component(1, "template"): + assert await setup.async_setup_component( + hass, + "template", + { + "template": { + "button": { + "press": {"service": "script.press"}, + field: test_template, + }, + } + }, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + _verify( + hass, + STATE_UNKNOWN, + { + attribute: expected, + }, + "button.template_button", + ) + + async def test_unique_id(hass: HomeAssistant) -> None: """Test: unique id is ok.""" with assert_setup_component(1, "template"): diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index 5201541e2e0..a15ae1e46c0 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -21,10 +21,13 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE as NUMBER_SERVICE_SET_VALUE, ) from homeassistant.components.template import DOMAIN +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_ICON, CONF_ENTITY_ID, + CONF_ICON, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, ) @@ -58,6 +61,20 @@ _VALUE_INPUT_NUMBER_CONFIG = { } } +TEST_STATE_ENTITY_ID = "number.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_REQUIRED = {"state": "0", "step": "1", "set_value": []} + async def async_setup_modern_format( hass: HomeAssistant, count: int, number_config: dict[str, Any] @@ -77,6 +94,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, number_config: dict[str, Any] +) -> None: + """Do setup of number integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "number": number_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_number( hass: HomeAssistant, @@ -89,6 +124,10 @@ async def setup_number( await async_setup_modern_format( hass, count, {"name": _TEST_OBJECT_ID, **number_config} ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": _TEST_OBJECT_ID, **number_config} + ) async def test_setup_config_entry( @@ -446,119 +485,49 @@ def _verify( assert attributes.get(CONF_UNIT_OF_MEASUREMENT) == expected_unit_of_measurement -async def test_icon_template(hass: HomeAssistant) -> None: - """Test template numbers with icon templates.""" - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": _VALUE_INPUT_NUMBER_CONFIG}, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("number_config", "attribute", "expected"), + [ + ( { - "template": { - "unique_id": "b", - "number": { - "state": f"{{{{ states('{_VALUE_INPUT_NUMBER}') }}}}", - "step": 1, - "min": 0, - "max": 100, - "set_value": { - "service": "input_number.set_value", - "data_template": { - "entity_id": _VALUE_INPUT_NUMBER, - "value": "{{ value }}", - }, - }, - "icon": "{% if ((states.input_number.value.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}", - }, - } + CONF_ICON: "{% if states.number.test_state.state == '1' %}mdi:check{% endif %}", + **TEST_REQUIRED, }, - ) - - hass.states.async_set(_VALUE_INPUT_NUMBER, 49) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 49 - assert state.attributes[ATTR_ICON] == "mdi:less" - - await hass.services.async_call( - INPUT_NUMBER_DOMAIN, - INPUT_NUMBER_SERVICE_SET_VALUE, - {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 51 - assert state.attributes[ATTR_ICON] == "mdi:greater" - - -async def test_icon_template_with_trigger(hass: HomeAssistant) -> None: - """Test template numbers with icon templates.""" - with assert_setup_component(1, "input_number"): - assert await setup.async_setup_component( - hass, - "input_number", - {"input_number": _VALUE_INPUT_NUMBER_CONFIG}, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", + ATTR_ICON, + "mdi:check", + ), + ( { - "template": { - "trigger": {"platform": "state", "entity_id": _VALUE_INPUT_NUMBER}, - "unique_id": "b", - "number": { - "state": "{{ trigger.to_state.state }}", - "step": 1, - "min": 0, - "max": 100, - "set_value": { - "service": "input_number.set_value", - "data_template": { - "entity_id": _VALUE_INPUT_NUMBER, - "value": "{{ value }}", - }, - }, - "icon": "{% if ((trigger.to_state.state or 0) | int) > 50 %}mdi:greater{% else %}mdi:less{% endif %}", - }, - } + CONF_PICTURE: "{% if states.number.test_state.state == '1' %}check.jpg{% endif %}", + **TEST_REQUIRED, }, - ) + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_number") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(_TEST_NUMBER) + assert state.attributes.get(attribute) == initial_expected_state - hass.states.async_set(_VALUE_INPUT_NUMBER, 49) - - await hass.async_block_till_done() - await hass.async_start() + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "1") await hass.async_block_till_done() state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 49 - assert state.attributes[ATTR_ICON] == "mdi:less" - await hass.services.async_call( - INPUT_NUMBER_DOMAIN, - INPUT_NUMBER_SERVICE_SET_VALUE, - {CONF_ENTITY_ID: _VALUE_INPUT_NUMBER, INPUT_NUMBER_ATTR_VALUE: 51}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_NUMBER) - assert float(state.state) == 51 - assert state.attributes[ATTR_ICON] == "mdi:greater" + assert state.attributes[attribute] == expected async def test_device_id( diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index b2bc56af44a..5e29993f0f6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -21,7 +21,15 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION as SELECT_SERVICE_SELECT_OPTION, ) from homeassistant.components.template import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, CONF_ENTITY_ID, STATE_UNKNOWN +from homeassistant.components.template.const import CONF_PICTURE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, + ATTR_ICON, + CONF_ENTITY_ID, + CONF_ICON, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -34,6 +42,24 @@ _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" +TEST_STATE_ENTITY_ID = "select.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + +TEST_OPTIONS = { + "state": "test", + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], +} async def async_setup_modern_format( @@ -54,6 +80,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, select_config: dict[str, Any] +) -> None: + """Do setup of select integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "select": select_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_select( hass: HomeAssistant, @@ -66,6 +110,10 @@ async def setup_select( await async_setup_modern_format( hass, count, {"name": _TEST_OBJECT_ID, **select_config} ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": _TEST_OBJECT_ID, **select_config} + ) async def test_setup_config_entry( @@ -395,138 +443,49 @@ def _verify( assert attributes.get(SELECT_ATTR_OPTIONS) == expected_options -async def test_template_icon_with_entities(hass: HomeAssistant) -> None: - """Test templates with values from other entities.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("select_config", "attribute", "expected"), + [ + ( { - "input_select": { - "option": { - "options": ["a", "b"], - "initial": "a", - "name": "Option", - }, - } + **TEST_OPTIONS, + CONF_ICON: "{% if states.select.test_state.state == 'yes' %}mdi:check{% endif %}", }, - ) - - with assert_setup_component(1, "template"): - assert await setup.async_setup_component( - hass, - "template", + ATTR_ICON, + "mdi:check", + ), + ( { - "template": { - "unique_id": "b", - "select": { - "state": f"{{{{ states('{_OPTION_INPUT_SELECT}') }}}}", - "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", - "select_option": { - "service": "input_select.select_option", - "data": { - "entity_id": _OPTION_INPUT_SELECT, - "option": "{{ option }}", - }, - }, - "optimistic": True, - "unique_id": "a", - "icon": f"{{% if (states('{_OPTION_INPUT_SELECT}') == 'a') %}}mdi:greater{{% else %}}mdi:less{{% endif %}}", - }, - } + **TEST_OPTIONS, + CONF_PICTURE: "{% if states.select.test_state.state == 'yes' %}check.jpg{% endif %}", }, - ) + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_select") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(_TEST_SELECT) + assert state.attributes.get(attribute) == initial_expected_state - await hass.async_block_till_done() - await hass.async_start() + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") await hass.async_block_till_done() state = hass.states.get(_TEST_SELECT) - assert state.state == "a" - assert state.attributes[ATTR_ICON] == "mdi:greater" - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state.state == "b" - assert state.attributes[ATTR_ICON] == "mdi:less" - - -async def test_template_icon_with_trigger(hass: HomeAssistant) -> None: - """Test trigger based template select.""" - with assert_setup_component(1, "input_select"): - assert await setup.async_setup_component( - hass, - "input_select", - { - "input_select": { - "option": { - "options": ["a", "b"], - "initial": "a", - "name": "Option", - }, - } - }, - ) - - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "trigger": {"platform": "state", "entity_id": _OPTION_INPUT_SELECT}, - "select": { - "unique_id": "b", - "state": "{{ trigger.to_state.state }}", - "options": f"{{{{ state_attr('{_OPTION_INPUT_SELECT}', '{INPUT_SELECT_ATTR_OPTIONS}') }}}}", - "select_option": { - "service": "input_select.select_option", - "data": { - "entity_id": _OPTION_INPUT_SELECT, - "option": "{{ option }}", - }, - }, - "optimistic": True, - "icon": "{% if (trigger.to_state.state or '') == 'a' %}mdi:greater{% else %}mdi:less{% endif %}", - }, - }, - }, - ) - - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "b"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state is not None - assert state.state == "b" - assert state.attributes[ATTR_ICON] == "mdi:less" - - await hass.services.async_call( - INPUT_SELECT_DOMAIN, - INPUT_SELECT_SERVICE_SELECT_OPTION, - {CONF_ENTITY_ID: _OPTION_INPUT_SELECT, INPUT_SELECT_ATTR_OPTION: "a"}, - blocking=True, - ) - await hass.async_block_till_done() - - state = hass.states.get(_TEST_SELECT) - assert state.state == "a" - assert state.attributes[ATTR_ICON] == "mdi:greater" + assert state.attributes[attribute] == expected async def test_device_id( diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 5db6a000ccc..443b0aa6e77 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -5,6 +5,8 @@ from typing import Any import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import template +from homeassistant.components.template.const import CONF_PICTURE from homeassistant.components.weather import ( ATTR_WEATHER_APPARENT_TEMPERATURE, ATTR_WEATHER_CLOUD_COVERAGE, @@ -21,12 +23,21 @@ from homeassistant.components.weather import ( SERVICE_GET_FORECASTS, Forecast, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_ENTITY_PICTURE, + ATTR_ICON, + CONF_ICON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import Context, HomeAssistant, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from .conftest import ConfigurationStyle + from tests.common import ( assert_setup_component, async_mock_restore_state_shutdown_restart, @@ -35,6 +46,80 @@ from tests.common import ( ATTR_FORECAST = "forecast" +TEST_OBJECT_ID = "template_weather" +TEST_WEATHER = f"weather.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "weather.test_state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} +TEST_REQUIRED = { + "condition_template": "cloudy", + "temperature_template": "{{ 20 }}", + "humidity_template": "{{ 25 }}", +} + + +async def async_setup_modern_format( + hass: HomeAssistant, count: int, weather_config: dict[str, Any] +) -> None: + """Do setup of weather integration via new format.""" + config = {"template": {"weather": weather_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, weather_config: dict[str, Any] +) -> None: + """Do setup of weather integration via trigger format.""" + config = {"template": {**TEST_STATE_TRIGGER, "weather": weather_config}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_weather( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + weather_config: dict[str, Any], +) -> None: + """Do setup of weather integration.""" + if style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **weather_config} + ) + if style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **weather_config} + ) + @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @pytest.mark.parametrize( @@ -990,3 +1075,48 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: assert state is not None assert state.state == "sunny" assert state.attributes.get(v_attr) == value + + +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + ("style", "initial_expected_state"), + [(ConfigurationStyle.MODERN, ""), (ConfigurationStyle.TRIGGER, None)], +) +@pytest.mark.parametrize( + ("weather_config", "attribute", "expected"), + [ + ( + { + CONF_ICON: "{% if states.weather.test_state.state == 'sunny' %}mdi:check{% endif %}", + **TEST_REQUIRED, + }, + ATTR_ICON, + "mdi:check", + ), + ( + { + CONF_PICTURE: "{% if states.weather.test_state.state == 'sunny' %}check.jpg{% endif %}", + **TEST_REQUIRED, + }, + ATTR_ENTITY_PICTURE, + "check.jpg", + ), + ], +) +@pytest.mark.usefixtures("setup_weather") +async def test_templated_optional_config( + hass: HomeAssistant, + attribute: str, + expected: str, + initial_expected_state: str | None, +) -> None: + """Test optional config templates.""" + state = hass.states.get(TEST_WEATHER) + assert state.attributes.get(attribute) == initial_expected_state + + state = hass.states.async_set(TEST_STATE_ENTITY_ID, "sunny") + await hass.async_block_till_done() + + state = hass.states.get(TEST_WEATHER) + + assert state.attributes[attribute] == expected diff --git a/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml b/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml new file mode 100644 index 00000000000..94a13f699ec --- /dev/null +++ b/tests/testing_config/blueprints/template/test_alarm_control_panel_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +alarm_control_panel: + availability: "{{ sensor | has_value }}" + state: "{{ 'armed_home' if is_state(sensor,'on') else 'disarmed' }}" diff --git a/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml b/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml new file mode 100644 index 00000000000..3cdda37644b --- /dev/null +++ b/tests/testing_config/blueprints/template/test_binary_sensor_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +binary_sensor: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor, 'on') }}" diff --git a/tests/testing_config/blueprints/template/test_cover_with_variables.yaml b/tests/testing_config/blueprints/template/test_cover_with_variables.yaml new file mode 100644 index 00000000000..dcef425f3a0 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_cover_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +cover: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + open_cover: [] + close_cover: [] diff --git a/tests/testing_config/blueprints/template/test_fan_with_variables.yaml b/tests/testing_config/blueprints/template/test_fan_with_variables.yaml new file mode 100644 index 00000000000..c37cd325420 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_fan_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +fan: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_image_with_variables.yaml b/tests/testing_config/blueprints/template/test_image_with_variables.yaml new file mode 100644 index 00000000000..990cf403f0c --- /dev/null +++ b/tests/testing_config/blueprints/template/test_image_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +image: + availability: "{{ sensor | has_value }}" + url: "{{ states(sensor) }}" diff --git a/tests/testing_config/blueprints/template/test_light_with_variables.yaml b/tests/testing_config/blueprints/template/test_light_with_variables.yaml new file mode 100644 index 00000000000..90b70d12105 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_light_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +light: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_lock_with_variables.yaml b/tests/testing_config/blueprints/template/test_lock_with_variables.yaml new file mode 100644 index 00000000000..3c2e53bdff4 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_lock_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +lock: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + lock: [] + unlock: [] diff --git a/tests/testing_config/blueprints/template/test_number_with_variables.yaml b/tests/testing_config/blueprints/template/test_number_with_variables.yaml new file mode 100644 index 00000000000..55c829a4a6e --- /dev/null +++ b/tests/testing_config/blueprints/template/test_number_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +number: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + set_value: [] + step: 1 diff --git a/tests/testing_config/blueprints/template/test_select_with_variables.yaml b/tests/testing_config/blueprints/template/test_select_with_variables.yaml new file mode 100644 index 00000000000..35d55f1abe9 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_select_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +select: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + options: "{{ ['option1', 'option2'] }}" + select_option: [] diff --git a/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml b/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml new file mode 100644 index 00000000000..41d5dcf5bb6 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_sensor_with_variables.yaml @@ -0,0 +1,16 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +sensor: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" diff --git a/tests/testing_config/blueprints/template/test_switch_with_variables.yaml b/tests/testing_config/blueprints/template/test_switch_with_variables.yaml new file mode 100644 index 00000000000..7e145de9976 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_switch_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +switch: + availability: "{{ sensor | has_value }}" + state: "{{ is_state(sensor,'on') }}" + turn_on: [] + turn_off: [] diff --git a/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml b/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml new file mode 100644 index 00000000000..63858da9943 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_vacuum_with_variables.yaml @@ -0,0 +1,17 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +vacuum: + availability: "{{ sensor | has_value }}" + state: "{{ states(sensor) }}" + start: [] diff --git a/tests/testing_config/blueprints/template/test_weather_with_variables.yaml b/tests/testing_config/blueprints/template/test_weather_with_variables.yaml new file mode 100644 index 00000000000..d50702bde81 --- /dev/null +++ b/tests/testing_config/blueprints/template/test_weather_with_variables.yaml @@ -0,0 +1,18 @@ +blueprint: + name: Test With Variables + description: Creates a test with variables + domain: template + input: + sensor: + name: Sensor Entity + description: The sensor entity + selector: + entity: + domain: sensor +variables: + sensor: !input sensor +weather: + availability: "{{ sensor | has_value }}" + condition_template: "{{ states(sensor) }}" + temperature_template: "{{ 20 }}" + humidity_template: "{{ 25 }}" From a29d5fb56c495e9ffdf8083b9898bf3556efd7a4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 18 Jun 2025 11:08:53 -0500 Subject: [PATCH 0385/1664] tts_output is optional in run-start (#147092) --- homeassistant/components/esphome/assist_satellite.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 073a1ec8ae9..53d5d449be8 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -332,7 +332,7 @@ class EsphomeAssistSatellite( } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_RUN_START: assert event.data is not None - if tts_output := event.data["tts_output"]: + if tts_output := event.data.get("tts_output"): path = tts_output["url"] url = async_process_play_media_url(self.hass, path) data_to_send = {"url": url} From 9adf493acdd0d1caf041836e04bee21599bfced6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 18 Jun 2025 17:58:50 +0100 Subject: [PATCH 0386/1664] Use non-autospec mock for Reolink's init tests (#146991) --- tests/components/reolink/conftest.py | 28 ++++ tests/components/reolink/test_host.py | 1 + tests/components/reolink/test_init.py | 224 +++++++++++--------------- 3 files changed, 125 insertions(+), 128 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d96931aaf26..69eeee99fab 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -65,11 +65,14 @@ def mock_setup_entry() -> Generator[AsyncMock]: 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.check_new_firmware = AsyncMock(return_value=False) + host_mock.subscribe = AsyncMock() host_mock.unsubscribe = AsyncMock(return_value=True) host_mock.logout = AsyncMock(return_value=True) host_mock.reboot = AsyncMock() host_mock.set_ptz_command = AsyncMock() + host_mock.get_motion_state_all_ch = AsyncMock(return_value=False) host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -138,8 +141,10 @@ def _init_host_mock(host_mock: MagicMock) -> None: # Disable tcp push by default for tests host_mock.baichuan.port = TEST_BC_PORT host_mock.baichuan.events_active = False + host_mock.baichuan.subscribe_events = AsyncMock() host_mock.baichuan.unsubscribe_events = AsyncMock() host_mock.baichuan.check_subscribe_events = AsyncMock() + host_mock.baichuan.get_privacy_mode = 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" @@ -242,3 +247,26 @@ def test_chime(reolink_connect: MagicMock) -> None: 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.""" + TEST_CHIME = Chime( + host=reolink_host, + 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_host.chime_list = [TEST_CHIME] + reolink_host.chime.return_value = TEST_CHIME + return TEST_CHIME diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index 3c2f434ccc7..f997a1ac08a 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -115,6 +115,7 @@ async def test_webhook_callback( assert hass.states.get(entity_id).state == STATE_OFF # test webhook callback success all channels + reolink_connect.get_motion_state_all_ch.return_value = True reolink_connect.motion_detected.return_value = True reolink_connect.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 482928560b9..ed71314e961 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -69,7 +69,7 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator -pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms") +pytestmark = pytest.mark.usefixtures("reolink_host", "reolink_platforms") CHIME_MODEL = "Reolink Chime" @@ -116,15 +116,14 @@ async def test_wait(*args, **key_args) -> None: ) async def test_failures_parametrized( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, attr: str, value: Any, expected: ConfigEntryState, ) -> None: """Test outcomes when changing errors.""" - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) assert await hass.config_entries.async_setup(config_entry.entry_id) is ( expected is ConfigEntryState.LOADED ) @@ -132,17 +131,15 @@ async def test_failures_parametrized( assert config_entry.state == expected - setattr(reolink_connect, attr, original) - async def test_firmware_error_twice( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test when the firmware update fails 2 times.""" - reolink_connect.check_new_firmware.side_effect = ReolinkError("Test error") + reolink_host.check_new_firmware.side_effect = ReolinkError("Test error") with patch("homeassistant.components.reolink.PLATFORMS", [Platform.UPDATE]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -158,13 +155,11 @@ async def test_firmware_error_twice( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - reolink_connect.check_new_firmware.reset_mock(side_effect=True) - async def test_credential_error_three( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, issue_registry: ir.IssueRegistry, ) -> None: @@ -174,7 +169,7 @@ async def test_credential_error_three( await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.get_states.side_effect = CredentialsInvalidError("Test error") + reolink_host.get_states.side_effect = CredentialsInvalidError("Test error") issue_id = f"config_entry_reauth_{DOMAIN}_{config_entry.entry_id}" for _ in range(NUM_CRED_ERRORS): @@ -185,31 +180,26 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues - reolink_connect.get_states.reset_mock(side_effect=True) - async def test_entry_reloading( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test the entry is reloaded correctly when settings change.""" - reolink_connect.is_nvr = False - reolink_connect.logout.reset_mock() + reolink_host.is_nvr = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert reolink_connect.logout.call_count == 0 + assert reolink_host.logout.call_count == 0 assert config_entry.title == "test_reolink_name" hass.config_entries.async_update_entry(config_entry, title="New Name") await hass.async_block_till_done() - assert reolink_connect.logout.call_count == 1 + assert reolink_host.logout.call_count == 1 assert config_entry.title == "New Name" - reolink_connect.is_nvr = True - @pytest.mark.parametrize( ("attr", "value", "expected_models"), @@ -241,7 +231,7 @@ async def test_removing_disconnected_cams( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, attr: str | None, @@ -249,7 +239,7 @@ async def test_removing_disconnected_cams( expected_models: list[str], ) -> None: """Test device and entity registry are cleaned up when camera is removed.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] assert await async_setup_component(hass, "config", {}) client = await hass_ws_client(hass) # setup CH 0 and NVR switch entities/device @@ -265,8 +255,7 @@ async def test_removing_disconnected_cams( # Try to remove the device after 'disconnecting' a camera. if attr is not None: - original = getattr(reolink_connect, attr) - setattr(reolink_connect, attr, value) + setattr(reolink_host, attr, value) expected_success = TEST_CAM_MODEL not in expected_models for device in device_entries: if device.model == TEST_CAM_MODEL: @@ -279,9 +268,6 @@ async def test_removing_disconnected_cams( device_models = [device.model for device in device_entries] assert sorted(device_models) == sorted(expected_models) - if attr is not None: - setattr(reolink_connect, attr, original) - @pytest.mark.parametrize( ("attr", "value", "expected_models"), @@ -307,8 +293,8 @@ async def test_removing_chime( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, attr: str | None, @@ -316,7 +302,7 @@ async def test_removing_chime( expected_models: list[str], ) -> None: """Test removing a chime.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] assert await async_setup_component(hass, "config", {}) client = await hass_ws_client(hass) # setup CH 0 and NVR switch entities/device @@ -336,11 +322,11 @@ async def test_removing_chime( async def test_remove_chime(*args, **key_args): """Remove chime.""" - test_chime.connect_state = -1 + reolink_chime.connect_state = -1 - test_chime.remove = test_remove_chime + reolink_chime.remove = test_remove_chime elif attr is not None: - setattr(test_chime, attr, value) + setattr(reolink_chime, attr, value) # Try to remove the device after 'disconnecting' a chime. expected_success = CHIME_MODEL not in expected_models @@ -444,7 +430,7 @@ async def test_removing_chime( async def test_migrate_entity_ids( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, original_id: str, @@ -464,8 +450,8 @@ async def test_migrate_entity_ids( return support_ch_uid return True - reolink_connect.channels = [0] - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.supported = mock_supported dev_entry = device_registry.async_get_or_create( identifiers={(DOMAIN, original_dev_id)}, @@ -513,7 +499,7 @@ async def test_migrate_entity_ids( async def test_migrate_with_already_existing_device( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: @@ -529,8 +515,8 @@ async def test_migrate_with_already_existing_device( return True return True - reolink_connect.channels = [0] - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.supported = mock_supported device_registry.async_get_or_create( identifiers={(DOMAIN, new_dev_id)}, @@ -562,7 +548,7 @@ async def test_migrate_with_already_existing_device( async def test_migrate_with_already_existing_entity( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: @@ -579,8 +565,8 @@ async def test_migrate_with_already_existing_entity( return True return True - reolink_connect.channels = [0] - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.supported = mock_supported dev_entry = device_registry.async_get_or_create( identifiers={(DOMAIN, dev_id)}, @@ -623,13 +609,13 @@ async def test_migrate_with_already_existing_entity( async def test_cleanup_mac_connection( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test cleanup of the MAC of a IPC which was set to the MAC of the host.""" - reolink_connect.channels = [0] - reolink_connect.baichuan.mac_address.return_value = None + reolink_host.channels = [0] + reolink_host.baichuan.mac_address.return_value = None entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" dev_id = f"{TEST_UID}_{TEST_UID_CAM}" domain = Platform.SWITCH @@ -666,19 +652,17 @@ async def test_cleanup_mac_connection( assert device assert device.connections == set() - reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM - async def test_cleanup_combined_with_NVR( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test cleanup of the device registry if IPC camera device was combined with the NVR device.""" - reolink_connect.channels = [0] - reolink_connect.baichuan.mac_address.return_value = None + reolink_host.channels = [0] + reolink_host.baichuan.mac_address.return_value = None entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" dev_id = f"{TEST_UID}_{TEST_UID_CAM}" domain = Platform.SWITCH @@ -726,18 +710,16 @@ async def test_cleanup_combined_with_NVR( ("OTHER_INTEGRATION", "SOME_ID"), } - reolink_connect.baichuan.mac_address.return_value = TEST_MAC_CAM - async def test_cleanup_hub_and_direct_connection( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, device_registry: dr.DeviceRegistry, ) -> None: """Test cleanup of the device registry if IPC camera device was connected directly and through the hub/NVR.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] entity_id = f"{TEST_UID}_{TEST_UID_CAM}_record_audio" dev_id = f"{TEST_UID}_{TEST_UID_CAM}" domain = Platform.SWITCH @@ -801,11 +783,11 @@ async def test_no_repair_issue( async def test_https_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when https local url is used.""" - reolink_connect.get_states = test_wait + reolink_host.get_states = test_wait await async_process_ha_core_config( hass, {"country": "GB", "internal_url": "https://test_homeassistant_address"} ) @@ -828,11 +810,11 @@ async def test_https_repair_issue( async def test_ssl_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when global ssl certificate is used.""" - reolink_connect.get_states = test_wait + reolink_host.get_states = test_wait assert await async_setup_component(hass, "webhook", {}) hass.config.api.use_ssl = True @@ -859,32 +841,30 @@ async def test_ssl_repair_issue( async def test_port_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, protocol: str, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when auto enable of ports fails.""" - reolink_connect.set_net_port.side_effect = ReolinkError("Test error") - reolink_connect.onvif_enabled = False - reolink_connect.rtsp_enabled = False - reolink_connect.rtmp_enabled = False - reolink_connect.protocol = protocol + reolink_host.set_net_port.side_effect = ReolinkError("Test error") + reolink_host.onvif_enabled = False + reolink_host.rtsp_enabled = False + reolink_host.rtmp_enabled = False + reolink_host.protocol = protocol assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert (DOMAIN, "enable_port") in issue_registry.issues - reolink_connect.set_net_port.reset_mock(side_effect=True) - async def test_webhook_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test repairs issue is raised when the webhook url is unreachable.""" - reolink_connect.get_states = test_wait + reolink_host.get_states = test_wait with ( patch("homeassistant.components.reolink.host.FIRST_ONVIF_TIMEOUT", new=0), patch( @@ -903,25 +883,24 @@ async def test_webhook_repair_issue( async def test_firmware_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test firmware issue is raised when too old firmware is used.""" - reolink_connect.camera_sw_version_update_required.return_value = True + reolink_host.camera_sw_version_update_required.return_value = True assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert (DOMAIN, "firmware_update_host") in issue_registry.issues - reolink_connect.camera_sw_version_update_required.return_value = False async def test_password_too_long_repair_issue( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, issue_registry: ir.IssueRegistry, ) -> None: """Test password too long issue is raised.""" - reolink_connect.valid_password.return_value = False + reolink_host.valid_password.return_value = False config_entry = MockConfigEntry( domain=DOMAIN, unique_id=format_mac(TEST_MAC), @@ -946,13 +925,12 @@ async def test_password_too_long_repair_issue( DOMAIN, f"password_too_long_{config_entry.entry_id}", ) in issue_registry.issues - reolink_connect.valid_password.return_value = True async def test_new_device_discovered( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test the entry is reloaded when a new camera or chime is detected.""" @@ -960,26 +938,24 @@ async def test_new_device_discovered( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.logout.reset_mock() - - assert reolink_connect.logout.call_count == 0 - reolink_connect.new_devices = True + assert reolink_host.logout.call_count == 0 + reolink_host.new_devices = True freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.logout.call_count == 1 + assert reolink_host.logout.call_count == 1 async def test_port_changed( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test config_entry port update when it has changed during initial login.""" assert config_entry.data[CONF_PORT] == TEST_PORT - reolink_connect.port = 4567 + reolink_host.port = 4567 assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -989,12 +965,12 @@ async def test_port_changed( async def test_baichuan_port_changed( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test config_entry baichuan port update when it has changed during initial login.""" assert config_entry.data[CONF_BC_PORT] == TEST_BC_PORT - reolink_connect.baichuan.port = 8901 + reolink_host.baichuan.port = 8901 assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -1005,14 +981,12 @@ async def test_baichuan_port_changed( async def test_privacy_mode_on( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test successful setup even when privacy mode is turned on.""" - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.get_states = AsyncMock( - side_effect=LoginPrivacyModeError("Test error") - ) + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.get_states = AsyncMock(side_effect=LoginPrivacyModeError("Test error")) with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1020,40 +994,36 @@ async def test_privacy_mode_on( assert config_entry.state == ConfigEntryState.LOADED - reolink_connect.baichuan.privacy_mode.return_value = False - async def test_LoginPrivacyModeError( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test normal update when get_states returns a LoginPrivacyModeError.""" - reolink_connect.baichuan.privacy_mode.return_value = False - reolink_connect.get_states = AsyncMock( - side_effect=LoginPrivacyModeError("Test error") - ) + reolink_host.baichuan.privacy_mode.return_value = False + reolink_host.get_states = AsyncMock(side_effect=LoginPrivacyModeError("Test error")) with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - reolink_connect.baichuan.check_subscribe_events.reset_mock() - assert reolink_connect.baichuan.check_subscribe_events.call_count == 0 + reolink_host.baichuan.check_subscribe_events.reset_mock() + assert reolink_host.baichuan.check_subscribe_events.call_count == 0 freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.baichuan.check_subscribe_events.call_count >= 1 + assert reolink_host.baichuan.check_subscribe_events.call_count >= 1 async def test_privacy_mode_change_callback( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test privacy mode changed callback.""" @@ -1068,13 +1038,12 @@ async def test_privacy_mode_change_callback( callback_mock = callback_mock_class() - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) - reolink_connect.baichuan.register_callback = callback_mock.register_callback - reolink_connect.baichuan.privacy_mode.return_value = True - reolink_connect.audio_record.return_value = True - reolink_connect.get_states = AsyncMock() + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.events_active = True + reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.register_callback = callback_mock.register_callback + reolink_host.baichuan.privacy_mode.return_value = True + reolink_host.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -1085,29 +1054,29 @@ async def test_privacy_mode_change_callback( assert hass.states.get(entity_id).state == STATE_UNAVAILABLE # simulate a TCP push callback signaling a privacy mode change - reolink_connect.baichuan.privacy_mode.return_value = False + reolink_host.baichuan.privacy_mode.return_value = False assert callback_mock.callback_func is not None callback_mock.callback_func() # check that a coordinator update was scheduled. - reolink_connect.get_states.reset_mock() - assert reolink_connect.get_states.call_count == 0 + reolink_host.get_states.reset_mock() + assert reolink_host.get_states.call_count == 0 freezer.tick(5) async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.get_states.call_count >= 1 + assert reolink_host.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_ON # test cleanup during unloading, first reset to privacy mode ON - reolink_connect.baichuan.privacy_mode.return_value = True + reolink_host.baichuan.privacy_mode.return_value = True callback_mock.callback_func() freezer.tick(5) async_fire_time_changed(hass) await hass.async_block_till_done() # now fire the callback again, but unload before refresh took place - reolink_connect.baichuan.privacy_mode.return_value = False + reolink_host.baichuan.privacy_mode.return_value = False callback_mock.callback_func() await hass.async_block_till_done() @@ -1120,7 +1089,7 @@ async def test_camera_wake_callback( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test camera wake callback.""" @@ -1135,13 +1104,12 @@ async def test_camera_wake_callback( callback_mock = callback_mock_class() - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) - reolink_connect.baichuan.register_callback = callback_mock.register_callback - reolink_connect.sleeping.return_value = True - reolink_connect.audio_record.return_value = True - reolink_connect.get_states = AsyncMock() + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.events_active = True + reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.register_callback = callback_mock.register_callback + reolink_host.sleeping.return_value = True + reolink_host.audio_record.return_value = True with ( patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]), @@ -1157,12 +1125,12 @@ async def test_camera_wake_callback( entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.sleeping.return_value = False - reolink_connect.get_states.reset_mock() - assert reolink_connect.get_states.call_count == 0 + reolink_host.sleeping.return_value = False + reolink_host.get_states.reset_mock() + assert reolink_host.get_states.call_count == 0 # simulate a TCP push callback signaling the battery camera woke up - reolink_connect.audio_record.return_value = False + reolink_host.audio_record.return_value = False assert callback_mock.callback_func is not None with ( patch( @@ -1182,7 +1150,7 @@ async def test_camera_wake_callback( await hass.async_block_till_done() # check that a coordinator update was scheduled. - assert reolink_connect.get_states.call_count >= 1 + assert reolink_host.get_states.call_count >= 1 assert hass.states.get(entity_id).state == STATE_OFF @@ -1201,7 +1169,7 @@ async def test_baichaun_only( async def test_remove( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test removing of the reolink integration.""" From 6befd065a161119e3423bd0cebe273665c7b7e0d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 18 Jun 2025 15:49:44 -0500 Subject: [PATCH 0387/1664] Bump aioesphomeapi to 32.2.4 (#147100) Bump aioesphomeapi --- 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 9b70aba4de1..6142b9ce5ec 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==32.2.1", + "aioesphomeapi==32.2.4", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index b69e1e69ccd..28ee85d6565 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8254ce9f261..dcc354338e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.4 # homeassistant.components.flo aioflo==2021.11.0 From 8d8ff011fcc8c7fd3cebcaaa1f96f52e010c7095 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Jun 2025 01:17:12 +0200 Subject: [PATCH 0388/1664] Minor improvements of service helper (#147079) --- homeassistant/helpers/service.py | 9 ++++++--- tests/helpers/test_service.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 6e1988fe4cd..4a10dfc5616 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -718,7 +718,6 @@ async def async_get_all_descriptions( for service_name in services_by_domain } # If we have a complete cache, check if it is still valid - all_cache: tuple[set[tuple[str, str]], dict[str, dict[str, Any]]] | None if all_cache := hass.data.get(ALL_SERVICE_DESCRIPTIONS_CACHE): previous_all_services, previous_descriptions_cache = all_cache # If the services are the same, we can return the cache @@ -744,7 +743,11 @@ async def async_get_all_descriptions( continue if TYPE_CHECKING: assert isinstance(int_or_exc, Exception) - _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) + _LOGGER.error( + "Failed to load services.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) if integrations: loaded = await hass.async_add_executor_job( @@ -772,7 +775,7 @@ async def async_get_all_descriptions( # Cache missing descriptions domain_yaml = loaded.get(domain) or {} # The YAML may be empty for dynamically defined - # services (ie shell_command) that never call + # services (e.g. shell_command) that never call # service.async_set_service_schema for the dynamic # service diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 6b464faa110..5d018f5f3ee 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1141,7 +1141,7 @@ async def test_async_get_all_descriptions_failing_integration( descriptions = await service.async_get_all_descriptions(hass) assert len(descriptions) == 3 - assert "Failed to load integration: logger" in caplog.text + assert "Failed to load services.yaml for integration: logger" in caplog.text # Services are empty defaults if the load fails but should # not raise From 3dba7e5bd24035991358507b921204f06ac0c7de Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 18 Jun 2025 21:12:37 -0500 Subject: [PATCH 0389/1664] Send intent progress events to ESPHome (#146966) --- homeassistant/components/esphome/assist_satellite.py | 7 +++++++ tests/components/esphome/test_assist_satellite.py | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index 53d5d449be8..fdeadd7feb1 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -60,6 +60,7 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType.VOICE_ASSISTANT_STT_START: PipelineEventType.STT_START, VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: PipelineEventType.STT_END, VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_START: PipelineEventType.INTENT_START, + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: PipelineEventType.INTENT_PROGRESS, VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: PipelineEventType.INTENT_END, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: PipelineEventType.TTS_START, VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, @@ -282,6 +283,12 @@ class EsphomeAssistSatellite( elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END: assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} + elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: + data_to_send = { + "tts_start_streaming": bool( + event.data and event.data.get("tts_start_streaming") + ), + } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index ec6091307b9..71977f0285c 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -240,6 +240,17 @@ async def test_pipeline_api_audio( ) assert satellite.state == AssistSatelliteState.PROCESSING + event_callback( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": True}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, + {"tts_start_streaming": True}, + ) + event_callback( PipelineEvent( type=PipelineEventType.INTENT_END, From f90a7404290c83fd25115d315140f86008ec6342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 19 Jun 2025 07:03:48 +0100 Subject: [PATCH 0390/1664] Use non-autospec mock for Reolink's binary_sensor, camera and diag tests (#147095) --- tests/components/reolink/conftest.py | 5 ++- .../components/reolink/test_binary_sensor.py | 34 +++++++++---------- tests/components/reolink/test_camera.py | 14 ++++---- tests/components/reolink/test_diagnostics.py | 4 +-- 4 files changed, 29 insertions(+), 28 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 69eeee99fab..0ca5612f8fd 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -73,6 +73,10 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.reboot = AsyncMock() host_mock.set_ptz_command = AsyncMock() host_mock.get_motion_state_all_ch = AsyncMock(return_value=False) + host_mock.get_stream_source = AsyncMock() + host_mock.get_snapshot = AsyncMock() + host_mock.get_encoding = AsyncMock(return_value="h264") + host_mock.ONVIF_event_callback = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC @@ -105,7 +109,6 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.camera_uid.return_value = TEST_UID_CAM host_mock.camera_online.return_value = True host_mock.channel_for_uid.return_value = 0 - host_mock.get_encoding.return_value = "h264" host_mock.firmware_update_available.return_value = False host_mock.session_active = True host_mock.timeout = 60 diff --git a/tests/components/reolink/test_binary_sensor.py b/tests/components/reolink/test_binary_sensor.py index 99c9efba002..e6275a2108e 100644 --- a/tests/components/reolink/test_binary_sensor.py +++ b/tests/components/reolink/test_binary_sensor.py @@ -21,11 +21,11 @@ async def test_motion_sensor( hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test binary sensor entity with motion sensor.""" - reolink_connect.model = TEST_DUO_MODEL - reolink_connect.motion_detected.return_value = True + reolink_host.model = TEST_DUO_MODEL + reolink_host.motion_detected.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -34,7 +34,7 @@ async def test_motion_sensor( entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_motion_lens_0" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.motion_detected.return_value = False + reolink_host.motion_detected.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -42,8 +42,8 @@ async def test_motion_sensor( assert hass.states.get(entity_id).state == STATE_OFF # test ONVIF webhook callback - reolink_connect.motion_detected.return_value = True - reolink_connect.ONVIF_event_callback.return_value = [0] + reolink_host.motion_detected.return_value = True + reolink_host.ONVIF_event_callback.return_value = [0] webhook_id = config_entry.runtime_data.host.webhook_id client = await hass_client_no_auth() await client.post(f"/api/webhook/{webhook_id}", data="test_data") @@ -56,11 +56,11 @@ async def test_smart_ai_sensor( hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test smart ai binary sensor entity.""" - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.smart_ai_state.return_value = True + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.smart_ai_state.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -69,7 +69,7 @@ async def test_smart_ai_sensor( entity_id = f"{Platform.BINARY_SENSOR}.{TEST_NVR_NAME}_crossline_zone1_person" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.baichuan.smart_ai_state.return_value = False + reolink_host.baichuan.smart_ai_state.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -80,7 +80,7 @@ async def test_smart_ai_sensor( async def test_tcp_callback( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test tcp callback using motion sensor.""" @@ -95,11 +95,11 @@ async def test_tcp_callback( callback_mock = callback_mock_class() - reolink_connect.model = TEST_HOST_MODEL - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) - reolink_connect.baichuan.register_callback = callback_mock.register_callback - reolink_connect.motion_detected.return_value = True + reolink_host.model = TEST_HOST_MODEL + reolink_host.baichuan.events_active = True + reolink_host.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.register_callback = callback_mock.register_callback + reolink_host.motion_detected.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -110,7 +110,7 @@ async def test_tcp_callback( assert hass.states.get(entity_id).state == STATE_ON # simulate a TCP push callback - reolink_connect.motion_detected.return_value = False + reolink_host.motion_detected.return_value = False assert callback_mock.callback_func is not None callback_mock.callback_func() diff --git a/tests/components/reolink/test_camera.py b/tests/components/reolink/test_camera.py index 4f18f769e02..4ab43de225f 100644 --- a/tests/components/reolink/test_camera.py +++ b/tests/components/reolink/test_camera.py @@ -25,7 +25,7 @@ async def test_camera( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test camera entity with fluent.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): @@ -37,28 +37,26 @@ async def test_camera( assert hass.states.get(entity_id).state == CameraState.IDLE # check getting a image from the camera - reolink_connect.get_snapshot.return_value = b"image" + reolink_host.get_snapshot.return_value = b"image" assert (await async_get_image(hass, entity_id)).content == b"image" - reolink_connect.get_snapshot.side_effect = ReolinkError("Test error") + reolink_host.get_snapshot.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await async_get_image(hass, entity_id) # check getting the stream source assert await async_get_stream_source(hass, entity_id) is not None - reolink_connect.get_snapshot.reset_mock(side_effect=True) - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_camera_no_stream_source( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test camera entity with no stream source.""" - reolink_connect.model = TEST_DUO_MODEL - reolink_connect.get_stream_source.return_value = None + reolink_host.model = TEST_DUO_MODEL + reolink_host.get_stream_source.return_value = None with patch("homeassistant.components.reolink.PLATFORMS", [Platform.CAMERA]): assert await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/reolink/test_diagnostics.py b/tests/components/reolink/test_diagnostics.py index d45163d3cf0..b347bae9ec0 100644 --- a/tests/components/reolink/test_diagnostics.py +++ b/tests/components/reolink/test_diagnostics.py @@ -15,8 +15,8 @@ from tests.typing import ClientSessionGenerator async def test_entry_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: From 6a16424bb47588d81f69c3bc72310e4b53df2f66 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:20:19 +0200 Subject: [PATCH 0391/1664] Fix nightly build (#147110) Update builder.yml --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index dc97e627ea4..d7bbfc8fa5e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -108,7 +108,7 @@ jobs: uses: dawidd6/action-download-artifact@v11 with: github_token: ${{secrets.GITHUB_TOKEN}} - repo: home-assistant/intents-package + repo: OHF-Voice/intents-package branch: main workflow: nightly.yaml workflow_conclusion: success From fada81e1ce47aa42848dddc61921e0e529059f7c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 19 Jun 2025 08:46:03 +0200 Subject: [PATCH 0392/1664] Bump ovoenergy to 2.0.1 (#147112) --- homeassistant/components/ovo_energy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 4 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json index af4a313206e..0fc90808bc9 100644 --- a/homeassistant/components/ovo_energy/manifest.json +++ b/homeassistant/components/ovo_energy/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["ovoenergy"], - "requirements": ["ovoenergy==2.0.0"] + "requirements": ["ovoenergy==2.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 28ee85d6565..f6968cc5317 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1632,7 +1632,7 @@ orvibo==1.1.2 ourgroceries==1.5.4 # homeassistant.components.ovo_energy -ovoenergy==2.0.0 +ovoenergy==2.0.1 # homeassistant.components.p1_monitor p1monitor==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dcc354338e5..666d43f3149 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1379,7 +1379,7 @@ oralb-ble==0.17.6 ourgroceries==1.5.4 # homeassistant.components.ovo_energy -ovoenergy==2.0.0 +ovoenergy==2.0.1 # homeassistant.components.p1_monitor p1monitor==3.1.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 48066ff6bf0..0576a5b9b6a 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -245,11 +245,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # opower > arrow > types-python-dateutil "arrow": {"types-python-dateutil"} }, - "ovo_energy": { - # https://github.com/timmo001/ovoenergy/issues/132 - # ovoenergy > incremental > setuptools - "incremental": {"setuptools"} - }, "pi_hole": {"hole": {"async-timeout"}}, "pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}}, "remote_rpi_gpio": { From 956f726ef3f9f5fde51152abe3d8ed9d4f68c9ce Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:20:29 +0200 Subject: [PATCH 0393/1664] Bump uiprotect to version 7.14.0 (#147102) --- 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 3c32935a995..f99d910adf9 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.13.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index f6968cc5317..63d662c1139 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.13.0 +uiprotect==7.14.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 666d43f3149..13906014d2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.13.0 +uiprotect==7.14.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 875d81cab2977bec2793a14f11028125dd84752e Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 19 Jun 2025 12:04:59 +0200 Subject: [PATCH 0394/1664] update pyHomee to v1.2.9 (#147094) --- homeassistant/components/homee/__init__.py | 2 +- homeassistant/components/homee/entity.py | 5 +++-- homeassistant/components/homee/lock.py | 8 ++++++-- homeassistant/components/homee/manifest.json | 2 +- homeassistant/components/homee/switch.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index e9eb1d86f02..0f90752733d 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo entry.runtime_data = homee entry.async_on_unload(homee.disconnect) - def _connection_update_callback(connected: bool) -> None: + async def _connection_update_callback(connected: bool) -> None: """Call when the device is notified of changes.""" if connected: _LOGGER.warning("Reconnected to Homee at %s", entry.data[CONF_HOST]) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index 4c85f52bb28..d8344c4226a 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -28,6 +28,7 @@ class HomeeEntity(Entity): self._entry = entry node = entry.runtime_data.get_node_by_id(attribute.node_id) # Homee hub itself has node-id -1 + assert node is not None if node.id == -1: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, entry.runtime_data.settings.uid)}, @@ -79,7 +80,7 @@ class HomeeEntity(Entity): def _on_node_updated(self, attribute: HomeeAttribute) -> None: self.schedule_update_ha_state() - def _on_connection_changed(self, connected: bool) -> None: + async def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() @@ -166,6 +167,6 @@ class HomeeNodeEntity(Entity): def _on_node_updated(self, node: HomeeNode) -> None: self.schedule_update_ha_state() - def _on_connection_changed(self, connected: bool) -> None: + async def _on_connection_changed(self, connected: bool) -> None: self._host_connected = connected self.schedule_update_ha_state() diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py index 4cfc34e11fe..8b3bf58040d 100644 --- a/homeassistant/components/homee/lock.py +++ b/homeassistant/components/homee/lock.py @@ -58,9 +58,13 @@ class HomeeLock(HomeeEntity, LockEntity): AttributeChangedBy, self._attribute.changed_by ) if self._attribute.changed_by == AttributeChangedBy.USER: - changed_id = self._entry.runtime_data.get_user_by_id( + user = self._entry.runtime_data.get_user_by_id( self._attribute.changed_by_id - ).username + ) + if user is not None: + changed_id = user.username + else: + changed_id = "Unknown" return f"{changed_by_name}-{changed_id}" diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 3c2a99c30dc..16ee6085439 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.8"] + "requirements": ["pyHomee==1.2.9"] } diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py index 041b96963f1..5e87a1b4002 100644 --- a/homeassistant/components/homee/switch.py +++ b/homeassistant/components/homee/switch.py @@ -28,6 +28,7 @@ def get_device_class( ) -> SwitchDeviceClass: """Check device class of Switch according to node profile.""" node = config_entry.runtime_data.get_node_by_id(attribute.node_id) + assert node is not None if node.profile in [ NodeProfile.ON_OFF_PLUG, NodeProfile.METERING_PLUG, diff --git a/requirements_all.txt b/requirements_all.txt index 63d662c1139..fa23a9566fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1798,7 +1798,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.8 +pyHomee==1.2.9 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 13906014d2e..7188e1f1395 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1509,7 +1509,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.8 +pyHomee==1.2.9 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From 1baba8b88015e04f1a0db996b7ad8f4c5c853826 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 19 Jun 2025 12:36:43 +0200 Subject: [PATCH 0395/1664] Adjust feature request links in issue reporting (#147130) --- .github/ISSUE_TEMPLATE/bug_report.yml | 5 ++--- .github/ISSUE_TEMPLATE/config.yml | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 87fed908c6e..94e876aa3ab 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,15 +1,14 @@ name: Report an issue with Home Assistant Core description: Report an issue with Home Assistant Core. -type: Bug body: - type: markdown attributes: value: | This issue form is for reporting bugs only! - If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr]. + If you have a feature or enhancement request, please [request them here instead][fr]. - [fr]: https://community.home-assistant.io/c/feature-requests + [fr]: https://github.com/orgs/home-assistant/discussions - type: textarea validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8a4c7d46708..e14233edfc9 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -10,8 +10,8 @@ contact_links: url: https://www.home-assistant.io/help about: We use GitHub for tracking bugs, check our website for resources on getting help. - name: Feature Request - url: https://community.home-assistant.io/c/feature-requests - about: Please use our Community Forum for making feature requests. + url: https://github.com/orgs/home-assistant/discussions + about: Please use this link to request new features or enhancements to existing features. - name: I'm unsure where to go url: https://www.home-assistant.io/join-chat about: If you are unsure where to go, then joining our chat is recommended; Just ask! From 77dca49c759e8ec764a9997c56f3d9738f41bad2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 19 Jun 2025 12:49:10 +0200 Subject: [PATCH 0396/1664] Fix pylint plugin for vacuum entity (#146467) * Clean out legacy VacuumEntity from pylint plugins * Fix * Fix pylint for vacuum * More fixes * Revert partial * Add back state --- homeassistant/components/lg_thinq/vacuum.py | 3 +- pylint/plugins/hass_enforce_class_module.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 77 ++++----------------- tests/pylint/test_enforce_type_hints.py | 10 +-- 4 files changed, 20 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py index 6cf2a9086b1..6b98b6d8f11 100644 --- a/homeassistant/components/lg_thinq/vacuum.py +++ b/homeassistant/components/lg_thinq/vacuum.py @@ -4,6 +4,7 @@ from __future__ import annotations from enum import StrEnum import logging +from typing import Any from thinqconnect import DeviceType from thinqconnect.integration import ExtendedProperty @@ -154,7 +155,7 @@ class ThinQStateVacuumEntity(ThinQEntity, StateVacuumEntity): ) ) - async def async_return_to_base(self, **kwargs) -> None: + async def async_return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" _LOGGER.debug( "[%s:%s] async_return_to_base", diff --git a/pylint/plugins/hass_enforce_class_module.py b/pylint/plugins/hass_enforce_class_module.py index cc7b33d9946..41c07819fe8 100644 --- a/pylint/plugins/hass_enforce_class_module.py +++ b/pylint/plugins/hass_enforce_class_module.py @@ -70,7 +70,7 @@ _MODULES: dict[str, set[str]] = { "todo": {"TodoListEntity"}, "tts": {"TextToSpeechEntity"}, "update": {"UpdateEntity", "UpdateEntityDescription"}, - "vacuum": {"StateVacuumEntity", "VacuumEntity", "VacuumEntityDescription"}, + "vacuum": {"StateVacuumEntity", "VacuumEntityDescription"}, "wake_word": {"WakeWordDetectionEntity"}, "water_heater": {"WaterHeaterEntity"}, "weather": { diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 45a3e41f91a..0760cd33821 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2789,12 +2789,12 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { matches=_RESTORE_ENTITY_MATCH, ), ClassTypeHintMatch( - base_class="ToggleEntity", - matches=_TOGGLE_ENTITY_MATCH, - ), - ClassTypeHintMatch( - base_class="_BaseVacuum", + base_class="StateVacuumEntity", matches=[ + TypeHintMatch( + function_name="state", + return_type=["str", None], + ), TypeHintMatch( function_name="battery_level", return_type=["int", None], @@ -2821,6 +2821,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { return_type=None, has_async_counterpart=True, ), + TypeHintMatch( + function_name="start", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="pause", + return_type=None, + has_async_counterpart=True, + ), TypeHintMatch( function_name="return_to_base", kwargs_type="Any", @@ -2860,63 +2870,6 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], ), - ClassTypeHintMatch( - base_class="VacuumEntity", - matches=[ - TypeHintMatch( - function_name="status", - return_type=["str", None], - ), - TypeHintMatch( - function_name="start_pause", - kwargs_type="Any", - return_type=None, - has_async_counterpart=True, - ), - TypeHintMatch( - function_name="async_pause", - return_type=None, - ), - TypeHintMatch( - function_name="async_start", - return_type=None, - ), - ], - ), - ClassTypeHintMatch( - base_class="StateVacuumEntity", - matches=[ - TypeHintMatch( - function_name="state", - return_type=["str", None], - ), - TypeHintMatch( - function_name="start", - return_type=None, - has_async_counterpart=True, - ), - TypeHintMatch( - function_name="pause", - return_type=None, - has_async_counterpart=True, - ), - TypeHintMatch( - function_name="async_turn_on", - kwargs_type="Any", - return_type=None, - ), - TypeHintMatch( - function_name="async_turn_off", - kwargs_type="Any", - return_type=None, - ), - TypeHintMatch( - function_name="async_toggle", - kwargs_type="Any", - return_type=None, - ), - ], - ), ], "water_heater": [ ClassTypeHintMatch( diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 9179a545256..ae426b13fcb 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1161,17 +1161,11 @@ def test_vacuum_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) - class Entity(): pass - class ToggleEntity(Entity): - pass - - class _BaseVacuum(Entity): - pass - - class VacuumEntity(_BaseVacuum, ToggleEntity): + class StateVacuumEntity(Entity): pass class MyVacuum( #@ - VacuumEntity + StateVacuumEntity ): def send_command( self, From 5bc2e271d2b639000f34bb7aed3bbe2fec590c02 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Jun 2025 12:52:01 +0200 Subject: [PATCH 0397/1664] Re-raise annotated_yaml.YAMLException as HomeAssistantError (#147129) * Re-raise annotated_yaml.YAMLException as HomeAssistantError * Fix comment --- homeassistant/util/yaml/loader.py | 18 +++++++++++------- tests/util/yaml/test_init.py | 4 ++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 1f8338a1ff7..0b5a9ca3c0e 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -6,7 +6,7 @@ from io import StringIO import os from typing import TextIO -from annotatedyaml import YAMLException, YamlTypeError +import annotatedyaml from annotatedyaml.loader import ( HAS_C_LOADER, JSON_TYPE, @@ -35,6 +35,10 @@ __all__ = [ ] +class YamlTypeError(HomeAssistantError): + """Raised by load_yaml_dict if top level data is not a dict.""" + + def load_yaml( fname: str | os.PathLike[str], secrets: Secrets | None = None ) -> JSON_TYPE | None: @@ -45,7 +49,7 @@ def load_yaml( """ try: return load_annotated_yaml(fname, secrets) - except YAMLException as exc: + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc @@ -59,9 +63,9 @@ def load_yaml_dict( """ try: return load_annotated_yaml_dict(fname, secrets) - except YamlTypeError: - raise - except YAMLException as exc: + except annotatedyaml.YamlTypeError as exc: + raise YamlTypeError(str(exc)) from exc + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc @@ -71,7 +75,7 @@ def parse_yaml( """Parse YAML with the fastest available loader.""" try: return parse_annotated_yaml(content, secrets) - except YAMLException as exc: + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc @@ -79,5 +83,5 @@ def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" try: return annotated_secret_yaml(loader, node) - except YAMLException as exc: + except annotatedyaml.YAMLException as exc: raise HomeAssistantError(str(exc)) from exc diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index dacbd2c1247..94c3dd204f7 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -559,6 +559,10 @@ def test_load_yaml_dict(expected_data: Any) -> None: @pytest.mark.usefixtures("try_both_loaders", "mock_hass_config_yaml") def test_load_yaml_dict_fail() -> None: """Test item without a key.""" + # Make sure we raise a subclass of HomeAssistantError, not + # annotated_yaml.YAMLException + assert issubclass(yaml_loader.YamlTypeError, HomeAssistantError) + with pytest.raises(yaml_loader.YamlTypeError): yaml_loader.load_yaml_dict(YAML_CONFIG_FILE) From 0db652080282142f88c536e389fc23aa6cdad8ed Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Jun 2025 12:59:35 +0200 Subject: [PATCH 0398/1664] Add comment in helpers.llm.ActionTool explaining limitations (#147116) --- homeassistant/helpers/llm.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 51b5510495f..1e4abb07ddb 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -901,6 +901,12 @@ class ActionTool(Tool): self._domain = domain self._action = action self.name = f"{domain}.{action}" + # Note: _get_cached_action_parameters only works for services which + # add their description directly to the service description cache. + # This is not the case for most services, but it is for scripts. + # If we want to use `ActionTool` for services other than scripts, we + # need to add a coroutine function to fetch the non-cached description + # and schema. self.description, self.parameters = _get_cached_action_parameters( hass, domain, action ) From 513045e4894b47de8fe3a8840f3eec964ad58a74 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 13:07:42 +0200 Subject: [PATCH 0399/1664] Update pytest warnings filter (#147132) --- pyproject.toml | 55 ++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c4cfe7a8593..83782631191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -527,15 +527,11 @@ filterwarnings = [ # -- fixed, waiting for release / update # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0 - "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", + "ignore:.*invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base", # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0 "ignore:pkg_resources is deprecated as an API:UserWarning:datadog.util.compat", # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2", - # https://github.com/majuss/lupupy/pull/15 - >0.3.2 - "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm", - # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0 - "ignore:pkg_resources is deprecated as an API:UserWarning:nextcord.health_check", # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0 "ignore::DeprecationWarning:holidays", # https://github.com/ReactiveX/RxPY/pull/716 - >4.0.4 @@ -549,7 +545,7 @@ filterwarnings = [ # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0 "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device", # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0 - "ignore:invalid escape sequence:SyntaxWarning:.*stringcase", + "ignore:.*invalid escape sequence:SyntaxWarning:.*stringcase", # https://github.com/xchwarze/samsung-tv-ws-api/pull/151 - >2.7.2 - 2024-12-06 # wrong stacklevel in aiohttp "ignore:verify_ssl is deprecated, use ssl=False instead:DeprecationWarning:aiohttp.client", @@ -572,47 +568,53 @@ filterwarnings = [ # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15 # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api", - # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22 + # https://github.com/thecynic/pylutron - v0.2.18 - 2025-04-15 "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron", # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils", - # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19 + # https://github.com/lextudio/pysnmp/blob/v7.1.21/pysnmp/smi/compiler.py#L23-L31 - v7.1.21 - 2025-06-19 "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler", "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler", - # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06 + # https://github.com/Python-roborock/python-roborock/issues/305 - 2.19.0 - 2025-05-13 "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api", - # https://github.com/Teslemetry/python-tesla-fleet-api - v1.1.1 - 2025-05-29 - "ignore:Protobuf gencode .* exactly one major version older than the runtime version 6.* at (car_server|common|errors|keys|managed_charging|signatures|universal_message|vcsec|vehicle):UserWarning:google.protobuf.runtime_version", # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10 "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const", # New in aiohttp - v3.9.0 "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # - SyntaxWarnings # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10 - "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", + "ignore:.*invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common", "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common", # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24 # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789 - "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera", + "ignore:.*invalid escape sequence:SyntaxWarning:.*panasonic_viera", # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15 # https://github.com/koolsb/pyblackbird/pull/9 -> closed - "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pyblackbird", # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05 - "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pyws66i", # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01 # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42 - "ignore:invalid escape sequence:SyntaxWarning:.*sanix", + "ignore:.*invalid escape sequence:SyntaxWarning:.*sanix", # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18 - "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty + "ignore:.*invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty # - pkg_resources + # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20 + "ignore:pkg_resources is deprecated as an API:UserWarning:aiomusiccast", # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17 "ignore:pkg_resources is deprecated as an API:UserWarning:pysiaalarm.data.data", - # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26 + # https://pypi.org/project/pybotvac/ - v0.0.28 - 2025-06-11 "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 "ignore:pkg_resources is deprecated as an API:UserWarning:pymystrom", + # - SyntaxWarning - is with literal + # https://github.com/majuss/lupupy/pull/15 - >0.3.2 + # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 + # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 + # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 + "ignore:\"is.*\" with '.*' literal:SyntaxWarning:importlib._bootstrap", # -- New in Python 3.13 # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 @@ -628,11 +630,11 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/bluecurrent/HomeAssistantAPI + # https://github.com/bluecurrent/HomeAssistantAPI/pull/19 - >=1.2.4 "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", - # https://github.com/graphql-python/gql + # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", # -- unmaintained projects, last release about 2+ years @@ -655,21 +657,16 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", - # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 - "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder", # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19 "ignore:pkg_resources is deprecated as an API:UserWarning:pilight", # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16 - "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)", + "ignore:.*invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery", # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05 - "ignore:invalid escape sequence:SyntaxWarning:.*ppadb", + "ignore:.*invalid escape sequence:SyntaxWarning:.*ppadb", # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10 - "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils", - # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19 - "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pydub.utils", # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21 - "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils", + "ignore:.*invalid escape sequence:SyntaxWarning:.*pypasser.utils", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", From c602a0e279ded182f9a374c7a39e29b279a8f901 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 19 Jun 2025 13:14:42 +0200 Subject: [PATCH 0400/1664] Deprecated hass.http.register_static_path now raises error (#147039) --- homeassistant/components/http/__init__.py | 8 +++++--- tests/components/http/test_cors.py | 5 ++++- tests/components/http/test_init.py | 13 +++++-------- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 8ee27039441..2c4b67e6c99 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -511,12 +511,14 @@ class HomeAssistantHTTP: ) -> None: """Register a folder or file to serve as a static path.""" frame.report_usage( - "calls hass.http.register_static_path which is deprecated because " - "it does blocking I/O in the event loop, instead " + "calls hass.http.register_static_path which " + "does blocking I/O in the event loop, instead " "call `await hass.http.async_register_static_paths(" f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`', exclude_integrations={"http"}, - core_behavior=frame.ReportBehavior.LOG, + core_behavior=frame.ReportBehavior.ERROR, + core_integration_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.ERROR, breaks_in_ha_version="2025.7", ) configs = [StaticPathConfig(url_path, path, cache_headers)] diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 0581c7bac2a..bddd66a7e81 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -16,6 +16,7 @@ from aiohttp.hdrs import ( from aiohttp.test_utils import TestClient import pytest +from homeassistant.components.http import StaticPathConfig from homeassistant.components.http.cors import setup_cors from homeassistant.core import HomeAssistant from homeassistant.helpers.http import KEY_ALLOW_CONFIGURED_CORS, HomeAssistantView @@ -157,7 +158,9 @@ async def test_cors_on_static_files( assert await async_setup_component( hass, "frontend", {"http": {"cors_allowed_origins": ["http://www.example.com"]}} ) - hass.http.register_static_path("/something", str(Path(__file__).parent)) + await hass.http.async_register_static_paths( + [StaticPathConfig("/something", str(Path(__file__).parent))] + ) client = await hass_client() resp = await client.options( diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 2937e673946..7858bbc123d 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -530,17 +530,14 @@ async def test_register_static_paths( """Test registering a static path with old api.""" assert await async_setup_component(hass, "frontend", {}) path = str(Path(__file__).parent) - hass.http.register_static_path("/something", path) - client = await hass_client() - resp = await client.get("/something/__init__.py") - assert resp.status == HTTPStatus.OK - assert ( + match_error = ( "Detected code that calls hass.http.register_static_path " - "which is deprecated because it does blocking I/O in the " - "event loop, instead call " + "which does blocking I/O in the event loop, instead call " "`await hass.http.async_register_static_paths" - ) in caplog.text + ) + with pytest.raises(RuntimeError, match=match_error): + hass.http.register_static_path("/something", path) async def test_ssl_issue_if_no_urls_configured( From 31eec6f471c230626898d36d6b4a480be58d25e9 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 19 Jun 2025 13:36:40 +0200 Subject: [PATCH 0401/1664] Add missing hyphen to "mains-powered" and "battery-powered" in `zha` (#147128) Add missing hyphen to "mains-powered" and "battery-powered" --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 95bf339f7d9..1327a78b0b3 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -182,9 +182,9 @@ "group_members_assume_state": "Group members assume state of group", "enable_identify_on_join": "Enable identify effect when devices join the network", "default_light_transition": "Default light transition time (seconds)", - "consider_unavailable_mains": "Consider mains powered devices unavailable after (seconds)", - "enable_mains_startup_polling": "Refresh state for mains powered devices on startup", - "consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)" + "consider_unavailable_mains": "Consider mains-powered devices unavailable after (seconds)", + "enable_mains_startup_polling": "Refresh state for mains-powered devices on startup", + "consider_unavailable_battery": "Consider battery-powered devices unavailable after (seconds)" }, "zha_alarm_options": { "title": "Alarm control panel options", From 7a5c088149b5ac2148106af9eaf209ac8457cc4f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 17:42:30 +0200 Subject: [PATCH 0402/1664] [ci] Bump cache key version (#147148) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5a5172f513f..19cc8bd3af7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 2 + CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.7" From da3d8a6332560e2e80593d4a0e260bf3085fe1f8 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 19 Jun 2025 17:56:47 +0200 Subject: [PATCH 0403/1664] Improve advanced Z-Wave battery discovery (#147127) --- .../components/zwave_js/binary_sensor.py | 34 +- homeassistant/components/zwave_js/const.py | 5 +- .../components/zwave_js/discovery.py | 31 +- .../zwave_js/discovery_data_template.py | 32 +- homeassistant/components/zwave_js/sensor.py | 47 +- tests/components/zwave_js/common.py | 1 - tests/components/zwave_js/conftest.py | 14 + .../zwave_js/fixtures/ring_keypad_state.json | 7543 +++++++++++++++++ .../components/zwave_js/test_binary_sensor.py | 52 +- tests/components/zwave_js/test_sensor.py | 93 +- 10 files changed, 7825 insertions(+), 27 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/ring_keypad_state.json diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 1439aa0ca0f..d70690ace31 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -318,12 +318,37 @@ PROPERTY_SENSOR_MAPPINGS: dict[str, PropertyZWaveJSEntityDescription] = { # Mappings for boolean sensors -BOOLEAN_SENSOR_MAPPINGS: dict[int, BinarySensorEntityDescription] = { - CommandClass.BATTERY: BinarySensorEntityDescription( - key=str(CommandClass.BATTERY), +BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescription] = { + (CommandClass.BATTERY, "backup"): BinarySensorEntityDescription( + key="battery_backup", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "disconnected"): BinarySensorEntityDescription( + key="battery_disconnected", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "isLow"): BinarySensorEntityDescription( + key="battery_is_low", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + (CommandClass.BATTERY, "lowFluid"): BinarySensorEntityDescription( + key="battery_low_fluid", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "overheating"): BinarySensorEntityDescription( + key="battery_overheating", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "rechargeable"): BinarySensorEntityDescription( + key="battery_rechargeable", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), } @@ -432,8 +457,9 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): # Entity class attributes self._attr_name = self.generate_name(include_value_name=True) + primary_value = self.info.primary_value if description := BOOLEAN_SENSOR_MAPPINGS.get( - self.info.primary_value.command_class + (primary_value.command_class, primary_value.property_) ): self.entity_description = description diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 3d626710d52..a99e9fd0113 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -139,7 +139,10 @@ ATTR_TWIST_ASSIST = "twist_assist" ADDON_SLUG = "core_zwave_js" # Sensor entity description constants -ENTITY_DESC_KEY_BATTERY = "battery" +ENTITY_DESC_KEY_BATTERY_LEVEL = "battery_level" +ENTITY_DESC_KEY_BATTERY_LIST_STATE = "battery_list_state" +ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY = "battery_maximum_capacity" +ENTITY_DESC_KEY_BATTERY_TEMPERATURE = "battery_temperature" ENTITY_DESC_KEY_CURRENT = "current" ENTITY_DESC_KEY_VOLTAGE = "voltage" ENTITY_DESC_KEY_ENERGY_MEASUREMENT = "energy_measurement" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 4e9a3321beb..92233dd2e77 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -913,7 +913,6 @@ DISCOVERY_SCHEMAS = [ hint="numeric_sensor", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.BATTERY, CommandClass.ENERGY_PRODUCTION, CommandClass.SENSOR_ALARM, CommandClass.SENSOR_MULTILEVEL, @@ -922,6 +921,36 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"level", "maximumCapacity"}, + ), + data_template=NumericSensorDataTemplate(), + ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"temperature"}, + ), + data_template=NumericSensorDataTemplate(), + ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="list", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"chargingStatus", "rechargeOrReplace"}, + ), + data_template=NumericSensorDataTemplate(), + ), ZWaveDiscoverySchema( platform=Platform.SENSOR, hint="numeric_sensor", diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index e619c6afc7c..731a786d226 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -133,7 +133,10 @@ from homeassistant.const import ( ) from .const import ( - ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_BATTERY_LEVEL, + ENTITY_DESC_KEY_BATTERY_LIST_STATE, + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, ENTITY_DESC_KEY_CO, ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, @@ -380,8 +383,31 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): def resolve_data(self, value: ZwaveValue) -> NumericSensorDataTemplateData: """Resolve helper class data for a discovered value.""" - if value.command_class == CommandClass.BATTERY: - return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE) + if value.command_class == CommandClass.BATTERY and value.property_ == "level": + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE + ) + if value.command_class == CommandClass.BATTERY and value.property_ in ( + "chargingStatus", + "rechargeOrReplace", + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_LIST_STATE, None + ) + if ( + value.command_class == CommandClass.BATTERY + and value.property_ == "maximumCapacity" + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE + ) + if ( + value.command_class == CommandClass.BATTERY + and value.property_ == "temperature" + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, UnitOfTemperature.CELSIUS + ) if value.command_class == CommandClass.METER: try: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 4db14d003b1..05fa785760b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -58,7 +58,10 @@ from .const import ( ATTR_VALUE, DATA_CLIENT, DOMAIN, - ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_BATTERY_LEVEL, + ENTITY_DESC_KEY_BATTERY_LIST_STATE, + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, ENTITY_DESC_KEY_CO, ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, @@ -95,17 +98,33 @@ from .migrate import async_migrate_statistics_sensors PARALLEL_UPDATES = 0 -# These descriptions should include device class. -ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ - tuple[str, str], SensorEntityDescription -] = { - (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - key=ENTITY_DESC_KEY_BATTERY, +# These descriptions should have a non None unit of measurement. +ENTITY_DESCRIPTION_KEY_UNIT_MAP: dict[tuple[str, str], SensorEntityDescription] = { + (ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), + (ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + ( + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, + UnitOfTemperature.CELSIUS, + ): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, @@ -285,8 +304,14 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ), } -# These descriptions are without device class. +# These descriptions are without unit of measurement. ENTITY_DESCRIPTION_KEY_MAP = { + ENTITY_DESC_KEY_BATTERY_LIST_STATE: SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_LIST_STATE, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ENTITY_DESC_KEY_CO: SensorEntityDescription( key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, @@ -538,7 +563,7 @@ def get_entity_description( """Return the entity description for the given data.""" data_description_key = data.entity_description_key or "" data_unit = data.unit_of_measurement or "" - return ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP.get( + return ENTITY_DESCRIPTION_KEY_UNIT_MAP.get( (data_description_key, data_unit), ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, @@ -588,6 +613,10 @@ async def async_setup_entry( entities.append( ZWaveListSensor(config_entry, driver, info, entity_description) ) + elif info.platform_hint == "list": + entities.append( + ZWaveListSensor(config_entry, driver, info, entity_description) + ) elif info.platform_hint == "config_parameter": entities.append( ZWaveConfigParameterSensor( diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 64bc981de11..578eeab5ec7 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -21,7 +21,6 @@ ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" VOLTAGE_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_3" CURRENT_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_4" SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports" -LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e0485ced091..25f40e4418d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -199,6 +199,12 @@ def climate_heatit_z_trm3_no_value_state_fixture() -> dict[str, Any]: return load_json_object_fixture("climate_heatit_z_trm3_no_value_state.json", DOMAIN) +@pytest.fixture(name="ring_keypad_state", scope="package") +def ring_keypad_state_fixture() -> dict[str, Any]: + """Load the Ring keypad state fixture data.""" + return load_json_object_fixture("ring_keypad_state.json", DOMAIN) + + @pytest.fixture(name="nortek_thermostat_state", scope="package") def nortek_thermostat_state_fixture() -> dict[str, Any]: """Load the nortek thermostat node state fixture data.""" @@ -876,6 +882,14 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: return Event("node removed", event_data) +@pytest.fixture(name="ring_keypad") +def ring_keypad_fixture(client: MagicMock, ring_keypad_state: NodeDataType) -> Node: + """Mock a Ring keypad node.""" + node = Node(client, copy.deepcopy(ring_keypad_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="integration") async def integration_fixture( hass: HomeAssistant, diff --git a/tests/components/zwave_js/fixtures/ring_keypad_state.json b/tests/components/zwave_js/fixtures/ring_keypad_state.json new file mode 100644 index 00000000000..3d003518b6e --- /dev/null +++ b/tests/components/zwave_js/fixtures/ring_keypad_state.json @@ -0,0 +1,7543 @@ +{ + "nodeId": 4, + "index": 0, + "installerIcon": 8193, + "userIcon": 8193, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 838, + "productId": 1025, + "productType": 257, + "firmwareVersion": "1.18.0", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0346/keypad_v2.json", + "isEmbedded": true, + "manufacturer": "Ring", + "manufacturerId": 838, + "label": "4AK1SZ", + "description": "Keypad v2", + "devices": [ + { + "productType": 257, + "productId": 769 + }, + { + "productType": 257, + "productId": 1025 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "compat": { + "disableStrictEntryControlDataValidation": true + }, + "metadata": { + "inclusion": "Classic Inclusion should be used if the controller does not support SmartStart.\n1. Initiate add flow for Security Devices in the Ring mobile application \u2013 Follow the guided add flow instructions provided in the Ring mobile application.\n2. Select add manually and enter the 5-digit DSK PIN found on the package of the Ring Alarm Keypad or the 5-digit DSK PIN found under the QR code on the device.\n3. After powering on the device, press and hold the #1 button for ~3 seconds. Release the button and the device will enter Classic inclusion mode which implements both classic inclusion with a Node Information Frame, and Network Wide Inclusion. During Classic Inclusion mode, the green Connection LED will blink three times followed by a brief pause, repeatedly. When Classic inclusion times-out, the device will blink alternating red and green a few times", + "exclusion": "1. Initiate remove 'Ring Alarm Keypad' flow in the Ring Alarm mobile application \u2013 Select the settings icon from device details page and choose 'Remove Device' to remove the device. This will place the controller into Remove or 'Z-Wave Exclusion' mode.\n2. Locate the pinhole reset button on the back of the device.\n3. With the controller in Remove (Z-Wave Exclusion) mode, use a paper clip or similar object and tap the pinhole button. The device's Connection LED turns on solid red to indicate the device was removed from the network.", + "reset": "Factory Default Instructions\n1. To restore Ring Alarm Keypad to factory default settings, locate the pinhole reset button on the device. This is found on the back of the device after removing the back bracket.\n2. Using a paperclip or similar object, insert it into the pinhole, press and hold the button down for 10 seconds.\n3. The device's Connection icon LED will rapidly blink green continuously for 10 seconds. After about 10 seconds, when the green blinking stops, release the button. The red LED will turn on solid to indicate the device was removed from the network.\nNote\nUse this procedure only in the event that the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4150/Ring%20Alarm%20Keypad%20Zwave.pdf" + } + }, + "label": "4AK1SZ", + "interviewAttempts": 0, + "isFrequentListening": "250ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 11, + "label": "Secure Keypad" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0346:0x0101:0x0401:1.18.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 27.5, + "lastSeen": "2025-06-18T11:17:39.315Z", + "rssi": -54, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -54, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 2, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-06-18T11:17:39.315Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 111, + "commandClassName": "Entry Control", + "property": "keyCacheTimeout", + "propertyName": "keyCacheTimeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "How long the key cache must wait for additional characters", + "label": "Key cache timeout", + "min": 1, + "max": 30, + "unit": "seconds", + "stateful": true, + "secret": false + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 111, + "commandClassName": "Entry Control", + "property": "keyCacheSize", + "propertyName": "keyCacheSize", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Number of character that must be stored before sending", + "label": "Key cache size", + "min": 4, + "max": 10, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Heartbeat Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Heartbeat Interval", + "default": 70, + "min": 1, + "max": 70, + "unit": "minutes", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 70 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Message Retry Attempt Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Message Retry Attempt Limit", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Delay Between Retry Attempts", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Delay Between Retry Attempts", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Announcement Audio Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Announcement Audio Volume", + "default": 7, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Key Tone Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Key Tone Volume", + "default": 6, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Siren Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Siren Volume", + "default": 10, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Long Press Duration: Emergency Buttons", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Hold time required to capture a long press", + "label": "Long Press Duration: Emergency Buttons", + "default": 3, + "min": 2, + "max": 5, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Long Press Duration: Number Pad", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Hold time required to capture a long press", + "label": "Long Press Duration: Number Pad", + "default": 3, + "min": 2, + "max": 5, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Timeout: Proximity Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Proximity Display", + "default": 5, + "min": 0, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Timeout: Display on Button Press", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Display on Button Press", + "default": 5, + "min": 0, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Timeout: Display on Status Change", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Display on Status Change", + "default": 5, + "min": 1, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Brightness: Security Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Brightness: Security Mode", + "default": 100, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Brightness: Key Backlight", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Brightness: Key Backlight", + "default": 100, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Key Backlight Ambient Light Sensor Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Key Backlight Ambient Light Sensor Level", + "default": 20, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Proximity Detection", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Proximity Detection", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "LED Ramp Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Ramp Time", + "default": 50, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Battery Low Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Low Threshold", + "default": 15, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 30 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Battery Warning Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Warning Threshold", + "default": 5, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Keypad Language", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Keypad Language", + "default": 30, + "min": 0, + "max": 31, + "states": { + "0": "English", + "2": "French", + "5": "Spanish" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "System Security Mode Blink Duration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "System Security Mode Blink Duration", + "default": 2, + "min": 1, + "max": 60, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Supervision Report Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Supervision Report Timeout", + "default": 10000, + "min": 500, + "max": 30000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "System Security Mode Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Allowable range: 1-600", + "label": "System Security Mode Display", + "default": 0, + "min": 0, + "max": 601, + "states": { + "0": "Always off", + "601": "Always on" + }, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1, + "propertyName": "param023_1", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2, + "propertyName": "param023_2", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4, + "propertyName": "param023_4", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8, + "propertyName": "param023_8", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16, + "propertyName": "param023_16", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 32, + "propertyName": "param023_32", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 64, + "propertyName": "param023_64", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 128, + "propertyName": "param023_128", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 256, + "propertyName": "param023_256", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 512, + "propertyName": "param023_512", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1024, + "propertyName": "param023_1024", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2048, + "propertyName": "param023_2048", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4096, + "propertyName": "param023_4096", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8192, + "propertyName": "param023_8192", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16384, + "propertyName": "param023_16384", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 32768, + "propertyName": "param023_32768", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 65536, + "propertyName": "param023_65536", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 131072, + "propertyName": "param023_131072", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 262144, + "propertyName": "param023_262144", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 524288, + "propertyName": "param023_524288", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1048576, + "propertyName": "param023_1048576", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2097152, + "propertyName": "param023_2097152", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4194304, + "propertyName": "param023_4194304", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8388608, + "propertyName": "param023_8388608", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16777216, + "propertyName": "param023_16777216", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 33554432, + "propertyName": "param023_33554432", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 67108864, + "propertyName": "param023_67108864", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 134217728, + "propertyName": "param023_134217728", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 268435456, + "propertyName": "param023_268435456", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 536870912, + "propertyName": "param023_536870912", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1073741824, + "propertyName": "param023_1073741824", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2147483648, + "propertyName": "param023_2147483648", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Calibrate Speaker", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Calibrate Speaker", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Motion Sensor Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Motion Sensor Timeout", + "default": 3, + "min": 0, + "max": 60, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Z-Wave Sleep Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "", + "label": "Z-Wave Sleep Timeout", + "default": 10, + "min": 0, + "max": 15, + "valueSize": 1, + "format": 1, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Languages Supported Report", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "This parameter reports a bitmask of supported languages", + "label": "Languages Supported Report", + "default": 37, + "min": 0, + "max": 4294967295, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Motion sensor status", + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Motion sensor status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Motion detection" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Power status", + "propertyName": "Power Management", + "propertyKeyName": "Power status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Power status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Power has been applied" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Software status", + "propertyName": "System", + "propertyKeyName": "Software status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Software status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "4": "System software failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Mains status", + "propertyName": "Power Management", + "propertyKeyName": "Mains status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Mains status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "2": "AC mains disconnected", + "3": "AC mains re-connected" + }, + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1025 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 257 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 838 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "temperature", + "propertyName": "temperature", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Temperature", + "min": -128, + "max": 127, + "unit": "\u00b0C", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "maximumCapacity", + "propertyName": "maximumCapacity", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maximum capacity", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.18", "1.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 28 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.18.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12.4" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "1.18.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.12.4" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 1, + "propertyName": "0", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0 (default) - Multilevel", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 3, + "propertyName": "0", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0 (default) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 4, + "propertyName": "0", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0 (default) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 5, + "propertyName": "0", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0 (default) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 7, + "propertyName": "0", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 9, + "propertyName": "0", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0 (default) - Sound level", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 8, + "propertyName": "0", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 6, + "propertyName": "0", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + }, + "value": "" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 1, + "propertyName": "Ready", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x03 (Ready) - Multilevel", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 3, + "propertyName": "Ready", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x03 (Ready) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 4, + "propertyName": "Ready", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x03 (Ready) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 5, + "propertyName": "Ready", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x03 (Ready) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 7, + "propertyName": "Ready", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 9, + "propertyName": "Ready", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x03 (Ready) - Sound level", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 8, + "propertyName": "Ready", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 6, + "propertyName": "Ready", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 1, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x02 (Not armed / disarmed) - Multilevel", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 3, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x02 (Not armed / disarmed) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 4, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x02 (Not armed / disarmed) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 5, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x02 (Not armed / disarmed) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 7, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 9, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x02 (Not armed / disarmed) - Sound level", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 8, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 6, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 1, + "propertyName": "Code not accepted", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x09 (Code not accepted) - Multilevel", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 3, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x09 (Code not accepted) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 4, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x09 (Code not accepted) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 5, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x09 (Code not accepted) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 7, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 9, + "propertyName": "Code not accepted", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x09 (Code not accepted) - Sound level", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 8, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 6, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 1, + "propertyName": "Armed Stay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0a (Armed Stay) - Multilevel", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 3, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0a (Armed Stay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 4, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0a (Armed Stay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 5, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0a (Armed Stay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 7, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 9, + "propertyName": "Armed Stay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0a (Armed Stay) - Sound level", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 8, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 6, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 1, + "propertyName": "Armed Away", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0b (Armed Away) - Multilevel", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 3, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0b (Armed Away) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 4, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0b (Armed Away) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 5, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0b (Armed Away) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 7, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 9, + "propertyName": "Armed Away", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0b (Armed Away) - Sound level", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 8, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 6, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 1, + "propertyName": "Alarming", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0c (Alarming) - Multilevel", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 3, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0c (Alarming) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 4, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0c (Alarming) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 5, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0c (Alarming) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 7, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 9, + "propertyName": "Alarming", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0c (Alarming) - Sound level", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 8, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 6, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 1, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0d (Alarming: Burglar) - Multilevel", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 3, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0d (Alarming: Burglar) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 4, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0d (Alarming: Burglar) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 5, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0d (Alarming: Burglar) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 7, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 9, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0d (Alarming: Burglar) - Sound level", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 8, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 6, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 1, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0e (Alarming: Smoke / Fire) - Multilevel", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 3, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 4, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 5, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 7, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 9, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0e (Alarming: Smoke / Fire) - Sound level", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 8, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 6, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 1, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0f (Alarming: Carbon Monoxide) - Multilevel", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 3, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 4, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 5, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 7, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 9, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0f (Alarming: Carbon Monoxide) - Sound level", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 8, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 6, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 1, + "propertyName": "Bypass challenge", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x10 (Bypass challenge) - Multilevel", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 3, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x10 (Bypass challenge) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 4, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x10 (Bypass challenge) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 5, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x10 (Bypass challenge) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 7, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 9, + "propertyName": "Bypass challenge", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x10 (Bypass challenge) - Sound level", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 8, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 6, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 1, + "propertyName": "Entry Delay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x11 (Entry Delay) - Multilevel", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 3, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x11 (Entry Delay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 4, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x11 (Entry Delay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 5, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x11 (Entry Delay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 7, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 9, + "propertyName": "Entry Delay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x11 (Entry Delay) - Sound level", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 8, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 6, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 1, + "propertyName": "Exit Delay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x12 (Exit Delay) - Multilevel", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 3, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x12 (Exit Delay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 4, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x12 (Exit Delay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 5, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x12 (Exit Delay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 7, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 9, + "propertyName": "Exit Delay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x12 (Exit Delay) - Sound level", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 8, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 6, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 1, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x13 (Alarming: Medical) - Multilevel", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 3, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x13 (Alarming: Medical) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 4, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x13 (Alarming: Medical) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 5, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x13 (Alarming: Medical) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 7, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 9, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x13 (Alarming: Medical) - Sound level", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 8, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 6, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 1, + "propertyName": "Node Identify", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x50 (Node Identify) - Multilevel", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 7, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 9, + "propertyName": "Node Identify", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x50 (Node Identify) - Sound level", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 8, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 6, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 1, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x60 (Generic event sound notification 1) - Multilevel", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 3, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x60 (Generic event sound notification 1) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 4, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x60 (Generic event sound notification 1) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 5, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x60 (Generic event sound notification 1) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 7, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 9, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x60 (Generic event sound notification 1) - Sound level", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 8, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 6, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 1, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x61 (Generic event sound notification 2) - Multilevel", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 3, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x61 (Generic event sound notification 2) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 4, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x61 (Generic event sound notification 2) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 5, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x61 (Generic event sound notification 2) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 7, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 9, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x61 (Generic event sound notification 2) - Sound level", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 8, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 6, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 1, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x62 (Generic event sound notification 3) - Multilevel", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 3, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x62 (Generic event sound notification 3) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 4, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x62 (Generic event sound notification 3) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 5, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x62 (Generic event sound notification 3) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 7, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 9, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x62 (Generic event sound notification 3) - Sound level", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 8, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 6, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 1, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x63 (Generic event sound notification 4) - Multilevel", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 3, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x63 (Generic event sound notification 4) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 4, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x63 (Generic event sound notification 4) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 5, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x63 (Generic event sound notification 4) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 7, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 9, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x63 (Generic event sound notification 4) - Sound level", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 8, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 6, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 1, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x64 (Generic event sound notification 5) - Multilevel", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 3, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x64 (Generic event sound notification 5) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 4, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x64 (Generic event sound notification 5) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 5, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x64 (Generic event sound notification 5) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 7, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 9, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x64 (Generic event sound notification 5) - Sound level", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 8, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 6, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 1, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x14 (Alarming: Freeze warning) - Multilevel", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 3, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x14 (Alarming: Freeze warning) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 4, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x14 (Alarming: Freeze warning) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 5, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x14 (Alarming: Freeze warning) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 7, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 9, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x14 (Alarming: Freeze warning) - Sound level", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 8, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 6, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 1, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x15 (Alarming: Water leak) - Multilevel", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 3, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x15 (Alarming: Water leak) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 4, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x15 (Alarming: Water leak) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 5, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x15 (Alarming: Water leak) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 7, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 9, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x15 (Alarming: Water leak) - Sound level", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 8, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 6, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 1, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x81 (Manufacturer defined 2) - Multilevel", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 3, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x81 (Manufacturer defined 2) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 4, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x81 (Manufacturer defined 2) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 5, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x81 (Manufacturer defined 2) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 7, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 9, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x81 (Manufacturer defined 2) - Sound level", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 8, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 6, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 1, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x82 (Manufacturer defined 3) - Multilevel", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 3, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x82 (Manufacturer defined 3) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 4, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x82 (Manufacturer defined 3) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 5, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x82 (Manufacturer defined 3) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 7, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 9, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x82 (Manufacturer defined 3) - Sound level", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 8, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 6, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 1, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x83 (Manufacturer defined 4) - Multilevel", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 3, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x83 (Manufacturer defined 4) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 4, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x83 (Manufacturer defined 4) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 5, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x83 (Manufacturer defined 4) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 7, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 9, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x83 (Manufacturer defined 4) - Sound level", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 8, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 6, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 1, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x84 (Manufacturer defined 5) - Multilevel", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 3, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x84 (Manufacturer defined 5) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 4, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x84 (Manufacturer defined 5) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 5, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x84 (Manufacturer defined 5) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 7, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 9, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x84 (Manufacturer defined 5) - Sound level", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 8, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 6, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 1, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x85 (Manufacturer defined 6) - Multilevel", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 3, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x85 (Manufacturer defined 6) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 4, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x85 (Manufacturer defined 6) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 5, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x85 (Manufacturer defined 6) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 7, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 9, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x85 (Manufacturer defined 6) - Sound level", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 8, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 6, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 4, + "index": 0, + "installerIcon": 8193, + "userIcon": 8193, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 11, + "label": "Secure Keypad" + } + }, + "commandClasses": [ + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 111, + "name": "Entry Control", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 93ac52f9041..5dfbb0f5bd8 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,10 +1,13 @@ """Test the Z-Wave JS binary sensor platform.""" +from datetime import timedelta + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_OFF, @@ -15,17 +18,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import ( DISABLED_LEGACY_BINARY_SENSOR, ENABLED_LEGACY_BINARY_SENSOR, - LOW_BATTERY_BINARY_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR, PROPERTY_DOOR_STATUS_BINARY_SENSOR, TAMPER_SENSOR, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -34,21 +37,56 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -async def test_low_battery_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration +async def test_battery_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ring_keypad: Node, + integration: MockConfigEntry, ) -> None: - """Test boolean binary sensor of type low battery.""" - state = hass.states.get(LOW_BATTERY_BINARY_SENSOR) + """Test boolean battery binary sensors.""" + entity_id = "binary_sensor.keypad_v2_low_battery_level" + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY - entity_entry = entity_registry.async_get(LOW_BATTERY_BINARY_SENSOR) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + disabled_binary_sensor_battery_entities = ( + "binary_sensor.keypad_v2_battery_is_disconnected", + "binary_sensor.keypad_v2_fluid_is_low", + "binary_sensor.keypad_v2_overheating", + "binary_sensor.keypad_v2_rechargeable", + "binary_sensor.keypad_v2_used_as_backup", + ) + + for entity_id in disabled_binary_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state is None # disabled by default + + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + for entity_id in disabled_binary_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + async def test_enabled_legacy_sensor( hass: HomeAssistant, ecolink_door_sensor, integration diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index c93b722334b..c3580df1f27 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS sensor platform.""" import copy +from datetime import timedelta import pytest from zwave_js_server.const.command_class.meter import MeterType @@ -26,6 +27,7 @@ from homeassistant.components.zwave_js.sensor import ( CONTROLLER_STATISTICS_KEY_MAP, NODE_STATISTICS_KEY_MAP, ) +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -35,6 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UV_INDEX, EntityCategory, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -45,6 +48,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import ( AIR_TEMPERATURE_SENSOR, @@ -57,7 +61,94 @@ from .common import ( VOLTAGE_SENSOR, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_battery_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ring_keypad: Node, + integration: MockConfigEntry, +) -> None: + """Test numeric battery sensors.""" + entity_id = "sensor.keypad_v2_battery_level" + state = hass.states.get(entity_id) + assert state + assert state.state == "100.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + + disabled_sensor_battery_entities = ( + "sensor.keypad_v2_chargingstatus", + "sensor.keypad_v2_maximum_capacity", + "sensor.keypad_v2_rechargeorreplace", + "sensor.keypad_v2_temperature", + ) + + for entity_id in disabled_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state is None # disabled by default + + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_id = "sensor.keypad_v2_chargingstatus" + state = hass.states.get(entity_id) + assert state + assert state.state == "Maintaining" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert ATTR_STATE_CLASS not in state.attributes + + entity_id = "sensor.keypad_v2_maximum_capacity" + state = hass.states.get(entity_id) + assert state + assert ( + state.state == "0" + ) # This should be None/unknown but will be fixed in a future PR. + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + entity_id = "sensor.keypad_v2_rechargeorreplace" + state = hass.states.get(entity_id) + assert state + assert state.state == "No" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert ATTR_STATE_CLASS not in state.attributes + + entity_id = "sensor.keypad_v2_temperature" + state = hass.states.get(entity_id) + assert state + assert ( + state.state == "0" + ) # This should be None/unknown but will be fixed in a future PR. + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT async def test_numeric_sensor( From 4aff0324428512a6e868e24eeb7c47cbe8677aab Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:55:14 +0200 Subject: [PATCH 0404/1664] Bump homematicip to 2.0.6 (#147151) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 163f3c402dc..d5af2859873 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.5"] + "requirements": ["homematicip==2.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index fa23a9566fc..a3f0c833d2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,7 +1170,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.5 +homematicip==2.0.6 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7188e1f1395..a27b9f5d199 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.5 +homematicip==2.0.6 # homeassistant.components.remember_the_milk httplib2==0.20.4 From b00342991272df2c0958fdf370572eef35c2ca20 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:04:28 -0700 Subject: [PATCH 0405/1664] Expose statistics selector, use for `recorder.get_statistics` (#147056) * Expose statistics selector, use for `recorder.get_statistics` * code review * syntax formatting * rerun ci --- .../components/recorder/services.yaml | 2 +- homeassistant/helpers/selector.py | 33 +++++++++++++++++++ tests/helpers/test_selector.py | 27 +++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml index 65aa797d91b..3ecd2be8af6 100644 --- a/homeassistant/components/recorder/services.yaml +++ b/homeassistant/components/recorder/services.yaml @@ -69,7 +69,7 @@ get_statistics: - sensor.energy_consumption - sensor.temperature selector: - entity: + statistic: multiple: true period: diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 2d7fd51cac7..322cfe34042 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1216,6 +1216,39 @@ class SelectSelector(Selector[SelectSelectorConfig]): return [parent_schema(vol.Schema(str)(val)) for val in data] +class StatisticSelectorConfig(BaseSelectorConfig, total=False): + """Class to represent a statistic selector config.""" + + multiple: bool + + +@SELECTORS.register("statistic") +class StatisticSelector(Selector[StatisticSelectorConfig]): + """Selector of a single or list of statistics.""" + + selector_type = "statistic" + + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("multiple", default=False): cv.boolean, + } + ) + + def __init__(self, config: StatisticSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str | list[str]: + """Validate the passed selection.""" + + if not self.config["multiple"]: + stat: str = vol.Schema(str)(data) + return stat + if not isinstance(data, list): + raise vol.Invalid("Value should be a list") + return [vol.Schema(str)(val) for val in data] + + class TargetSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a target selector config.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 3ddbecaf48d..51ee467b029 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -1262,3 +1262,30 @@ def test_label_selector_schema(schema, valid_selections, invalid_selections) -> def test_floor_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test floor selector.""" _test_selector("floor", schema, valid_selections, invalid_selections) + + +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + [ + ( + {}, + ("sensor.temperature",), + (None, ["sensor.temperature"]), + ), + ( + {"multiple": True}, + (["sensor.temperature", "sensor:external_temperature"], []), + ("sensor.temperature",), + ), + ( + {"multiple": False}, + ("sensor.temperature",), + (None, ["sensor.temperature"]), + ), + ], +) +def test_statistic_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test statistic selector.""" + _test_selector("statistic", schema, valid_selections, invalid_selections) From cf67a6845485880912fbca5e3e50a9fae6879200 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 20:24:51 +0200 Subject: [PATCH 0406/1664] Use PEP 695 TypeVar syntax for paperless_ngx (#147156) --- .../components/paperless_ngx/coordinator.py | 9 +++------ .../components/paperless_ngx/entity.py | 10 ++++------ .../components/paperless_ngx/sensor.py | 17 +++++++++-------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/paperless_ngx/coordinator.py b/homeassistant/components/paperless_ngx/coordinator.py index d5960bed49b..270fd8063dc 100644 --- a/homeassistant/components/paperless_ngx/coordinator.py +++ b/homeassistant/components/paperless_ngx/coordinator.py @@ -5,7 +5,6 @@ from __future__ import annotations from abc import abstractmethod from dataclasses import dataclass from datetime import timedelta -from typing import TypeVar from pypaperless import Paperless from pypaperless.exceptions import ( @@ -25,8 +24,6 @@ from .const import DOMAIN, LOGGER type PaperlessConfigEntry = ConfigEntry[PaperlessData] -TData = TypeVar("TData") - UPDATE_INTERVAL_STATISTICS = timedelta(seconds=120) UPDATE_INTERVAL_STATUS = timedelta(seconds=300) @@ -39,7 +36,7 @@ class PaperlessData: status: PaperlessStatusCoordinator -class PaperlessCoordinator(DataUpdateCoordinator[TData]): +class PaperlessCoordinator[DataT](DataUpdateCoordinator[DataT]): """Coordinator to manage fetching Paperless-ngx API.""" config_entry: PaperlessConfigEntry @@ -63,7 +60,7 @@ class PaperlessCoordinator(DataUpdateCoordinator[TData]): update_interval=update_interval, ) - async def _async_update_data(self) -> TData: + async def _async_update_data(self) -> DataT: """Update data via internal method.""" try: return await self._async_update_data_internal() @@ -89,7 +86,7 @@ class PaperlessCoordinator(DataUpdateCoordinator[TData]): ) from err @abstractmethod - async def _async_update_data_internal(self) -> TData: + async def _async_update_data_internal(self) -> DataT: """Update data via paperless-ngx API.""" diff --git a/homeassistant/components/paperless_ngx/entity.py b/homeassistant/components/paperless_ngx/entity.py index e7eb0f0edcf..59cd13c5209 100644 --- a/homeassistant/components/paperless_ngx/entity.py +++ b/homeassistant/components/paperless_ngx/entity.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Generic, TypeVar - from homeassistant.components.sensor import EntityDescription from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -11,17 +9,17 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import PaperlessCoordinator -TCoordinator = TypeVar("TCoordinator", bound=PaperlessCoordinator) - -class PaperlessEntity(CoordinatorEntity[TCoordinator], Generic[TCoordinator]): +class PaperlessEntity[CoordinatorT: PaperlessCoordinator]( + CoordinatorEntity[CoordinatorT] +): """Defines a base Paperless-ngx entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: TCoordinator, + coordinator: CoordinatorT, description: EntityDescription, ) -> None: """Initialize the Paperless-ngx entity.""" diff --git a/homeassistant/components/paperless_ngx/sensor.py b/homeassistant/components/paperless_ngx/sensor.py index e3f601b68e6..5d6bfe1347e 100644 --- a/homeassistant/components/paperless_ngx/sensor.py +++ b/homeassistant/components/paperless_ngx/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from pypaperless.models import Statistic, Status from pypaperless.models.common import StatusType @@ -23,23 +22,23 @@ from homeassistant.util.unit_conversion import InformationConverter from .coordinator import ( PaperlessConfigEntry, + PaperlessCoordinator, PaperlessStatisticCoordinator, PaperlessStatusCoordinator, - TData, ) -from .entity import PaperlessEntity, TCoordinator +from .entity import PaperlessEntity PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class PaperlessEntityDescription(SensorEntityDescription, Generic[TData]): +class PaperlessEntityDescription[DataT](SensorEntityDescription): """Describes Paperless-ngx sensor entity.""" - value_fn: Callable[[TData], StateType] + value_fn: Callable[[DataT], StateType] -SENSOR_STATISTICS: tuple[PaperlessEntityDescription, ...] = ( +SENSOR_STATISTICS: tuple[PaperlessEntityDescription[Statistic], ...] = ( PaperlessEntityDescription[Statistic]( key="documents_total", translation_key="documents_total", @@ -78,7 +77,7 @@ SENSOR_STATISTICS: tuple[PaperlessEntityDescription, ...] = ( ), ) -SENSOR_STATUS: tuple[PaperlessEntityDescription, ...] = ( +SENSOR_STATUS: tuple[PaperlessEntityDescription[Status], ...] = ( PaperlessEntityDescription[Status]( key="storage_total", translation_key="storage_total", @@ -258,7 +257,9 @@ async def async_setup_entry( async_add_entities(entities) -class PaperlessSensor(PaperlessEntity[TCoordinator], SensorEntity): +class PaperlessSensor[CoordinatorT: PaperlessCoordinator]( + PaperlessEntity[CoordinatorT], SensorEntity +): """Defines a Paperless-ngx sensor entity.""" entity_description: PaperlessEntityDescription From b8dfb2c85001ac3f050914f5ce206a32d5995bd2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 20:25:45 +0200 Subject: [PATCH 0407/1664] Use PEP 695 TypeVar syntax for eheimdigital (#147154) --- .../components/eheimdigital/number.py | 26 +++++++++---------- .../components/eheimdigital/select.py | 24 ++++++++--------- .../components/eheimdigital/sensor.py | 22 ++++++++-------- homeassistant/components/eheimdigital/time.py | 22 +++++++--------- 4 files changed, 46 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py index 03f27aa82df..53382e3aead 100644 --- a/homeassistant/components/eheimdigital/number.py +++ b/homeassistant/components/eheimdigital/number.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Generic, TypeVar, override +from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -30,16 +30,16 @@ from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalNumberDescription[_DeviceT: EheimDigitalDevice]( + NumberEntityDescription +): """Class describing EHEIM Digital sensor entities.""" - value_fn: Callable[[_DeviceT_co], float | None] - set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]] - uom_fn: Callable[[_DeviceT_co], str] | None = None + value_fn: Callable[[_DeviceT], float | None] + set_value_fn: Callable[[_DeviceT, float], Awaitable[None]] + uom_fn: Callable[[_DeviceT], str] | None = None CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -136,7 +136,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the number entities for one or multiple devices.""" - entities: list[EheimDigitalNumber[EheimDigitalDevice]] = [] + entities: list[EheimDigitalNumber[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities.extend( @@ -163,18 +163,18 @@ async def async_setup_entry( async_setup_device_entities(coordinator.hub.devices) -class EheimDigitalNumber( - EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co] +class EheimDigitalNumber[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], NumberEntity ): """Represent a EHEIM Digital number entity.""" - entity_description: EheimDigitalNumberDescription[_DeviceT_co] + entity_description: EheimDigitalNumberDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalNumberDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalNumberDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital number entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/eheimdigital/select.py b/homeassistant/components/eheimdigital/select.py index 41ab13e3bd4..5c42055441a 100644 --- a/homeassistant/components/eheimdigital/select.py +++ b/homeassistant/components/eheimdigital/select.py @@ -2,7 +2,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass -from typing import Generic, TypeVar, override +from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -17,15 +17,15 @@ from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalSelectDescription(SelectEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalSelectDescription[_DeviceT: EheimDigitalDevice]( + SelectEntityDescription +): """Class describing EHEIM Digital select entities.""" - value_fn: Callable[[_DeviceT_co], str | None] - set_value_fn: Callable[[_DeviceT_co, str], Awaitable[None]] + value_fn: Callable[[_DeviceT], str | None] + set_value_fn: Callable[[_DeviceT, str], Awaitable[None]] CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -59,7 +59,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the number entities for one or multiple devices.""" - entities: list[EheimDigitalSelect[EheimDigitalDevice]] = [] + entities: list[EheimDigitalSelect[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities.extend( @@ -75,18 +75,18 @@ async def async_setup_entry( async_setup_device_entities(coordinator.hub.devices) -class EheimDigitalSelect( - EheimDigitalEntity[_DeviceT_co], SelectEntity, Generic[_DeviceT_co] +class EheimDigitalSelect[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], SelectEntity ): """Represent an EHEIM Digital select entity.""" - entity_description: EheimDigitalSelectDescription[_DeviceT_co] + entity_description: EheimDigitalSelectDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalSelectDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalSelectDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital select entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py index 3d809cc14dc..82038b40865 100644 --- a/homeassistant/components/eheimdigital/sensor.py +++ b/homeassistant/components/eheimdigital/sensor.py @@ -2,7 +2,7 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Generic, TypeVar, override +from typing import Any, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -20,14 +20,14 @@ from .entity import EheimDigitalEntity # Coordinator is used to centralize the data updates PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalSensorDescription[_DeviceT: EheimDigitalDevice]( + SensorEntityDescription +): """Class describing EHEIM Digital sensor entities.""" - value_fn: Callable[[_DeviceT_co], float | str | None] + value_fn: Callable[[_DeviceT], float | str | None] CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -75,7 +75,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the light entities for one or multiple devices.""" - entities: list[EheimDigitalSensor[EheimDigitalDevice]] = [] + entities: list[EheimDigitalSensor[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities += [ @@ -91,18 +91,18 @@ async def async_setup_entry( async_setup_device_entities(coordinator.hub.devices) -class EheimDigitalSensor( - EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co] +class EheimDigitalSensor[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], SensorEntity ): """Represent a EHEIM Digital sensor entity.""" - entity_description: EheimDigitalSensorDescription[_DeviceT_co] + entity_description: EheimDigitalSensorDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalSensorDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalSensorDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital number entity.""" super().__init__(coordinator, device) diff --git a/homeassistant/components/eheimdigital/time.py b/homeassistant/components/eheimdigital/time.py index 49834c827b9..f14a4150eff 100644 --- a/homeassistant/components/eheimdigital/time.py +++ b/homeassistant/components/eheimdigital/time.py @@ -3,7 +3,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import time -from typing import Generic, TypeVar, final, override +from typing import Any, final, override from eheimdigital.classic_vario import EheimDigitalClassicVario from eheimdigital.device import EheimDigitalDevice @@ -19,15 +19,13 @@ from .entity import EheimDigitalEntity, exception_handler PARALLEL_UPDATES = 0 -_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True) - @dataclass(frozen=True, kw_only=True) -class EheimDigitalTimeDescription(TimeEntityDescription, Generic[_DeviceT_co]): +class EheimDigitalTimeDescription[_DeviceT: EheimDigitalDevice](TimeEntityDescription): """Class describing EHEIM Digital time entities.""" - value_fn: Callable[[_DeviceT_co], time | None] - set_value_fn: Callable[[_DeviceT_co, time], Awaitable[None]] + value_fn: Callable[[_DeviceT], time | None] + set_value_fn: Callable[[_DeviceT, time], Awaitable[None]] CLASSICVARIO_DESCRIPTIONS: tuple[ @@ -79,7 +77,7 @@ async def async_setup_entry( device_address: dict[str, EheimDigitalDevice], ) -> None: """Set up the time entities for one or multiple devices.""" - entities: list[EheimDigitalTime[EheimDigitalDevice]] = [] + entities: list[EheimDigitalTime[Any]] = [] for device in device_address.values(): if isinstance(device, EheimDigitalClassicVario): entities.extend( @@ -103,18 +101,18 @@ async def async_setup_entry( @final -class EheimDigitalTime( - EheimDigitalEntity[_DeviceT_co], TimeEntity, Generic[_DeviceT_co] +class EheimDigitalTime[_DeviceT: EheimDigitalDevice]( + EheimDigitalEntity[_DeviceT], TimeEntity ): """Represent an EHEIM Digital time entity.""" - entity_description: EheimDigitalTimeDescription[_DeviceT_co] + entity_description: EheimDigitalTimeDescription[_DeviceT] def __init__( self, coordinator: EheimDigitalUpdateCoordinator, - device: _DeviceT_co, - description: EheimDigitalTimeDescription[_DeviceT_co], + device: _DeviceT, + description: EheimDigitalTimeDescription[_DeviceT], ) -> None: """Initialize an EHEIM Digital time entity.""" super().__init__(coordinator, device) From 73d0d87705773fabfd37edf7bea5ea12bb1e75d3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 20:26:07 +0200 Subject: [PATCH 0408/1664] Use PEP 695 TypeVar syntax for nextdns (#147155) --- homeassistant/components/nextdns/coordinator.py | 8 ++++---- homeassistant/components/nextdns/entity.py | 8 ++++++-- homeassistant/components/nextdns/sensor.py | 13 +++++++------ 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 3bc5dfe60d1..9b82e82ffe0 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import TYPE_CHECKING, TypeVar +from typing import TYPE_CHECKING from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -33,10 +33,10 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -CoordinatorDataT = TypeVar("CoordinatorDataT", bound=NextDnsData) - -class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]): +class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( + DataUpdateCoordinator[CoordinatorDataT] +): """Class to manage fetching NextDNS data API.""" config_entry: NextDnsConfigEntry diff --git a/homeassistant/components/nextdns/entity.py b/homeassistant/components/nextdns/entity.py index 26e0a5dd9ef..7e86d1d246c 100644 --- a/homeassistant/components/nextdns/entity.py +++ b/homeassistant/components/nextdns/entity.py @@ -1,14 +1,18 @@ """Define NextDNS entities.""" +from nextdns.model import NextDnsData + from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator +from .coordinator import NextDnsUpdateCoordinator -class NextDnsEntity(CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]]): +class NextDnsEntity[CoordinatorDataT: NextDnsData]( + CoordinatorEntity[NextDnsUpdateCoordinator[CoordinatorDataT]] +): """Define NextDNS entity.""" _attr_has_entity_name = True diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index b03f262cbeb..1b43f7c9c25 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from nextdns import ( AnalyticsDnssec, @@ -13,6 +12,7 @@ from nextdns import ( AnalyticsProtocols, AnalyticsStatus, ) +from nextdns.model import NextDnsData from homeassistant.components.sensor import ( SensorEntity, @@ -32,15 +32,14 @@ from .const import ( ATTR_PROTOCOLS, ATTR_STATUS, ) -from .coordinator import CoordinatorDataT from .entity import NextDnsEntity PARALLEL_UPDATES = 0 @dataclass(frozen=True, kw_only=True) -class NextDnsSensorEntityDescription( - SensorEntityDescription, Generic[CoordinatorDataT] +class NextDnsSensorEntityDescription[CoordinatorDataT: NextDnsData]( + SensorEntityDescription ): """NextDNS sensor entity description.""" @@ -297,10 +296,12 @@ async def async_setup_entry( ) -class NextDnsSensor(NextDnsEntity, SensorEntity): +class NextDnsSensor[CoordinatorDataT: NextDnsData]( + NextDnsEntity[CoordinatorDataT], SensorEntity +): """Define an NextDNS sensor.""" - entity_description: NextDnsSensorEntityDescription + entity_description: NextDnsSensorEntityDescription[CoordinatorDataT] @property def native_value(self) -> StateType: From 2c13c70e128a5efdcad2ec76970f90236fdeb8f0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 20:39:09 +0200 Subject: [PATCH 0409/1664] Update ruff to 0.12.0 (#147106) --- .pre-commit-config.yaml | 2 +- homeassistant/__main__.py | 12 +-- homeassistant/auth/mfa_modules/notify.py | 8 +- homeassistant/auth/mfa_modules/totp.py | 10 +-- homeassistant/bootstrap.py | 9 +-- homeassistant/components/airly/config_flow.py | 6 +- .../components/automation/helpers.py | 5 +- homeassistant/components/backup/__init__.py | 6 +- homeassistant/components/control4/__init__.py | 4 +- .../components/conversation/__init__.py | 2 +- .../components/downloader/services.py | 3 +- homeassistant/components/esphome/dashboard.py | 4 +- homeassistant/components/frontend/__init__.py | 3 +- .../components/google_assistant/helpers.py | 11 ++- .../components/hassio/update_helper.py | 9 +-- .../silabs_multiprotocol_addon.py | 21 ++---- .../homekit_controller/config_flow.py | 9 +-- homeassistant/components/http/__init__.py | 3 +- homeassistant/components/http/ban.py | 3 +- homeassistant/components/intent/timers.py | 5 +- homeassistant/components/mqtt/__init__.py | 6 +- homeassistant/components/mqtt/client.py | 22 ++---- homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/components/mqtt/entity.py | 3 +- homeassistant/components/mqtt/util.py | 6 +- homeassistant/components/network/__init__.py | 4 +- homeassistant/components/notify/legacy.py | 3 +- .../components/ollama/conversation.py | 5 +- homeassistant/components/onboarding/views.py | 6 +- homeassistant/components/profiler/__init__.py | 18 ++--- .../recorder/auto_repairs/schema.py | 6 +- .../components/recorder/history/__init__.py | 10 +-- homeassistant/components/recorder/pool.py | 4 +- .../components/recorder/statistics.py | 2 +- homeassistant/components/recorder/util.py | 7 +- .../components/rmvtransport/sensor.py | 3 +- homeassistant/components/script/helpers.py | 2 +- homeassistant/components/ssdp/server.py | 2 +- homeassistant/components/stream/__init__.py | 13 ++-- homeassistant/components/stream/core.py | 8 +- homeassistant/components/stream/fmp4utils.py | 4 +- .../components/system_health/__init__.py | 2 +- homeassistant/components/template/helpers.py | 3 +- homeassistant/components/template/light.py | 1 - .../components/tensorflow/image_processing.py | 9 +-- .../components/thread/diagnostics.py | 4 +- homeassistant/components/tplink/sensor.py | 3 +- homeassistant/components/tts/media_source.py | 4 +- homeassistant/components/tuya/__init__.py | 2 +- .../components/websocket_api/commands.py | 15 ++-- homeassistant/components/zha/websocket_api.py | 4 +- .../components/zwave_js/triggers/event.py | 4 +- homeassistant/config.py | 3 +- homeassistant/core.py | 25 +++---- homeassistant/core_config.py | 11 ++- homeassistant/exceptions.py | 3 +- homeassistant/helpers/area_registry.py | 9 +-- homeassistant/helpers/backup.py | 3 +- homeassistant/helpers/config_entry_flow.py | 9 +-- homeassistant/helpers/config_validation.py | 13 ++-- homeassistant/helpers/deprecation.py | 7 +- homeassistant/helpers/device_registry.py | 6 +- homeassistant/helpers/entity_registry.py | 3 +- homeassistant/helpers/json.py | 5 +- homeassistant/helpers/llm.py | 3 +- homeassistant/helpers/network.py | 6 +- homeassistant/helpers/recorder.py | 11 ++- homeassistant/helpers/service.py | 6 +- homeassistant/helpers/storage.py | 2 +- homeassistant/helpers/sun.py | 4 +- homeassistant/helpers/system_info.py | 3 +- homeassistant/helpers/template.py | 25 +++---- homeassistant/helpers/typing.py | 3 +- homeassistant/loader.py | 6 +- homeassistant/scripts/check_config.py | 3 +- homeassistant/setup.py | 3 +- homeassistant/util/async_.py | 3 +- homeassistant/util/signal_type.pyi | 5 +- pyproject.toml | 5 ++ requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- script/lint_and_test.py | 3 +- script/version_bump.py | 2 +- tests/common.py | 9 +-- tests/components/backup/conftest.py | 3 +- tests/components/conftest.py | 75 +++++++------------ .../dlna_dms/test_dms_device_source.py | 6 +- tests/components/keyboard/test_init.py | 4 +- tests/components/lirc/test_init.py | 4 +- tests/components/mqtt/test_init.py | 13 ++-- tests/components/nibe_heatpump/conftest.py | 3 +- tests/components/sms/test_init.py | 2 +- tests/conftest.py | 59 +++++---------- tests/helpers/test_frame.py | 10 ++- tests/test_loader.py | 10 +-- tests/test_main.py | 4 +- 96 files changed, 291 insertions(+), 427 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cf896f8b12c..30351a9381e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.12.0 hooks: - id: ruff-check args: diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index b9d98832705..6fd48c4809c 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -38,8 +38,7 @@ def validate_python() -> None: def ensure_config_path(config_dir: str) -> None: """Validate the configuration directory.""" - # pylint: disable-next=import-outside-toplevel - from . import config as config_util + from . import config as config_util # noqa: PLC0415 lib_dir = os.path.join(config_dir, "deps") @@ -80,8 +79,7 @@ def ensure_config_path(config_dir: str) -> None: def get_arguments() -> argparse.Namespace: """Get parsed passed in arguments.""" - # pylint: disable-next=import-outside-toplevel - from . import config as config_util + from . import config as config_util # noqa: PLC0415 parser = argparse.ArgumentParser( description="Home Assistant: Observe, Control, Automate.", @@ -177,8 +175,7 @@ def main() -> int: validate_os() if args.script is not None: - # pylint: disable-next=import-outside-toplevel - from . import scripts + from . import scripts # noqa: PLC0415 return scripts.run(args.script) @@ -188,8 +185,7 @@ def main() -> int: ensure_config_path(config_dir) - # pylint: disable-next=import-outside-toplevel - from . import config, runner + from . import config, runner # noqa: PLC0415 safe_mode = config.safe_mode_enabled(config_dir) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index b60a3012aac..978758bebb1 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -52,28 +52,28 @@ _LOGGER = logging.getLogger(__name__) def _generate_secret() -> str: """Generate a secret.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return str(pyotp.random_base32()) def _generate_random() -> int: """Generate a 32 digit number.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return int(pyotp.random_base32(length=32, chars=list("1234567890"))) def _generate_otp(secret: str, count: int) -> str: """Generate one time password.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return str(pyotp.HOTP(secret).at(count)) def _verify_otp(secret: str, otp: str, count: int) -> bool: """Verify one time password.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 return bool(pyotp.HOTP(secret).verify(otp, count)) diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 625b273f39a..b344043b832 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -37,7 +37,7 @@ DUMMY_SECRET = "FPPTH34D4E3MI2HG" def _generate_qr_code(data: str) -> str: """Generate a base64 PNG string represent QR Code image of data.""" - import pyqrcode # pylint: disable=import-outside-toplevel + import pyqrcode # noqa: PLC0415 qr_code = pyqrcode.create(data) @@ -59,7 +59,7 @@ def _generate_qr_code(data: str) -> str: def _generate_secret_and_qr_code(username: str) -> tuple[str, str, str]: """Generate a secret, url, and QR code.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 ota_secret = pyotp.random_base32() url = pyotp.totp.TOTP(ota_secret).provisioning_uri( @@ -107,7 +107,7 @@ class TotpAuthModule(MultiFactorAuthModule): def _add_ota_secret(self, user_id: str, secret: str | None = None) -> str: """Create a ota_secret for user.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 ota_secret: str = secret or pyotp.random_base32() @@ -163,7 +163,7 @@ class TotpAuthModule(MultiFactorAuthModule): def _validate_2fa(self, user_id: str, code: str) -> bool: """Validate two factor authentication code.""" - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 if (ota_secret := self._users.get(user_id)) is None: # type: ignore[union-attr] # even we cannot find user, we still do verify @@ -196,7 +196,7 @@ class TotpSetupFlow(SetupFlow[TotpAuthModule]): Return self.async_show_form(step_id='init') if user_input is None. Return self.async_create_entry(data={'result': result}) if finish. """ - import pyotp # pylint: disable=import-outside-toplevel + import pyotp # noqa: PLC0415 errors: dict[str, str] = {} diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 55aeaef2554..810c1f1e8d2 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -394,7 +394,7 @@ async def async_setup_hass( def open_hass_ui(hass: core.HomeAssistant) -> None: """Open the UI.""" - import webbrowser # pylint: disable=import-outside-toplevel + import webbrowser # noqa: PLC0415 if hass.config.api is None or "frontend" not in hass.config.components: _LOGGER.warning("Cannot launch the UI because frontend not loaded") @@ -561,8 +561,7 @@ async def async_enable_logging( if not log_no_color: try: - # pylint: disable-next=import-outside-toplevel - from colorlog import ColoredFormatter + from colorlog import ColoredFormatter # noqa: PLC0415 # basicConfig must be called after importing colorlog in order to # ensure that the handlers it sets up wraps the correct streams. @@ -606,7 +605,7 @@ async def async_enable_logging( ) threading.excepthook = lambda args: logging.getLogger().exception( "Uncaught thread exception", - exc_info=( # type: ignore[arg-type] + exc_info=( # type: ignore[arg-type] # noqa: LOG014 args.exc_type, args.exc_value, args.exc_traceback, @@ -1060,5 +1059,5 @@ async def _async_setup_multi_components( _LOGGER.error( "Error setting up integration %s - received exception", domain, - exc_info=(type(result), result, result.__traceback__), + exc_info=(type(result), result, result.__traceback__), # noqa: LOG014 ) diff --git a/homeassistant/components/airly/config_flow.py b/homeassistant/components/airly/config_flow.py index de60ef84efa..19ebb096a31 100644 --- a/homeassistant/components/airly/config_flow.py +++ b/homeassistant/components/airly/config_flow.py @@ -39,14 +39,14 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() try: - location_point_valid = await test_location( + location_point_valid = await check_location( websession, user_input["api_key"], user_input["latitude"], user_input["longitude"], ) if not location_point_valid: - location_nearest_valid = await test_location( + location_nearest_valid = await check_location( websession, user_input["api_key"], user_input["latitude"], @@ -88,7 +88,7 @@ class AirlyFlowHandler(ConfigFlow, domain=DOMAIN): ) -async def test_location( +async def check_location( client: ClientSession, api_key: str, latitude: float, diff --git a/homeassistant/components/automation/helpers.py b/homeassistant/components/automation/helpers.py index c529fbd504e..d90054252a4 100644 --- a/homeassistant/components/automation/helpers.py +++ b/homeassistant/components/automation/helpers.py @@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "automation_blueprints" def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: """Return True if any automation references the blueprint.""" - from . import automations_with_blueprint # pylint: disable=import-outside-toplevel + from . import automations_with_blueprint # noqa: PLC0415 return len(automations_with_blueprint(hass, blueprint_path)) > 0 @@ -28,8 +28,7 @@ async def _reload_blueprint_automations( @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get automation blueprints.""" - # pylint: disable-next=import-outside-toplevel - from .config import AUTOMATION_BLUEPRINT_SCHEMA + from .config import AUTOMATION_BLUEPRINT_SCHEMA # noqa: PLC0415 return blueprint.DomainBlueprints( hass, diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index daf9337a8a8..51503230530 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -94,8 +94,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not with_hassio: reader_writer = CoreBackupReaderWriter(hass) else: - # pylint: disable-next=import-outside-toplevel, hass-component-root-import - from homeassistant.components.hassio.backup import SupervisorBackupReaderWriter + # pylint: disable-next=hass-component-root-import + from homeassistant.components.hassio.backup import ( # noqa: PLC0415 + SupervisorBackupReaderWriter, + ) reader_writer = SupervisorBackupReaderWriter(hass) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index df5771fe5bb..3d84d6edd69 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -54,10 +54,10 @@ class Control4RuntimeData: type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] -async def call_c4_api_retry(func, *func_args): +async def call_c4_api_retry(func, *func_args): # noqa: RET503 """Call C4 API function and retry on failure.""" # Ruff doesn't understand this loop - the exception is always raised after the retries - for i in range(API_RETRY_TIMES): # noqa: RET503 + for i in range(API_RETRY_TIMES): try: return await func(*func_args) except client_exceptions.ClientError as exception: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index fff2c00641f..cf62704b34d 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -271,7 +271,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) # Temporary migration. We can remove this in 2024.10 - from homeassistant.components.assist_pipeline import ( # pylint: disable=import-outside-toplevel + from homeassistant.components.assist_pipeline import ( # noqa: PLC0415 async_migrate_engine, ) diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index 7f651c6b1f9..cce8c9d65b0 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -108,8 +108,7 @@ def download_file(service: ServiceCall) -> None: _LOGGER.debug("%s -> %s", url, final_path) with open(final_path, "wb") as fil: - for chunk in req.iter_content(1024): - fil.write(chunk) + fil.writelines(req.iter_content(1024)) _LOGGER.debug("Downloading of %s done", url) service.hass.bus.fire( diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 5f879edf005..a12af89aca2 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -63,9 +63,7 @@ class ESPHomeDashboardManager: if not (data := self._data) or not (info := data.get("info")): return if is_hassio(self._hass): - from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel - get_addons_info, - ) + from homeassistant.components.hassio import get_addons_info # noqa: PLC0415 if (addons := get_addons_info(self._hass)) is not None and info[ "addon_slug" diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9a0627f9f42..9694c299b23 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -364,8 +364,7 @@ def _frontend_root(dev_repo_path: str | None) -> pathlib.Path: if dev_repo_path is not None: return pathlib.Path(dev_repo_path) / "hass_frontend" # Keep import here so that we can import frontend without installing reqs - # pylint: disable-next=import-outside-toplevel - import hass_frontend + import hass_frontend # noqa: PLC0415 return hass_frontend.where() diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 4309a99c0ca..6d4c9e1d219 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -212,8 +212,7 @@ class AbstractConfig(ABC): def async_enable_report_state(self) -> None: """Enable proactive mode.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from .report_state import async_enable_report_state + from .report_state import async_enable_report_state # noqa: PLC0415 if self._unsub_report_state is None: self._unsub_report_state = async_enable_report_state(self.hass, self) @@ -395,8 +394,7 @@ class AbstractConfig(ABC): async def _handle_local_webhook(self, hass, webhook_id, request): """Handle an incoming local SDK message.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from . import smart_home + from . import smart_home # noqa: PLC0415 self._local_last_active = utcnow() @@ -655,8 +653,9 @@ class GoogleEntity: if "matter" in self.hass.config.components and any( x for x in device_entry.identifiers if x[0] == "matter" ): - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.matter import get_matter_device_info + from homeassistant.components.matter import ( # noqa: PLC0415 + get_matter_device_info, + ) # Import matter can block the event loop for multiple seconds # so we import it here to avoid blocking the event loop during diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py index 65a3ba38485..f44ee0700fc 100644 --- a/homeassistant/components/hassio/update_helper.py +++ b/homeassistant/components/hassio/update_helper.py @@ -29,8 +29,7 @@ async def update_addon( client = get_supervisor_client(hass) if backup: - # pylint: disable-next=import-outside-toplevel - from .backup import backup_addon_before_update + from .backup import backup_addon_before_update # noqa: PLC0415 await backup_addon_before_update(hass, addon, addon_name, installed_version) @@ -50,8 +49,7 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) -> client = get_supervisor_client(hass) if backup: - # pylint: disable-next=import-outside-toplevel - from .backup import backup_core_before_update + from .backup import backup_core_before_update # noqa: PLC0415 await backup_core_before_update(hass) @@ -71,8 +69,7 @@ async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> N client = get_supervisor_client(hass) if backup: - # pylint: disable-next=import-outside-toplevel - from .backup import backup_core_before_update + from .backup import backup_core_before_update # noqa: PLC0415 await backup_core_before_update(hass) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 2b08031405f..294ed83bad1 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -309,8 +309,7 @@ class OptionsFlowHandler(OptionsFlow, ABC): def __init__(self, config_entry: ConfigEntry) -> None: """Set up the options flow.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.radio_manager import ( + from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) @@ -451,16 +450,11 @@ class OptionsFlowHandler(OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.radio_manager import ( + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 + from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.silabs_multiprotocol import ( + from homeassistant.components.zha.silabs_multiprotocol import ( # noqa: PLC0415 async_get_channel as async_get_zha_channel, ) @@ -747,11 +741,8 @@ class OptionsFlowHandler(OptionsFlow, ABC): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Perform initial backup and reconfigure ZHA.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN - - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.zha.radio_manager import ( + from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN # noqa: PLC0415 + from homeassistant.components.zha.radio_manager import ( # noqa: PLC0415 ZhaMultiPANMigrationHelper, ) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 0acf57fe55b..df6d4498f9c 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -355,11 +355,10 @@ class HomekitControllerFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="ignored_model") # Late imports in case BLE is not available - # pylint: disable-next=import-outside-toplevel - from aiohomekit.controller.ble.discovery import BleDiscovery - - # pylint: disable-next=import-outside-toplevel - from aiohomekit.controller.ble.manufacturer_data import HomeKitAdvertisement + from aiohomekit.controller.ble.discovery import BleDiscovery # noqa: PLC0415 + from aiohomekit.controller.ble.manufacturer_data import ( # noqa: PLC0415 + HomeKitAdvertisement, + ) mfr_data = discovery_info.manufacturer_data diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 2c4b67e6c99..cdf3347e24f 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -278,8 +278,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_certificate is not None and (hass.config.external_url or hass.config.internal_url) is None ): - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 CloudNotAvailable, async_remote_ui_url, ) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 821d44eebaa..71f3d54bef6 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -136,8 +136,7 @@ async def process_wrong_login(request: Request) -> None: _LOGGER.warning(log_msg) # Circular import with websocket_api - # pylint: disable=import-outside-toplevel - from homeassistant.components import persistent_notification + from homeassistant.components import persistent_notification # noqa: PLC0415 persistent_notification.async_create( hass, notification_msg, "Login attempt failed", NOTIFICATION_ID_LOGIN diff --git a/homeassistant/components/intent/timers.py b/homeassistant/components/intent/timers.py index d641f8dc6b5..06be933ba6b 100644 --- a/homeassistant/components/intent/timers.py +++ b/homeassistant/components/intent/timers.py @@ -444,8 +444,9 @@ class TimerManager: timer.finish() if timer.conversation_command: - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.conversation import async_converse + from homeassistant.components.conversation import ( # noqa: PLC0415 + async_converse, + ) self.hass.async_create_background_task( async_converse( diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ae010bf18c9..9e3dc59f852 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -354,8 +354,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: def write_dump() -> None: with open(hass.config.path("mqtt_dump.txt"), "w", encoding="utf8") as fp: - for msg in messages: - fp.write(",".join(msg) + "\n") + fp.writelines([",".join(msg) + "\n" for msg in messages]) async def finish_dump(_: datetime) -> None: """Write dump to file.""" @@ -608,8 +607,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry ) -> bool: """Remove MQTT config entry from a device.""" - # pylint: disable-next=import-outside-toplevel - from . import device_automation + from . import device_automation # noqa: PLC0415 await device_automation.async_removed_from_device(hass, device_entry.id) return True diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index c2bcb306d0b..5d2b422a909 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -293,10 +293,9 @@ class MqttClientSetup: """ # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel + from paho.mqtt import client as mqtt # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from .async_client import AsyncMQTTClient + from .async_client import AsyncMQTTClient # noqa: PLC0415 config = self._config clean_session: bool | None = None @@ -524,8 +523,7 @@ class MQTT: """Start the misc periodic.""" assert self._misc_timer is None, "Misc periodic already started" _LOGGER.debug("%s: Starting client misc loop", self.config_entry.title) - # pylint: disable=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 # Inner function to avoid having to check late import # each time the function is called. @@ -665,8 +663,7 @@ class MQTT: async def async_connect(self, client_available: asyncio.Future[bool]) -> None: """Connect to the host. Does not process messages yet.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 result: int | None = None self._available_future = client_available @@ -724,8 +721,7 @@ class MQTT: async def _reconnect_loop(self) -> None: """Reconnect to the MQTT server.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 while True: if not self.connected: @@ -1228,7 +1224,7 @@ class MQTT: """Handle a callback exception.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt # noqa: PLC0415 _LOGGER.warning( "Error returned from MQTT server: %s", @@ -1273,8 +1269,7 @@ class MQTT: ) -> None: """Wait for ACK from broker or raise on error.""" if result_code != 0: - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt + import paho.mqtt.client as mqtt # noqa: PLC0415 raise HomeAssistantError( translation_domain=DOMAIN, @@ -1322,8 +1317,7 @@ class MQTT: def _matcher_for_topic(subscription: str) -> Callable[[str], bool]: - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.matcher import MQTTMatcher + from paho.mqtt.matcher import MQTTMatcher # noqa: PLC0415 matcher = MQTTMatcher() # type: ignore[no-untyped-call] matcher[subscription] = True diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b41e549093d..ca15a899c01 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -3493,7 +3493,7 @@ def try_connection( """Test if we can connect to an MQTT broker.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt # noqa: PLC0415 mqtt_client_setup = MqttClientSetup(user_input) mqtt_client_setup.setup() diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 1202f04ed42..b62d42a80d0 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -640,8 +640,7 @@ async def cleanup_device_registry( entities, triggers or tags. """ # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_trigger, tag + from . import device_trigger, tag # noqa: PLC0415 device_registry = dr.async_get(hass) entity_registry = er.async_get(hass) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index e3996c80a8a..1bf743d3da7 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -163,16 +163,14 @@ async def async_forward_entry_setup_and_setup_discovery( tasks: list[asyncio.Task] = [] if "device_automation" in new_platforms: # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_automation + from . import device_automation # noqa: PLC0415 tasks.append( create_eager_task(device_automation.async_setup_entry(hass, config_entry)) ) if "tag" in new_platforms: # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import tag + from . import tag # noqa: PLC0415 tasks.append(create_eager_task(tag.async_setup_entry(hass, config_entry))) if new_entity_platforms := (new_platforms - {"tag", "device_automation"}): diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 14c7dc55cf0..dd5344faa56 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -175,9 +175,7 @@ async def async_get_announce_addresses(hass: HomeAssistant) -> list[str]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" # Avoid circular issue: http->network->websocket_api->http - from .websocket import ( # pylint: disable=import-outside-toplevel - async_register_websocket_commands, - ) + from .websocket import async_register_websocket_commands # noqa: PLC0415 await async_get_network(hass) diff --git a/homeassistant/components/notify/legacy.py b/homeassistant/components/notify/legacy.py index 46538aad921..f5703022e12 100644 --- a/homeassistant/components/notify/legacy.py +++ b/homeassistant/components/notify/legacy.py @@ -282,8 +282,7 @@ class BaseNotificationService: for name, target in self.targets.items(): target_name = slugify(f"{self._target_service_name_prefix}_{name}") - if target_name in stale_targets: - stale_targets.remove(target_name) + stale_targets.discard(target_name) if ( target_name in self.registered_targets and target == self.registered_targets[target_name] diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index e304a39f061..1717d0b24b2 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -322,8 +322,9 @@ class OllamaConversationEntity( num_keep = 2 * max_messages + 1 drop_index = len(message_history.messages) - num_keep message_history.messages = [ - message_history.messages[0] - ] + message_history.messages[drop_index:] + message_history.messages[0], + *message_history.messages[drop_index:], + ] async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a42577b9f34..a897d04562f 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -218,8 +218,7 @@ class UserOnboardingView(_BaseOnboardingStepView): # Return authorization code for fetching tokens and connect # during onboarding. - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.auth import create_auth_code + from homeassistant.components.auth import create_auth_code # noqa: PLC0415 auth_code = create_auth_code(hass, data["client_id"], credentials) return self.json({"auth_code": auth_code}) @@ -309,8 +308,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): ) # Return authorization code so we can redirect user and log them in - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.auth import create_auth_code + from homeassistant.components.auth import create_auth_code # noqa: PLC0415 auth_code = create_auth_code( hass, data["client_id"], refresh_token.credential diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index de14dc30d54..749b73e5aee 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -166,7 +166,7 @@ async def async_setup_entry( # noqa: C901 # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 obj_type = call.data[CONF_TYPE] @@ -192,7 +192,7 @@ async def async_setup_entry( # noqa: C901 # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 for lru in objgraph.by_type(_LRU_CACHE_WRAPPER_OBJECT): lru = cast(_lru_cache_wrapper, lru) @@ -399,7 +399,7 @@ async def _async_generate_profile(hass: HomeAssistant, call: ServiceCall): # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import cProfile # pylint: disable=import-outside-toplevel + import cProfile # noqa: PLC0415 start_time = int(time.time() * 1000000) persistent_notification.async_create( @@ -436,7 +436,7 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - from guppy import hpy # pylint: disable=import-outside-toplevel + from guppy import hpy # noqa: PLC0415 start_time = int(time.time() * 1000000) persistent_notification.async_create( @@ -467,7 +467,7 @@ def _write_profile(profiler, cprofile_path, callgrind_path): # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - from pyprof2calltree import convert # pylint: disable=import-outside-toplevel + from pyprof2calltree import convert # noqa: PLC0415 profiler.create_stats() profiler.dump_stats(cprofile_path) @@ -482,14 +482,14 @@ def _log_objects(*_): # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 _LOGGER.critical("Memory Growth: %s", objgraph.growth(limit=1000)) def _get_function_absfile(func: Any) -> str | None: """Get the absolute file path of a function.""" - import inspect # pylint: disable=import-outside-toplevel + import inspect # noqa: PLC0415 abs_file: str | None = None with suppress(Exception): @@ -510,7 +510,7 @@ def _safe_repr(obj: Any) -> str: def _find_backrefs_not_to_self(_object: Any) -> list[str]: - import objgraph # pylint: disable=import-outside-toplevel + import objgraph # noqa: PLC0415 return [ _safe_repr(backref) @@ -526,7 +526,7 @@ def _log_object_sources( # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - import gc # pylint: disable=import-outside-toplevel + import gc # noqa: PLC0415 gc.collect() diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py index cf3addd4f20..e14a165f81f 100644 --- a/homeassistant/components/recorder/auto_repairs/schema.py +++ b/homeassistant/components/recorder/auto_repairs/schema.py @@ -242,7 +242,7 @@ def correct_db_schema_utf8( f"{table_name}.4-byte UTF-8" in schema_errors or f"{table_name}.utf8mb4_unicode_ci" in schema_errors ): - from ..migration import ( # pylint: disable=import-outside-toplevel + from ..migration import ( # noqa: PLC0415 _correct_table_character_set_and_collation, ) @@ -258,9 +258,7 @@ def correct_db_schema_precision( table_name = table_object.__tablename__ if f"{table_name}.double precision" in schema_errors: - from ..migration import ( # pylint: disable=import-outside-toplevel - _modify_columns, - ) + from ..migration import _modify_columns # noqa: PLC0415 precision_columns = _get_precision_column_types(table_object) # Attempt to convert timestamp columns to µs precision diff --git a/homeassistant/components/recorder/history/__init__.py b/homeassistant/components/recorder/history/__init__.py index a28027adb1a..469d6694640 100644 --- a/homeassistant/components/recorder/history/__init__.py +++ b/homeassistant/components/recorder/history/__init__.py @@ -45,7 +45,7 @@ def get_full_significant_states_with_session( ) -> dict[str, list[State]]: """Return a dict of significant states during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_full_significant_states_with_session as _legacy_get_full_significant_states_with_session, ) @@ -70,7 +70,7 @@ def get_last_state_changes( ) -> dict[str, list[State]]: """Return the last number_of_states.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_last_state_changes as _legacy_get_last_state_changes, ) @@ -94,7 +94,7 @@ def get_significant_states( ) -> dict[str, list[State | dict[str, Any]]]: """Return a dict of significant states during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_significant_states as _legacy_get_significant_states, ) @@ -130,7 +130,7 @@ def get_significant_states_with_session( ) -> dict[str, list[State | dict[str, Any]]]: """Return a dict of significant states during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 get_significant_states_with_session as _legacy_get_significant_states_with_session, ) @@ -164,7 +164,7 @@ def state_changes_during_period( ) -> dict[str, list[State]]: """Return a list of states that changed during a time period.""" if not get_instance(hass).states_meta_manager.active: - from .legacy import ( # pylint: disable=import-outside-toplevel + from .legacy import ( # noqa: PLC0415 state_changes_during_period as _legacy_state_changes_during_period, ) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 30e277d7c0a..d8d7ddb832a 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -90,7 +90,7 @@ class RecorderPool(SingletonThreadPool, NullPool): if threading.get_ident() in self.recorder_and_worker_thread_ids: super().dispose() - def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return] + def _do_get(self) -> ConnectionPoolEntry: # type: ignore[return] # noqa: RET503 if threading.get_ident() in self.recorder_and_worker_thread_ids: return super()._do_get() try: @@ -100,7 +100,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # which is allowed but discouraged since its much slower return self._do_get_db_connection_protected() # In the event loop, raise an exception - raise_for_blocking_call( # noqa: RET503 + raise_for_blocking_call( self._do_get_db_connection_protected, strict=True, advise_msg=ADVISE_MSG, diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 7f41358dddf..7326519b14e 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -2855,7 +2855,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool: # to indicate we need to run again return False - from .migration import _drop_index # pylint: disable=import-outside-toplevel + from .migration import _drop_index # noqa: PLC0415 for table in STATISTICS_TABLES: _drop_index(instance.get_session, table, f"ix_{table}_start") diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index b7b1a8e17a3..cff3e868def 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -258,7 +258,7 @@ def basic_sanity_check(cursor: SQLiteCursor) -> bool: def validate_sqlite_database(dbpath: str) -> bool: """Run a quick check on an sqlite database to see if it is corrupt.""" - import sqlite3 # pylint: disable=import-outside-toplevel + import sqlite3 # noqa: PLC0415 try: conn = sqlite3.connect(dbpath) @@ -402,9 +402,8 @@ def _datetime_or_none(value: str) -> datetime | None: def build_mysqldb_conv() -> dict: """Build a MySQLDB conv dict that uses cisco8601 to parse datetimes.""" # Late imports since we only call this if they are using mysqldb - # pylint: disable=import-outside-toplevel - from MySQLdb.constants import FIELD_TYPE - from MySQLdb.converters import conversions + from MySQLdb.constants import FIELD_TYPE # noqa: PLC0415 + from MySQLdb.converters import conversions # noqa: PLC0415 return {**conversions, FIELD_TYPE.DATETIME: _datetime_or_none} diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 92f4f5a0434..52437cc00be 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -264,8 +264,7 @@ class RMVDepartureData: for dest in self._destinations: if dest in journey["stops"]: dest_found = True - if dest in _deps_not_found: - _deps_not_found.remove(dest) + _deps_not_found.discard(dest) _nextdep["destination"] = dest if not dest_found: diff --git a/homeassistant/components/script/helpers.py b/homeassistant/components/script/helpers.py index 31aac506b35..53228517b18 100644 --- a/homeassistant/components/script/helpers.py +++ b/homeassistant/components/script/helpers.py @@ -12,7 +12,7 @@ DATA_BLUEPRINTS = "script_blueprints" def _blueprint_in_use(hass: HomeAssistant, blueprint_path: str) -> bool: """Return True if any script references the blueprint.""" - from . import scripts_with_blueprint # pylint: disable=import-outside-toplevel + from . import scripts_with_blueprint # noqa: PLC0415 return len(scripts_with_blueprint(hass, blueprint_path)) > 0 diff --git a/homeassistant/components/ssdp/server.py b/homeassistant/components/ssdp/server.py index 3a164fa374b..b6e105b9560 100644 --- a/homeassistant/components/ssdp/server.py +++ b/homeassistant/components/ssdp/server.py @@ -97,7 +97,7 @@ async def _async_find_next_available_port(source: AddressTupleVXType) -> int: test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) for port in range(UPNP_SERVER_MIN_PORT, UPNP_SERVER_MAX_PORT): - addr = (source[0],) + (port,) + source[2:] + addr = (source[0], port, *source[2:]) try: test_socket.bind(addr) except OSError: diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 8fa4c69ac5a..9426b5b04de 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -119,7 +119,7 @@ def _check_stream_client_error( Raise StreamOpenClientError if an http client error is encountered. """ - from .worker import try_open_stream # pylint: disable=import-outside-toplevel + from .worker import try_open_stream # noqa: PLC0415 pyav_options, _ = _convert_stream_options(hass, source, options or {}) try: @@ -234,7 +234,7 @@ CONFIG_SCHEMA = vol.Schema( def set_pyav_logging(enable: bool) -> None: """Turn PyAV logging on or off.""" - import av # pylint: disable=import-outside-toplevel + import av # noqa: PLC0415 av.logging.set_level(av.logging.VERBOSE if enable else av.logging.FATAL) @@ -267,8 +267,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(set_pyav_logging, debug_enabled) # Keep import here so that we can import stream integration without installing reqs - # pylint: disable-next=import-outside-toplevel - from .recorder import async_setup_recorder + from .recorder import async_setup_recorder # noqa: PLC0415 hass.data[DOMAIN] = {} hass.data[DOMAIN][ATTR_ENDPOINTS] = {} @@ -460,8 +459,7 @@ class Stream: def _run_worker(self) -> None: """Handle consuming streams and restart keepalive streams.""" # Keep import here so that we can import stream integration without installing reqs - # pylint: disable-next=import-outside-toplevel - from .worker import StreamState, stream_worker + from .worker import StreamState, stream_worker # noqa: PLC0415 stream_state = StreamState(self.hass, self.outputs, self._diagnostics) wait_timeout = 0 @@ -556,8 +554,7 @@ class Stream: """Make a .mp4 recording from a provided stream.""" # Keep import here so that we can import stream integration without installing reqs - # pylint: disable-next=import-outside-toplevel - from .recorder import RecorderOutput + from .recorder import RecorderOutput # noqa: PLC0415 # Check for file access if not self.hass.config.is_allowed_path(video_path): diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index b804055a740..44dfe2c323d 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -439,8 +439,9 @@ class KeyFrameConverter: # Keep import here so that we can import stream integration # without installing reqs - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.camera.img_util import TurboJPEGSingleton + from homeassistant.components.camera.img_util import ( # noqa: PLC0415 + TurboJPEGSingleton, + ) self._packet: Packet | None = None self._event: asyncio.Event = asyncio.Event() @@ -471,8 +472,7 @@ class KeyFrameConverter: # Keep import here so that we can import stream integration without # installing reqs - # pylint: disable-next=import-outside-toplevel - from av import CodecContext + from av import CodecContext # noqa: PLC0415 self._codec_context = cast( "VideoCodecContext", CodecContext.create(codec_context.name, "r") diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py index 5080678e3ca..3d2c40c752b 100644 --- a/homeassistant/components/stream/fmp4utils.py +++ b/homeassistant/components/stream/fmp4utils.py @@ -146,11 +146,11 @@ def get_codec_string(mp4_bytes: bytes) -> str: return ",".join(codecs) -def find_moov(mp4_io: BufferedIOBase) -> int: +def find_moov(mp4_io: BufferedIOBase) -> int: # noqa: RET503 """Find location of moov atom in a BufferedIOBase mp4.""" index = 0 # Ruff doesn't understand this loop - the exception is always raised at the end - while 1: # noqa: RET503 + while 1: mp4_io.seek(index) box_header = mp4_io.read(8) if len(box_header) != 8 or box_header[0:4] == b"\x00\x00\x00\x00": diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 37e9ee3d929..7ab6d77e137 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -231,7 +231,7 @@ async def handle_info( "Error fetching system info for %s - %s", domain, key, - exc_info=(type(exception), exception, exception.__traceback__), + exc_info=(type(exception), exception, exception.__traceback__), # noqa: LOG014 ) event_msg["success"] = False event_msg["error"] = {"type": "failed", "error": "unknown"} diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 660227f65dc..2cd587de5a1 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -54,8 +54,7 @@ async def _reload_blueprint_templates(hass: HomeAssistant, blueprint_path: str) @callback def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: """Get template blueprints.""" - # pylint: disable-next=import-outside-toplevel - from .config import TEMPLATE_BLUEPRINT_SCHEMA + from .config import TEMPLATE_BLUEPRINT_SCHEMA # noqa: PLC0415 return blueprint.DomainBlueprints( hass, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index fa393c76ab4..10870462bc9 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -536,7 +536,6 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): effect, self.entity_id, self._effect_list, - exc_info=True, ) common_params["effect"] = effect diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index 05be56d444d..696bc40fd2d 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -156,9 +156,8 @@ def setup_platform( # These imports shouldn't be moved to the top, because they depend on code from the model_dir. # (The model_dir is created during the manual setup process. See integration docs.) - # pylint: disable=import-outside-toplevel - from object_detection.builders import model_builder - from object_detection.utils import config_util, label_map_util + from object_detection.builders import model_builder # noqa: PLC0415 + from object_detection.utils import config_util, label_map_util # noqa: PLC0415 except ImportError: _LOGGER.error( "No TensorFlow Object Detection library found! Install or compile " @@ -169,7 +168,7 @@ def setup_platform( try: # Display warning that PIL will be used if no OpenCV is found. - import cv2 # noqa: F401 pylint: disable=import-outside-toplevel + import cv2 # noqa: F401, PLC0415 except ImportError: _LOGGER.warning( "No OpenCV library found. TensorFlow will process image with " @@ -354,7 +353,7 @@ class TensorFlowImageProcessor(ImageProcessingEntity): start = time.perf_counter() try: - import cv2 # pylint: disable=import-outside-toplevel + import cv2 # noqa: PLC0415 img = cv2.imdecode(np.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) inp = img[:, :, [2, 1, 0]] # BGR->RGB diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index e6149214af4..c66aec3bac9 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -117,9 +117,7 @@ def _get_neighbours(ndb: NDB) -> dict[str, Neighbour]: def _get_routes_and_neighbors(): """Get the routes and neighbours from pyroute2.""" # Import in the executor since import NDB can take a while - from pyroute2 import ( # pylint: disable=no-name-in-module, import-outside-toplevel - NDB, - ) + from pyroute2 import NDB # pylint: disable=no-name-in-module # noqa: PLC0415 with NDB() as ndb: routes, reverse_routes = _get_possible_thread_routes(ndb) diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py index cc35b1fd142..967853da629 100644 --- a/homeassistant/components/tplink/sensor.py +++ b/homeassistant/components/tplink/sensor.py @@ -317,8 +317,7 @@ class TPLinkSensorEntity(CoordinatedTPLinkFeatureEntity, SensorEntity): value = self.entity_description.convert_fn(value) if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from datetime import date, datetime + from datetime import date, datetime # noqa: PLC0415 assert isinstance(value, str | int | float | date | datetime | None) diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 91192fdca13..4ff4f93d9cd 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -40,7 +40,7 @@ def generate_media_source_id( cache: bool | None = None, ) -> str: """Generate a media source ID for text-to-speech.""" - from . import async_resolve_engine # pylint: disable=import-outside-toplevel + from . import async_resolve_engine # noqa: PLC0415 if (engine := async_resolve_engine(hass, engine)) is None: raise HomeAssistantError("Invalid TTS provider selected") @@ -193,7 +193,7 @@ class TTSMediaSource(MediaSource): @callback def _engine_item(self, engine: str, params: str | None = None) -> BrowseMediaSource: """Return provider item.""" - from . import TextToSpeechEntity # pylint: disable=import-outside-toplevel + from . import TextToSpeechEntity # noqa: PLC0415 if (engine_instance := get_engine_instance(self.hass, engine)) is None: raise BrowseError("Unknown provider") diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index 32119add5f4..106075e9314 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -94,7 +94,7 @@ class SharingMQCompat(SharingMQ): """Start the MQTT client.""" # We don't import on the top because some integrations # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + import paho.mqtt.client as mqtt # noqa: PLC0415 mqttc = mqtt.Client(client_id=mq_config.client_id) mqttc.username_pw_set(mq_config.username, mq_config.password) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 9c371a8399d..498a986e806 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -735,8 +735,7 @@ async def handle_subscribe_trigger( ) -> None: """Handle subscribe trigger command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import trigger + from homeassistant.helpers import trigger # noqa: PLC0415 trigger_config = await trigger.async_validate_trigger_config(hass, msg["trigger"]) @@ -786,8 +785,7 @@ async def handle_test_condition( ) -> None: """Handle test condition command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import condition + from homeassistant.helpers import condition # noqa: PLC0415 # Do static + dynamic validation of the condition config = await condition.async_validate_condition_config(hass, msg["condition"]) @@ -812,8 +810,10 @@ async def handle_execute_script( ) -> None: """Handle execute script command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers.script import Script, async_validate_actions_config + from homeassistant.helpers.script import ( # noqa: PLC0415 + Script, + async_validate_actions_config, + ) script_config = await async_validate_actions_config(hass, msg["sequence"]) @@ -877,8 +877,7 @@ async def handle_validate_config( ) -> None: """Handle validate config command.""" # Circular dep - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import condition, script, trigger + from homeassistant.helpers import condition, script, trigger # noqa: PLC0415 result = {} diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 07d897bcfd6..08097880591 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -772,7 +772,7 @@ async def websocket_device_cluster_commands( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster commands.""" - import voluptuous_serialize # pylint: disable=import-outside-toplevel + import voluptuous_serialize # noqa: PLC0415 zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] @@ -1080,7 +1080,7 @@ async def websocket_get_configuration( ) -> None: """Get ZHA configuration.""" config_entry: ConfigEntry = get_config_entry(hass) - import voluptuous_serialize # pylint: disable=import-outside-toplevel + import voluptuous_serialize # noqa: PLC0415 def custom_serializer(schema: Any) -> Any: """Serialize additional types for voluptuous_serialize.""" diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index b8b8662c0b5..f74357327e9 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -166,9 +166,9 @@ async def async_attach_trigger( if ( config[ATTR_PARTIAL_DICT_MATCH] and isinstance(event_data[key], dict) - and isinstance(event_data_filter[key], dict) + and isinstance(val, dict) ): - for key2, val2 in event_data_filter[key].items(): + for key2, val2 in val.items(): if key2 not in event_data[key] or event_data[key][key2] != val2: return continue diff --git a/homeassistant/config.py b/homeassistant/config.py index c3f02539f7d..ca1c87e4a11 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -1321,8 +1321,7 @@ async def async_check_ha_config_file(hass: HomeAssistant) -> str | None: This method is a coroutine. """ - # pylint: disable-next=import-outside-toplevel - from .helpers import check_config + from .helpers import check_config # noqa: PLC0415 res = await check_config.async_check_ha_config_file(hass) diff --git a/homeassistant/core.py b/homeassistant/core.py index afffb883741..c5d4ca79371 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -179,8 +179,7 @@ class EventStateReportedData(EventStateEventData): def _deprecated_core_config() -> Any: - # pylint: disable-next=import-outside-toplevel - from . import core_config + from . import core_config # noqa: PLC0415 return core_config.Config @@ -428,8 +427,7 @@ class HomeAssistant: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" - # pylint: disable-next=import-outside-toplevel - from .core_config import Config + from .core_config import Config # noqa: PLC0415 # This is a dictionary that any component can store any data on. self.data = HassDict() @@ -458,7 +456,7 @@ class HomeAssistant: """Report and raise if we are not running in the event loop thread.""" if self.loop_thread_id != threading.get_ident(): # frame is a circular import, so we import it here - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_non_thread_safe_operation(what) @@ -522,8 +520,7 @@ class HomeAssistant: await self.async_start() if attach_signals: - # pylint: disable-next=import-outside-toplevel - from .helpers.signal import async_register_signal_handling + from .helpers.signal import async_register_signal_handling # noqa: PLC0415 async_register_signal_handling(self) @@ -643,7 +640,7 @@ class HomeAssistant: args: parameters for method to call. """ # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_add_job`, which should be reviewed against " @@ -699,7 +696,7 @@ class HomeAssistant: args: parameters for method to call. """ # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_add_hass_job`, which should be reviewed against " @@ -802,7 +799,7 @@ class HomeAssistant: target: target to call. """ if self.loop_thread_id != threading.get_ident(): - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_non_thread_safe_operation("hass.async_create_task") return self.async_create_task_internal(target, name, eager_start) @@ -973,7 +970,7 @@ class HomeAssistant: args: parameters for method to call. """ # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_run_job`, which should be reviewed against " @@ -1517,7 +1514,7 @@ class EventBus: """ _verify_event_type_length_or_raise(event_type) if self._hass.loop_thread_id != threading.get_ident(): - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_non_thread_safe_operation("hass.bus.async_fire") return self.async_fire_internal( @@ -1622,7 +1619,7 @@ class EventBus: """ if run_immediately in (True, False): # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_listen` with run_immediately", @@ -1692,7 +1689,7 @@ class EventBus: """ if run_immediately in (True, False): # late import to avoid circular imports - from .helpers import frame # pylint: disable=import-outside-toplevel + from .helpers import frame # noqa: PLC0415 frame.report_usage( "calls `async_listen_once` with run_immediately", diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py index f1ba96daae4..5ccd8a49f32 100644 --- a/homeassistant/core_config.py +++ b/homeassistant/core_config.py @@ -538,8 +538,7 @@ class Config: def __init__(self, hass: HomeAssistant, config_dir: str) -> None: """Initialize a new config object.""" - # pylint: disable-next=import-outside-toplevel - from .components.zone import DEFAULT_RADIUS + from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 self.hass = hass @@ -845,8 +844,7 @@ class Config: ) -> dict[str, Any]: """Migrate to the new version.""" - # pylint: disable-next=import-outside-toplevel - from .components.zone import DEFAULT_RADIUS + from .components.zone import DEFAULT_RADIUS # noqa: PLC0415 data = old_data if old_major_version == 1 and old_minor_version < 2: @@ -863,8 +861,9 @@ class Config: try: owner = await self.hass.auth.async_get_owner() if owner is not None: - # pylint: disable-next=import-outside-toplevel - from .components.frontend import storage as frontend_store + from .components.frontend import ( # noqa: PLC0415 + storage as frontend_store, + ) owner_store = await frontend_store.async_user_store( self.hass, owner.id diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 0b2d2c071c5..23416480dd7 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -23,8 +23,7 @@ def import_async_get_exception_message() -> Callable[ Defaults to English, requires translations to already be cached. """ - # pylint: disable-next=import-outside-toplevel - from .helpers.translation import ( + from .helpers.translation import ( # noqa: PLC0415 async_get_exception_message as async_get_exception_message_import, ) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index ba02ed51f6b..cfc250754ec 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -475,8 +475,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback def _async_setup_cleanup(self) -> None: """Set up the area registry cleanup.""" - # pylint: disable-next=import-outside-toplevel - from . import ( # Circular dependencies + from . import ( # Circular dependencies # noqa: PLC0415 floor_registry as fr, label_registry as lr, ) @@ -543,8 +542,7 @@ def async_entries_for_label(registry: AreaRegistry, label_id: str) -> list[AreaE def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: """Validate temperature entity.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.sensor import SensorDeviceClass + from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415 if not (state := hass.states.get(entity_id)): raise ValueError(f"Entity {entity_id} does not exist") @@ -558,8 +556,7 @@ def _validate_temperature_entity(hass: HomeAssistant, entity_id: str) -> None: def _validate_humidity_entity(hass: HomeAssistant, entity_id: str) -> None: """Validate humidity entity.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.sensor import SensorDeviceClass + from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415 if not (state := hass.states.get(entity_id)): raise ValueError(f"Entity {entity_id} does not exist") diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py index b3607f6653c..e445bef4aae 100644 --- a/homeassistant/helpers/backup.py +++ b/homeassistant/helpers/backup.py @@ -43,8 +43,7 @@ def async_initialize_backup(hass: HomeAssistant) -> None: registers the basic backup websocket API which is used by frontend to subscribe to backup events. """ - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.backup import basic_websocket + from homeassistant.components.backup import basic_websocket # noqa: PLC0415 hass.data[DATA_BACKUP] = BackupData() basic_websocket.async_register_websocket_handlers(hass) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 45e2e7cf35f..761a9c5714e 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -222,16 +222,14 @@ class WebhookFlowHandler(config_entries.ConfigFlow): return self.async_show_form(step_id="user") # Local import to be sure cloud is loaded and setup - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 async_active_subscription, async_create_cloudhook, async_is_connected, ) # Local import to be sure webhook is loaded and setup - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.webhook import ( + from homeassistant.components.webhook import ( # noqa: PLC0415 async_generate_id, async_generate_url, ) @@ -281,7 +279,6 @@ async def webhook_async_remove_entry( return # Local import to be sure cloud is loaded and setup - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import async_delete_cloudhook + from homeassistant.components.cloud import async_delete_cloudhook # noqa: PLC0415 await async_delete_cloudhook(hass, entry.data["webhook_id"]) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 31a3e365071..5445cb51ac9 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -721,8 +721,7 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") if not (hass := _async_get_hass_or_none()): - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 report_usage( ( @@ -750,8 +749,7 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") if not (hass := _async_get_hass_or_none()): - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 report_usage( ( @@ -1151,9 +1149,9 @@ def custom_serializer(schema: Any) -> Any: def _custom_serializer(schema: Any, *, allow_section: bool) -> Any: """Serialize additional types for voluptuous_serialize.""" - from homeassistant import data_entry_flow # pylint: disable=import-outside-toplevel + from homeassistant import data_entry_flow # noqa: PLC0415 - from . import selector # pylint: disable=import-outside-toplevel + from . import selector # noqa: PLC0415 if schema is positive_time_period_dict: return {"type": "positive_time_period_dict"} @@ -1216,8 +1214,7 @@ def _no_yaml_config_schema( """Return a config schema which logs if attempted to setup from YAML.""" def raise_issue() -> None: - # pylint: disable-next=import-outside-toplevel - from .issue_registry import IssueSeverity, async_create_issue + from .issue_registry import IssueSeverity, async_create_issue # noqa: PLC0415 # HomeAssistantError is raised if called from the wrong thread with contextlib.suppress(HomeAssistantError): diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 101b9731caf..20b5b7ebab9 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -190,11 +190,10 @@ def _print_deprecation_warning_internal_impl( *, log_when_no_integration_is_found: bool, ) -> None: - # pylint: disable=import-outside-toplevel - from homeassistant.core import async_get_hass_or_none - from homeassistant.loader import async_suggest_report_issue + from homeassistant.core import async_get_hass_or_none # noqa: PLC0415 + from homeassistant.loader import async_suggest_report_issue # noqa: PLC0415 - from .frame import MissingIntegrationFrame, get_integration_frame + from .frame import MissingIntegrationFrame, get_integration_frame # noqa: PLC0415 logger = logging.getLogger(module_name) if breaks_in_ha_version: diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 4f36ff8ec94..a6313381492 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1018,8 +1018,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): and old.area_id is None ): # Circular dep - # pylint: disable-next=import-outside-toplevel - from . import area_registry as ar + from . import area_registry as ar # noqa: PLC0415 area = ar.async_get(self.hass).async_get_or_create(suggested_area) area_id = area.id @@ -1622,8 +1621,7 @@ def async_cleanup( @callback def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: """Clean up device registry when entities removed.""" - # pylint: disable-next=import-outside-toplevel - from . import entity_registry, label_registry as lr + from . import entity_registry, label_registry as lr # noqa: PLC0415 @callback def _label_removed_from_registry_filter( diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0cb668a5ffd..0b61c3e8f16 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1745,8 +1745,7 @@ def async_config_entry_disabled_by_changed( @callback def _async_setup_cleanup(hass: HomeAssistant, registry: EntityRegistry) -> None: """Clean up device registry when entities removed.""" - # pylint: disable-next=import-outside-toplevel - from . import category_registry as cr, event, label_registry as lr + from . import category_registry as cr, event, label_registry as lr # noqa: PLC0415 @callback def _removed_from_registry_filter( diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index a97dd48bf61..176bcfcd7c4 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -235,10 +235,7 @@ def find_paths_unserializable_data( This method is slow! Only use for error handling. """ - from homeassistant.core import ( # pylint: disable=import-outside-toplevel - Event, - State, - ) + from homeassistant.core import Event, State # noqa: PLC0415 to_process = deque([(bad_data, "$")]) invalid = {} diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 1e4abb07ddb..5d9e4c3bdef 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -216,8 +216,7 @@ class APIInstance: async def async_call_tool(self, tool_input: ToolInput) -> JsonObjectType: """Call a LLM tool, validate args and return the response.""" - # pylint: disable=import-outside-toplevel - from homeassistant.components.conversation import ( + from homeassistant.components.conversation import ( # noqa: PLC0415 ConversationTraceEventType, async_conversation_trace_append, ) diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py index 67c4448724e..6f4aadaf786 100644 --- a/homeassistant/helpers/network.py +++ b/homeassistant/helpers/network.py @@ -186,8 +186,7 @@ def get_url( known_hostnames = ["localhost"] if is_hassio(hass): # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.hassio import get_host_info + from homeassistant.components.hassio import get_host_info # noqa: PLC0415 if host_info := get_host_info(hass): known_hostnames.extend( @@ -318,8 +317,7 @@ def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) - """Get external Home Assistant Cloud URL of this instance.""" if "cloud" in hass.config.components: # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 CloudNotAvailable, async_remote_ui_url, ) diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py index 7ad319419c1..1698646d6b5 100644 --- a/homeassistant/helpers/recorder.py +++ b/homeassistant/helpers/recorder.py @@ -35,8 +35,7 @@ class RecorderData: @callback def async_migration_in_progress(hass: HomeAssistant) -> bool: """Check to see if a recorder migration is in progress.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 return recorder.util.async_migration_in_progress(hass) @@ -44,8 +43,7 @@ def async_migration_in_progress(hass: HomeAssistant) -> bool: @callback def async_migration_is_live(hass: HomeAssistant) -> bool: """Check to see if a recorder migration is live.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 return recorder.util.async_migration_is_live(hass) @@ -58,8 +56,9 @@ def async_initialize_recorder(hass: HomeAssistant) -> None: registers the basic recorder websocket API which is used by frontend to determine if the recorder is migrating the database. """ - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder.basic_websocket_api import async_setup + from homeassistant.components.recorder.basic_websocket_api import ( # noqa: PLC0415 + async_setup, + ) hass.data[DATA_RECORDER] = RecorderData() async_setup(hass) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 4a10dfc5616..51d9c97ceeb 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -85,8 +85,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE: HassKey[ @cache def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import ( + from homeassistant.components import ( # noqa: PLC0415 alarm_control_panel, assist_satellite, calendar, @@ -1296,8 +1295,7 @@ def async_register_entity_service( if schema is None or isinstance(schema, dict): schema = cv.make_entity_service_schema(schema) elif not cv.is_entity_service_schema(schema): - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 report_usage( "registers an entity service with a non entity service schema", diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index fe94be68763..2dd9decb582 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -354,7 +354,7 @@ class Store[_T: Mapping[str, Any] | Sequence[Any]]: corrupt_path, err, ) - from .issue_registry import ( # pylint: disable=import-outside-toplevel + from .issue_registry import ( # noqa: PLC0415 IssueSeverity, async_create_issue, ) diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py index 8f5e2418b14..1c35f45d713 100644 --- a/homeassistant/helpers/sun.py +++ b/homeassistant/helpers/sun.py @@ -31,8 +31,8 @@ def get_astral_location( hass: HomeAssistant, ) -> tuple[astral.location.Location, astral.Elevation]: """Get an astral location for the current Home Assistant configuration.""" - from astral import LocationInfo # pylint: disable=import-outside-toplevel - from astral.location import Location # pylint: disable=import-outside-toplevel + from astral import LocationInfo # noqa: PLC0415 + from astral.location import Location # noqa: PLC0415 latitude = hass.config.latitude longitude = hass.config.longitude diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index df9679dcb08..30b7616319d 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -42,8 +42,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: # may not be loaded yet and we don't want to # do blocking I/O in the event loop to import it. if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import hassio + from homeassistant.components import hassio # noqa: PLC0415 else: hassio = await async_import_module(hass, "homeassistant.components.hassio") diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9079d6af300..acf78f70380 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -210,9 +210,7 @@ def async_setup(hass: HomeAssistant) -> bool: if new_size > current_size: lru.set_size(new_size) - from .event import ( # pylint: disable=import-outside-toplevel - async_track_time_interval, - ) + from .event import async_track_time_interval # noqa: PLC0415 cancel = async_track_time_interval( hass, _async_adjust_lru_sizes, timedelta(minutes=10) @@ -527,8 +525,7 @@ class Template: Note: A valid hass instance should always be passed in. The hass parameter will be non optional in Home Assistant Core 2025.10. """ - # pylint: disable-next=import-outside-toplevel - from .frame import ReportBehavior, report_usage + from .frame import ReportBehavior, report_usage # noqa: PLC0415 if not isinstance(template, str): raise TypeError("Expected template to be a string") @@ -1141,8 +1138,7 @@ class TemplateStateBase(State): def format_state(self, rounded: bool, with_unit: bool) -> str: """Return a formatted version of the state.""" # Import here, not at top-level, to avoid circular import - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.sensor import ( + from homeassistant.components.sensor import ( # noqa: PLC0415 DOMAIN as SENSOR_DOMAIN, async_rounded_state, ) @@ -1278,7 +1274,7 @@ def forgiving_boolean[_T]( """Try to convert value to a boolean.""" try: # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 return cv.boolean(value) except vol.Invalid: @@ -1303,7 +1299,7 @@ def result_as_boolean(template_result: Any | None) -> bool: def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: """Expand out any groups and zones into entity states.""" # circular import. - from . import entity as entity_helper # pylint: disable=import-outside-toplevel + from . import entity as entity_helper # noqa: PLC0415 search = list(args) found = {} @@ -1376,8 +1372,7 @@ def integration_entities(hass: HomeAssistant, entry_name: str) -> Iterable[str]: return entities # fallback to just returning all entities for a domain - # pylint: disable-next=import-outside-toplevel - from .entity import entity_sources + from .entity import entity_sources # noqa: PLC0415 return [ entity_id @@ -1421,7 +1416,7 @@ def device_name(hass: HomeAssistant, lookup_value: str) -> str | None: ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1579,7 +1574,7 @@ def area_id(hass: HomeAssistant, lookup_value: str) -> str | None: ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1617,7 +1612,7 @@ def area_name(hass: HomeAssistant, lookup_value: str) -> str | None: dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 try: cv.entity_id(lookup_value) @@ -1698,7 +1693,7 @@ def labels(hass: HomeAssistant, lookup_value: Any = None) -> Iterable[str | None ent_reg = entity_registry.async_get(hass) # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel + from . import config_validation as cv # noqa: PLC0415 lookup_value = str(lookup_value) diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py index 65774a0b168..dde456bf7bc 100644 --- a/homeassistant/helpers/typing.py +++ b/homeassistant/helpers/typing.py @@ -41,8 +41,7 @@ def _deprecated_typing_helper(attr: str) -> DeferredDeprecatedAlias: """Help to make a DeferredDeprecatedAlias.""" def value_fn() -> Any: - # pylint: disable-next=import-outside-toplevel - import homeassistant.core + import homeassistant.core # noqa: PLC0415 return getattr(homeassistant.core, attr) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 0980a6f2ba9..6a3061b0d2a 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -291,7 +291,7 @@ def _get_custom_components(hass: HomeAssistant) -> dict[str, Integration]: return {} try: - import custom_components # pylint: disable=import-outside-toplevel + import custom_components # noqa: PLC0415 except ImportError: return {} @@ -1392,7 +1392,7 @@ async def async_get_integrations( # Now the rest use resolve_from_root if needed: - from . import components # pylint: disable=import-outside-toplevel + from . import components # noqa: PLC0415 integrations = await hass.async_add_executor_job( _resolve_integrations_from_root, hass, components, needed @@ -1728,7 +1728,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: sys.path.insert(0, hass.config.config_dir) with suppress(ImportError): - import custom_components # pylint: disable=import-outside-toplevel # noqa: F401 + import custom_components # noqa: F401, PLC0415 sys.path.remove(hass.config.config_dir) sys.path_importer_cache.pop(hass.config.config_dir, None) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 981f0a26926..213a45a48e9 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -47,8 +47,7 @@ WARNING_STR = "General Warnings" def color(the_color, *args, reset=None): """Color helper.""" - # pylint: disable-next=import-outside-toplevel - from colorlog.escape_codes import escape_codes, parse_colors + from colorlog.escape_codes import escape_codes, parse_colors # noqa: PLC0415 try: if not args: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 39f0a7656f3..a631eb07ca2 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -101,8 +101,7 @@ def async_notify_setup_error( This method must be run in the event loop. """ - # pylint: disable-next=import-outside-toplevel - from .components import persistent_notification + from .components import persistent_notification # noqa: PLC0415 if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None: errors = hass.data[_DATA_PERSISTENT_ERRORS] = {} diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index f8901d11114..593a169f75e 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -36,8 +36,7 @@ def create_eager_task[_T]( # If there is no running loop, create_eager_task is being called from # the wrong thread. # Late import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from homeassistant.helpers import frame + from homeassistant.helpers import frame # noqa: PLC0415 frame.report_usage("attempted to create an asyncio task from a thread") raise diff --git a/homeassistant/util/signal_type.pyi b/homeassistant/util/signal_type.pyi index 9987c3a0931..933467c351a 100644 --- a/homeassistant/util/signal_type.pyi +++ b/homeassistant/util/signal_type.pyi @@ -31,9 +31,8 @@ def _test_signal_type_typing() -> None: # noqa: PYI048 This is tested during the mypy run. Do not move it to 'tests'! """ - # pylint: disable=import-outside-toplevel - from homeassistant.core import HomeAssistant - from homeassistant.helpers.dispatcher import ( + from homeassistant.core import HomeAssistant # noqa: PLC0415 + from homeassistant.helpers.dispatcher import ( # noqa: PLC0415 async_dispatcher_connect, async_dispatcher_send, ) diff --git a/pyproject.toml b/pyproject.toml index 83782631191..0213dbf27ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -287,6 +287,7 @@ disable = [ # "global-statement", # PLW0603, ruff catches new occurrences, needs more work "global-variable-not-assigned", # PLW0602 "implicit-str-concat", # ISC001 + "import-outside-toplevel", # PLC0415 "import-self", # PLW0406 "inconsistent-quotes", # Q000 "invalid-envvar-default", # PLW1508 @@ -812,6 +813,7 @@ ignore = [ "PLR0913", # Too many arguments to function call ({c_args} > {max_args}) "PLR0915", # Too many statements ({statements} > {max_statements}) "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLW1641", # __eq__ without __hash__ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception "PT018", # Assertion should be broken down into multiple parts @@ -835,6 +837,9 @@ ignore = [ "TRY400", # Use `logging.exception` instead of `logging.error` # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "UP046", # Non PEP 695 generic class + "UP047", # Non PEP 696 generic function + "UP049", # Avoid private type parameter names # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules "W191", diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index ba05be7043b..1abbf3977cf 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.11.12 +ruff==0.12.0 yamllint==1.37.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 95966ddbdab..72bd1ab3e7d 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -27,7 +27,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ stdlib-list==0.10.0 \ pipdeptree==2.26.1 \ tqdm==4.67.1 \ - ruff==0.11.12 \ + ruff==0.12.0 \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ diff --git a/script/lint_and_test.py b/script/lint_and_test.py index fb350c113b9..44d9e5d8eb7 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -42,8 +42,7 @@ def printc(the_color, *args): def validate_requirements_ok(): """Validate requirements, returns True of ok.""" - # pylint: disable-next=import-outside-toplevel - from gen_requirements_all import main as req_main + from gen_requirements_all import main as req_main # noqa: PLC0415 return req_main(True) == 0 diff --git a/script/version_bump.py b/script/version_bump.py index ff94c01a5a2..2a7d82937f1 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -198,7 +198,7 @@ def main() -> None: def test_bump_version() -> None: """Make sure it all works.""" - import pytest + import pytest # noqa: PLC0415 assert bump_version(Version("0.56.0"), "beta") == Version("0.56.1b0") assert bump_version(Version("0.56.0b3"), "beta") == Version("0.56.0b4") diff --git a/tests/common.py b/tests/common.py index 66129ecc9c3..322a47c8a09 100644 --- a/tests/common.py +++ b/tests/common.py @@ -452,11 +452,9 @@ def async_fire_mqtt_message( # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.client import MQTTMessage + from paho.mqtt.client import MQTTMessage # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.mqtt import MqttData + from homeassistant.components.mqtt import MqttData # noqa: PLC0415 if isinstance(payload, str): payload = payload.encode("utf-8") @@ -1736,8 +1734,7 @@ def async_get_persistent_notifications( def async_mock_cloud_connection_status(hass: HomeAssistant, connected: bool) -> None: """Mock a signal the cloud disconnected.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.cloud import ( + from homeassistant.components.cloud import ( # noqa: PLC0415 SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState, ) diff --git a/tests/components/backup/conftest.py b/tests/components/backup/conftest.py index 8fffdba7cc2..b2dac6a6f8f 100644 --- a/tests/components/backup/conftest.py +++ b/tests/components/backup/conftest.py @@ -166,8 +166,7 @@ def mock_backup_generation_fixture( @pytest.fixture def mock_backups() -> Generator[None]: """Fixture to setup test backups.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.backup import backup as core_backup + from homeassistant.components.backup import backup as core_backup # noqa: PLC0415 class CoreLocalBackupAgent(core_backup.CoreLocalBackupAgent): def __init__(self, hass: HomeAssistant) -> None: diff --git a/tests/components/conftest.py b/tests/components/conftest.py index e0db306cae9..48198757c25 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -98,8 +98,9 @@ def entity_registry_enabled_by_default() -> Generator[None]: @pytest.fixture(name="stub_blueprint_populate") def stub_blueprint_populate_fixture() -> Generator[None]: """Stub copying the blueprints to the config folder.""" - # pylint: disable-next=import-outside-toplevel - from .blueprint.common import stub_blueprint_populate_fixture_helper + from .blueprint.common import ( # noqa: PLC0415 + stub_blueprint_populate_fixture_helper, + ) yield from stub_blueprint_populate_fixture_helper() @@ -108,8 +109,7 @@ def stub_blueprint_populate_fixture() -> Generator[None]: @pytest.fixture(name="mock_tts_get_cache_files") def mock_tts_get_cache_files_fixture() -> Generator[MagicMock]: """Mock the list TTS cache function.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import mock_tts_get_cache_files_fixture_helper + from .tts.common import mock_tts_get_cache_files_fixture_helper # noqa: PLC0415 yield from mock_tts_get_cache_files_fixture_helper() @@ -119,8 +119,7 @@ def mock_tts_init_cache_dir_fixture( init_tts_cache_dir_side_effect: Any, ) -> Generator[MagicMock]: """Mock the TTS cache dir in memory.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import mock_tts_init_cache_dir_fixture_helper + from .tts.common import mock_tts_init_cache_dir_fixture_helper # noqa: PLC0415 yield from mock_tts_init_cache_dir_fixture_helper(init_tts_cache_dir_side_effect) @@ -128,8 +127,9 @@ def mock_tts_init_cache_dir_fixture( @pytest.fixture(name="init_tts_cache_dir_side_effect") def init_tts_cache_dir_side_effect_fixture() -> Any: """Return the cache dir.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import init_tts_cache_dir_side_effect_fixture_helper + from .tts.common import ( # noqa: PLC0415 + init_tts_cache_dir_side_effect_fixture_helper, + ) return init_tts_cache_dir_side_effect_fixture_helper() @@ -142,8 +142,7 @@ def mock_tts_cache_dir_fixture( request: pytest.FixtureRequest, ) -> Generator[Path]: """Mock the TTS cache dir with empty dir.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import mock_tts_cache_dir_fixture_helper + from .tts.common import mock_tts_cache_dir_fixture_helper # noqa: PLC0415 yield from mock_tts_cache_dir_fixture_helper( tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request @@ -153,8 +152,7 @@ def mock_tts_cache_dir_fixture( @pytest.fixture(name="tts_mutagen_mock") def tts_mutagen_mock_fixture() -> Generator[MagicMock]: """Mock writing tags.""" - # pylint: disable-next=import-outside-toplevel - from .tts.common import tts_mutagen_mock_fixture_helper + from .tts.common import tts_mutagen_mock_fixture_helper # noqa: PLC0415 yield from tts_mutagen_mock_fixture_helper() @@ -162,8 +160,9 @@ def tts_mutagen_mock_fixture() -> Generator[MagicMock]: @pytest.fixture(name="mock_conversation_agent") def mock_conversation_agent_fixture(hass: HomeAssistant) -> MockAgent: """Mock a conversation agent.""" - # pylint: disable-next=import-outside-toplevel - from .conversation.common import mock_conversation_agent_fixture_helper + from .conversation.common import ( # noqa: PLC0415 + mock_conversation_agent_fixture_helper, + ) return mock_conversation_agent_fixture_helper(hass) @@ -180,8 +179,7 @@ def prevent_ffmpeg_subprocess() -> Generator[None]: @pytest.fixture def mock_light_entities() -> list[MockLight]: """Return mocked light entities.""" - # pylint: disable-next=import-outside-toplevel - from .light.common import MockLight + from .light.common import MockLight # noqa: PLC0415 return [ MockLight("Ceiling", STATE_ON), @@ -193,8 +191,7 @@ def mock_light_entities() -> list[MockLight]: @pytest.fixture def mock_sensor_entities() -> dict[str, MockSensor]: """Return mocked sensor entities.""" - # pylint: disable-next=import-outside-toplevel - from .sensor.common import get_mock_sensor_entities + from .sensor.common import get_mock_sensor_entities # noqa: PLC0415 return get_mock_sensor_entities() @@ -202,8 +199,7 @@ def mock_sensor_entities() -> dict[str, MockSensor]: @pytest.fixture def mock_switch_entities() -> list[MockSwitch]: """Return mocked toggle entities.""" - # pylint: disable-next=import-outside-toplevel - from .switch.common import get_mock_switch_entities + from .switch.common import get_mock_switch_entities # noqa: PLC0415 return get_mock_switch_entities() @@ -211,8 +207,7 @@ def mock_switch_entities() -> list[MockSwitch]: @pytest.fixture def mock_legacy_device_scanner() -> MockScanner: """Return mocked legacy device scanner entity.""" - # pylint: disable-next=import-outside-toplevel - from .device_tracker.common import MockScanner + from .device_tracker.common import MockScanner # noqa: PLC0415 return MockScanner() @@ -220,8 +215,7 @@ def mock_legacy_device_scanner() -> MockScanner: @pytest.fixture def mock_legacy_device_tracker_setup() -> Callable[[HomeAssistant, MockScanner], None]: """Return setup callable for legacy device tracker setup.""" - # pylint: disable-next=import-outside-toplevel - from .device_tracker.common import mock_legacy_device_tracker_setup + from .device_tracker.common import mock_legacy_device_tracker_setup # noqa: PLC0415 return mock_legacy_device_tracker_setup @@ -231,8 +225,7 @@ def addon_manager_fixture( hass: HomeAssistant, supervisor_client: AsyncMock ) -> AddonManager: """Return an AddonManager instance.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_manager + from .hassio.common import mock_addon_manager # noqa: PLC0415 return mock_addon_manager(hass) @@ -288,8 +281,7 @@ def addon_store_info_fixture( addon_store_info_side_effect: Any | None, ) -> AsyncMock: """Mock Supervisor add-on store info.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_store_info + from .hassio.common import mock_addon_store_info # noqa: PLC0415 return mock_addon_store_info(supervisor_client, addon_store_info_side_effect) @@ -305,8 +297,7 @@ def addon_info_fixture( supervisor_client: AsyncMock, addon_info_side_effect: Any | None ) -> AsyncMock: """Mock Supervisor add-on info.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_info + from .hassio.common import mock_addon_info # noqa: PLC0415 return mock_addon_info(supervisor_client, addon_info_side_effect) @@ -316,8 +307,7 @@ def addon_not_installed_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> AsyncMock: """Mock add-on not installed.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_not_installed + from .hassio.common import mock_addon_not_installed # noqa: PLC0415 return mock_addon_not_installed(addon_store_info, addon_info) @@ -327,8 +317,7 @@ def addon_installed_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> AsyncMock: """Mock add-on already installed but not running.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_installed + from .hassio.common import mock_addon_installed # noqa: PLC0415 return mock_addon_installed(addon_store_info, addon_info) @@ -338,8 +327,7 @@ def addon_running_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> AsyncMock: """Mock add-on already running.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_running + from .hassio.common import mock_addon_running # noqa: PLC0415 return mock_addon_running(addon_store_info, addon_info) @@ -350,8 +338,7 @@ def install_addon_side_effect_fixture( ) -> Any | None: """Return the install add-on side effect.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_install_addon_side_effect + from .hassio.common import mock_install_addon_side_effect # noqa: PLC0415 return mock_install_addon_side_effect(addon_store_info, addon_info) @@ -371,8 +358,7 @@ def start_addon_side_effect_fixture( addon_store_info: AsyncMock, addon_info: AsyncMock ) -> Any | None: """Return the start add-on options side effect.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_start_addon_side_effect + from .hassio.common import mock_start_addon_side_effect # noqa: PLC0415 return mock_start_addon_side_effect(addon_store_info, addon_info) @@ -419,8 +405,7 @@ def set_addon_options_side_effect_fixture( addon_options: dict[str, Any], ) -> Any | None: """Return the set add-on options side effect.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_set_addon_options_side_effect + from .hassio.common import mock_set_addon_options_side_effect # noqa: PLC0415 return mock_set_addon_options_side_effect(addon_options) @@ -446,8 +431,7 @@ def uninstall_addon_fixture(supervisor_client: AsyncMock) -> AsyncMock: @pytest.fixture(name="create_backup") def create_backup_fixture() -> Generator[AsyncMock]: """Mock create backup.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_create_backup + from .hassio.common import mock_create_backup # noqa: PLC0415 yield from mock_create_backup() @@ -486,8 +470,7 @@ def store_info_fixture( @pytest.fixture(name="addon_stats") def addon_stats_fixture(supervisor_client: AsyncMock) -> AsyncMock: """Mock addon stats info.""" - # pylint: disable-next=import-outside-toplevel - from .hassio.common import mock_addon_stats + from .hassio.common import mock_addon_stats # noqa: PLC0415 return mock_addon_stats(supervisor_client) diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 5576066f781..e9a03f9fb31 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -275,7 +275,7 @@ async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> requested_count=1, ) for parent_id, title in zip( - ["0"] + object_ids[:-1], path.split("/"), strict=False + ["0", *object_ids[:-1]], path.split("/"), strict=False ) ] assert result.url == res_abs_url @@ -293,7 +293,7 @@ async def test_resolve_media_path(hass: HomeAssistant, dms_device_mock: Mock) -> requested_count=1, ) for parent_id, title in zip( - ["0"] + object_ids[:-1], path.split("/"), strict=False + ["0", *object_ids[:-1]], path.split("/"), strict=False ) ] assert result.url == res_abs_url @@ -351,7 +351,7 @@ async def test_resolve_path_browsed(hass: HomeAssistant, dms_device_mock: Mock) requested_count=1, ) for parent_id, title in zip( - ["0"] + object_ids[:-1], path.split("/"), strict=False + ["0", *object_ids[:-1]], path.split("/"), strict=False ) ] assert result.didl_metadata.id == object_ids[-1] diff --git a/tests/components/keyboard/test_init.py b/tests/components/keyboard/test_init.py index f590c9dd1a4..69355efd761 100644 --- a/tests/components/keyboard/test_init.py +++ b/tests/components/keyboard/test_init.py @@ -13,9 +13,7 @@ async def test_repair_issue_is_created( issue_registry: ir.IssueRegistry, ) -> None: """Test repair issue is created.""" - from homeassistant.components.keyboard import ( # pylint:disable=import-outside-toplevel - DOMAIN, - ) + from homeassistant.components.keyboard import DOMAIN # noqa: PLC0415 assert await async_setup_component( hass, diff --git a/tests/components/lirc/test_init.py b/tests/components/lirc/test_init.py index 6a0747143df..7cc430d8dd0 100644 --- a/tests/components/lirc/test_init.py +++ b/tests/components/lirc/test_init.py @@ -13,9 +13,7 @@ async def test_repair_issue_is_created( issue_registry: ir.IssueRegistry, ) -> None: """Test repair issue is created.""" - from homeassistant.components.lirc import ( # pylint: disable=import-outside-toplevel - DOMAIN, - ) + from homeassistant.components.lirc import DOMAIN # noqa: PLC0415 assert await async_setup_component( hass, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index af9975de1ea..f789d7f3be1 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -683,11 +683,9 @@ async def test_receiving_message_with_non_utf8_topic_gets_logged( # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.client import MQTTMessage + from paho.mqtt.client import MQTTMessage # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.mqtt.models import MqttData + from homeassistant.components.mqtt.models import MqttData # noqa: PLC0415 msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02") msg.payload = b"Payload" @@ -1001,10 +999,9 @@ async def test_dump_service( async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() - writes = mopen.return_value.write.mock_calls - assert len(writes) == 2 - assert writes[0][1][0] == "bla/1,test1\n" - assert writes[1][1][0] == "bla/2,test2\n" + writes = mopen.return_value.writelines.mock_calls + assert len(writes) == 1 + assert writes[0][1][0] == ["bla/1,test1\n", "bla/2,test2\n"] async def test_mqtt_ws_remove_discovered_device( diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index 47b65772a24..9357163f72a 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -55,8 +55,7 @@ async def fixture_mock_connection(mock_connection_construct): @pytest.fixture(name="coils") async def fixture_coils(mock_connection: MockConnection): """Return a dict with coil data.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.nibe_heatpump import HeatPump + from homeassistant.components.nibe_heatpump import HeatPump # noqa: PLC0415 get_coils_original = HeatPump.get_coils get_coil_by_address_original = HeatPump.get_coil_by_address diff --git a/tests/components/sms/test_init.py b/tests/components/sms/test_init.py index 68c57ba7f55..05448ce0f57 100644 --- a/tests/components/sms/test_init.py +++ b/tests/components/sms/test_init.py @@ -22,7 +22,7 @@ async def test_repair_issue_is_created( issue_registry: ir.IssueRegistry, ) -> None: """Test repair issue is created.""" - from homeassistant.components.sms import ( # pylint: disable=import-outside-toplevel + from homeassistant.components.sms import ( # noqa: PLC0415 DEPRECATED_ISSUE_ID, DOMAIN, ) diff --git a/tests/conftest.py b/tests/conftest.py index 8b5c5e26c36..ef31eee4004 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -201,8 +201,7 @@ def pytest_runtest_setup() -> None: # Setup HAFakeDatetime converter for pymysql try: - # pylint: disable-next=import-outside-toplevel - import MySQLdb.converters as MySQLdb_converters + import MySQLdb.converters as MySQLdb_converters # noqa: PLC0415 except ImportError: pass else: @@ -1036,7 +1035,7 @@ async def _mqtt_mock_entry( """Fixture to mock a delayed setup of the MQTT config entry.""" # Local import to avoid processing MQTT modules when running a testcase # which does not use MQTT. - from homeassistant.components import mqtt # pylint: disable=import-outside-toplevel + from homeassistant.components import mqtt # noqa: PLC0415 if mqtt_config_entry_data is None: mqtt_config_entry_data = {mqtt.CONF_BROKER: "mock-broker"} @@ -1317,7 +1316,7 @@ def disable_mock_zeroconf_resolver( @pytest.fixture def mock_zeroconf() -> Generator[MagicMock]: """Mock zeroconf.""" - from zeroconf import DNSCache # pylint: disable=import-outside-toplevel + from zeroconf import DNSCache # noqa: PLC0415 with ( patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc, @@ -1337,10 +1336,8 @@ def mock_zeroconf() -> Generator[MagicMock]: @pytest.fixture def mock_async_zeroconf(mock_zeroconf: MagicMock) -> Generator[MagicMock]: """Mock AsyncZeroconf.""" - from zeroconf import DNSCache, Zeroconf # pylint: disable=import-outside-toplevel - from zeroconf.asyncio import ( # pylint: disable=import-outside-toplevel - AsyncZeroconf, - ) + from zeroconf import DNSCache, Zeroconf # noqa: PLC0415 + from zeroconf.asyncio import AsyncZeroconf # noqa: PLC0415 with patch( "homeassistant.components.zeroconf.HaAsyncZeroconf", spec=AsyncZeroconf @@ -1496,15 +1493,13 @@ def recorder_db_url( tmp_path = tmp_path_factory.mktemp("recorder") db_url = "sqlite:///" + str(tmp_path / "pytest.db") elif db_url.startswith("mysql://"): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy_utils + import sqlalchemy_utils # noqa: PLC0415 charset = "utf8mb4' COLLATE = 'utf8mb4_unicode_ci" assert not sqlalchemy_utils.database_exists(db_url) sqlalchemy_utils.create_database(db_url, encoding=charset) elif db_url.startswith("postgresql://"): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy_utils + import sqlalchemy_utils # noqa: PLC0415 assert not sqlalchemy_utils.database_exists(db_url) sqlalchemy_utils.create_database(db_url, encoding="utf8") @@ -1512,8 +1507,7 @@ def recorder_db_url( if db_url == "sqlite://" and persistent_database: rmtree(tmp_path, ignore_errors=True) elif db_url.startswith("mysql://"): - # pylint: disable-next=import-outside-toplevel - import sqlalchemy as sa + import sqlalchemy as sa # noqa: PLC0415 made_url = sa.make_url(db_url) db = made_url.database @@ -1544,8 +1538,7 @@ async def _async_init_recorder_component( wait_setup: bool, ) -> None: """Initialize the recorder asynchronously.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 config = dict(add_config) if add_config else {} if recorder.CONF_DB_URL not in config: @@ -1596,21 +1589,16 @@ async def async_test_recorder( enable_migrate_event_ids: bool, ) -> AsyncGenerator[RecorderInstanceContextManager]: """Yield context manager to setup recorder instance.""" - # pylint: disable-next=import-outside-toplevel - from homeassistant.components import recorder + from homeassistant.components import recorder # noqa: PLC0415 + from homeassistant.components.recorder import migration # noqa: PLC0415 - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.recorder import migration - - # pylint: disable-next=import-outside-toplevel - from .components.recorder.common import async_recorder_block_till_done - - # pylint: disable-next=import-outside-toplevel - from .patch_recorder import real_session_scope + from .components.recorder.common import ( # noqa: PLC0415 + async_recorder_block_till_done, + ) + from .patch_recorder import real_session_scope # noqa: PLC0415 if TYPE_CHECKING: - # pylint: disable-next=import-outside-toplevel - from sqlalchemy.orm.session import Session + from sqlalchemy.orm.session import Session # noqa: PLC0415 @contextmanager def debug_session_scope( @@ -1857,8 +1845,7 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: # Late imports to avoid loading bleak unless we need it - # pylint: disable-next=import-outside-toplevel - from habluetooth import scanner as bluetooth_scanner + from habluetooth import scanner as bluetooth_scanner # noqa: PLC0415 # We need to drop the stop method from the object since we patched # out start and this fixture will expire before the stop method is called @@ -1878,13 +1865,9 @@ def mock_bleak_scanner_start() -> Generator[MagicMock]: @pytest.fixture def hassio_env(supervisor_is_connected: AsyncMock) -> Generator[None]: """Fixture to inject hassio env.""" - from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel - HassioAPIError, - ) + from homeassistant.components.hassio import HassioAPIError # noqa: PLC0415 - from .components.hassio import ( # pylint: disable=import-outside-toplevel - SUPERVISOR_TOKEN, - ) + from .components.hassio import SUPERVISOR_TOKEN # noqa: PLC0415 with ( patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), @@ -1906,9 +1889,7 @@ async def hassio_stubs( supervisor_client: AsyncMock, ) -> RefreshToken: """Create mock hassio http client.""" - from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel - HassioAPIError, - ) + from homeassistant.components.hassio import HassioAPIError # noqa: PLC0415 with ( patch( diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index e99db76dcbc..54ebfaf953e 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -39,8 +39,9 @@ async def test_get_integration_logger( @pytest.mark.usefixtures("enable_custom_integrations", "hass") async def test_extract_frame_resolve_module() -> None: """Test extracting the current frame from integration context.""" - # pylint: disable-next=import-outside-toplevel - from custom_components.test_integration_frame import call_get_integration_frame + from custom_components.test_integration_frame import ( # noqa: PLC0415 + call_get_integration_frame, + ) integration_frame = call_get_integration_frame() @@ -56,8 +57,9 @@ async def test_extract_frame_resolve_module() -> None: @pytest.mark.usefixtures("enable_custom_integrations", "hass") async def test_get_integration_logger_resolve_module() -> None: """Test getting the logger from integration context.""" - # pylint: disable-next=import-outside-toplevel - from custom_components.test_integration_frame import call_get_integration_logger + from custom_components.test_integration_frame import ( # noqa: PLC0415 + call_get_integration_logger, + ) logger = call_get_integration_logger(__name__) diff --git a/tests/test_loader.py b/tests/test_loader.py index 16515cbd4e6..2d5ad76aa8a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -134,8 +134,7 @@ async def test_custom_component_name(hass: HomeAssistant) -> None: assert platform.__package__ == "custom_components.test" # Test custom components is mounted - # pylint: disable-next=import-outside-toplevel - from custom_components.test_package import TEST + from custom_components.test_package import TEST # noqa: PLC0415 assert TEST == 5 @@ -1295,12 +1294,11 @@ async def test_config_folder_not_in_path() -> None: # Verify that we are unable to import this file from top level with pytest.raises(ImportError): - # pylint: disable-next=import-outside-toplevel - import check_config_not_in_path # noqa: F401 + import check_config_not_in_path # noqa: F401, PLC0415 # Verify that we are able to load the file with absolute path - # pylint: disable-next=import-outside-toplevel,hass-relative-import - import tests.testing_config.check_config_not_in_path # noqa: F401 + # pylint: disable-next=hass-relative-import + import tests.testing_config.check_config_not_in_path # noqa: F401, PLC0415 async def test_async_get_component_preloads_config_and_config_flow( diff --git a/tests/test_main.py b/tests/test_main.py index d32ca59a846..acb0146545e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -36,7 +36,7 @@ def test_validate_python(mock_exit) -> None: with patch( "sys.version_info", new_callable=PropertyMock( - return_value=(REQUIRED_PYTHON_VER[0] - 1,) + REQUIRED_PYTHON_VER[1:] + return_value=(REQUIRED_PYTHON_VER[0] - 1, *REQUIRED_PYTHON_VER[1:]) ), ): main.validate_python() @@ -55,7 +55,7 @@ def test_validate_python(mock_exit) -> None: with patch( "sys.version_info", new_callable=PropertyMock( - return_value=(REQUIRED_PYTHON_VER[:2]) + (REQUIRED_PYTHON_VER[2] + 1,) + return_value=(*REQUIRED_PYTHON_VER[:2], REQUIRED_PYTHON_VER[2] + 1) ), ): main.validate_python() From 341d9f15f025185bd912f5c25ba12231c62dad96 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 19 Jun 2025 16:50:14 -0500 Subject: [PATCH 0410/1664] Add ask_question action to Assist satellite (#145233) * Add get_response to Assist satellite and ESPHome * Rename get_response to ask_question * Add possible answers to questions * Add wildcard support and entity test * Add ESPHome test * Refactor to remove async_ask_question * Use single entity_id instead of target * Fix error message * Remove ESPHome test * Clean up * Revert fix --- .../components/assist_satellite/__init__.py | 96 ++++++++++- .../components/assist_satellite/entity.py | 161 +++++++++++++++++- .../components/assist_satellite/icons.json | 3 + .../components/assist_satellite/manifest.json | 3 +- .../components/assist_satellite/services.yaml | 32 ++++ .../components/assist_satellite/strings.json | 30 ++++ requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../assist_satellite/test_entity.py | 123 +++++++++++++ 9 files changed, 447 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 3338f223bc9..f1f38f343f9 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -1,13 +1,23 @@ """Base class for assist satellite entities.""" +from dataclasses import asdict import logging from pathlib import Path +from typing import Any +from hassil.util import ( + PUNCTUATION_END, + PUNCTUATION_END_WORD, + PUNCTUATION_START, + PUNCTUATION_START_WORD, +) import voluptuous as vol from homeassistant.components.http import StaticPathConfig from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType @@ -23,6 +33,7 @@ from .const import ( ) from .entity import ( AssistSatelliteAnnouncement, + AssistSatelliteAnswer, AssistSatelliteConfiguration, AssistSatelliteEntity, AssistSatelliteEntityDescription, @@ -34,6 +45,7 @@ from .websocket_api import async_register_websocket_api __all__ = [ "DOMAIN", "AssistSatelliteAnnouncement", + "AssistSatelliteAnswer", "AssistSatelliteConfiguration", "AssistSatelliteEntity", "AssistSatelliteEntityDescription", @@ -86,6 +98,62 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_start_conversation", [AssistSatelliteEntityFeature.START_CONVERSATION], ) + + async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: + """Handle a Show View service call.""" + satellite_entity_id: str = call.data[ATTR_ENTITY_ID] + satellite_entity: AssistSatelliteEntity | None = component.get_entity( + satellite_entity_id + ) + if satellite_entity is None: + raise HomeAssistantError( + f"Invalid Assist satellite entity id: {satellite_entity_id}" + ) + + ask_question_args = { + "question": call.data.get("question"), + "question_media_id": call.data.get("question_media_id"), + "preannounce": call.data.get("preannounce", False), + "answers": call.data.get("answers"), + } + + if preannounce_media_id := call.data.get("preannounce_media_id"): + ask_question_args["preannounce_media_id"] = preannounce_media_id + + answer = await satellite_entity.async_internal_ask_question(**ask_question_args) + + if answer is None: + raise HomeAssistantError("No answer from satellite") + + return asdict(answer) + + hass.services.async_register( + domain=DOMAIN, + service="ask_question", + service_func=handle_ask_question, + schema=vol.All( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Optional("question"): str, + vol.Optional("question_media_id"): str, + vol.Optional("preannounce"): bool, + vol.Optional("preannounce_media_id"): str, + vol.Optional("answers"): [ + { + vol.Required("id"): str, + vol.Required("sentences"): vol.All( + cv.ensure_list, + [cv.string], + has_one_non_empty_item, + has_no_punctuation, + ), + } + ], + }, + cv.has_at_least_one_key("question", "question_media_id"), + ), + supports_response=SupportsResponse.ONLY, + ) hass.data[CONNECTION_TEST_DATA] = {} async_register_websocket_api(hass) hass.http.register_view(ConnectionTestView()) @@ -110,3 +178,29 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.data[DATA_COMPONENT].async_unload_entry(entry) + + +def has_no_punctuation(value: list[str]) -> list[str]: + """Validate result does not contain punctuation.""" + for sentence in value: + if ( + PUNCTUATION_START.search(sentence) + or PUNCTUATION_END.search(sentence) + or PUNCTUATION_START_WORD.search(sentence) + or PUNCTUATION_END_WORD.search(sentence) + ): + raise vol.Invalid("sentence should not contain punctuation") + + return value + + +def has_one_non_empty_item(value: list[str]) -> list[str]: + """Validate result has at least one item.""" + if len(value) < 1: + raise vol.Invalid("at least one sentence is required") + + for sentence in value: + if not sentence: + raise vol.Invalid("sentences cannot be empty") + + return value diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index dc20c7650d7..d32bad2c824 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -4,12 +4,16 @@ from abc import abstractmethod import asyncio from collections.abc import AsyncIterable import contextlib -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import StrEnum import logging import time from typing import Any, Literal, final +from hassil import Intents, recognize +from hassil.expression import Expression, ListReference, Sequence +from hassil.intents import WildcardSlotList + from homeassistant.components import conversation, media_source, stt, tts from homeassistant.components.assist_pipeline import ( OPTION_PREFERRED, @@ -105,6 +109,20 @@ class AssistSatelliteAnnouncement: """Media ID to be played before announcement.""" +@dataclass +class AssistSatelliteAnswer: + """Answer to a question.""" + + id: str | None + """Matched answer id or None if no answer was matched.""" + + sentence: str + """Raw sentence text from user response.""" + + slots: dict[str, Any] = field(default_factory=dict) + """Matched slots from answer.""" + + class AssistSatelliteEntity(entity.Entity): """Entity encapsulating the state and functionality of an Assist satellite.""" @@ -120,8 +138,10 @@ class AssistSatelliteEntity(entity.Entity): _is_announcing = False _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None + _stt_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None + _ask_question_future: asyncio.Future[str | None] | None = None __assist_satellite_state = AssistSatelliteState.IDLE @@ -309,6 +329,112 @@ class AssistSatelliteEntity(entity.Entity): """Start a conversation from the satellite.""" raise NotImplementedError + async def async_internal_ask_question( + self, + question: str | None = None, + question_media_id: str | None = None, + preannounce: bool = True, + preannounce_media_id: str = PREANNOUNCE_URL, + answers: list[dict[str, Any]] | None = None, + ) -> AssistSatelliteAnswer | None: + """Ask a question and get a user's response from the satellite. + + If question_media_id is not provided, question is synthesized to audio + with the selected pipeline. + + If question_media_id is provided, it is played directly. It is possible + to omit the message and the satellite will not show any text. + + If preannounce is True, a sound is played before the start message or media. + If preannounce_media_id is provided, it overrides the default sound. + + Calls async_start_conversation. + """ + await self._cancel_running_pipeline() + + if question is None: + question = "" + + announcement = await self._resolve_announcement_media_id( + question, + question_media_id, + preannounce_media_id=preannounce_media_id if preannounce else None, + ) + + if self._is_announcing: + raise SatelliteBusyError + + self._is_announcing = True + self._set_state(AssistSatelliteState.RESPONDING) + self._ask_question_future = asyncio.Future() + + try: + # Wait for announcement to finish + await self.async_start_conversation(announcement) + + # Wait for response text + response_text = await self._ask_question_future + if response_text is None: + raise HomeAssistantError("No answer from question") + + if not answers: + return AssistSatelliteAnswer(id=None, sentence=response_text) + + return self._question_response_to_answer(response_text, answers) + finally: + self._is_announcing = False + self._set_state(AssistSatelliteState.IDLE) + self._ask_question_future = None + + def _question_response_to_answer( + self, response_text: str, answers: list[dict[str, Any]] + ) -> AssistSatelliteAnswer: + """Match text to a pre-defined set of answers.""" + + # Build intents and match + intents = Intents.from_dict( + { + "language": self.hass.config.language, + "intents": { + "QuestionIntent": { + "data": [ + { + "sentences": answer["sentences"], + "metadata": {"answer_id": answer["id"]}, + } + for answer in answers + ] + } + }, + } + ) + + # Assume slot list references are wildcards + wildcard_names: set[str] = set() + for intent in intents.intents.values(): + for intent_data in intent.data: + for sentence in intent_data.sentences: + _collect_list_references(sentence, wildcard_names) + + for wildcard_name in wildcard_names: + intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name) + + # Match response text + result = recognize(response_text, intents) + if result is None: + # No match + return AssistSatelliteAnswer(id=None, sentence=response_text) + + assert result.intent_metadata + return AssistSatelliteAnswer( + id=result.intent_metadata["answer_id"], + sentence=response_text, + slots={ + entity_name: entity.value + for entity_name, entity in result.entities.items() + }, + ) + async def async_accept_pipeline_from_satellite( self, audio_stream: AsyncIterable[bytes], @@ -351,6 +477,11 @@ class AssistSatelliteEntity(entity.Entity): self._internal_on_pipeline_event(PipelineEvent(PipelineEventType.RUN_END)) return + if (self._ask_question_future is not None) and ( + start_stage == PipelineStage.STT + ): + end_stage = PipelineStage.STT + device_id = self.registry_entry.device_id if self.registry_entry else None # Refresh context if necessary @@ -433,6 +564,16 @@ class AssistSatelliteEntity(entity.Entity): self._set_state(AssistSatelliteState.IDLE) elif event.type is PipelineEventType.STT_START: self._set_state(AssistSatelliteState.LISTENING) + elif event.type is PipelineEventType.STT_END: + # Intercepting text for ask question + if ( + (self._ask_question_future is not None) + and (not self._ask_question_future.done()) + and event.data + ): + self._ask_question_future.set_result( + event.data.get("stt_output", {}).get("text") + ) elif event.type is PipelineEventType.INTENT_START: self._set_state(AssistSatelliteState.PROCESSING) elif event.type is PipelineEventType.TTS_START: @@ -443,6 +584,12 @@ class AssistSatelliteEntity(entity.Entity): if not self._run_has_tts: self._set_state(AssistSatelliteState.IDLE) + if (self._ask_question_future is not None) and ( + not self._ask_question_future.done() + ): + # No text for ask question + self._ask_question_future.set_result(None) + self.on_pipeline_event(event) @callback @@ -577,3 +724,15 @@ class AssistSatelliteEntity(entity.Entity): media_id_source=media_id_source, preannounce_media_id=preannounce_media_id, ) + + +def _collect_list_references(expression: Expression, list_names: set[str]) -> None: + """Collect list reference names recursively.""" + if isinstance(expression, Sequence): + seq: Sequence = expression + for item in seq.items: + _collect_list_references(item, list_names) + elif isinstance(expression, ListReference): + # {list} + list_ref: ListReference = expression + list_names.add(list_ref.slot_name) diff --git a/homeassistant/components/assist_satellite/icons.json b/homeassistant/components/assist_satellite/icons.json index 1ed29541621..fc2589ea506 100644 --- a/homeassistant/components/assist_satellite/icons.json +++ b/homeassistant/components/assist_satellite/icons.json @@ -10,6 +10,9 @@ }, "start_conversation": { "service": "mdi:forum" + }, + "ask_question": { + "service": "mdi:microphone-question" } } } diff --git a/homeassistant/components/assist_satellite/manifest.json b/homeassistant/components/assist_satellite/manifest.json index 68a3ceafd4f..97362f157e4 100644 --- a/homeassistant/components/assist_satellite/manifest.json +++ b/homeassistant/components/assist_satellite/manifest.json @@ -5,5 +5,6 @@ "dependencies": ["assist_pipeline", "http", "stt", "tts"], "documentation": "https://www.home-assistant.io/integrations/assist_satellite", "integration_type": "entity", - "quality_scale": "internal" + "quality_scale": "internal", + "requirements": ["hassil==2.2.3"] } diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index d88710c4c4e..c5484e22dad 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -54,3 +54,35 @@ start_conversation: required: false selector: text: +ask_question: + fields: + entity_id: + required: true + selector: + entity: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + question: + required: false + example: "What kind of music would you like to play?" + default: "" + selector: + text: + question_media_id: + required: false + selector: + text: + preannounce: + required: false + default: true + selector: + boolean: + preannounce_media_id: + required: false + selector: + text: + answers: + required: false + selector: + object: diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index b69711c7106..e0bf2bcfb94 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -59,6 +59,36 @@ "description": "Custom media ID to play before the start message or media." } } + }, + "ask_question": { + "name": "Ask question", + "description": "Asks a question and gets the user's response.", + "fields": { + "entity_id": { + "name": "Entity", + "description": "Assist satellite entity to ask the question on." + }, + "question": { + "name": "Question", + "description": "The question to ask." + }, + "question_media_id": { + "name": "Question media ID", + "description": "The media ID of the question to use instead of text-to-speech." + }, + "preannounce": { + "name": "Preannounce", + "description": "Play a sound before the start message or media." + }, + "preannounce_media_id": { + "name": "Preannounce media ID", + "description": "Custom media ID to play before the start message or media." + }, + "answers": { + "name": "Answers", + "description": "Possible answers to the question." + } + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index a3f0c833d2f..cf683a09e67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1129,6 +1129,7 @@ hass-nabucasa==0.102.0 # homeassistant.components.splunk hass-splunk==0.1.1 +# homeassistant.components.assist_satellite # homeassistant.components.conversation hassil==2.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a27b9f5d199..3f513185014 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -984,6 +984,7 @@ habluetooth==3.49.0 # homeassistant.components.cloud hass-nabucasa==0.102.0 +# homeassistant.components.assist_satellite # homeassistant.components.conversation hassil==2.2.3 diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 8050b23f5ff..3473b23bedd 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Generator +from dataclasses import asdict from unittest.mock import Mock, patch import pytest @@ -20,6 +21,7 @@ from homeassistant.components.assist_pipeline import ( ) from homeassistant.components.assist_satellite import ( AssistSatelliteAnnouncement, + AssistSatelliteAnswer, SatelliteBusyError, ) from homeassistant.components.assist_satellite.const import PREANNOUNCE_URL @@ -708,6 +710,127 @@ async def test_start_conversation_default_preannounce( ) +@pytest.mark.parametrize( + ("service_data", "response_text", "expected_answer"), + [ + ( + {"preannounce": False}, + "jazz", + AssistSatelliteAnswer(id=None, sentence="jazz"), + ), + ( + { + "answers": [ + {"id": "jazz", "sentences": ["[some] jazz [please]"]}, + {"id": "rock", "sentences": ["[some] rock [please]"]}, + ], + "preannounce": False, + }, + "Some Rock, please.", + AssistSatelliteAnswer(id="rock", sentence="Some Rock, please."), + ), + ( + { + "answers": [ + { + "id": "genre", + "sentences": ["genre {genre} [please]"], + }, + { + "id": "artist", + "sentences": ["artist {artist} [please]"], + }, + ], + "preannounce": False, + }, + "artist Pink Floyd", + AssistSatelliteAnswer( + id="artist", + sentence="artist Pink Floyd", + slots={"artist": "Pink Floyd"}, + ), + ), + ], +) +async def test_ask_question( + hass: HomeAssistant, + init_components: ConfigEntry, + entity: MockAssistSatellite, + service_data: dict, + response_text: str, + expected_answer: AssistSatelliteAnswer, +) -> None: + """Test asking a question on a device and matching an answer.""" + entity_id = "assist_satellite.test_entity" + question_text = "What kind of music would you like to listen to?" + + await async_update_pipeline( + hass, async_get_pipeline(hass), stt_engine="test-stt-engine", stt_language="en" + ) + + async def speech_to_text(self, *args, **kwargs): + self.process_event( + PipelineEvent( + PipelineEventType.STT_END, {"stt_output": {"text": response_text}} + ) + ) + + return response_text + + original_start_conversation = entity.async_start_conversation + + async def async_start_conversation(start_announcement): + # Verify state change + assert entity.state == AssistSatelliteState.RESPONDING + await original_start_conversation(start_announcement) + + audio_stream = object() + with ( + patch( + "homeassistant.components.assist_pipeline.pipeline.PipelineRun.prepare_speech_to_text" + ), + patch( + "homeassistant.components.assist_pipeline.pipeline.PipelineRun.speech_to_text", + speech_to_text, + ), + ): + await entity.async_accept_pipeline_from_satellite( + audio_stream, start_stage=PipelineStage.STT + ) + + with ( + patch( + "homeassistant.components.tts.generate_media_source_id", + return_value="media-source://generated", + ), + patch( + "homeassistant.components.tts.async_resolve_engine", + return_value="tts.cloud", + ), + patch( + "homeassistant.components.tts.async_create_stream", + return_value=MockResultStream(hass, "wav", b""), + ), + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=PlayMedia( + url="https://www.home-assistant.io/resolved.mp3", + mime_type="audio/mp3", + ), + ), + patch.object(entity, "async_start_conversation", new=async_start_conversation), + ): + response = await hass.services.async_call( + "assist_satellite", + "ask_question", + {"entity_id": entity_id, "question": question_text, **service_data}, + blocking=True, + return_response=True, + ) + assert entity.state == AssistSatelliteState.IDLE + assert response == asdict(expected_answer) + + async def test_wake_word_start_keeps_responding( hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite ) -> None: From 11564e3df5931d40f861da058fbba24538e38033 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Fri, 20 Jun 2025 07:56:20 +0200 Subject: [PATCH 0411/1664] Fix Z-Wave device class endpoint discovery (#142171) * Add test fixture and test for Glass 9 shutter * Fix zwave_js device class discovery matcher * Fall back to node device class * Fix test_special_meters modifying node state * Handle value added after node ready --- .../components/zwave_js/discovery.py | 44 +- tests/components/zwave_js/conftest.py | 14 + .../cover_mco_home_glass_9_shutter_state.json | 4988 +++++++++++++++++ .../fixtures/touchwand_glass9_state.json | 3467 ++++++++++++ tests/components/zwave_js/test_discovery.py | 18 + tests/components/zwave_js/test_init.py | 3 +- tests/components/zwave_js/test_sensor.py | 22 + 7 files changed, 8547 insertions(+), 9 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json create mode 100644 tests/components/zwave_js/fixtures/touchwand_glass9_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 92233dd2e77..3b541a733cc 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1334,21 +1334,49 @@ def async_discover_single_value( continue # check device_class_generic + # If the value has an endpoint but it is missing on the node + # we can't match the endpoint device class to the schema device class. + # This could happen if the value is discovered after the node is ready. if schema.device_class_generic and ( - not value.node.device_class - or not any( - value.node.device_class.generic.label == val - for val in schema.device_class_generic + ( + (endpoint := value.endpoint) is None + or (node_endpoint := value.node.endpoints.get(endpoint)) is None + or (device_class := node_endpoint.device_class) is None + or not any( + device_class.generic.label == val + for val in schema.device_class_generic + ) + ) + and ( + (device_class := value.node.device_class) is None + or not any( + device_class.generic.label == val + for val in schema.device_class_generic + ) ) ): continue # check device_class_specific + # If the value has an endpoint but it is missing on the node + # we can't match the endpoint device class to the schema device class. + # This could happen if the value is discovered after the node is ready. if schema.device_class_specific and ( - not value.node.device_class - or not any( - value.node.device_class.specific.label == val - for val in schema.device_class_specific + ( + (endpoint := value.endpoint) is None + or (node_endpoint := value.node.endpoints.get(endpoint)) is None + or (device_class := node_endpoint.device_class) is None + or not any( + device_class.specific.label == val + for val in schema.device_class_specific + ) + ) + and ( + (device_class := value.node.device_class) is None + or not any( + device_class.specific.label == val + for val in schema.device_class_specific + ) ) ): continue diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 25f40e4418d..138bcd63ede 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -301,6 +301,12 @@ def shelly_europe_ltd_qnsh_001p10_state_fixture() -> dict[str, Any]: return load_json_object_fixture("shelly_europe_ltd_qnsh_001p10_state.json", DOMAIN) +@pytest.fixture(name="touchwand_glass9_state", scope="package") +def touchwand_glass9_state_fixture() -> dict[str, Any]: + """Load the Touchwand Glass 9 shutter node state fixture data.""" + return load_json_object_fixture("touchwand_glass9_state.json", DOMAIN) + + @pytest.fixture(name="merten_507801_state", scope="package") def merten_507801_state_fixture() -> dict[str, Any]: """Load the Merten 507801 Shutter node state fixture data.""" @@ -1040,6 +1046,14 @@ def shelly_qnsh_001P10_cover_shutter_fixture( return node +@pytest.fixture(name="touchwand_glass9") +def touchwand_glass9_fixture(client, touchwand_glass9_state) -> Node: + """Mock a Touchwand glass9 node.""" + node = Node(client, copy.deepcopy(touchwand_glass9_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state) -> Node: """Mock a Merten 507801 Shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json b/tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json new file mode 100644 index 00000000000..13b5d0495f9 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_mco_home_glass_9_shutter_state.json @@ -0,0 +1,4988 @@ +{ + "home_assistant": { + "installation_type": "Home Assistant Container", + "version": "2024.7.4", + "dev": false, + "hassio": false, + "virtualenv": false, + "python_version": "3.12.4", + "docker": true, + "arch": "armv7l", + "timezone": "Asia/Jerusalem", + "os_name": "Linux", + "os_version": "5.4.142-g5227ff0e2a5c-dirty", + "run_as_root": true + }, + "custom_components": { + "oref_alert": { + "documentation": "https://github.com/amitfin/oref_alert", + "version": "v2.11.3", + "requirements": ["haversine==2.8.1", "shapely==2.0.4"] + }, + "scheduler": { + "documentation": "https://github.com/nielsfaber/scheduler-component", + "version": "v0.0.0", + "requirements": [] + }, + "hebcal": { + "documentation": "https://github.com/rt400/Jewish-Sabbaths-Holidays", + "version": "2.4.0", + "requirements": [] + }, + "hacs": { + "documentation": "https://hacs.xyz/docs/configuration/start", + "version": "1.34.0", + "requirements": ["aiogithubapi>=22.10.1"] + } + }, + "integration_manifest": { + "domain": "zwave_js", + "name": "Z-Wave", + "codeowners": ["home-assistant/z-wave"], + "config_flow": true, + "dependencies": ["http", "repairs", "usb", "websocket_api"], + "documentation": "https://www.home-assistant.io/integrations/zwave_js", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["zwave_js_server"], + "quality_scale": "platinum", + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.57.0"], + "usb": [ + { + "vid": "0658", + "pid": "0200", + "known_devices": ["Aeotec Z-Stick Gen5+", "Z-WaveMe UZB"] + }, + { + "vid": "10C4", + "pid": "8A2A", + "description": "*z-wave*", + "known_devices": ["Nortek HUSBZB-1"] + } + ], + "zeroconf": ["_zwave-js-server._tcp.local."], + "is_built_in": true + }, + "setup_times": { + "null": { + "setup": 0.06139277799957199 + }, + "01J4GRKFXZDKNDWCNE0ZWKH65M": { + "config_entry_setup": 0.22992777000035858, + "config_entry_platform_setup": 0.12791325299986056, + "wait_base_component": -0.009490847998677054 + } + }, + "data": { + "versionInfo": { + "driverVersion": "13.0.2", + "serverVersion": "1.37.0", + "minSchemaVersion": 0, + "maxSchemaVersion": 37 + }, + "entities": [ + { + "domain": "sensor", + "entity_id": "sensor.gp9_air_temperature", + "original_name": "Air temperature", + "original_device_class": "temperature", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "\u00b0C", + "value_id": "46-49-1-Air temperature", + "primary_value": { + "command_class": 49, + "command_class_name": "Multilevel Sensor", + "endpoint": 1, + "property": "Air temperature", + "property_name": "Air temperature", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_kwh", + "original_name": "Electric Consumption [kWh]", + "original_device_class": "energy", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "kWh", + "value_id": "46-50-8-value-65537", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 65537, + "property_key_name": "Electric_kWh_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_w", + "original_name": "Electric Consumption [W]", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-8-value-66049", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 66049, + "property_key_name": "Electric_W_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_v", + "original_name": "Electric Consumption [V]", + "original_device_class": "voltage", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "V", + "value_id": "46-50-8-value-66561", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 66561, + "property_key_name": "Electric_V_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_a", + "original_name": "Electric Consumption [A]", + "original_device_class": "current", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "A", + "value_id": "46-50-8-value-66817", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "value", + "property_name": "value", + "property_key": 66817, + "property_key_name": "Electric_A_Consumed" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_reset_accumulated_values", + "original_name": "Reset accumulated values", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-50-8-reset", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 8, + "property": "reset", + "property_name": "reset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmtype", + "original_name": "alarmType", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-alarmType", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "alarmType", + "property_name": "alarmType", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmlevel", + "original_name": "alarmLevel", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-alarmLevel", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "alarmLevel", + "property_name": "alarmLevel", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_power_management_over_current_status", + "original_name": "Power Management Over-current status", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_idle_power_management_over_current_status", + "original_name": "Idle Power Management Over-current status", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_kwh_9", + "original_name": "Electric Consumption [kWh] (9)", + "original_device_class": "energy", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "kWh", + "value_id": "46-50-9-value-65537", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 65537, + "property_key_name": "Electric_kWh_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_w_9", + "original_name": "Electric Consumption [W] (9)", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-9-value-66049", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 66049, + "property_key_name": "Electric_W_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_v_9", + "original_name": "Electric Consumption [V] (9)", + "original_device_class": "voltage", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "V", + "value_id": "46-50-9-value-66561", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 66561, + "property_key_name": "Electric_V_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_a_9", + "original_name": "Electric Consumption [A] (9)", + "original_device_class": "current", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "A", + "value_id": "46-50-9-value-66817", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "value", + "property_name": "value", + "property_key": 66817, + "property_key_name": "Electric_A_Consumed" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_reset_accumulated_values_9", + "original_name": "Reset accumulated values (9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-50-9-reset", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 9, + "property": "reset", + "property_name": "reset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmtype_9", + "original_name": "alarmType (9)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-alarmType", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "alarmType", + "property_name": "alarmType", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmlevel_9", + "original_name": "alarmLevel (9)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-alarmLevel", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "alarmLevel", + "property_name": "alarmLevel", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_power_management_over_current_status_9", + "original_name": "Power Management Over-current status (9)", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_idle_power_management_over_current_status_9", + "original_name": "Idle Power Management Over-current status (9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_kwh_10", + "original_name": "Electric Consumption [kWh] (10)", + "original_device_class": "energy", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "kWh", + "value_id": "46-50-10-value-65537", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 65537, + "property_key_name": "Electric_kWh_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_w_10", + "original_name": "Electric Consumption [W] (10)", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-10-value-66049", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66049, + "property_key_name": "Electric_W_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_v_10", + "original_name": "Electric Consumption [V] (10)", + "original_device_class": "voltage", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "V", + "value_id": "46-50-10-value-66561", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66561, + "property_key_name": "Electric_V_Consumed" + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_consumption_a_10", + "original_name": "Electric Consumption [A] (10)", + "original_device_class": "current", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "A", + "value_id": "46-50-10-value-66817", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66817, + "property_key_name": "Electric_A_Consumed" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_reset_accumulated_values_10", + "original_name": "Reset accumulated values (10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-50-10-reset", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "reset", + "property_name": "reset", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmtype_10", + "original_name": "alarmType (10)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-alarmType", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "alarmType", + "property_name": "alarmType", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_alarmlevel_10", + "original_name": "alarmLevel (10)", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-alarmLevel", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "alarmLevel", + "property_name": "alarmLevel", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_power_management_over_current_status_10", + "original_name": "Power Management Over-current status (10)", + "original_device_class": "enum", + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "button", + "entity_id": "button.gp9_idle_power_management_over_current_status_10", + "original_name": "Idle Power Management Over-current status (10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status" + } + }, + { + "domain": "light", + "entity_id": "light.gp9", + "original_name": "", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 32, + "unit_of_measurement": null, + "value_id": "46-38-8-currentValue", + "primary_value": { + "command_class": 38, + "command_class_name": "Multilevel Switch", + "endpoint": 8, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "light", + "entity_id": "light.gp9_9", + "original_name": "(9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 32, + "unit_of_measurement": null, + "value_id": "46-38-9-currentValue", + "primary_value": { + "command_class": 38, + "command_class_name": "Multilevel Switch", + "endpoint": 9, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "light", + "entity_id": "light.gp9_10", + "original_name": "(10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 32, + "unit_of_measurement": null, + "value_id": "46-38-10-currentValue", + "primary_value": { + "command_class": 38, + "command_class_name": "Multilevel Switch", + "endpoint": 10, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id", + "original_name": "Scene ID", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-2-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 2, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_3", + "original_name": "Scene ID (3)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-3-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 3, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_4", + "original_name": "Scene ID (4)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-4-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 4, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_5", + "original_name": "Scene ID (5)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-5-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 5, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_6", + "original_name": "Scene ID (6)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-6-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 6, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_7", + "original_name": "Scene ID (7)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-7-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 7, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_8", + "original_name": "Scene ID (8)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-8-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 8, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_9", + "original_name": "Scene ID (9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-9-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 9, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_10", + "original_name": "Scene ID (10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-10-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 10, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_11", + "original_name": "Scene ID (11)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-11-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 11, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_12", + "original_name": "Scene ID (12)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-12-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 12, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "event", + "entity_id": "event.gp9_scene_id_13", + "original_name": "Scene ID (13)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-43-13-sceneId", + "primary_value": { + "command_class": 43, + "command_class_name": "Scene Activation", + "endpoint": 13, + "property": "sceneId", + "property_name": "sceneId", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_3", + "original_name": "(3)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-3-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 3, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_4", + "original_name": "(4)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-4-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 4, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_5", + "original_name": "(5)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-5-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 5, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_6", + "original_name": "(6)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-6-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 6, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_7", + "original_name": "(7)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-7-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 7, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_8", + "original_name": "(8)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-8-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 8, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_9", + "original_name": "(9)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-9-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 9, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_10", + "original_name": "(10)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-10-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 10, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_11", + "original_name": "(11)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-11-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 11, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_12", + "original_name": "(12)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-12-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 12, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "switch", + "entity_id": "switch.gp9_13", + "original_name": "(13)", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-13-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 13, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.gp9_over_current_detected", + "original_name": "Over-current detected", + "original_device_class": "safety", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-8-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 8, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status", + "state_key": 6 + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.gp9_over_current_detected_9", + "original_name": "Over-current detected (9)", + "original_device_class": "safety", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-9-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 9, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status", + "state_key": 6 + } + }, + { + "domain": "binary_sensor", + "entity_id": "binary_sensor.gp9_over_current_detected_10", + "original_name": "Over-current detected (10)", + "original_device_class": "safety", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": "diagnostic", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-113-10-Power Management-Over-current status", + "primary_value": { + "command_class": 113, + "command_class_name": "Notification", + "endpoint": 10, + "property": "Power Management", + "property_name": "Power Management", + "property_key": "Over-current status", + "property_key_name": "Over-current status", + "state_key": 6 + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_w", + "original_name": "Electric [W]", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-9-value-66051", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param123", + "original_name": "param123", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-123", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param120", + "original_name": "param120", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-120", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param124", + "original_name": "param124", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-124", + "primary_value": null + }, + { + "domain": "number", + "entity_id": "number.gp9_param121", + "original_name": "param121", + "original_device_class": null, + "disabled": true, + "disabled_by": "integration", + "hidden_by": null, + "original_icon": null, + "entity_category": "config", + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-112-0-121", + "primary_value": null + }, + { + "domain": "switch", + "entity_id": "switch.gp9", + "original_name": "", + "original_device_class": null, + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": null, + "value_id": "46-37-2-currentValue", + "primary_value": { + "command_class": 37, + "command_class_name": "Binary Switch", + "endpoint": 2, + "property": "currentValue", + "property_name": "currentValue", + "property_key": null, + "property_key_name": null + } + }, + { + "domain": "sensor", + "entity_id": "sensor.gp9_electric_w_10", + "original_name": "Electric [W]", + "original_device_class": "power", + "disabled": false, + "disabled_by": null, + "hidden_by": null, + "original_icon": null, + "entity_category": null, + "supported_features": 0, + "unit_of_measurement": "W", + "value_id": "46-50-10-value-66051", + "primary_value": { + "command_class": 50, + "command_class_name": "Meter", + "endpoint": 10, + "property": "value", + "property_name": "value", + "property_key": 66051, + "property_key_name": "Electric_W_unknown (0x03)" + } + } + ], + "state": { + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 351, + "productId": 33030, + "productType": 36865, + "firmwareVersion": "4.13.8", + "zwavePlusVersion": 2, + "name": "gp9", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/root/zwave/store/config/devices/0x015f/glass9.json", + "isEmbedded": false, + "manufacturer": "TouchWand Co., Ltd.", + "manufacturerId": 351, + "label": "Glass9", + "description": "Glass 9", + "devices": [ + { + "productType": 36865, + "productId": 33030 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "compat": { + "removeCCs": {} + } + }, + "label": "Glass9", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 13, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x015f:0x9001:0x8106:4.13.8", + "statistics": { + "commandsTX": 829, + "commandsRX": 923, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 4, + "rtt": 224.8, + "lastSeen": "2024-08-06T04:06:52.580Z", + "rssi": -49, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -49, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-08-06T04:06:15.046Z", + "protocol": 0, + "values": { + "46-91-0-slowRefresh": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + "46-114-0-manufacturerId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 351 + }, + "46-114-0-productType": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 36865 + }, + "46-114-0-productId": { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 33030 + }, + "46-134-0-libraryType": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + "46-134-0-protocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + "46-134-0-firmwareVersions": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["4.13", "2.0"] + }, + "46-134-0-hardwareVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + "46-134-0-sdkVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + "46-134-0-applicationFrameworkAPIVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.8" + }, + "46-134-0-applicationFrameworkBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + "46-134-0-hostInterfaceVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + "46-134-0-hostInterfaceBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-134-0-zWaveProtocolVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + "46-134-0-zWaveProtocolBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + "46-134-0-applicationVersion": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "4.13.8" + }, + "46-134-0-applicationBuildNumber": { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + "46-49-1-Air temperature": { + "endpoint": 1, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 31.1, + "nodeId": 46 + }, + "46-37-2-currentValue": { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-2-targetValue": { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-2-duration": { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-2-sceneId": { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-2-dimmingDuration": { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-3-currentValue": { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-3-targetValue": { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-3-duration": { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-3-sceneId": { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-3-dimmingDuration": { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-4-currentValue": { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-4-targetValue": { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-4-duration": { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-4-sceneId": { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-4-dimmingDuration": { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-5-currentValue": { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-5-targetValue": { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-5-duration": { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-5-sceneId": { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-5-dimmingDuration": { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-6-currentValue": { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-6-targetValue": { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-6-duration": { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-6-sceneId": { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-6-dimmingDuration": { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-7-currentValue": { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-7-targetValue": { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-7-duration": { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-7-sceneId": { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-7-dimmingDuration": { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-8-currentValue": { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-8-targetValue": { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-8-duration": { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-38-8-targetValue": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 63 + }, + "46-38-8-currentValue": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-38-8-Up": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-8-Down": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-8-duration": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + "46-38-8-restorePrevious": { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + "46-43-8-sceneId": { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-8-dimmingDuration": { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-8-value-65537": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-50-8-value-66049": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.5 + }, + "46-50-8-value-66561": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + "46-50-8-value-66817": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + "46-50-8-reset": { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + "46-113-8-alarmType": { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-8-alarmLevel": { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-8-Power Management-Over-current status": { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + "46-37-9-currentValue": { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-9-targetValue": { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-9-duration": { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-38-9-targetValue": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-38-9-currentValue": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 54 + }, + "46-38-9-Up": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-9-Down": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-9-duration": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + "46-38-9-restorePrevious": { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + "46-43-9-sceneId": { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-9-dimmingDuration": { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-9-value-65537": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-50-9-value-66049": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.7 + }, + "46-50-9-value-66561": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + "46-50-9-value-66817": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + "46-50-9-reset": { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + "46-113-9-alarmType": { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-9-alarmLevel": { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-9-Power Management-Over-current status": { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + "46-37-10-currentValue": { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-10-targetValue": { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-10-duration": { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-38-10-targetValue": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 56 + }, + "46-38-10-currentValue": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-38-10-Up": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-10-Down": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + "46-38-10-duration": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + "46-38-10-restorePrevious": { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + "46-43-10-sceneId": { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-10-dimmingDuration": { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-10-value-65537": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-50-10-value-66049": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 9 + }, + "46-50-10-value-66561": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + "46-50-10-value-66817": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + "46-50-10-reset": { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + "46-113-10-alarmType": { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-10-alarmLevel": { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + "46-113-10-Power Management-Over-current status": { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + "46-37-11-currentValue": { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-11-targetValue": { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-11-duration": { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-11-sceneId": { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-11-dimmingDuration": { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-12-currentValue": { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-12-targetValue": { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + "46-37-12-duration": { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-12-sceneId": { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-12-dimmingDuration": { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-37-13-currentValue": { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + "46-37-13-targetValue": { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + "46-37-13-duration": { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + "46-43-13-sceneId": { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + "46-43-13-dimmingDuration": { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + "46-50-10-value-66051": { + "commandClassName": "Meter", + "commandClass": 50, + "property": "value", + "propertyKey": 66051, + "endpoint": 10, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 3 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "propertyName": "value", + "propertyKeyName": "Electric_W_unknown (0x03)", + "nodeId": 46, + "value": 9.2 + } + }, + "endpoints": { + "0": { + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + } + ] + }, + "1": { + "nodeId": 46, + "index": 1, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + } + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "2": { + "nodeId": 46, + "index": 2, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "3": { + "nodeId": 46, + "index": 3, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "4": { + "nodeId": 46, + "index": 4, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "5": { + "nodeId": 46, + "index": 5, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "6": { + "nodeId": 46, + "index": 6, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "7": { + "nodeId": 46, + "index": 7, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "8": { + "nodeId": 46, + "index": 8, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "9": { + "nodeId": 46, + "index": 9, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "10": { + "nodeId": 46, + "index": 10, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "11": { + "nodeId": 46, + "index": 11, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "12": { + "nodeId": 46, + "index": 12, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + "13": { + "nodeId": 46, + "index": 13, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + } + } + } + } +} diff --git a/tests/components/zwave_js/fixtures/touchwand_glass9_state.json b/tests/components/zwave_js/fixtures/touchwand_glass9_state.json new file mode 100644 index 00000000000..a84797b75d4 --- /dev/null +++ b/tests/components/zwave_js/fixtures/touchwand_glass9_state.json @@ -0,0 +1,3467 @@ +{ + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 351, + "productId": 33030, + "productType": 36865, + "firmwareVersion": "4.13.8", + "zwavePlusVersion": 2, + "name": "gp9", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/root/zwave/store/config/devices/0x015f/glass9.json", + "isEmbedded": false, + "manufacturer": "TouchWand Co., Ltd.", + "manufacturerId": 351, + "label": "Glass9", + "description": "Glass 9", + "devices": [ + { + "productType": 36865, + "productId": 33030 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "compat": { + "removeCCs": {} + } + }, + "label": "Glass9", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 13, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x015f:0x9001:0x8106:4.13.8", + "statistics": { + "commandsTX": 829, + "commandsRX": 923, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 4, + "rtt": 224.8, + "lastSeen": "2024-08-06T04:06:52.580Z", + "rssi": -49, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -49, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-08-06T04:06:15.046Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 351 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 36865 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 33030 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["4.13", "2.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "4.13.8" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 437 + }, + { + "endpoint": 1, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 5, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 0 + }, + "unit": "\u00b0C", + "stateful": true, + "secret": false + }, + "value": 31.1, + "nodeId": 46 + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 6, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 7, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 63 + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.5 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + { + "endpoint": 8, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 8, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 54 + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 8.7 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + { + "endpoint": 9, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 9, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 56 + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 9 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66561, + "propertyName": "value", + "propertyKeyName": "Electric_V_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [V]", + "ccSpecific": { + "meterType": 1, + "scale": 4, + "rateType": 1 + }, + "unit": "V", + "stateful": true, + "secret": false + }, + "value": 231.8 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66817, + "propertyName": "value", + "propertyKeyName": "Electric_A_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [A]", + "ccSpecific": { + "meterType": 1, + "scale": 5, + "rateType": 1 + }, + "unit": "A", + "stateful": true, + "secret": false + }, + "value": 0.04 + }, + { + "endpoint": 10, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 10, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 11, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 12, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "sceneId", + "propertyName": "sceneId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Scene ID", + "valueChangeOptions": ["transitionDuration"], + "min": 1, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 13, + "commandClass": 43, + "commandClassName": "Scene Activation", + "property": "dimmingDuration", + "propertyName": "dimmingDuration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": true, + "label": "Dimming duration", + "stateful": true, + "secret": false + } + }, + { + "commandClassName": "Meter", + "commandClass": 50, + "property": "value", + "propertyKey": 66051, + "endpoint": 10, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 3 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "propertyName": "value", + "propertyKeyName": "Electric_W_unknown (0x03)", + "nodeId": 46, + "value": 9.2 + } + ], + "endpoints": [ + { + "nodeId": 46, + "index": 0, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 1, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 33, + "label": "Multilevel Sensor" + }, + "specific": { + "key": 1, + "label": "Routing Multilevel Sensor" + } + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 5, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 2, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 3, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 4, + "installerIcon": 1792, + "userIcon": 1792, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 5, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 6, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 7, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 8, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 9, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 10, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + } + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 4, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 11, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 12, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 46, + "index": 13, + "installerIcon": 2048, + "userIcon": 2048, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + }, + { + "id": 43, + "name": "Scene Activation", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 02296262d1f..c8bfca2b35f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -56,6 +56,24 @@ async def test_iblinds_v2(hass: HomeAssistant, client, iblinds_v2, integration) assert state +async def test_touchwand_glass9( + hass: HomeAssistant, + client: MagicMock, + touchwand_glass9: Node, + integration: MockConfigEntry, +) -> None: + """Test a touchwand_glass9 is discovered as a cover.""" + node = touchwand_glass9 + node_device_class = node.device_class + assert node_device_class + assert node_device_class.specific.label == "Unused" + + assert not hass.states.async_entity_ids_count("light") + assert hass.states.async_entity_ids_count("cover") == 3 + state = hass.states.get("cover.gp9") + assert state + + async def test_zvidar_state(hass: HomeAssistant, client, zvidar, integration) -> None: """Test that an ZVIDAR Z-CM-V01 multilevel switch value is discovered as a cover.""" node = zvidar diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index fa82b051e59..4350d7f7649 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -27,7 +27,7 @@ from homeassistant.components.persistent_notification import async_dismiss from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id, get_device_id_ext from homeassistant.config_entries import ConfigEntryDisabler, ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import ( area_registry as ar, @@ -366,6 +366,7 @@ async def test_listen_done_after_setup( @pytest.mark.usefixtures("client") +@pytest.mark.parametrize("platforms", [[Platform.SENSOR]]) async def test_new_entity_on_value_added( hass: HomeAssistant, multisensor_6: Node, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index c3580df1f27..ef77e22bbec 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -655,6 +655,17 @@ async def test_special_meters( "value": 659.813, }, ) + node_data["endpoints"].append( + { + "nodeId": 102, + "index": 10, + "installerIcon": 1792, + "userIcon": 1792, + "commandClasses": [ + {"id": 50, "name": "Meter", "version": 3, "isSecure": False} + ], + } + ) # Add an ElectricScale.KILOVOLT_AMPERE_REACTIVE value to the state so we can test that # it is handled differently (no device class) node_data["values"].append( @@ -678,6 +689,17 @@ async def test_special_meters( "value": 659.813, }, ) + node_data["endpoints"].append( + { + "nodeId": 102, + "index": 11, + "installerIcon": 1792, + "userIcon": 1792, + "commandClasses": [ + {"id": 50, "name": "Meter", "version": 3, "isSecure": False} + ], + } + ) node = Node(client, node_data) event = {"node": node} client.driver.controller.emit("node added", event) From d16ec81727948154aa040b49fb0582de5803b298 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 08:10:06 +0200 Subject: [PATCH 0412/1664] Migrate justnimbus to use runtime_data (#147170) --- homeassistant/components/justnimbus/__init__.py | 15 ++++++--------- .../components/justnimbus/coordinator.py | 8 ++++++-- homeassistant/components/justnimbus/sensor.py | 9 +++------ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/justnimbus/__init__.py b/homeassistant/components/justnimbus/__init__.py index 123807d887c..5f369027b00 100644 --- a/homeassistant/components/justnimbus/__init__.py +++ b/homeassistant/components/justnimbus/__init__.py @@ -2,15 +2,14 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, PLATFORMS -from .coordinator import JustNimbusCoordinator +from .const import PLATFORMS +from .coordinator import JustNimbusConfigEntry, JustNimbusCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: JustNimbusConfigEntry) -> bool: """Set up JustNimbus from a config entry.""" if "zip_code" in entry.data: coordinator = JustNimbusCoordinator(hass, entry) @@ -18,13 +17,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: JustNimbusConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/justnimbus/coordinator.py b/homeassistant/components/justnimbus/coordinator.py index a6945c45417..b51058a8e54 100644 --- a/homeassistant/components/justnimbus/coordinator.py +++ b/homeassistant/components/justnimbus/coordinator.py @@ -16,13 +16,17 @@ from .const import CONF_ZIP_CODE, DOMAIN _LOGGER = logging.getLogger(__name__) +type JustNimbusConfigEntry = ConfigEntry[JustNimbusCoordinator] + class JustNimbusCoordinator(DataUpdateCoordinator[justnimbus.JustNimbusModel]): """Data update coordinator.""" - config_entry: ConfigEntry + config_entry: JustNimbusConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__( + self, hass: HomeAssistant, config_entry: JustNimbusConfigEntry + ) -> None: """Initialize the coordinator.""" super().__init__( hass, diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py index 1e288e272cd..88f12cad113 100644 --- a/homeassistant/components/justnimbus/sensor.py +++ b/homeassistant/components/justnimbus/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, EntityCategory, @@ -24,8 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from . import JustNimbusCoordinator -from .const import DOMAIN +from .coordinator import JustNimbusConfigEntry, JustNimbusCoordinator from .entity import JustNimbusEntity @@ -102,16 +100,15 @@ SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: JustNimbusConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the JustNimbus sensor.""" - coordinator: JustNimbusCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( JustNimbusSensor( device_id=entry.data[CONF_CLIENT_ID], description=description, - coordinator=coordinator, + coordinator=entry.runtime_data, ) for description in SENSOR_TYPES ) From 0a5d13f10466e595100045b8194a5b314e9eb08b Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 20 Jun 2025 08:10:44 +0200 Subject: [PATCH 0413/1664] fix and improve cover tests for homee (#147164) --- tests/components/homee/test_cover.py | 74 ++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index 4f85b2dd7cc..a3e26abc52a 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -66,6 +66,35 @@ async def test_open_close_stop_cover( assert call[0] == (mock_homee.nodes[0].id, 1, index) +async def test_open_close_reverse_cover( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test opening the cover.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.nodes[0].attributes[0].is_reversed = True + + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + assert calls[0][0] == (mock_homee.nodes[0].id, 1, 1) # Open + assert calls[1][0] == (mock_homee.nodes[0].id, 1, 0) # Close + + async def test_set_cover_position( hass: HomeAssistant, mock_homee: MagicMock, @@ -76,30 +105,29 @@ async def test_set_cover_position( await setup_integration(hass, mock_config_entry) - # Slats have a range of -45 to 90. await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 100}, + {ATTR_ENTITY_ID: "cover.test_cover", ATTR_POSITION: 100}, blocking=True, ) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 0}, + {ATTR_ENTITY_ID: "cover.test_cover", ATTR_POSITION: 0}, blocking=True, ) await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_slats", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_cover", ATTR_POSITION: 50}, blocking=True, ) calls = mock_homee.set_value.call_args_list positions = [0, 100, 50] for call in calls: - assert call[0] == (1, 2, positions.pop(0)) + assert call[0] == (3, 2, positions.pop(0)) async def test_close_open_slats( @@ -137,6 +165,42 @@ async def test_close_open_slats( assert call[0] == (mock_homee.nodes[0].id, 2, index) +async def test_close_open_reversed_slats( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test closing and opening slats.""" + mock_homee.nodes = [build_mock_node("cover_with_slats_position.json")] + mock_homee.nodes[0].attributes[1].is_reversed = True + + await setup_integration(hass, mock_config_entry) + + attributes = hass.states.get("cover.test_slats").attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: "cover.test_slats"}, + blocking=True, + ) + + calls = mock_homee.set_value.call_args_list + assert calls[0][0] == (mock_homee.nodes[0].id, 2, 2) # Close + assert calls[1][0] == (mock_homee.nodes[0].id, 2, 1) # Open + + async def test_set_slat_position( hass: HomeAssistant, mock_homee: MagicMock, From 73bed96a0f2ac2c6bde3394cfcee21e27e2ad57c Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 20 Jun 2025 08:11:20 +0200 Subject: [PATCH 0414/1664] remove unwanted attribute in homee sensor tests (#147158) --- tests/components/homee/fixtures/sensors.json | 21 ---- .../homee/snapshots/test_sensor.ambr | 109 +++++++++--------- tests/components/homee/test_sensor.py | 4 +- 3 files changed, 58 insertions(+), 76 deletions(-) diff --git a/tests/components/homee/fixtures/sensors.json b/tests/components/homee/fixtures/sensors.json index 50daa59c99f..1c743195a20 100644 --- a/tests/components/homee/fixtures/sensors.json +++ b/tests/components/homee/fixtures/sensors.json @@ -81,27 +81,6 @@ "data": "", "name": "" }, - { - "id": 34, - "node_id": 1, - "instance": 2, - "minimum": 0, - "maximum": 100, - "current_value": 100.0, - "target_value": 100.0, - "last_value": 100.0, - "unit": "%", - "step_value": 1.0, - "editable": 0, - "type": 8, - "state": 1, - "last_changed": 1709982926, - "changed_by": 1, - "changed_by_id": 0, - "based_on": 1, - "data": "", - "name": "" - }, { "id": 4, "node_id": 1, diff --git a/tests/components/homee/snapshots/test_sensor.ambr b/tests/components/homee/snapshots/test_sensor.ambr index b5975af2d54..4e4eb98f28c 100644 --- a/tests/components/homee/snapshots/test_sensor.ambr +++ b/tests/components/homee/snapshots/test_sensor.ambr @@ -52,59 +52,6 @@ 'state': '100.0', }) # --- -# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-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.test_multisensor_battery_2', - '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': 'homee', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'battery_instance', - 'unique_id': '00055511EECC-1-34', - 'unit_of_measurement': '%', - }) -# --- -# name: test_sensor_snapshot[sensor.test_multisensor_battery_2-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'battery', - 'friendly_name': 'Test MultiSensor Battery', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.test_multisensor_battery_2', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100.0', - }) -# --- # name: test_sensor_snapshot[sensor.test_multisensor_current_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -490,6 +437,62 @@ 'state': '2000.0', }) # --- +# name: test_sensor_snapshot[sensor.test_multisensor_external_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.test_multisensor_external_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': 'External temperature', + 'platform': 'homee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'external_temperature', + 'unique_id': '00055511EECC-1-34', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_snapshot[sensor.test_multisensor_external_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test MultiSensor External temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_multisensor_external_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23.6', + }) +# --- # name: test_sensor_snapshot[sensor.test_multisensor_floor_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 14a9320ffa1..1d4ad4b0f66 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -47,7 +47,7 @@ async def test_up_down_values( assert hass.states.get("sensor.test_multisensor_state").state == OPEN_CLOSE_MAP[0] - attribute = mock_homee.nodes[0].attributes[28] + attribute = mock_homee.nodes[0].attributes[27] for i in range(1, 5): await async_update_attribute_value(hass, attribute, i) assert ( @@ -77,7 +77,7 @@ async def test_window_position( == WINDOW_MAP[0] ) - attribute = mock_homee.nodes[0].attributes[33] + attribute = mock_homee.nodes[0].attributes[32] for i in range(1, 3): await async_update_attribute_value(hass, attribute, i) assert ( From 2e21493c198731b0486400b0cc711faca478ef47 Mon Sep 17 00:00:00 2001 From: Krisjanis Lejejs Date: Fri, 20 Jun 2025 11:18:03 +0300 Subject: [PATCH 0415/1664] Bump hass-nabucasa from 0.102.0 to 0.103.0 (#147186) --- 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 0d1aca60c8f..b5c73e08f3e 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.102.0"], + "requirements": ["hass-nabucasa==0.103.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index df0c6ef7452..3eb77beed93 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -36,7 +36,7 @@ fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 habluetooth==3.49.0 -hass-nabucasa==0.102.0 +hass-nabucasa==0.103.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.3 diff --git a/pyproject.toml b/pyproject.toml index 0213dbf27ab..4295c23740f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration - "hass-nabucasa==0.102.0", + "hass-nabucasa==0.103.0", # hassil is indirectly imported from onboarding via the import chain # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs # to be setup in stage 0, but we don't want to also promote cloud with all its diff --git a/requirements.txt b/requirements.txt index bf963ecc52d..b47d33e7a44 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 ha-ffmpeg==3.2.2 -hass-nabucasa==0.102.0 +hass-nabucasa==0.103.0 hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index cf683a09e67..367bd2f5048 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1124,7 +1124,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.102.0 +hass-nabucasa==0.103.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f513185014..abede3d5e7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -982,7 +982,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.102.0 +hass-nabucasa==0.103.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 973700542b062e11139c21650d01adbdb14c07ba Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:19:19 +0200 Subject: [PATCH 0416/1664] Move kmtronic coordinator to separate module (#147182) --- homeassistant/components/kmtronic/__init__.py | 30 ++---------- .../components/kmtronic/coordinator.py | 46 +++++++++++++++++++ 2 files changed, 49 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/kmtronic/coordinator.py diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index edec0b32af2..b49efebc35e 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -1,10 +1,5 @@ """The kmtronic integration.""" -import asyncio -from datetime import timedelta -import logging - -import aiohttp from pykmtronic.auth import Auth from pykmtronic.hub import KMTronicHubAPI @@ -12,14 +7,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER, UPDATE_LISTENER +from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN, UPDATE_LISTENER +from .coordinator import KMtronicCoordinator PLATFORMS = [Platform.SWITCH] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up kmtronic from a config entry.""" @@ -31,24 +24,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], ) hub = KMTronicHubAPI(auth) - - async def async_update_data(): - try: - async with asyncio.timeout(10): - await hub.async_update_relays() - except aiohttp.client_exceptions.ClientResponseError as err: - raise UpdateFailed(f"Wrong credentials: {err}") from err - except aiohttp.client_exceptions.ClientConnectorError as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{MANUFACTURER} {hub.name}", - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = KMtronicCoordinator(hass, entry, hub) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/kmtronic/coordinator.py b/homeassistant/components/kmtronic/coordinator.py new file mode 100644 index 00000000000..8a94949dea6 --- /dev/null +++ b/homeassistant/components/kmtronic/coordinator.py @@ -0,0 +1,46 @@ +"""The kmtronic integration.""" + +import asyncio +from datetime import timedelta +import logging + +from aiohttp.client_exceptions import ClientConnectorError, ClientResponseError +from pykmtronic.hub import KMTronicHubAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import MANUFACTURER + +PLATFORMS = [Platform.SWITCH] + +_LOGGER = logging.getLogger(__name__) + + +class KMtronicCoordinator(DataUpdateCoordinator[None]): + """Coordinator for KMTronic.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, hub: KMTronicHubAPI + ) -> None: + """Initialize the KMTronic coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{MANUFACTURER} {hub.name}", + update_interval=timedelta(seconds=30), + ) + self.hub = hub + + async def _async_update_data(self) -> None: + """Fetch the latest data from the source.""" + try: + async with asyncio.timeout(10): + await self.hub.async_update_relays() + except ClientResponseError as err: + raise UpdateFailed(f"Wrong credentials: {err}") from err + except ClientConnectorError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err From e23cac8befb080a3e7e1a4a31f9c9a7d7bc7c052 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:23:41 +0200 Subject: [PATCH 0417/1664] Simplify remove listener in kodi (#147183) --- homeassistant/components/kodi/__init__.py | 12 ++---------- homeassistant/components/kodi/const.py | 1 - 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/kodi/__init__.py b/homeassistant/components/kodi/__init__.py index d3c7d4da724..5400d142f28 100644 --- a/homeassistant/components/kodi/__init__.py +++ b/homeassistant/components/kodi/__init__.py @@ -17,13 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - CONF_WS_PORT, - DATA_CONNECTION, - DATA_KODI, - DATA_REMOVE_LISTENER, - DOMAIN, -) +from .const import CONF_WS_PORT, DATA_CONNECTION, DATA_KODI, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.MEDIA_PLAYER] @@ -58,13 +52,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _close(event): await conn.close() - remove_stop_listener = hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close) + entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = { DATA_CONNECTION: conn, DATA_KODI: kodi, - DATA_REMOVE_LISTENER: remove_stop_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -78,6 +71,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: data = hass.data[DOMAIN].pop(entry.entry_id) await data[DATA_CONNECTION].close() - data[DATA_REMOVE_LISTENER]() return unload_ok diff --git a/homeassistant/components/kodi/const.py b/homeassistant/components/kodi/const.py index 479b02e0fb5..167ea2a4725 100644 --- a/homeassistant/components/kodi/const.py +++ b/homeassistant/components/kodi/const.py @@ -6,7 +6,6 @@ CONF_WS_PORT = "ws_port" DATA_CONNECTION = "connection" DATA_KODI = "kodi" -DATA_REMOVE_LISTENER = "remove_listener" DEFAULT_PORT = 8080 DEFAULT_SSL = False From d0e77eb1e26a0e126b67dc933a0ce921f35650ee Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:24:56 +0200 Subject: [PATCH 0418/1664] Migrate keymitt_ble to use runtime_data (#147179) --- .../components/keymitt_ble/__init__.py | 19 +++++-------------- .../components/keymitt_ble/coordinator.py | 7 ++++--- .../components/keymitt_ble/entity.py | 2 +- .../components/keymitt_ble/switch.py | 9 +++------ 4 files changed, 13 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 7fea46d7a02..01948006852 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,26 +2,20 @@ from __future__ import annotations -import logging - from microbot import MicroBotApiClient from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import MicroBotDataUpdateCoordinator +from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator -_LOGGER: logging.Logger = logging.getLogger(__package__) PLATFORMS: list[str] = [Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MicroBotConfigEntry) -> bool: """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) token: str = entry.data[CONF_ACCESS_TOKEN] bdaddr: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address(hass, bdaddr) @@ -35,7 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, client=client, ble_device=ble_device ) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(coordinator.async_start()) @@ -43,9 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MicroBotConfigEntry) -> bool: """Handle removal of an entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/keymitt_ble/coordinator.py b/homeassistant/components/keymitt_ble/coordinator.py index 3e72826ac5d..9d2b250ba82 100644 --- a/homeassistant/components/keymitt_ble/coordinator.py +++ b/homeassistant/components/keymitt_ble/coordinator.py @@ -11,14 +11,15 @@ from homeassistant.components import bluetooth from homeassistant.components.bluetooth.passive_update_coordinator import ( PassiveBluetoothDataUpdateCoordinator, ) -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from bleak.backends.device import BLEDevice _LOGGER: logging.Logger = logging.getLogger(__package__) -PLATFORMS: list[str] = [Platform.SWITCH] + +type MicroBotConfigEntry = ConfigEntry[MicroBotDataUpdateCoordinator] class MicroBotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): @@ -31,7 +32,7 @@ class MicroBotDataUpdateCoordinator(PassiveBluetoothDataUpdateCoordinator): ble_device: BLEDevice, ) -> None: """Initialize.""" - self.api: MicroBotApiClient = client + self.api = client self.data: dict[str, Any] = {} self.ble_device = ble_device super().__init__( diff --git a/homeassistant/components/keymitt_ble/entity.py b/homeassistant/components/keymitt_ble/entity.py index b5229e6917e..94bb1498744 100644 --- a/homeassistant/components/keymitt_ble/entity.py +++ b/homeassistant/components/keymitt_ble/entity.py @@ -19,7 +19,7 @@ class MicroBotEntity(PassiveBluetoothCoordinatorEntity[MicroBotDataUpdateCoordin _attr_has_entity_name = True - def __init__(self, coordinator, config_entry): + def __init__(self, coordinator: MicroBotDataUpdateCoordinator) -> None: """Initialise the entity.""" super().__init__(coordinator) self._address = self.coordinator.ble_device.address diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py index 57d3af98062..dab7d8c2d36 100644 --- a/homeassistant/components/keymitt_ble/switch.py +++ b/homeassistant/components/keymitt_ble/switch.py @@ -7,7 +7,6 @@ from typing import Any import voluptuous as vol from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( @@ -16,8 +15,7 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.typing import VolDictType -from .const import DOMAIN -from .coordinator import MicroBotDataUpdateCoordinator +from .coordinator import MicroBotConfigEntry from .entity import MicroBotEntity CALIBRATE = "calibrate" @@ -30,12 +28,11 @@ CALIBRATE_SCHEMA: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MicroBotConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MicroBot based on a config entry.""" - coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([MicroBotBinarySwitch(coordinator, entry)]) + async_add_entities([MicroBotBinarySwitch(entry.runtime_data)]) platform = async_get_current_platform() platform.async_register_entity_service( CALIBRATE, From e315cb9859e4af7b0b098aed7cbe8fc5617474ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:25:08 +0200 Subject: [PATCH 0419/1664] Migrate kostal_plenticore to use runtime_data (#147188) --- .../components/kostal_plenticore/__init__.py | 19 ++++++------------- .../kostal_plenticore/coordinator.py | 10 ++++++---- .../kostal_plenticore/diagnostics.py | 8 +++----- .../components/kostal_plenticore/number.py | 8 +++----- .../components/kostal_plenticore/select.py | 8 +++----- .../components/kostal_plenticore/sensor.py | 8 +++----- .../components/kostal_plenticore/switch.py | 8 +++----- .../kostal_plenticore/test_helper.py | 4 ++-- 8 files changed, 29 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index 3675b4342b4..c549a8d338f 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -4,42 +4,35 @@ import logging from pykoplenti import ApiException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import Plenticore +from .coordinator import Plenticore, PlenticoreConfigEntry _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.NUMBER, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: PlenticoreConfigEntry) -> bool: """Set up Kostal Plenticore Solar Inverter from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - plenticore = Plenticore(hass, entry) if not await plenticore.async_setup(): return False - hass.data[DOMAIN][entry.entry_id] = plenticore + entry.runtime_data = plenticore 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, entry: PlenticoreConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - # remove API object - plenticore = hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): try: - await plenticore.async_unload() + await entry.runtime_data.async_unload() except ApiException as err: _LOGGER.error("Error logging out from inverter: %s", err) diff --git a/homeassistant/components/kostal_plenticore/coordinator.py b/homeassistant/components/kostal_plenticore/coordinator.py index f87f8ca630a..d312130bb54 100644 --- a/homeassistant/components/kostal_plenticore/coordinator.py +++ b/homeassistant/components/kostal_plenticore/coordinator.py @@ -30,6 +30,8 @@ from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) +type PlenticoreConfigEntry = ConfigEntry[Plenticore] + class Plenticore: """Manages the Plenticore API.""" @@ -166,12 +168,12 @@ class DataUpdateCoordinatorMixin: class PlenticoreUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base implementation of DataUpdateCoordinator for Plenticore data.""" - config_entry: ConfigEntry + config_entry: PlenticoreConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PlenticoreConfigEntry, logger: logging.Logger, name: str, update_inverval: timedelta, @@ -248,12 +250,12 @@ class SettingDataUpdateCoordinator( class PlenticoreSelectUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """Base implementation of DataUpdateCoordinator for Plenticore data.""" - config_entry: ConfigEntry + config_entry: PlenticoreConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: PlenticoreConfigEntry, logger: logging.Logger, name: str, update_inverval: timedelta, diff --git a/homeassistant/components/kostal_plenticore/diagnostics.py b/homeassistant/components/kostal_plenticore/diagnostics.py index 3978869c524..4d4d61f56a7 100644 --- a/homeassistant/components/kostal_plenticore/diagnostics.py +++ b/homeassistant/components/kostal_plenticore/diagnostics.py @@ -5,23 +5,21 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_IDENTIFIERS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import Plenticore +from .coordinator import PlenticoreConfigEntry TO_REDACT = {CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: PlenticoreConfigEntry ) -> dict[str, dict[str, Any]]: """Return diagnostics for a config entry.""" data = {"config_entry": async_redact_data(config_entry.as_dict(), TO_REDACT)} - plenticore: Plenticore = hass.data[DOMAIN][config_entry.entry_id] + plenticore = config_entry.runtime_data # Get information from Kostal Plenticore library available_process_data = await plenticore.client.get_process_data() diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 7efb00cf8f4..ddb0a84a6cc 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -14,15 +14,13 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import SettingDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator from .helper import PlenticoreDataFormatter _LOGGER = logging.getLogger(__name__) @@ -74,11 +72,11 @@ NUMBER_SETTINGS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add Kostal Plenticore Number entities.""" - plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data entities = [] diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 61929b9fadc..86ffb63966d 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -7,15 +7,13 @@ from datetime import timedelta import logging from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import Plenticore, SelectDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, SelectDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,11 +41,11 @@ SELECT_SETTINGS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Select widget.""" - plenticore: Plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data available_settings_data = await plenticore.client.get_settings() select_data_update_coordinator = SelectDataUpdateCoordinator( diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 1be7fb06e7b..aafd6bb1ff6 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -29,8 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import ProcessDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, ProcessDataUpdateCoordinator from .helper import PlenticoreDataFormatter _LOGGER = logging.getLogger(__name__) @@ -808,11 +806,11 @@ SENSOR_PROCESS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Sensors.""" - plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data entities = [] diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index e3d5f830c78..44eced7ca4a 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -8,15 +8,13 @@ import logging from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN -from .coordinator import SettingDataUpdateCoordinator +from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,11 +47,11 @@ SWITCH_SETTINGS_DATA = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: PlenticoreConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add kostal plenticore Switch.""" - plenticore = hass.data[DOMAIN][entry.entry_id] + plenticore = entry.runtime_data entities = [] diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py index acd33f82a27..96cdc99144b 100644 --- a/tests/components/kostal_plenticore/test_helper.py +++ b/tests/components/kostal_plenticore/test_helper.py @@ -67,7 +67,7 @@ async def test_plenticore_async_setup_g1( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + plenticore = mock_config_entry.runtime_data assert plenticore.device_info == DeviceInfo( configuration_url="http://192.168.1.2", @@ -119,7 +119,7 @@ async def test_plenticore_async_setup_g2( assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + plenticore = mock_config_entry.runtime_data assert plenticore.device_info == DeviceInfo( configuration_url="http://192.168.1.2", From 8f661fc5cf3f5cd8d04493487e11648f6425d885 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:26:53 +0200 Subject: [PATCH 0420/1664] Migrate kegtron to use runtime_data (#147177) --- homeassistant/components/kegtron/__init__.py | 28 +++++++++----------- homeassistant/components/kegtron/sensor.py | 10 +++---- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/kegtron/__init__.py b/homeassistant/components/kegtron/__init__.py index d7485be0840..ec2ebee6995 100644 --- a/homeassistant/components/kegtron/__init__.py +++ b/homeassistant/components/kegtron/__init__.py @@ -14,27 +14,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type KegtronConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: KegtronConfigEntry) -> bool: """Set up Kegtron BLE device from a config entry.""" address = entry.unique_id assert address is not None data = KegtronBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.PASSIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -42,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: KegtronConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/kegtron/sensor.py b/homeassistant/components/kegtron/sensor.py index 602c61f96ff..f0023e8ef6a 100644 --- a/homeassistant/components/kegtron/sensor.py +++ b/homeassistant/components/kegtron/sensor.py @@ -8,11 +8,9 @@ from kegtron_ble import ( Units, ) -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -30,7 +28,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import KegtronConfigEntry from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -109,13 +107,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: KegtronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Kegtron BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( From 32314dbb13f1bc5251835c1ab621213c579a70c6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:27:07 +0200 Subject: [PATCH 0421/1664] Simplify update_listener in kmtronic (#147184) --- homeassistant/components/kmtronic/__init__.py | 7 ++----- homeassistant/components/kmtronic/const.py | 2 -- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index b49efebc35e..1c2cfb7cc31 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -8,7 +8,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platfor from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN, UPDATE_LISTENER +from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN from .coordinator import KMtronicCoordinator PLATFORMS = [Platform.SWITCH] @@ -35,8 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - update_listener = entry.add_update_listener(async_update_options) - hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] = update_listener + entry.async_on_unload(entry.add_update_listener(async_update_options)) return True @@ -50,8 +49,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - update_listener = hass.data[DOMAIN][entry.entry_id][UPDATE_LISTENER] - update_listener() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py index 3bdb3074851..2381ad57998 100644 --- a/homeassistant/components/kmtronic/const.py +++ b/homeassistant/components/kmtronic/const.py @@ -8,5 +8,3 @@ DATA_HUB = "hub" DATA_COORDINATOR = "coordinator" MANUFACTURER = "KMtronic" - -UPDATE_LISTENER = "update_listener" From 05343392a757967056792ab3ab6b7c5c606d5622 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:27:47 +0200 Subject: [PATCH 0422/1664] Simplify update_listener in keenetic_ndms2 (#147173) --- homeassistant/components/keenetic_ndms2/__init__.py | 6 +----- homeassistant/components/keenetic_ndms2/const.py | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index e2ca17ebce8..a4447dcd904 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -20,7 +20,6 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTER, - UNDO_UPDATE_LISTENER, ) from .router import KeeneticRouter @@ -36,11 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: router = KeeneticRouter(hass, entry) await router.async_setup() - undo_listener = entry.add_update_listener(update_listener) + entry.async_on_unload(entry.add_update_listener(update_listener)) hass.data[DOMAIN][entry.entry_id] = { ROUTER: router, - UNDO_UPDATE_LISTENER: undo_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -50,8 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() - unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) diff --git a/homeassistant/components/keenetic_ndms2/const.py b/homeassistant/components/keenetic_ndms2/const.py index 0b415a9502f..d7db0673690 100644 --- a/homeassistant/components/keenetic_ndms2/const.py +++ b/homeassistant/components/keenetic_ndms2/const.py @@ -6,7 +6,6 @@ from homeassistant.components.device_tracker import ( DOMAIN = "keenetic_ndms2" ROUTER = "router" -UNDO_UPDATE_LISTENER = "undo_update_listener" DEFAULT_TELNET_PORT = 23 DEFAULT_SCAN_INTERVAL = 120 DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.total_seconds() From 8c1e43c07c5bb207a36e48b4dbbe00146edb0f98 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 20 Jun 2025 10:28:35 +0200 Subject: [PATCH 0423/1664] Bump pypck to 0.8.9 (#147174) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 30584bc33f6..9e300716d3e 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.8", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 367bd2f5048..425d09bd2eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2237,7 +2237,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.8 +pypck==0.8.9 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index abede3d5e7d..924ebc07ef7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1858,7 +1858,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.8 +pypck==0.8.9 # homeassistant.components.pglab pypglab==0.0.5 From fde36d5034904c461df4992f01c2ecd7d1e9098a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:31:28 +0200 Subject: [PATCH 0424/1664] Simplify update_listener in konnected (#147172) --- homeassistant/components/konnected/__init__.py | 9 +-------- homeassistant/components/konnected/const.py | 2 -- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 25c731ac7f4..dd4dbc7dbe5 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -58,7 +58,6 @@ from .const import ( PIN_TO_ZONE, STATE_HIGH, STATE_LOW, - UNDO_UPDATE_LISTENER, UPDATE_ENDPOINT, ZONE_TO_PIN, ZONES, @@ -261,10 +260,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # config entry specific data to enable unload - hass.data[DOMAIN][entry.entry_id] = { - UNDO_UPDATE_LISTENER: entry.add_update_listener(async_entry_updated) - } + entry.async_on_unload(entry.add_update_listener(async_entry_updated)) return True @@ -272,11 +268,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - if unload_ok: hass.data[DOMAIN][CONF_DEVICES].pop(entry.data[CONF_ID]) - hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index c4dd67e7d39..ffaa548003b 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -44,5 +44,3 @@ ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} ENDPOINT_ROOT = "/api/konnected" UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}" SIGNAL_DS18B20_NEW = "konnected.ds18b20.new" - -UNDO_UPDATE_LISTENER = "undo_update_listener" From 84e94222544cc87f220fcc9a26471c629c584dfc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:33:17 +0200 Subject: [PATCH 0425/1664] Move juicenet coordinator to separate module (#147168) --- homeassistant/components/juicenet/__init__.py | 18 ++-------- .../components/juicenet/coordinator.py | 33 +++++++++++++++++++ homeassistant/components/juicenet/device.py | 10 +++--- homeassistant/components/juicenet/entity.py | 10 +++--- homeassistant/components/juicenet/number.py | 15 +++++---- homeassistant/components/juicenet/sensor.py | 17 +++++++--- homeassistant/components/juicenet/switch.py | 14 +++++--- 7 files changed, 74 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/juicenet/coordinator.py diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index fcfca7f2492..6cfdd85c6b7 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,6 +1,5 @@ """The JuiceNet integration.""" -from datetime import timedelta import logging import aiohttp @@ -14,9 +13,9 @@ from homeassistant.exceptions import 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 homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR +from .coordinator import JuiceNetCoordinator from .device import JuiceNetApi _LOGGER = logging.getLogger(__name__) @@ -74,20 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return False _LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices)) - async def async_update_data(): - """Update all device states from the JuiceNet API.""" - for device in juicenet.devices: - await device.update_state(True) - return True - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name="JuiceNet", - update_method=async_update_data, - update_interval=timedelta(seconds=30), - ) + coordinator = JuiceNetCoordinator(hass, entry, juicenet) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/juicenet/coordinator.py b/homeassistant/components/juicenet/coordinator.py new file mode 100644 index 00000000000..7a89416e400 --- /dev/null +++ b/homeassistant/components/juicenet/coordinator.py @@ -0,0 +1,33 @@ +"""The JuiceNet integration.""" + +from datetime import timedelta +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .device import JuiceNetApi + +_LOGGER = logging.getLogger(__name__) + + +class JuiceNetCoordinator(DataUpdateCoordinator[None]): + """Coordinator for JuiceNet.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, juicenet_api: JuiceNetApi + ) -> None: + """Initialize the JuiceNet coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name="JuiceNet", + update_interval=timedelta(seconds=30), + ) + self.juicenet_api = juicenet_api + + async def _async_update_data(self) -> None: + for device in self.juicenet_api.devices: + await device.update_state(True) diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py index daec88c2a94..b38b0efd68a 100644 --- a/homeassistant/components/juicenet/device.py +++ b/homeassistant/components/juicenet/device.py @@ -1,19 +1,21 @@ """Adapter to wrap the pyjuicenet api for home assistant.""" +from pyjuicenet import Api, Charger + class JuiceNetApi: """Represent a connection to JuiceNet.""" - def __init__(self, api): + def __init__(self, api: Api) -> None: """Create an object from the provided API instance.""" self.api = api - self._devices = [] + self._devices: list[Charger] = [] - async def setup(self): + async def setup(self) -> None: """JuiceNet device setup.""" self._devices = await self.api.get_devices() @property - def devices(self) -> list: + def devices(self) -> list[Charger]: """Get a list of devices managed by this account.""" return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index b3433948582..d54ccb5accb 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -3,21 +3,19 @@ from pyjuicenet import Charger from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import JuiceNetCoordinator -class JuiceNetDevice(CoordinatorEntity): +class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]): """Represent a base JuiceNet device.""" _attr_has_entity_name = True def __init__( - self, device: Charger, key: str, coordinator: DataUpdateCoordinator + self, device: Charger, key: str, coordinator: JuiceNetCoordinator ) -> None: """Initialise the sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index 69323884f61..ff8c357a115 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from pyjuicenet import Api, Charger +from pyjuicenet import Charger from homeassistant.components.number import ( DEFAULT_MAX_VALUE, @@ -14,10 +14,11 @@ from homeassistant.components.number import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice +from .coordinator import JuiceNetCoordinator +from .device import JuiceNetApi +from .entity import JuiceNetEntity @dataclass(frozen=True, kw_only=True) @@ -47,8 +48,8 @@ async def async_setup_entry( ) -> None: """Set up the JuiceNet Numbers.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: Api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] + api: JuiceNetApi = juicenet_data[JUICENET_API] + coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] entities = [ JuiceNetNumber(device, description, coordinator) @@ -58,7 +59,7 @@ async def async_setup_entry( async_add_entities(entities) -class JuiceNetNumber(JuiceNetDevice, NumberEntity): +class JuiceNetNumber(JuiceNetEntity, NumberEntity): """Implementation of a JuiceNet number.""" entity_description: JuiceNetNumberEntityDescription @@ -67,7 +68,7 @@ class JuiceNetNumber(JuiceNetDevice, NumberEntity): self, device: Charger, description: JuiceNetNumberEntityDescription, - coordinator: DataUpdateCoordinator, + coordinator: JuiceNetCoordinator, ) -> None: """Initialise the number.""" super().__init__(device, description.key, coordinator) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 7bf0639f5d0..e3ae35da2ce 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pyjuicenet import Charger + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -21,7 +23,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice +from .coordinator import JuiceNetCoordinator +from .device import JuiceNetApi +from .entity import JuiceNetEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -74,8 +78,8 @@ async def async_setup_entry( ) -> None: """Set up the JuiceNet Sensors.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] + api: JuiceNetApi = juicenet_data[JUICENET_API] + coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] entities = [ JuiceNetSensorDevice(device, coordinator, description) @@ -85,11 +89,14 @@ async def async_setup_entry( async_add_entities(entities) -class JuiceNetSensorDevice(JuiceNetDevice, SensorEntity): +class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity): """Implementation of a JuiceNet sensor.""" def __init__( - self, device, coordinator, description: SensorEntityDescription + self, + device: Charger, + coordinator: JuiceNetCoordinator, + description: SensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(device, description.key, coordinator) diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py index 9f34b7afdb3..e8a16e9da8f 100644 --- a/homeassistant/components/juicenet/switch.py +++ b/homeassistant/components/juicenet/switch.py @@ -2,13 +2,17 @@ from typing import Any +from pyjuicenet import Charger + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .entity import JuiceNetDevice +from .coordinator import JuiceNetCoordinator +from .device import JuiceNetApi +from .entity import JuiceNetEntity async def async_setup_entry( @@ -18,20 +22,20 @@ async def async_setup_entry( ) -> None: """Set up the JuiceNet switches.""" juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api = juicenet_data[JUICENET_API] - coordinator = juicenet_data[JUICENET_COORDINATOR] + api: JuiceNetApi = juicenet_data[JUICENET_API] + coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] async_add_entities( JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices ) -class JuiceNetChargeNowSwitch(JuiceNetDevice, SwitchEntity): +class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity): """Implementation of a JuiceNet switch.""" _attr_translation_key = "charge_now" - def __init__(self, device, coordinator): + def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None: """Initialise the switch.""" super().__init__(device, "charge_now", coordinator) From 88683a318d5137eb4b1b8ab9df90fb934b507b0a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 20 Jun 2025 10:34:43 +0200 Subject: [PATCH 0426/1664] Add support of taking a camera snapshot via go2rtc (#145205) --- homeassistant/components/camera/__init__.py | 4 + homeassistant/components/camera/webrtc.py | 9 + homeassistant/components/go2rtc/__init__.py | 105 +++++--- tests/common.py | 6 + tests/components/camera/test_init.py | 148 +++++++++--- tests/components/feedreader/__init__.py | 8 +- tests/components/feedreader/conftest.py | 27 +-- tests/components/go2rtc/__init__.py | 31 +++ tests/components/go2rtc/conftest.py | 121 +++++++++- tests/components/go2rtc/fixtures/snapshot.jpg | Bin 0 -> 293320 bytes tests/components/go2rtc/test_init.py | 225 ++++++------------ 11 files changed, 441 insertions(+), 243 deletions(-) create mode 100644 tests/components/go2rtc/fixtures/snapshot.jpg diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index ee9d1cbc94f..8348c53cd1c 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -240,6 +240,10 @@ async def _async_get_stream_image( height: int | None = None, wait_for_next_keyframe: bool = False, ) -> bytes | None: + if (provider := camera._webrtc_provider) and ( # noqa: SLF001 + image := await provider.async_get_image(camera, width=width, height=height) + ) is not None: + return image if not camera.stream and CameraEntityFeature.STREAM in camera.supported_features: camera.stream = await camera.async_create_stream() if camera.stream: diff --git a/homeassistant/components/camera/webrtc.py b/homeassistant/components/camera/webrtc.py index 9ad50430f83..c2de5eac0a0 100644 --- a/homeassistant/components/camera/webrtc.py +++ b/homeassistant/components/camera/webrtc.py @@ -156,6 +156,15 @@ class CameraWebRTCProvider(ABC): """Close the session.""" return ## This is an optional method so we need a default here. + async def async_get_image( + self, + camera: Camera, + width: int | None = None, + height: int | None = None, + ) -> bytes | None: + """Get an image from the camera.""" + return None + @callback def async_register_webrtc_provider( diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 31acdd2de50..4e15b93330c 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -1,8 +1,11 @@ """The go2rtc component.""" +from __future__ import annotations + import logging import shutil +from aiohttp import ClientSession from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError from awesomeversion import AwesomeVersion from go2rtc_client import Go2RtcRestClient @@ -32,7 +35,7 @@ from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOM from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import ( config_validation as cv, discovery_flow, @@ -98,6 +101,7 @@ CONFIG_SCHEMA = vol.Schema( _DATA_GO2RTC: HassKey[str] = HassKey(DOMAIN) _RETRYABLE_ERRORS = (ClientConnectionError, ServerConnectionError) +type Go2RtcConfigEntry = ConfigEntry[WebRTCProvider] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -151,13 +155,14 @@ async def _remove_go2rtc_entries(hass: HomeAssistant) -> None: await hass.config_entries.async_remove(entry.entry_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Set up go2rtc from a config entry.""" - url = hass.data[_DATA_GO2RTC] + url = hass.data[_DATA_GO2RTC] + session = async_get_clientsession(hass) + client = Go2RtcRestClient(session, url) # Validate the server URL try: - client = Go2RtcRestClient(async_get_clientsession(hass), url) version = await client.validate_server_version() if version < AwesomeVersion(RECOMMENDED_VERSION): ir.async_create_issue( @@ -188,13 +193,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.warning("Could not connect to go2rtc instance on %s (%s)", url, err) return False - provider = WebRTCProvider(hass, url) - async_register_webrtc_provider(hass, provider) + provider = entry.runtime_data = WebRTCProvider(hass, url, session, client) + entry.async_on_unload(async_register_webrtc_provider(hass, provider)) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: Go2RtcConfigEntry) -> bool: """Unload a go2rtc config entry.""" + await entry.runtime_data.teardown() return True @@ -206,12 +212,18 @@ async def _get_binary(hass: HomeAssistant) -> str | None: class WebRTCProvider(CameraWebRTCProvider): """WebRTC provider.""" - def __init__(self, hass: HomeAssistant, url: str) -> None: + def __init__( + self, + hass: HomeAssistant, + url: str, + session: ClientSession, + rest_client: Go2RtcRestClient, + ) -> None: """Initialize the WebRTC provider.""" self._hass = hass self._url = url - self._session = async_get_clientsession(hass) - self._rest_client = Go2RtcRestClient(self._session, url) + self._session = session + self._rest_client = rest_client self._sessions: dict[str, Go2RtcWsClient] = {} @property @@ -232,32 +244,16 @@ class WebRTCProvider(CameraWebRTCProvider): send_message: WebRTCSendMessage, ) -> None: """Handle the WebRTC offer and return the answer via the provided callback.""" + try: + await self._update_stream_source(camera) + except HomeAssistantError as err: + send_message(WebRTCError("go2rtc_webrtc_offer_failed", str(err))) + return + self._sessions[session_id] = ws_client = Go2RtcWsClient( self._session, self._url, source=camera.entity_id ) - if not (stream_source := await camera.stream_source()): - send_message( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") - ) - return - - streams = await self._rest_client.streams.list() - - if (stream := streams.get(camera.entity_id)) is None or not any( - stream_source == producer.url for producer in stream.producers - ): - await self._rest_client.streams.add( - camera.entity_id, - [ - stream_source, - # We are setting any ffmpeg rtsp related logs to debug - # Connection problems to the camera will be logged by the first stream - # Therefore setting it to debug will not hide any important logs - f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - ], - ) - @callback def on_messages(message: ReceiveMessages) -> None: """Handle messages.""" @@ -291,3 +287,48 @@ class WebRTCProvider(CameraWebRTCProvider): """Close the session.""" ws_client = self._sessions.pop(session_id) self._hass.async_create_task(ws_client.close()) + + async def async_get_image( + self, + camera: Camera, + width: int | None = None, + height: int | None = None, + ) -> bytes | None: + """Get an image from the camera.""" + await self._update_stream_source(camera) + return await self._rest_client.get_jpeg_snapshot( + camera.entity_id, width, height + ) + + async def _update_stream_source(self, camera: Camera) -> None: + """Update the stream source in go2rtc config if needed.""" + if not (stream_source := await camera.stream_source()): + await self.teardown() + raise HomeAssistantError("Camera has no stream source") + + if not self.async_is_supported(stream_source): + await self.teardown() + raise HomeAssistantError("Stream source is not supported by go2rtc") + + streams = await self._rest_client.streams.list() + + if (stream := streams.get(camera.entity_id)) is None or not any( + stream_source == producer.url for producer in stream.producers + ): + await self._rest_client.streams.add( + camera.entity_id, + [ + stream_source, + # We are setting any ffmpeg rtsp related logs to debug + # Connection problems to the camera will be logged by the first stream + # Therefore setting it to debug will not hide any important logs + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", + ], + ) + + async def teardown(self) -> None: + """Tear down the provider.""" + for ws_client in self._sessions.values(): + await ws_client.close() + self._sessions.clear() diff --git a/tests/common.py b/tests/common.py index 322a47c8a09..d184d2b46fb 100644 --- a/tests/common.py +++ b/tests/common.py @@ -567,6 +567,12 @@ def get_fixture_path(filename: str, integration: str | None = None) -> pathlib.P ) +@lru_cache +def load_fixture_bytes(filename: str, integration: str | None = None) -> bytes: + """Load a fixture.""" + return get_fixture_path(filename, integration).read_bytes() + + @lru_cache def load_fixture(filename: str, integration: str | None = None) -> str: """Load a fixture.""" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 7c56d142920..839394edbef 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,5 +1,6 @@ """The tests for the camera component.""" +from collections.abc import Callable from http import HTTPStatus import io from types import ModuleType @@ -876,6 +877,41 @@ async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) - assert "token=" in new_entity_picture +async def _register_test_webrtc_provider(hass: HomeAssistant) -> Callable[[], None]: + class SomeTestProvider(CameraWebRTCProvider): + """Test provider.""" + + @property + def domain(self) -> str: + """Return domain.""" + return "test" + + @callback + def async_is_supported(self, stream_source: str) -> bool: + """Determine if the provider supports the stream source.""" + return True + + async def async_handle_async_webrtc_offer( + self, + camera: Camera, + offer_sdp: str, + session_id: str, + send_message: WebRTCSendMessage, + ) -> None: + """Handle the WebRTC offer and return the answer via the provided callback.""" + send_message(WebRTCAnswer("answer")) + + async def async_on_webrtc_candidate( + self, session_id: str, candidate: RTCIceCandidateInit + ) -> None: + """Handle the WebRTC candidate.""" + + provider = SomeTestProvider() + unsub = async_register_webrtc_provider(hass, provider) + await hass.async_block_till_done() + return unsub + + async def _test_capabilities( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -908,38 +944,7 @@ async def _test_capabilities( await test(expected_stream_types) # Test with WebRTC provider - - class SomeTestProvider(CameraWebRTCProvider): - """Test provider.""" - - @property - def domain(self) -> str: - """Return domain.""" - return "test" - - @callback - def async_is_supported(self, stream_source: str) -> bool: - """Determine if the provider supports the stream source.""" - return True - - async def async_handle_async_webrtc_offer( - self, - camera: Camera, - offer_sdp: str, - session_id: str, - send_message: WebRTCSendMessage, - ) -> None: - """Handle the WebRTC offer and return the answer via the provided callback.""" - send_message(WebRTCAnswer("answer")) - - async def async_on_webrtc_candidate( - self, session_id: str, candidate: RTCIceCandidateInit - ) -> None: - """Handle the WebRTC candidate.""" - - provider = SomeTestProvider() - async_register_webrtc_provider(hass, provider) - await hass.async_block_till_done() + await _register_test_webrtc_provider(hass) await test(expected_stream_types_with_webrtc_provider) @@ -1026,3 +1031,82 @@ async def test_camera_capabilities_changing_native_support( await hass.async_block_till_done() await _test_capabilities(hass, hass_ws_client, cam.entity_id, set(), set()) + + +@pytest.mark.usefixtures("mock_camera", "mock_stream_source") +async def test_snapshot_service_webrtc_provider( + hass: HomeAssistant, +) -> None: + """Test snapshot service with the webrtc provider.""" + await async_setup_component(hass, "camera", {}) + await hass.async_block_till_done() + unsub = await _register_test_webrtc_provider(hass) + camera_obj = get_camera_from_entity_id(hass, "camera.demo_camera") + assert camera_obj._webrtc_provider + + with ( + patch.object(camera_obj, "use_stream_for_stills", return_value=True), + patch("homeassistant.components.camera.open"), + patch.object( + camera_obj._webrtc_provider, + "async_get_image", + wraps=camera_obj._webrtc_provider.async_get_image, + ) as webrtc_get_image_mock, + patch.object(camera_obj, "stream", AsyncMock()) as stream_mock, + patch( + "homeassistant.components.camera.os.makedirs", + ), + patch.object(hass.config, "is_allowed_path", return_value=True), + ): + # WebRTC is not supporting get_image and the default implementation returns None + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: camera_obj.entity_id, + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + stream_mock.async_get_image.assert_called_once() + webrtc_get_image_mock.assert_called_once_with( + camera_obj, width=None, height=None + ) + + webrtc_get_image_mock.reset_mock() + stream_mock.reset_mock() + + # Now provider supports get_image + webrtc_get_image_mock.return_value = b"Images bytes" + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: camera_obj.entity_id, + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + stream_mock.async_get_image.assert_not_called() + webrtc_get_image_mock.assert_called_once_with( + camera_obj, width=None, height=None + ) + + # Deregister provider + unsub() + await hass.async_block_till_done() + assert camera_obj._webrtc_provider is None + webrtc_get_image_mock.reset_mock() + stream_mock.reset_mock() + + await hass.services.async_call( + camera.DOMAIN, + camera.SERVICE_SNAPSHOT, + { + ATTR_ENTITY_ID: camera_obj.entity_id, + camera.ATTR_FILENAME: "/test/snapshot.jpg", + }, + blocking=True, + ) + stream_mock.async_get_image.assert_called_once() + webrtc_get_image_mock.assert_not_called() diff --git a/tests/components/feedreader/__init__.py b/tests/components/feedreader/__init__.py index cb017ed944d..9973741a8c3 100644 --- a/tests/components/feedreader/__init__.py +++ b/tests/components/feedreader/__init__.py @@ -7,13 +7,7 @@ from homeassistant.components.feedreader.const import CONF_MAX_ENTRIES, DOMAIN from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, load_fixture - - -def load_fixture_bytes(src: str) -> bytes: - """Return byte stream of fixture.""" - feed_data = load_fixture(src, DOMAIN) - return bytes(feed_data, "utf-8") +from tests.common import MockConfigEntry def create_mock_entry( diff --git a/tests/components/feedreader/conftest.py b/tests/components/feedreader/conftest.py index 1e7d50c3835..296d345cca7 100644 --- a/tests/components/feedreader/conftest.py +++ b/tests/components/feedreader/conftest.py @@ -2,78 +2,77 @@ import pytest +from homeassistant.components.feedreader.const import DOMAIN from homeassistant.components.feedreader.coordinator import EVENT_FEEDREADER from homeassistant.core import Event, HomeAssistant -from . import load_fixture_bytes - -from tests.common import async_capture_events +from tests.common import async_capture_events, load_fixture_bytes @pytest.fixture(name="feed_one_event") def fixture_feed_one_event(hass: HomeAssistant) -> bytes: """Load test feed data for one event.""" - return load_fixture_bytes("feedreader.xml") + return load_fixture_bytes("feedreader.xml", DOMAIN) @pytest.fixture(name="feed_two_event") def fixture_feed_two_events(hass: HomeAssistant) -> bytes: """Load test feed data for two event.""" - return load_fixture_bytes("feedreader1.xml") + return load_fixture_bytes("feedreader1.xml", DOMAIN) @pytest.fixture(name="feed_21_events") def fixture_feed_21_events(hass: HomeAssistant) -> bytes: """Load test feed data for twenty one events.""" - return load_fixture_bytes("feedreader2.xml") + return load_fixture_bytes("feedreader2.xml", DOMAIN) @pytest.fixture(name="feed_three_events") def fixture_feed_three_events(hass: HomeAssistant) -> bytes: """Load test feed data for three events.""" - return load_fixture_bytes("feedreader3.xml") + return load_fixture_bytes("feedreader3.xml", DOMAIN) @pytest.fixture(name="feed_four_events") def fixture_feed_four_events(hass: HomeAssistant) -> bytes: """Load test feed data for three events.""" - return load_fixture_bytes("feedreader4.xml") + return load_fixture_bytes("feedreader4.xml", DOMAIN) @pytest.fixture(name="feed_atom_event") def fixture_feed_atom_event(hass: HomeAssistant) -> bytes: """Load test feed data for atom event.""" - return load_fixture_bytes("feedreader5.xml") + return load_fixture_bytes("feedreader5.xml", DOMAIN) @pytest.fixture(name="feed_identically_timed_events") def fixture_feed_identically_timed_events(hass: HomeAssistant) -> bytes: """Load test feed data for two events published at the exact same time.""" - return load_fixture_bytes("feedreader6.xml") + return load_fixture_bytes("feedreader6.xml", DOMAIN) @pytest.fixture(name="feed_without_items") def fixture_feed_without_items(hass: HomeAssistant) -> bytes: """Load test feed without any items.""" - return load_fixture_bytes("feedreader7.xml") + return load_fixture_bytes("feedreader7.xml", DOMAIN) @pytest.fixture(name="feed_only_summary") def fixture_feed_only_summary(hass: HomeAssistant) -> bytes: """Load test feed data with one event containing only a summary, no content.""" - return load_fixture_bytes("feedreader8.xml") + return load_fixture_bytes("feedreader8.xml", DOMAIN) @pytest.fixture(name="feed_htmlentities") def fixture_feed_htmlentities(hass: HomeAssistant) -> bytes: """Load test feed data with HTML Entities.""" - return load_fixture_bytes("feedreader9.xml") + return load_fixture_bytes("feedreader9.xml", DOMAIN) @pytest.fixture(name="feed_atom_htmlentities") def fixture_feed_atom_htmlentities(hass: HomeAssistant) -> bytes: """Load test ATOM feed data with HTML Entities.""" - return load_fixture_bytes("feedreader10.xml") + return load_fixture_bytes("feedreader10.xml", DOMAIN) @pytest.fixture(name="events") diff --git a/tests/components/go2rtc/__init__.py b/tests/components/go2rtc/__init__.py index 0971541efa5..26a8c467c0d 100644 --- a/tests/components/go2rtc/__init__.py +++ b/tests/components/go2rtc/__init__.py @@ -1 +1,32 @@ """Go2rtc tests.""" + +from homeassistant.components.camera import Camera, CameraEntityFeature + + +class MockCamera(Camera): + """Mock Camera Entity.""" + + _attr_name = "Test" + _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM + + def __init__(self) -> None: + """Initialize the mock entity.""" + super().__init__() + self._stream_source: str | None = "rtsp://stream" + + def set_stream_source(self, stream_source: str | None) -> None: + """Set the stream source.""" + self._stream_source = stream_source + + async def stream_source(self) -> str | None: + """Return the source of the stream. + + This is used by cameras with CameraEntityFeature.STREAM + and StreamType.HLS. + """ + return self._stream_source + + @property + def use_stream_for_stills(self) -> bool: + """Always use the RTSP stream to generate snapshots.""" + return True diff --git a/tests/components/go2rtc/conftest.py b/tests/components/go2rtc/conftest.py index abb139b89bf..bd6d3841dad 100644 --- a/tests/components/go2rtc/conftest.py +++ b/tests/components/go2rtc/conftest.py @@ -7,8 +7,24 @@ from awesomeversion import AwesomeVersion from go2rtc_client.rest import _StreamClient, _WebRTCClient import pytest -from homeassistant.components.go2rtc.const import RECOMMENDED_VERSION +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.go2rtc.const import DOMAIN, RECOMMENDED_VERSION from homeassistant.components.go2rtc.server import Server +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import MockCamera + +from tests.common import ( + MockConfigEntry, + MockModule, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) GO2RTC_PATH = "homeassistant.components.go2rtc" @@ -18,7 +34,7 @@ def rest_client() -> Generator[AsyncMock]: """Mock a go2rtc rest client.""" with ( patch( - "homeassistant.components.go2rtc.Go2RtcRestClient", + "homeassistant.components.go2rtc.Go2RtcRestClient", autospec=True ) as mock_client, patch("homeassistant.components.go2rtc.server.Go2RtcRestClient", mock_client), ): @@ -94,3 +110,104 @@ def server(server_start: AsyncMock, server_stop: AsyncMock) -> Generator[AsyncMo """Mock a go2rtc server.""" with patch(f"{GO2RTC_PATH}.Server", wraps=Server) as mock_server: yield mock_server + + +@pytest.fixture(name="is_docker_env") +def is_docker_env_fixture() -> bool: + """Fixture to provide is_docker_env return value.""" + return True + + +@pytest.fixture +def mock_is_docker_env(is_docker_env: bool) -> Generator[Mock]: + """Mock is_docker_env.""" + with patch( + "homeassistant.components.go2rtc.is_docker_env", + return_value=is_docker_env, + ) as mock_is_docker_env: + yield mock_is_docker_env + + +@pytest.fixture(name="go2rtc_binary") +def go2rtc_binary_fixture() -> str: + """Fixture to provide go2rtc binary name.""" + return "/usr/bin/go2rtc" + + +@pytest.fixture +def mock_get_binary(go2rtc_binary: str) -> Generator[Mock]: + """Mock _get_binary.""" + with patch( + "homeassistant.components.go2rtc.shutil.which", + return_value=go2rtc_binary, + ) as mock_which: + yield mock_which + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + rest_client: AsyncMock, + mock_is_docker_env: Generator[Mock], + mock_get_binary: Generator[Mock], + server: Mock, +) -> None: + """Initialize the go2rtc integration.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + +TEST_DOMAIN = "test" + + +@pytest.fixture +def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: + """Test mock config entry.""" + entry = MockConfigEntry(domain=TEST_DOMAIN) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def init_test_integration( + hass: HomeAssistant, + integration_config_entry: ConfigEntry, +) -> MockCamera: + """Initialize components.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.CAMERA] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, Platform.CAMERA + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + test_camera = MockCamera() + setup_test_component_platform( + hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True + ) + mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) + + with mock_config_flow(TEST_DOMAIN, ConfigFlow): + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + return test_camera diff --git a/tests/components/go2rtc/fixtures/snapshot.jpg b/tests/components/go2rtc/fixtures/snapshot.jpg new file mode 100644 index 0000000000000000000000000000000000000000..d8bf2053caffd68f833a923ecaab07c5dfef6057 GIT binary patch literal 293320 zcmbrEw8o(nDeex%-QC?oaT44eg1Z%WDems>v?M^GI0Q>@FRrD9KnpE>Z)fh@ zzu-P+KkbLz*_|^xdw%EnZ{xoM6hbXEO*Ir$R1_4{e}VGfHcA`{=09O!V!e5Tg^P=W zi%Ud^k55QMO-%YPsM#s$DE|$1W-cZMCN5?4d1a*7B)Z7fjD+dGXE|f%8*_VR`bt3bRzcKXJ6qVVRJ>54> z4V8!sO?!#b>Z{R0%`Ph%8Y)ULKNjO*F7tZWn&PneZm|~6SuI$xgWj3(R?$MOY!`NZ zYTKNw@2E=(-Hi~6&F69h6DVnvj=~p-JYBPHm&La0nF778l*n`VnCZ4k0!EUS zd5O*CupLehj9WRr2!dH*{Xdpj!-p$)kg@l^?oJwIWMx~17w7_QYY_JP()_84uHegni@f;c=`MX%?aLV6< z`U>6WK~Tq#Kx5*9_O)MCI=hM;yH1+5EdLHnIeTK4)O8eZ_-w~t=vM_=E{BrkUTdX( zI}vHJIOnR>V_TONlbFNBw|to$Eha)ylUoaO{a*hTQnzTRNcmvM>BEIvTX?ORl<7)x zSu3x`tT)M9u2mnXZyS)Ndj-Om@6oYQ>R6wQhI`;*mkbCCI4CyF?vcz+)g@(r7h(Vm zC3KMLegVLtYWa76dFQJ2v>MLZ7!ETQ@iFjZ`2^>Lxg0rvK(cY`IElP?j`7UDHH{#g zv?l>#^h16O^h31?i5zb)W9s;m z{Jc{0XzZzHlIm{GRHEQ)OS34?xnP^8HnSkeW--oJOQmqNHU0#w25dDr3fSXrQ47iY zwvbuCdMpy%p+df2+@vOW`GE=fS-t)r_rPyvVr9*6L=i5Y&BQ6hNs}`{uhUovnTK4$ zCMi#OL7nFD%_S=7UyZW(gx@BHXF~y-Ly7fZw; z(BrN+iR@dG15FJ&WIb|4prA%mf5r8!O^bLC@E0=pp}|Dz^p&tWW?PIDod>Q9DNZj5 z*u0zJz*Bzxugb@N4a@be+;8%uhx`gkrxr%!G$n(|=TnI-%d|Kvhq$i_Q>_+niZlP5Z$D9n1y=`#<;ru8D z4ghFg)h8QAZ*9yeY?dqg1x&beEwxHbTutOgqdA234ELa%W4@|>!{Z?NH*g2#q_CjUqU=1t38$ZeTQh$Ty^$3 z<0yw2Auos0D|JXuVLPfLvtg9y*VWz*ySy+_N1#Azv@#Z?LiZf(q+{ri=V&I4#}xgZ z?FXv1rGzD+jfYj~3Q^_V%0ysyld)E7!emjCpD-87^6DG-9wg?*?22h?RiWr>yK;J^ z#)SwTx&@^&>wL%;%e{FltI|zd#0;;nf^$m^m`%BgP3-zs2P|AYETn0V;<&#Ag+lx) z7Ux!*0@&64_^vUJ|H=k2bk{P+9KJKSo-+2}Tph0~tS-_-R6W2*ql@k6owGOkSr+>d zUqsK}=A_4g0#S8w0&Tk16+@15g$U=IVn>1u+%bVtO)9>YamHx_24r&_8zbU zVp?CB%hj&CL`DX(1y!zA0N#}nzD=-cj%Cz%*LPudusXda&zG?-qu4|eXzXU{IAAG* z5=}8ExY-OiM-GIBYE88g5B54|`2;x)_&-)PHYytlrP-SML0r2Q4kub9R&w;tVZ^JT zAY(?YZsCvZB+fx}es_9$MkOzgP;SyQz;7^C`BnR6%a=cU?Wk{(r{^eaA>CZUZ8*zy zfNIKJ&Oqt$P~^!%3D5UgjwavUtG)R`*QZWt2@<}}DE;PdR=I$8*MrxU798T#{g<Cx6@%h?HY&Va?aa!4Lnb`)1s_9J|@zduD+d|K<>_gNJL1#7G=qh zYq6}kNBHgRj};Hg)!88yQ6diqjMn0MK~Ps3?8m^opxxU-xC-e^1L3skthKU*oWEzB zZ7N*)$E&p>Rets6n?EP^%pQ(1s0NpMStQTlC3;z!_!*}Ol05ra*#lI~b(^9~Xie<+e9EUVK(+Jv>plqpGt+_#Q8H>W1I z*nY~~-&IdDMrXNR)ntc6Mb)j6ZC{t(kebs?!!jZ)x0OnVlx8`YUa(KOY3oUpE~ zEpK5Bg_chSo&}q`XYOjlY^`o-bX*3W?dPfXzWch%--EGQ;zanM^hNhS0}cLcg0aph zXFRDTrp?`1YBx3c3ifVq9)>_I^G{$3#Kl#9>t-Z)BoBkOuhgdyjaC6uv2+{k!|!

fc4-*vh4g&vez(n~c_DHk$s zJj41Sy}P(OV3M^Ti(_|&;wVV#aH&1O>Noj?c@%z8g^qLka};IB=De4AuY8_;`K5eq zTZa`XayL44zLgl(zE%#nPp5|9&39+Jzvp0KbfiNq>_(brRqfJRsH_623trcAdfS@C(8xB*3O?#On>d3#nXL@T11%XB)*;>#(($% z=cWl(j+deqK9h4n|C0Zw`*@jJX-gWWh*R~*cHzf}A&*yE6Qxd(?%17ToDSH4a5@+> z5-LH7<3U_iDK|OLj>2x*SqAlNAR-P4 znw%h8?O0T2WoL)Zx;Jcf(+pk4TJ=u2&A}of{vpNrY|SEpl4d@vVZv8aaQXo84e*gS ztG%U$+0*!_c6Mgn!rC7kUTMq80SW_vSWfz}I_|MKFQg`d!p(RlWRUTJH-rME$Y$I2 z3&Z=cx`q?EkJ%HGcxq9QYS;)3VZiR;D~nGQLCW@;m?kPKD3ayCx_kvzZLhOOp*6k- zl=^`Mt;c|nFv0S!v%Gg~5;Z7%MrYeprJl;0X}qy-%8Y$4>x!~UYAkq}vbU*1`?{S7 z%$v2K2j4L(lbD%#a9PAQ_oA;K$wuyriqE)Fsv0 zwVOcJ*yDulR~yLH2Bf#AE~N`WSKJF%>#;4Pw9g>0j{~>T5k1tYojkO_m}3X5QHr75 z!$#M=sZMF^+)(+Nymi&=CG`X^7@DqMxQxhV?JGTG%M=g=A3t~|Da=|(wRCeDB=1eh zr)n3YFdc~0DspN1R~n>fDgm37Y!ih=FVyYDbmGpOJ^w>79zT5{NuC=gBdUAOnWfZD zoaZ$V#a)P^vhl15c3GreGwMe>Ded{>2<`q+w49Df9hq8%89V7O#S~CGT2>C%U9f>2 z-3LQn#$5~C=>Wvdkm?wRt!2&cC^A; zo$FdfEq%C!pWQU`ZCyp#1XAwv0#EM}f7?t>OCk<#Q>+?|tSUtwim@}NaFbc`W(`Kt zI%$=ViDFG{p}`1Is&E>I0n-bC2%pk!4%wx#U?`l>a{1Iq8dk}gq!IKcgV9!wYPs{E zFel*QF$L-r@$~nT|GxWS7@2pb;_ktACgSM~`D+%e zua7fio-lZLSMPjZNpr5qwfwm?QBxE0+7wZ{h{EYG0BoJERI)7Vu{I?4R=G@6E*-D! z+52D(l6t*#f-yQD9}vXbd*rc^Z%Qkns`--;C&&kmP! z&`b1oaG39-ePlQ!;w{Ci#pb6us3v(FAVy$w=c#x+2x`M7i@%w!Rf$=VQ`&K5nZG!{oRRDANqJO z1=Ty02Nf)C;D^KTjCCgTJOx+%ck$vWwKI)LpmahZnZmiz#KnKUEqrE|3lwXpyt7su zb5J3ba(rrc?!LR!9dP|IWDQ84b$keutXK2@WhqC!lmJYLgq7Q`9ZlPohEKMw>yd7g zZTdbpT`0N`Ma|Z{f?#8w)yz^D`?n4vmDu>WT;$r-?sav1UF0eET21L4O~-9_pkkuw zjzWdnXi(vB!X+|qyqZy@OsVv{Rh70?c!3&`_n8OTHA=cQkZ z3XeaYizGG&odCs7S(9tG$9}lKEQVk3@EVT=Vqw=k9(t2e)1vO5h)r7^k8O<&?)>hX zkzEeEN7*<&ijm0_3&7ENa&35LOFGF?phrEG(D}Re3kgv<$FckjD;j5RjS>e=U$2!p z62V-XLf8(ERic-n6VIXO<52i8zh0j=M9NE-=8P1Jwooic5Sd>3CsOTMN%ND~KNt}w z4PkZ=I|RItd@q;moy1CuvRU{M5)$dN7usCC0S$-+&}OJf@XX# z6O>Xrd+zHCb{Z;1W-bZ_;F00tCR`&*N^W{zn|Wjk6B8Ego`kAAIl?vFa-m(+gra8T zhE@)+W3jsC#Y&^_^Qr2014TSH-_z__DYJxj)_RF&F($MQJF$+^hR5zmH?spDCMu1L z%g3=fe(mtG(2(VCBY-+6XE(HuXz$qA^DUwYvkOGhmQ1m8@~I*|_CufeC&F+DrAzg`@$?rW*61eUgic_D5YcY=IY|xG?9t7k$zuobt>{6 zRTO#P$A^nq0$vTXHRtDEx_wn0t2MAkB)P1GE@g`a-QW;)ahQqTpL9`$k=zOD`@5IU z5~c%__A*t(&mEcJik_3dE&`aE4h9^}=vwa@DAjpoak1;+$f-GTM-NJMW8PS*YTm3D zd_+@5UlHwB3{l8RKW z3v*TIHGE1A_2~R{~Z}mBNc>9WrRvyY|Lz8!DD;T~|>FP&WGWN3j1ipgBWSRH)cgs6zt2kC^ zN-!KcFmyv|`c1f%!H**hkZ|uWOT~-xCoN%-MYmE`%BQ6m=?esn$Du4(S+xfv`6!0( zLUrG39xAYRa6)_fhr`bD^G;55ZskFq->(rxeGAuj_qnF~!f!t_8<|v^;2STT={k|| zJM{d^_G>JGbdfGm zE7MGA=&KCrUXR(yH8jZ+ivM`IEVKl5#pDIa#ol#adqB9 z?6vpD#4a`#DdfCTKqYMzvmSIx(#_iZH${X`Fu97hLvO0RG#`vSKTPnwL}cvnLC9Hd zReTx2*v$|oiF(exteg+>12he@&ApAOrn0gHeZ)$|bH9K-15L!uFZ*kEG|0F2XYYm2 zm%Q<5G+*MiHDEM#geVt3_v-Jk=kSb%vL`knrc+@k;&3kET@4NDU`Co|T?^S}tT2uL z8_oXir+zH{}v?OeO9?IcYwY#u8Z*{&S1^W zj{1aW<)^b?N7WHCdA|O7){+V^oC(fsOZGVS9Yuc^g{j7}VY;(hB1t>^9zLA$7qk4a zDAM$7m1c3SWe9%AI1#Ew+Gq+2WlUMY)gQLqFg7DGX7Z>$nG2Q@sMvc-+wbr88=6S- zP+C{j*LZKec<>NPwe@{C%-L>C;!9tyL>^HoY`{04+x_l-zaz=RuY_lGoQ+L7=WUn^ zC!mh#gN8p(eG#wc>7(rXxCgp#sRNb^UL<9)iOD-m+=00lN7vl1M>Z|_}kPl5!W6A4I)PP`w2zD&)X`a^Yghq-APaUajvRk{Ic z^6~4<8UmVPthg!wFU4OnpDW4>de@Fe%Gn#Y>Hv7JSKk;B^0kO~hi97W;=tYek{z)a z&;pFu?U?k8Tzj2m-Fhg2{v%L5+yZ2##qR>e$>Sj9F+UFa*-m|_MMbQT1EeQNJ;Gl& zIFfx^a$n|zU0k;xIxP4gGikXS*MXT*tG~@*?AZT;TeR`bsK}M9>*Czk)K5D5 zmz)U!k=)y^L>4;NqqrVMx!Q&eGglaRX07*Sn+B?O)!cQexkS00VyENnm7TWAjD8>M zsgMzMu&R%hBS#?>dR9TOjhQM>0XFI=1u3h<(fvI z=~E((?ax-bp@n81E53_tUn~fFvo&2#@4Y!akuUg|h6`>;HLNx&nZ;iC1~vs2%86hs z;dlZVpEW;~P@oINLok;U#qJWuVRy$#r_b@OkH{ag?*AVOz!*M%F!*s>e z*jo9ojX7@-8|SyjRez=Hw#Dra8l3qzY1x+?Lt?bSRPOL!b?i=Hi0!#)tb%*`)yWXt!F?VofJy9Z$08%s zIz18CVSE0kpyxb%$3J*@?CIhvK(}J_fuP})CyryeU@txGr{7YN)bGA^2di2Ua9=$| zxM!u=I2g0oz9!8G-*rpibt==vd;RZ;p2oD!f`2<5PWeQy%M|)q|HS^@+3s3YWgeO5 z&(HR*%>M8@PH>pn{k-RmM+rn;Sl9l7_8UdXSYycGTbq4Z*kh{=C`Ek$`t%>lQtqhp zVr2a2{oi@7GPw?;$cTT0d^W^Xf+ma($O@4h%+$5vmLiri)XO&ja#A4b>&Yap1b+QJ z#UJi|XUOI#sH_){G6D3g#kxw#7CZa2c(mAZE(CmG7}l|tE?N5hR}PA6g=f7W^-v#>r%`7>)y_*ieg9nk&5Er$^E;0MENyic zdV5%UU3!3GcJ3bre&Ni|9{!X-9;^ZX)Ck$$iHzLw4NZTYoJJAci_ zMXc=I&_$9z=mOgG-GX8ZZ8UC`#3j5w~7lhBfv=cd^(wo$3E|{!Y<@*0-J-27qqb# z%u~zvL5T&2$oTcBpObD3UKBwFqhz5Jx>aUt2S$>G=<%<1dUW5~t7}_!)sq<)d6MFF zulfSGj9Qw7%bPnearS&f3EnQnJpBzyhI%c_sO$EP5p$^P=kkoF(SG0T>6rg=^GpBS zS7dM~h=roLOcWFp4L#@c%2s!lLd4SpmMVkGOLW{PhyO$Ad1{w8z2QC?d=I)p+`mXl z(Gu`}yYPgOByV0o3r?TeOOcPOK1Sl|@A}#lPEUei7W;~4P`2NU0M4%|$zqaU(YN)~ zht-AwVWKSsv`2)Q$KMzeU#|`O+RtSzF{fNxDST+Jc7UkA*^2!>3hRikJ)yN?J z8kt=_-;?uAB1^AoTkDjY&~e|v&+iK|OO~!e_MCeUBg8Wgd?Un9Lqh-Vr_bM~--cCG zz}I7AFH5VN3W_$)Pw(ibyxC=cY<>>LzMdrB0cpMJV~@U)32GYRSy&mXo{jmCr%8S) zRaSQ6SXZmf(d%pZ83yq>58->;xRpA$e#;I*6TnM-?Ogqyz5Ju&q{YrN>9e_4&3R&R zWLU`#U-_3a8$S^awB^u)AEob2Z@QlL-u5^z3jU`54<+N?TKD@~^Rw{9`3Ow$g5jUa z6sWPwh?Fz#yvqzRzQHrY_ZUN>Rdx_WDotp+r6745whv1ZX8QJB*^QBhsIi3w5soUQ za$nu<&0~*?>1s>yp!)iKqH|B%*N~UZYl?<&{Pz_Apt{cE@CW|0@TEoAEWopjb~bV$ zWAA7AiC>6pi@cQFB3tyDQHAEk-+=7U3Z+c`h#mT%Xym-4%-iKZUc)CLnY?SLQb@m8 zUnN`btUXcSC#(1G4PpMF3|b$JQi3Q&kjX1qWFn1gwAZ_q$AThlwk~TLDF$)hl47#1 z@Vcs)6^nuu%5ILq@VfF$-q|3-H7{$5ZJ%HX`;n=k+gO5>3vcrl>@H`TcX=*y>y-rT zu=~BHJ!`S*hYf$&O?hhS$hVHZ^FLlRLk;q|6W1>k1;?H<9q*+RcQsIS&V*$9iWcs3 z>ZM95z}}((e=+=MSrn~EWpHb{Es z=+l?LeJq2})~(ixfa$qiU2Cs$ts!~>68OcpL*Iv+{pBNHY3|6Nm<6GWm5jH5W*5kbGiJ4_>y<>6o<=H7xdT=eKY6jL}w^|+$OU_Q=>PXy{4fMm-ZQf(mv@{c} zFH_5q_v?VP6iv@JL|W{60pN=#AxK!uPcuo6``qA1e_!veW6Oc-D>pn@&&-^YkoVOg zI&@bTWqrk*NheO%RYef(e&;(`x$+8W_@x+k#o6zRA#ryTB8W-I-%No1r(D5-@QP-8 z>hb!2Unmnw>!F88%xM;yR6Z}=rh3ujBi*ids#WQ>oC5{qioLeoZPnG)rb9A+KU#aZ zJ<*PO)eh02tR4sd2)NX5SqOMC3wgI^(j!P-cWdGW_WwO>o%NZZY%(%UsffBqS2m*D z`t7P<4SVi|SF7-6eVMi7#fa?T&GR}0>= z&+&!s@rxJbh&ww0KYQW_0lF5)^8N%oh@USbbhK&9t>t^2n1h0B`o&@2t__nJPoHT< zsfNsYnm^G52npWxz{@8=c}S>Rp$cGF@0m;>t8D8XwoHo4Gk>Ga;rH+yF5gcOXe;@s zwAMwuPf(ozJ=OrRmJhapwwRmfW$I9Q;#^J5K=0+r&BW6AxA4$E>ip{z6-Qs3c6gdr zrdBTfa`Sw8%I60oKZXvJ?t!>nqeqxySbmJoBiYI9_9a)hL0`VPJB1z{U!^uJXsYrX z*UX1beykF1cRhXLH(q0i;&qF6Q9XbD^tC;ReNj>(_{~Gs+D406p@?HCwgfh7DsipR zexB<}kWa!t{%Q7WFzfmf(cVph*#!2?55X5VXBQA^8OmflS(m2M@zj-Gy5^rhzdYj= zT^Lu-*eds%uAhdrAiWesiNMxp@ySBiVCdF!X24=~=hFAs$TnRbm+&=9|j`AA;hLa~w zk!7=23J!rO@E1v$MG86Aw}w?y26<-E1!Kn-Qym@ii#;DrW%hQ;@XgO%$pInee4lQv znv>a$$F7DI3V62XXZBeXtNT%62GlPswS#cH042@K$0DI*7fw5MJp|Q{EL5Qux}uX5 z_k&AePK_>_KNT zhZ`|BXD6&ILlFz_DT?V6@&9ja))$Um z*)>RJ8RHoY|C{d`q@F!~_vn8mpSOJp(G1F?1zhUB-~ahpcq~5BKkuw7K$e6@Rc}ry zs{O8m#*!D6K$sd0bA7xaCL{o0uluiquK6X-+0q}PL)+GdB(=&$K&o1@c)RfzItXl+$$wb8CV%3|#YbR|1nodawx@xo!WX%8gU*l8HyI zo$C>8d>g9@$V@VHVGb(X*C?_bt;! zUUr`3xTIm<=X~HhR3lya)}DeuBPp^PcJ|IgOVg5q)Vy;j761=iYE>R-Md!Hk-d{%J zF?@Gy9Z8=vDBLo2w0coWt>Q)|h*4DK>Lfe;&TPBT9GD-f<0kwjX)@N!x^V>s%rm&N zE$TmKkfm~iHF{#QdU+hLeZ3v!;}^wAvYyW=lGrLR?oxl3u-9EoL6rt5`O)S7?M}g z$aqC4ZA?aL)Q4l})S`%M-p62Wt5w6q9*SRHHcH%D`kk-n(zcPTLgcuqP2i~VE8yfU zd)?xdFuT2Q1^j)&Ss)q!pMg`k0>7S$XmYY#z|IvFTCBN6wzLsxn7*%Xn9tO6dbW1T z(|80N#iJbkX4ZZpW9N~e6QsDZB?((z#~5Vf@CopNzNJN5`Ux4ltvJ2B_ao4y43*a( zxeNXHp!?3L-*}0}sHb)f<{&1|`1PbCMhUqZeZ^$Uy92bb@XsImBw)NpY^ zr8G|LreAT5CK)XBk9ZkUIE}#^FZH$W8qw&R!0%k3v35QoeeIiiTGLps$Z7;0-pyGv zmf@H0c#;tdB9~+FRdx2eJS%w5UVf$>coqg+m)3ifM^$<_I@R<5vb4zDl(cEr5<+zJ z(W*#!lL;dXPhQB*;jB3&u-%PI=SscGE%4&lLfjNd0DnWx zZ0HaK4~vmIQCWqD2t`^TmB!c-UO{Ykp~h!%@$m9ixg;6=at5`eFXz;Bv0-Ita3d!! z?ByHiZE;dNzk2DleN$}Ti}w5sY_->iGzBbxqFVcDu4rchLzbGv?w30|>nsC>BJaE zE@bum1%%7>pLeAH-iUV5OXSpCingQNYCg>0r;}8!@L;|`LPvCumseA83lcj=B)8@f zRZVU}g}x*UZk|`SEtgm1s#Lpn3f1hwYwmKhod5cqaKx*V#Lv06xp>+#Fz~ArN+U7K zHy>wc7*bvZDQxw$7ZUkEI0Ixl;vw8*i_nOJ0;7mU8Z=+uJUmMayv2;a{aN%jA0XKt zhOB{c{{H9!VlOK{7dU3Kqr->Ow1KPW_s|VI-^5{}!nP<;`f@mgT%_4}Tf)c%dtb8x z{164={O}EqODn$jcA`&`zx;!KxT>RzQ#uDC+9Y0h%SWonOivxHB4P@A%nLvGMuSD2 z9ou1*gp`I6{JKu1UiW+U%MqCKXUq4w5wA!4Cw~3j{@4`001Rzx=lf={bUfx)_z{i0 z_JE6~(T>!KrOfw!t3(55@>F!a4BRm5cEl`9!#Qg${ML263y0pX-^C)(GDOluRv=`w zWob<*u|sNzIMe*eZkySL`ks=q(Zd`tpeWLu*TV5oUM_ZwBvj~nUsG6mP(#}SFwJbr za7dlhI?R{i^9Md%sk8S~I4p%xF4MDlX0X{C6!z^ia;6WaB5E!A)9@z;eT$5%UQgzS zPZp;O88b8Ks>0*z*?HEweFPknmc7Nii0xV@<5VX?thMhM0ni+$N?nk>MC;XOIS7kb z!hR0t)*>6@<&e9`r{z~|c>Wl*NuXwFb)UsZM;}A#~vPAy} z;@VNk@K*Q%!+-97tEDo(0+^QeR%cC(M%*`cqYB5>O%jg1p>L}0Lq50+gj=u}^t#V9 zI8C!Tj~$kA=ME=8KyM`^i@vIXdll%U!wP%E>Z1}?Vw!#ynlF!qjAV=|Vqq0c{g)r(<86*72xtbJ(t?TX1SZ=hyXB3MPzxS2qvciMTu9zW zjLz9nCJ{ZelTFShOv{skxBmZ{<9mg?uSX;Gg|wm7a<2mLomOgG+{O^sKj?-x>srw1 zQ%LLmBtxOICW63mVJjmJ$oA@-`KXS~oXFd~x$!F0nv#ue3&TRAFZ%-^oO{gX!PEqGOlAe7-Q8MgBJfCO&=`F|^V(ub z^o!|I*{%@zAg7qerCT>d+ifPXpkQ`=w+6j=wgy+Q4jRZ`u3VX?SI)Yu((Jjs2vA$H z59E&|5kESbnYmgfbv?L$@@J3xtT3wyrecXvC$-T2qNR_(A_VDDlf!y;)-a|95%{0yZinTPE@++&N?=U zw;YpzE{R!B%W)70d~5rSgAs)y&M6%IB7f+BXR`Q4CX}JM&DZ4F|=|g!uaVFgb2a z%!3gvEHh?d()^y8@{0pBYCKY0Lm@qgTyi8Hd{b4uDx`HuWHyQl9*+O}owrt!OXbSo zU`8g#4sjXRMX>G@J;>N7>;TT{-O?D{ksChd#VFqLYYEeHrcTf2Wwo?d#&m zn*1x(m8{wpN24XHK1su7DSWEKFUuxa>V7u8jb0T}<;&OyD29?B2%^DFH z3d!%ymp$8qNER-f2`ao+mp4y)Mw>va7SJX;F>3F1M-_h*fd0cBU07Oii4At^^Q;+oYCvTBu;BwI)8ODKO1A_&0 z@2s7DSw-%(>{^rZ2YiI8@*Pu+>aiFTFmdnaW$B*3KYj0>8_iZz!meuOWEo(|6M5XL zmtZaK5ERIxloZAntZ=aR7Y&pMU-46=QUs0!oHL0N>#FTSM1JXzWrUK%Fy*~^w{?Fq z;C<5bv4`Yqm-7uI{2Pb@gLV{t(IAYSTvJqxB^>*P!QOYXn37!SQ#G)%nG4@365%^o zX^km5kFu?>5kJF{eVnapwA#Il9&cysYS)cz03m3-fsfiK-cNheTf~;7N!bppi`G)r z)O+01YbVfgR*|<}4haZ;YTcv>MFjqI(+C?rb-)ycIqBf-l5WM2SjAa@ExX7$0#wf_ z|0v|7x+gPXDjKY8GR228KU(0jn5x7yf7A5UG`K>zHuqll@g?htI1|CrO%T?q*O$`1 zEpi?l3;#|WDgt1(*A_WiM6~f9coRDHmD)8LmuQNY!o~U<`|`Q@qLm(Zw?Y+V_9bUe zu9o%qfKh}kY%GUy(OmX)=EAS-D4OpYM_UXrGE9i7zQ%TyEH>kcOpCKpUA0F0>OAIr zjBd=0T_R!vjFV<{G=NvG>i+FKu!^OP(6L-70-eqXLfm4mtk>phgt_` z5z6c0Y$fU9K$la$Z(2wX;cPV&-gw{#0~&$m;l8RkAofHlU9SNz?w?0Qlx~}q6?cQ>U@e6d0?njtetNidr@eJoOksD7YS8`G(#`NtC`9VYrx?I zv_6MOus^FSc~rcKa$O)KJR?X#^2k85^0!Ru??5%Dqm}scs0t^P7F#W-Oi-4>b-)9{ z>BQM8ysx7AxzSDGt8($+QL5NIXNd3_cMU5-4aewK^-R=Gr#AnC)`PjAynNMq^xs^- zs{&veEuvbQ14omFlog|HFG$@r_m7U!k}u_U4@w`I4y2O68FqX&6)ojj`}18XOtEw0 z&r=Az@1c~BiZjV=o*BO=&QR-Z_ zc#Tt#)ACi%!Po9alO;TeB;>o-ZHUek8a&X6`}D)vTy0Ia&f8|txFKXZz<;NDu*qc|=R#>h(EfZT+D3wZ?PW&*cT zja+Du9=4790{FSq7cuSU>iuHg)ikuoH9sFWd1ZJc^VvOO%TIr4Y097=HFJ6kv~cOk zeE)H2f9YJj0Csr35U8rjv(l#X#?_{D&??mvEJrD8)j?6y?xqB4FYoy@-2!mu%W;ME zqq>V33YN5Gr!(d~D!@+3IYhE*4ElX0dmsQ5VtplPiIAhm&fH~J{GimRa|K=qmw`Y} z&F8EYe2>VV+2Tn0QO<>P#Zz4DL?a%@6_=Hg`MQRuIn)k8vcwO5SZ{0L#JB#N`%)-2kfQ3^Dqk88utsGtp zI2){HgaB3Y4Bg70a@eacQ1pR7P+giUiBK;tDjU-?TT?D~aSm?wn4;-GNcM^bVUsW6 zhMrMMOHJQWTb>HjPiy7Oe!(iW={iYuhF;-D>{y-LXfq^w{(mT@lcP|NA;FvS_lYnA z*rmBuk&sY-<6KZ@_hlZi?5gP!NDVu`Xw|a|T$0nr0_yW0*KH^zG~=D|r{~UhnY* zE_-Rg8rWfnJY!~aav$I`Z38A0WpN|H14cF?D1Qde+>TO3&Zd+mO#=j6CB}+r#|PE2 z$U_-Sar~P(+Cu$g3~-7~nsL2*u3#g?N;lcOABi}=weS4(bGIV$eU@cC?xJI^P})m1 zd|RG-okpwrx$;`3UQdasEYtQI+33^B3#km$wBwa*x8z&)3WtMVrcUOOT}Ok_p#)Xo zjt59<#Bb2uH{IoiMY3RJuXUlLCrr5Pn?KIw4i0qcu?VVwdOT7xmD0!{k@zIW%*7^8 zO%zE_+8;KG?lw)$CUfK?1w3yscb-q>*kQ62h`-E;hYH^3n4v||Uqd7Bi=KY={QM8) z?aBG`vGX+5V?{J<)Ql2XsKAr$A6ax5QsMDScR3=n%CwVv{PQ0akXnjenoTgr={k8jrCD7)+X98_mlc$>o( z%YUx5KewCVHbsx`(@%eU&B@i=>o$eIhi*SDBK}O_Tvd?i9qRWXwEQ`AGNdmer4t`J z@+0_D*M6KodqqE;x;|QA66`z;4O7!-TAlG{w)_T71d#i^dE{H4{}bf39=?Z;*)nqw z*>v*4-nV@JAIj@fT&XY-)&frNNg0iJRC=K!AvJz|-naseFHSb2Tnwx!e5gXzapTEC zDPiA0rJ&F6i=II#yDf7FgK=Z7Xf0m`byP9c zm_k$KDqGxYb(2pJi0LBQ@7&^+c^66FQ*jXqhMxYN3+nn+`1$%T&0cuoYA7t?Hsew1 zKNPuV>%4n;$EVji9^G{}{?w@b9f$};8UAdFu7`53q3mE?FjAM>nvUNG@x4_f@1^%I zW5s?1_sL%^q@ylEnmSS+_WarXID9#2_u-oN*V&qB&xzy*+>hy|TENsA%XuOma~3K$ zz>#p#CQ3%y^ABeUUCZ$(q9pU&9Eho9Bd958^LbzLuX)bNF!c1}pZy(|=eH=o$}0}7 z3U)%m%gryXWfvd%yndYluKtpoDd%Qn52X`T32^1Ea3MoI^Itaq6qJNhL*O52iI>7J zOL~^JDX43N?PAPw;js05Wq}R1wPYZBQ85{inxs|f9?$~m;eZIZDK6-QwPalo0_@3+ zTwAo;&_YJO!H*`^xNZ7{N7NI%_EDJZT=QD(GvsL0g|GPOY{L zSE;IUyK0QOY?fPNm8{5za4{H*%}cF zVd*ml`U>*ohBSSkkF9g5Yn=vy^OtNiUG204jB#`Q*U25R(~T@WBk|L#i$Dfz57Ddp z)enqw_mm?OM^5=~GI9;kM@{`Hab7Q6>UFoZ1p0Tya-QnWF;KB2Dmc{}cX_!ml>-Fq z(O*5)h?vn8UBD|pFbJ^J!{HvLdP34P-ztfc87J$2IRHg+AY&bvtXCW1R`<$k(r#Hx z2*Pu1m*ijHw#$5s)tju!f*}}#k(cwqr-@Fey|^Tw<@bWk8l!$&{cbaw5zq&(sKlH; zw=L6@^Q`QDsCWygwwmZ)G-x46k>FCC;8GxXA-D$!?p6ryR*Jj3yL*A+Qna{h(c(^_ zNLy%WU%vl+_q}^_va(jr%&ggGpFLY=e*W{KZhTIrZs1kmjD}+F=Qw8t7)=;{cnGtG zpj>AJ(@>8pu~z*4)#K$^Tt%v?$Bgd}=32LkTFdd-#)VRo&-59(3&f)QrWw(EgXq5u*b#2G55D#hG#8%}~>#1P$|CmI3|rbRqy zeL{BTdsXPjweor9F+=?L0@QCOg_X;Dqo*~FKjRHCO7f*_?9hx2cJKr9WpV6cuDRrj zKrQ8pI!EORkd?=L<<>D zekbR`q3@ZsVhjg|wjMZz;X2is@ZMZcS*hyOS+2S=f9};_v0TDDY-fJ_;(Zw1dTEq! zX`l^-$H)1T6PT&Q3Vlx9k@7Yd%Gw_Ah`~^v2i7i^!`9nLOPKlGgR|LmOUgx{6M4F> z0mjSH`Q{WWg28o~%X5`l@fOj-_**E-x+MbXsI{WaEis{X;HbzTB%A^z3KCx7|3p(j zLO`IRA>kDM2TTE#012{i#N>(^Y&as#eb#K_d>UMrel!hVRFmJtbgy6&%8D4Ws;kv( zddQ4;>*qwtW{+(+Lj;a=P(ox!c}e53Omt~$E1UP(X%iDXfey9H>+9UlaysNaDDh7u ziVg4=%b7kHJ1;AV8~qRkt>Z5-l3CZ!cv65h)yqIvi9?0<=X=X+YwTUGTeW1#l8t0G zX-w^sa;NH$d$_QaNc`6MW2oj)6f;BP&q{Pmu-g=g*utkT(45$aXAgPg$zRUh%)k3B znM>ck2qz>u-=1z}CetBCk8m`~aSld-WQyS>lwh-^P#Mu_%zQ^Td;9cMKEsQl!f9Vy z8y=io8=ZsNRtc@M%SAr`@03$HVY5XQIKkP@rXMMoowV=sP^lBsPjZ~c*%Bs{tEdz! zwOk}<=-ek#?34s8Em`2I*UpKFjZS}(vT?INb?5>$)nrdpb#Ua35{L_J3`~y};cqX^ z6`5-DhpJ>yEpVt-H!sd{KWWddrRsh7R>{mglA3;uV_%yhGe=RtzH-VhUm=E`A3;f0 z2me)EPxap8+U7>C6s9M-_{5y%z!|Y&iwao?E|H$ET&3 z$_a#U7vSmRd5f)XsVloGDcy@;srByM*9ok`r|l`q7T7$DGZgoEY163|bpt3&1LdRq zHLF*h2~6Wk2JT;0aP{1ZON9~xQ(7xoSPYFN&FfBm=FoFx&U(9o(Rn)&CGGfyVeJ&l z+MY_3_225}6t{>oxYAw|gQc=EroH_==ty43eKJ5FKiS*c;?dAiZnYDMzJJGZYQO#S8?hT)a5{w=!pELxxh zCJ8p{w`f;0#Y3!m`SS%{vlLg0k+zwP=6Oy%eG~--`AZ&!_iA>;uL&x!(Zfz_RF8sR zIU;fcmS`46?kVPaGSUFx2&B1)KcVui(jxP`|ioY7XEs$LKEa2DiaMxu($3#fxtv3x&7)L2AbL z5VNl06T}CNW;=FM8xr7jg$TdF$4Al)xkk+{M5I?#Tt!k;0C?_E}?dvpAu{ooohn4H%w|90i#e$;L z-Qy-}aV-`e|e zlGW54lf*?d7^vz<)z`$NtS4|?^~zR(ur`yu0i)sZ`@6lu8t;1@HMNw63Jym~1DDw-UF4cm9fc(wYMSlS&eN#WTvwkzk!j0QOKl{C~s9$1DzO7`D zo16Qt&LJLN`?0K<%Nq&4Cg(dTk)zAQkn7DX(rLwa#-o$`pQhEBzkcb(*p#3lKa1^{ z%8l4QbG>CWn60%g217+>Eiib^SLmkjzQ6Z#3J{f}pW1Yo#`n8AwAfzXlgEBQ2riwj z6=Vv3O*_}qqkEX$I=!7nAJ`0Z4CZ7DRSN%1xKhR9w>oXm09j3z*W8S#_Z;I9TREPZ z?_Tge8|w1yN$!5cXmyz!R`DGs_{}!}a88B?Gu1Jh(2zIMM%vD;iHVh*q^v}FewU^Z ze*By6NpV$Gv){WYy)qeRc-tQ7q;}XQLWPM-zQ`PYF_U(R`|-?H!dW+S`8dH$gK1LK zZ8O75TEv^;ZM{laCbazoP;#*}MS1<1kx*5}*)E-Jdb}gs+2&JftEK;8m{&GSH*cD6 z-brIG3k~zq45A2q?y%C?3 zi&xDtpLlgmjqYrC)caaaPR>5@sA(#!QZqpX;a_+x;ZDTcX3->!ThUCt)6zOqo6E6pMi>%n|h$@DP*Y6ux%X@jz@ zQk&5D2vVDfxdI|_t*kP0SlN|2RckxS{GB|AMSfTj>wFwn6r2cO^gqEk1H6`*P135- zc;!r8Mld=*7){KYoo*Cg3l<7@$d$2F$~!6SHfCrt^xQgmna3BLhv1LQB@~~u z2d-OxE9h9>$d5A+io`ZVKI;HlUC!YAX-4L@^4nL`yll96s^n;zQz}&$YolDw0-jG` ztrQ~q)}w_IqDAzPr!IMy&_15Lz7uwJ19BE27D1&@KMzP_u0^Pj(?|8l>G|7fHFism zQi4?K%EV9Bn~IQ)={=9@aTbsmoqcRbsoR8U3{l()!r-bKJ&YVGL&?6Mh8~hsMuhpL z+Y6E7`rzJ*^V1Wx0j?dGyi4BTpB@Rl7_qniA2f@Db6952{k5yg&OXSbQ$N}b$v~XM+ z$LsM45_4_O`*6D>*hV#xbwH zw8Eei_dMJzleHzb_x!wlR7uu2?w&47k83AIXAT=ZRfTKIF&OF;^gbk%VwXA@9&I|v z`__>hs5o9zH4IuYjRPV9ivY(LQoN4!4}@BcgKyq(eoC~EYc~=%Jg~5EDP6j<$V1*u6vBn0w9n}$lD937@ zS8|av=yJkJX+{r|VqqfTOeRNRgqlxMN-LVrZzbBx!&ox9%Xm>(xd3!(6N-w!RB3Is zG`f*5quCjNJnBUXwnZv*5ZV9otN-(=k)QvKQucrO)oB0u)%pMw7%6IrIiQ-81b-`c zWz-yi5e(qe0<;VOP-j|#;p}IxaY+9gPl{GmmsJ1&aQDoK0^qto`GNrej7*FWn~?tb zlc-l&0DUwQ9`T{inWTaZ#vcET&^Ot~C{p=mMfEEH=^~K5^O$Dm#Nm;hmHKZAt#O?D zz}-7vzq=a%=I__>(iZ02*_=oMVSwsaZ;D*D9QTg04(BrcWiDw8S2G<(i0uE2!H|g>Z!BuL z4kUZ2Kxaf(4uHi5W8QFLmIKOB%mc28PXqhkk0D(0KgdlAj5!(YA_stHuIy8 z;}KWo>?i7g7V78FWQ;Ckw9E-01)!b&{&oCUBj@rVbbu>-5l&Q$SqJ`)2(-(0AEOS% z8;exrM7vOvvFezy49a68LZ^^z+<&81;sfz{DRp{)7Z-xL;+K5yR0 zoZpQV#W!hbp^zC)C*xU@lAvCzT33Vd+Ruh1oZk!Iz4dxB>VGrE3B=;W)^QKkAwuzY zDufRE!pJJDB0oCNRQ`?pxioc0U?5HNK^#l)lO1XuorCoNr%?v#W`P9F-AVV?1msEn zyFUXk8m}q-`vVv|YB8R_o;W`#*#{CXz;)ll93)fXW95ge3FZhiD~SFi+(Ao9iqr^D zZQD9&AI#&NXdjA z%<1?A>?D5W<$S2ieCPhvOassT`jN+_sm5}+s_H}9!CX1K33b4ME;cl+YU%dLGe4l` zM?mq%zSXXdqd3WSN$%dSC1d!)nEw9H*$06dQD?PULjcNgY|HbNtMh|Zzi#)r50XK` zALRY+t0djql34RDe($W`cKSc;KCb_!kuZ8J(mO21r~yDCRL~`IiF~q5Ro{tr{rTeG zad2--sh)d#{G(Uo{`&rt!g>JW@?>l{sif~YCoo2LUw|ZSFUc68P^B{jYP( z0l;+UNvTS6Y3DoCxe<`S34DpvnwVKw0Z;2*s#C+@v&7X%BW0CH$% zY88{K&H285JsxA~J!WqN^wA99nC0E=Dv)nqbA6AaQNZYBRg1YiB7O40zizMQ#J(EE zoCLEA<`M-9I{Ndx5l4^$P&mt~7TR^)&v;J`hRbu;%roD?d&y7BMh>nWPjIM8B{vim;T=j<3XS2|qvgfNL z!{;~v3NUm)znB>st$n7ClQ%XY*f~1kwefuyCWT_jiMDb?st*Df0KI<7mZ}WTkm-A# z)#d-_4cKoO&tyaA#OMMBR~L8i`CIz;@fW9@+=YB?%s(#FCo7+p!@}bH_+kFW?cV3^ zdoU2S<^HML_4`u~x?;|EtUjM5YQwLboZYML*ydPxzu!xm;jVq8l|qr~c-c@kwIodu z`6(czVaLyOJ0Pxs#k~Z8CAij+Mh5`n)N9mr=S*@=M||JSbO|Vt@uwI->PeI9U>pn- zgQvWO*^!(y%FbI66i?j$*iBdgD;=^38?z1hP-KSq^7~qpD2x@fNek zl~D(X3cu~pqV;puf*5rQo**6@TL|BX9I0KVmVS-Rjw_n!S`zAK@QR@2|ed=FYaEUZunIU$^Zt?f~KXZ{_VxSCc3+30p#s&<)5yJLAjxbfyCBuSzp|*r1qd? zci%#9wqcum^^U^%EA3oE%jN=h7Yyn|b`35OdbNpeZVdr(f-T8tF?4zb$Pm%lS)g`A z3094E5bj+er2ZH8^7czSw0umGa;vnsKYX~fe&U)xb%*}}(4H99@C@BukA`BM)Vg9c z$CGfaUmBwOogUga`BO*^ z+8`G+tY6FZY@tJYan0F0gVT1cdR1Iw*GcL@j`@h%L@RV?Xz?#c@C`yB!+|f9>caB~ zHxUxFIdu3|UZZ(ORK~k~imTCp#OM->Z$2Z9VEZuQdZe zLVs#feJHo3)dE{4`@0k7FEPHh(rj4!j^;U1s>JUhUwxac9au*xcd9vw!H||FQmhnC zpWCj?|D7BqE8Ld!6}PE!l7Or`VfOF~UYAn@mT~ysmj_S2pf#X!h9Hm)Yb(~E+#=)c z-ir)8HQoxyDt|eE@O3sO_&cajOFX$8EFQmK##hDFw|8C|`jeu_=c4Fj0y>nY*aPH# z`WoNt$~b|BqgKs^7(+KA`;@(!a*`GMnXh5F%3vdEDLMCDJ-oQmOBasT&Be$qLU8ub z_o8C3`k}hlV~&;CvRD_yNuaNVVCR1i2lwNsYc{aTfkrrlBCGclO}6YDZ|T_TjKIMH z*t7Y&njk_oJYXz|!yAl9yC6f%)G$Cjkh+tTLR#gh1acFB7iIhSu-`U#`@GETsvHmi$58!xImP?vTJ2!vW$ihE;1S%5dOo zE#7M(SPRIL`PG&9^3k(tk&b5s3N!-93xk_xFr+EfTU(azxL}RclXNhQ$)-k|PN@;3 zHUpWJih-b2kjIt236PuiUDQ_!qax$5{Apl>O_eGkg7TL!SpQhgwY(lwqt&BRW5`OO z4Q-_?;ceC`4-KoJjK<2*f)kgDLr^+I2|Iz?%DGBd7t_!PPy}|^g|p^$-igGo3R%4V5 zH6h4&Cx-6L^7*|vob3ZLDiDUc>BVn#Te>QPST@aIZv_< z%d+8MutQrLI9*;CyKO}M1JpI@JH25JjUW-UZw^0{N54;$3mcwMay!1 zg)*;-fk(LrGk+S|3eN{g3w+^QPy4{*c~0o@j?{^^WsL5Lm^PplOA z>b2`ngKuGP*OI5~^4DEp4Uk*iyhpZI$%Y>s&*mb4uNf|Dx^Sq{0=9++kW7|3E%t=% z#8Nh$CfZRxvF_hpsr4;f+hB~yz&#ao*xjJJBk9DgKf86R9ij}Kq@Oz$mxFUH-S;uZ zd{PKMoO!6}A?A6$ODmu~+79)gg@{I&O>w5DR6uSeo| zTSF+SM>~fcJG`cRE72VoT3p9$z&`o$xTz2QA7GN({2xH;il&`5cB(`7MQxKPPg&i6 z)TZD3#jxg-k$8D0y)9YBw@kC_(1u3|_UgoB+w^DoS@R?4O34#S8UCD1Qy00wOHYeToRtZ+hh6tAao*lrth&8Q^fXPFjFTT^-pZa7_r~LdEC{3imT1 zchDcYIj`X5p>`ph3sK~IE2j^Uvif^Ttj^)$kqr&U#9Hznx~*&LoW1ohw!|D4h~obI zS9yA4n4kyZDMjrnMM2KZGSQizUGgrh|5tObtLoOh+F$p7fLLitI%^j5{jVtTSFf!G zDh#zM)a427&RRYnD-Vfhl(hr-&DLtgf?C~)2%yag>KV59J7OGo`9jj-!^@$%K)tOB zG(g!`4BD3p3|Ol-eC+dLHD-D`eJ7`%=8XRV1b>FVIT@^MLVPRNp0I1{ZtswR)SU4?a7z#YHPI}b8psU`Q z=6K!xSs|01x*mhpAzCubw}BYJ?(jBWCRVi7wm?cjD-z(2l2+S1e46yKDUnSa_TA;IuGk#O>}-1;OsA9h$*mAC?GQ*BwG`c84Z6 zX*FQb8{tI%R_hg1Ctq}tQg?hqLokw7C^m90LnK0>Tkse-QT(mwkGn6ALvmlH$b5TO z!n7YkH@3BbZPK0mZ=6L?BzMApZf}aK zZa4>&q@KVhx0R%+A;J+Vv8Yq>D+BS#ekq7K3RZ${$D&U0Ngx&;J=1)I?ay=p-Xl#(hSsmuqq z2!XM*$~m~sn^@&I)}_8^K63{|h@hp?T+7uw98zw|(a>>Eb6^3*!tt{{Wee}O=@<^PFUJoHMQ~ zkWfKya5pL=v65E9BZsLnwC%8gLr%$%CEJzb%=Nrr@Q=f7(1E~60o-$pvw`9~XI#L5 zgiN^MccW!hDQVK82v#|`8>A%)9s4dnUk}!tOh8&vP)&KM-wX3YZwO^L1W0X7gLh&N z^W<LPEbzGep$JdXkDHKmtb(ScAU%|Cg*2=Z zOGqaRM0pe&1@<0)!NGAR`y2W;J?QC(cx>O1{x zGsCB>Wgi^cy7N0-os{$ZA01i+pIKF3=={-L&_0ni(5g_zX&B$){_&!>`1ei%0+mcGV=RXRg*Zey1Jh_9mWN!ktzIaHF>JJ>c5ci)JN=zT(p?^x@Q-jr$syL zu#qYX_xs0hp8_~eTHs<;6!doxIqbt&r3vJ)g+RAn$Oube$%t!a{cZk_wi=U=B~JEI%vON{Os&^(Bxz2 zU$QyoyUFCT2`OD@OMTs&A7y{nt~65nBYtZy?lRfM#nh=4WZ5sDaKfOURCbH6S%212 z`g3A7>TWhYYkDNsG*E{R?e*B;_|vk=@s|h}`<-g>TM6T)`q>R)pLRO7?h2{Pz*RXM zkf$cGqM1B<9?@r(RE5L$x*rew^yUQni>@`8Z!xZw18&X6PD#Jg2YmjYYqUswZBFnO z^%3Wg=j%k^#ZR-Y#(v(eTe5HN^l|~AtA%C_MjuL%OqTkcWhZ3G{7qLA4NwsGVOCL= z?sy#q5))79CJ;_iR6_d>181X@aJb=Y8U-a;gX15drRDmmgo%6QS?}d3`8gc;Mu)AG zGXK=r^v^?2*^u)~+M1_Hs~_xh$^nA`LwNylJ9Wr)0sg}rFxvS+?iTSoh~tM@M=Fvq zj5}aJ#L;iPwfmlxfPaAA{s`pncfYM~;WnQQU>rXyEx$?GgQ0EV1liXDP$z5<^<~DD z2k`{bf+^`{RM^!VP2#gVvHFFtw!W{!)>eV09p|5=mVUW6N$X8PkobF7$CQ~uGy6#M zikJg(|J)h+1#xI(H;DIV1V1tdew}>o&p6Wf`pYz^?~Th5;^@zREmf(HB+sGGQF6Cr zbE?l0x^WshM;gh!$&NL+>YgP&cN|guOi+43WsfT5u@ISV_g$hWoZ8FK%Z(nPvf z4X`#$b}D`TrKpTQ_qW891(Me-4(8`cgeAm318WUSH8E(?Jh5kT6X|hU5i3$(>R2n@ zv)y6*_U97MEL1`A<@S@5>CVv42{Z9;0U2lSPHK_pO7OV9o=YT^tU6T<;>idz8O{BB ztuS8Lw_5)Q#)iN-&zYADcv!HQp@RU7KKT8S;TT9*#*5IPyg?_kYA*-!plwaBYN3+# zD_Z1i*b_Yh;0*FBozMSlYsPML-sBC$@fy^SLqD&-FEci@;meG~f9huG`W3MeR{I2O zJ}&;SQPh!p|E&CoA^JXub+0n8#q~IAL6X`kz?__Ub3mo)(16G+NmG* ze?i3UG$dIx3N1}apACp({{is46BJ_Ow!&2UM62{e-r6=`h0m;VtiRh1^8W+wiImm8 zt|1CPQsDF`F@V+A0ViEvQe~!Z(}5^|^i<0yIv=p}q_1F?M&QEz{ziu^*cFvV)4wRq z=hOFmGxX!tq(9}3i!V2A+!Wk5DCx9#JGG&w_KLbk3rYQkpyA$!o3*7Csq?ZK=*QC% z16}17TRCJfGWqee{;}@Oxvqi^-ub5paY3iLHk>w-65Z|9UG&p1z#36OYzr8<{@x`w z>vO1`xB2w55+(UA>J^z0lJmUFE#3^T>M~OGT-DtF%f4`~(@Mjx9mT?1*;(2E<zt>g%c-+I+olj>@;23r!tze&1(uL@=F=C%etQ}Xo z##bw4+7k1(6~6qmycK{t*#a^~5nk0~I_(kgwQwELi$UUB=uhscRg}aMj)S&gNLQwE zD4#E^FU|2D2jjkU)zc)8?Cr{z7B7w#q<4N91y^5Y0J`|H{Dkg7f~-%J()H0;{q+m4 zPaF8W3wX7Z-+r1zZCREDEpU9iGs(+{;^00lzMl+RkUmr4KCKrGR&Ell7HHLJUG0pC z)*_co`PMN~HZ+XJ5w#su)A{YY+JX^qecixqMIXx#cB(CS7!o1q=v+wmbex8oI9QYS zweamCGKpDPs+}k(T)F32hSdtjnLvqkO_5m=w+m%gZnd`{40WcT`H3ZrIY3@~1t5sT zIxk0ZvHOao4WXP`zp%ZZS8MkzT+tjw0e2sDUsTmoK3lBssSeFibH4ItgsFhqHO?#n zY;|{8OV2P~(e&&H1@ex&lWr9v{jGVMkO90#dK%Uc_QXY)Fxhd~rmNhEDAmN*o))jjyLeEt4O+rC57YkM(gN1*{Q)V!i<7oO4b zW^&;TK5GNz1&b!Xb(N4ZE^R9%FfQp^rHi-72?#y?z?uc`EQ&RXZsU1kBCE>YvIL}@ zzV=>VQ+&}5G{2vSB_dlqTPJ7Vt{`VsQU+&B>N2`D(^Mm0br{~;7K5t5&Z`N~#I zk-kHY^=!Cb@A;-SRItpGHVYej79A!@hB$0^q-#LxKKeJ^Io*LV-NmdE7UvaE56AQ+ z%xnXqa%wV=k2`Pu$`cDh@rFj(E;woVB5hZVAVG3BKbKejFNh@sU|yN8+jV?df&<@! z9U)LPJFgJ>tX~HiBrbZ?U+N%J z{+_zHFd=9YNpR@ppOq+1=u26VVL&dOrQX6Ke4ZO56MAc`Y6$e6Nc>XsWHp)eN~zmZ zglC?`$HijVBatmZ!+RX7+;l86Z?X9_GP*d)j|NJ@LJ+XU9d2xgYHp<2sD9ziAKgt- zA%G&MHfEpa=@Pi!{5D|)0^hX}lfA=rcMic!txsu5jq7eDxi5%~EcgeoSx4q?=;o?= zSN;K-O!qLQuQBfbI-H#!F@D!gs=915;-=zK=fi?TF4$o|FBKpbLsWUM;VMS)mawNm z1$)6~#Oi_Ny!y9v!FrRxiGq!;`A?-iZv-un(SISGOfNQ6{} zW4Xv!!5XPD>_gmYrrg{%V8S12l-Sv@YdO2n_vG{MQrKbonw@b;D-fehlX}LGiMMs~ z_5KT^+MZZG#KxM}ARoDYx#G43Li~H9bQJHnmY^p%fq7$G^rnz&$>sWwDt!+wKw@M* z9<`)p>u?21`bpI58I{Ph4V@3++&$fSJ7kt0)*;eLF(wD^&{~@3>(nX3sh1{`s%wIK z#`1Dz()E*~0V zQ@BIU=vQ(Bx-~Pg_>9SK^yEm$85{6bsQQ>ytIX{XKn`~2h*FSiS@JSNsAx@@YV`Qt zHT1V>#@pze5`M(c^*h#|TlwZJjW6HtM3Z@^{FpFyzI#uw{R#=x@lG3m5?gIQnNM9( zDQ^c~eTlB>GOxi#ZR|2{8x;6y9Fb$r=Bqm<*O;E)Vu4E>>tHo(*2eG=x}oeazHKNL ztW$|prmN?NCfwPv)REY)wM#pw7@V*VF-!d|>^;#a%amI+&*+Vm&sCBZdOOhM7oQGmDI4JuKQLNSP1<*Jv zuNAD@8`X{yD9F^n&@PQtu|}dQo5r_r`7Izs_y(T$!pk~WtClR zCtLwJcg|y%`{Eu0DRK4)1Ipy1vC^I8W#o@QW-C_B+5)1hY$$;CihAwLR*KPkV=@74 z&yw-|66ifH+8t!w{;OCd@)y=uXHbErn_+cH7Y>Mbkj9}6(@h49$UJ=g z0OQtw<&8tQE&c4A6B`55%&lnN3qFIR$pO|693Dzhr zh(!GXoS@_|fyOKzE^p^T;y(84NP40!$hB)H;*ogC=VwxiFITo@aMq|gh_>k$r&FxFDe|4Qxx zg20-J)9D_VtrtF?h!&i8iJkmMDA~G?hsJT==2?Db;*Hb?p8vVlQ6-?($)=G}eYFA>veTabz+GgIqoSUpKy(9j{vtX3^?uh#db`{)Lg61_^5fJ zD~xUwKOaw6Y#*E0<{-^J_LN18HQJ3)#Ze_BIF*4=y9So`exJv9~UMe#x0wf>02V2nFJGH z4=YdZgK;S4-fE_D3p`yo)-WKG<7GCigyTecC`{TwHvyI9V*t9c3m5gKbKX&L>6cD? z<2q$sG7Nm#?R41j-(?Z~ZZEY9{R|sv-Pn#5tS|LY+NooKXO0&Q7SPd;+1uhg?`Dc? zpnq6}W}swv^U)ks!^X`@H4Er|X{8c7Zr!F_z|UyWx`WF+GSf+b;S>;=+=2o&+01SQ zX;^7=Uz^}mZlYaTQy+Dd$^atO3uZ^FQ<*{QVYV11%vco@&Tfx)Ru&9liTv}(rtMjr z=zH9O^X)j%D+5EBTJApM#cF73%(N(d^|8>Q6_c@m`?gSsXW5QXiwhye^4p=fD&<+J zady9u+KU-6S-}Zbbnfia`v0QE5JnP!kjNH-Z@qIlodtl&h zKNRVLs1TgQrGX8*!_DL}&Ri64_YJa+FscY32v%~4fx2<%Yo?MYW2%~{NY%Nu@ljh! zgm8ECMo!H(4J1hx@ss6NP5o3*M#(>-`m?C z8THljBBjxDc~x|DRWsy1jZ#)gsZC$unmZSsHy7?_y1kS3`S5yYdy&CLjkfW=h{tj9 z`La>*^rJA~V!lgTbHlJ#H%Z59@qBQZ3069bB1Bb5l$4^zkvIJk8H&qCW6&*x?%6~UCHTB zl)41dbsxVMb#S$fMu2l?WwqEvs_WqgQz?aNfB30t*_Fx58Vx#WqLzZs!wmYr6~4t< zk{#Pd9;&{YPOyW+moR`JJ(5bLPLYyy{zQvn6dCo&7jiZQB$z*?l_HDdzl ztAUE^8r}YhLAF^UDfE@^0g?U}Z8X>;d@P%6VxM>gqousL?V7hkl&)v%VX- zSGCFa(JF{3_OvYg+Nt^B839pOEdL4=r}_tI_2C!s(`Vpz6ra}$&0vQe-_#&e)IF;_ zYVU88E^{{}YhfPR;VV%#w8|i4s8)V7s%Km?o(OZcb5;P0aB`r?4I4<+WVGblzXn2B zitF@{#T!3kXTQssBH7hZu^PE${P0u3wj*QEKR}Vx6nIAjjolBA51e`^S)>EbkC`ac zbNKT1)h$!aBuIdQH81YhHm`){NXcQVwl};+2DV&})N%h&4i$p=BZF@^ZR8$;GOjRos-Gyq{F3{;1Oq&3L$&pm%f|*54fVbD?z4d0oP1s^z zz7}`%B4X%wevk93!%vAE+Sx{daJCz(T~!PBCtnNi>GRHBiO<5uV)_MSi_%oAysi74 zW?T0AHz$M*!m7Vx53WutsgmS2U)Nscth|X{Tx53b2|CiBYH&fVPkZSKj|Do9va7N~H(M)ezZ6;9Mo5{L7a&K^A- zlmugS=F*(cT*S^86*|JaGnJ>98H}O*(9#7~}UCvmBIE5yCVFr~TC|R`#ycDyjv9{{T-C zYm_TUe2ILthoQe^x;u&Q%{)x=jxjt*aPay!w^$-tgD`rs z9@QrplU=7eb8(?|NLimA@$bYcoX(XYqK6STN^Y7u-$u?oU7^YSPb;%8@n>0eJ|Ec6 zcXG8}tLl-=&cE~;z}(5>7Uq}Wxe=SaHxB#CXZJp}NO5p&d_{HT-ZVGDfk!F8b1vvosb~7GD*Q(BVe~rpM$o79 z0H5BQl6&SKAjX(O*X=y#`P#j|`ZsLPbFWn(u?C)ki_kkYX5AAGPLIMj{v9SfQ zSV}9B&5jaPSJwrxPuDsF$d@iGOzA4%{vY#~Lbhl-4g`}p?_oQ1Sgr=IG2DEc?_+Po zE)wbxb|X4Nz}+T7hZ({UvtO5|n!Eh?ZM!~5p6foDx!^*il5mNcDP4a#xu!;^;KUAW z^1X`~`K^3uu<%}7s7iE2I6&x`TQ20ODvpQFF=4cOV(3!9s`V-)J+gg6;=F?)ms9-L^mbb)FSA)n5dy zyFJjxGB+RgV0v;i*+M(>S=x}b7#}KmH=LKL`+#E&RlWP?gVWA!lXwEPd1&YhLcn`>?+;y(TP@Mi}iv0PZ0eMpj|lcd9Xt7Y+SjVe3#wEUrqwKi== zpu{8QLzI5)UAjh|JV4BzUHYx3z;C2rL3Yu_PLe^tfl^(9@Kzzh~Q9tVJs4l%wZ7O3)uSn z>ehRm6^XDMZIzc~^gU~ho&U*0^6Q}nawjM|0RR;R4Gj(b|38t9LP$W*C#`8g=N7`7 zQgYKgxUlzK3ikhP{Nz7{7iKL9%BNqeP^Kz>>3f^Zz@9oj+M1_j@P);lX32rfPW|4g za5py}kI&hfSc!>?PySR@vpBW00P9%fOYRr7tU4xHM(td&M7d$8bthx$au;}5LW?SbcqP;D*`PlsGSVMTM;t9uFwj7#A?mxpm$y!L) zb+N$qQ`{9J>1&E3)m5DZ(&A!-m{OQtW*^&vFS8iqOtP)3{vVRQ!<`N9eY@Y5mRb>e zOT?;8P`ii_vs8(_)u>rp@v+5DqDHLR)vDU0YVWENGxltYDypeXU%%_U&iND0^PK10 z=N>WEY5#70Pk&SCOgY*2+(bnPm}E!^z(F20#6FlyUeHNeK-Fu6mii94RR2$oAx4b< zL|iN`bN}#>SN{mKNP{8fNJGAE=;1{64x}$ju21t0Y+rx(p@)PC3Fm=$0J>|W`4UTz z)p}P3HW6VFe>HqTDb+*?zPHa%3`NhF2>a_8_#-!}4?V;%E^9$BrIcZUpbm%?;gz%m z&*ptKjWDP1a(GJ7GX}!TINjA8uPG~`ZD22@e;|a$i#H$u)-CC^eTI^JGi*Z!)zo`m zzu=sAZL+r9*+(ckgL&@wf3Ofxo`O^iaFqG0O$0K#fI<{8N4#vPEppHcQr(6lI#;L zNI777^dWFY24_|BKYYDhsW+-Tg)9x#_>p6NayL^)!4&5V4x{Hx*6|Q`hXvO)+3N~c zWLr+;$U|P5xL5lO_hj%)YM(#4rsC9md?n1WZNb}Eswg#`2T5|bD53XIGN5djqho_Q ztyVs_t6~BtkUX_P&vfHiA*LG=)S15tz59|i8IP%hRg+F3zC#Y%1sXUbY?gGg0y29jS!~7;c z)&|2$%D^gY43DLNw#b$>#Id{}g@?Cd_jI6^39d_P(az57LYNDdIHv-X5JwC&hOk} zBKq6}p_DUK={`@;iK~|a7D4&_jt1SaJu*`&!DX1Tp=%f7E2~cWa0@VVqef|&KTP6M zT3dfxz_x8YoRy2AU`j&;(neyM@+DQh{gtzsUWMSXIR6CJad*AOS`HV9w{#?bSOV>*RAk&Hz0Ifq%EoiYT`8 z6)VJ3q>=Sr>6;Xm)V8m149#qJY=U4d25IV1i1U_xei>(jxV@Fq`YNq|w@8nMI5^N~f;2<_ z0=9E^?ri74pj`YI;nehWr~nn6rRsOe2!DcT^uJrX-mp!>ivJB8*E4Z9;l1<1vJvvN zH{d=y%bU{(Vu?__TfCW!YRq`8G(sqX{XGAPshgS>*>kYuuzJ_^ON!?JcXe)^-5wQr z7LDb!@#}ySUvtn2N}AA7S0>gWBktQ~s?rNc$Rrt}~k>`DGthQ~%poHBe zjayc&N4-+E{j>6{rq*p72bc2g`M);pw5vRC-!Ai5viTX;uTxICrJAchrE_GXjgykJvD)=j3;}`fz>rI$%eK-9<|dkJvKBVJKyP1 zri%bfb>79yu()e*$O18X|bQ{gGG$!1AXtn@<6K*S-0tUKW{`Knk;Op z;|0Ng14Tmh89m$awzo?1RxlAXO_6{}<%dR=j``k{DyKA?zt^2DagxQ_b2sY|F}#=w z#oHiFk}?0@jz9R{Erg}DPaYp1w8t?cmgg)picv40vgh4&aAY|NJQ!xKSDC|LnOgTN ztG|^#nC&a+m9adt14 z6U+KI=Eka;6H8z32v}%EY~%SzIAL87;~$+6tYIoRGjo+JOjN@qSLXyik7h~F+e!cN zj?RH!dKqO~p5)BQQqj)7btLc;kr#Gji^L=70LdWi(M?aSUKr~bgDKdROF}OW)t=H# zTF?uU(OZ@P_J+d)D)^JutjeTy=0>;+hVxz*j7X#YHkX=>Np$eH;c-0* z>?3}=ij@+#z9pXtP82PW+~@2Y(&BhO26S1S|FmriD79hjuRt$(umYb5a}G${c7@J_ zQ*}=xw!URU5Dt~%2tM^8^*Qw!{%VKvBw)g?33(NuQDx>5*8QgiL4@pSkPJo)ONP$VTs5R#yya{woA7(Jen*`-=e^P3L- zHe@zq+Q)v~jrTDI^Fy)Td3K;tYjWIw-buPaR2s38JpcDzV3YVVq@wbIop`IyW9SC`{QTVJ!QyRG|{aA z!MNeVlOo?&S2N;>rbaPOE%;b8OLbh~NF_*a%m$yazbb;5;6O9Nk)oEXPf==uT*Lc=MYt?TM9odHFTC-hW&+a*dmmzuf>1EYqo2YI z5LXF-Rg5xF)LHFKi;qn7I~wsM-dthIk0khmwl*90M$5mJ;AGwzsJ#igm+y zb3IF`a<+?-B9`Y>^z|-8RD!v6cw;F3|1K4ZdlW;#S1LlrQYwzx7t+Q2yrcencqt(% zsGD$G$IIYN%!2+8@;^dep#r@L<^0`kc^)w1B3|z$)_tiAjZn698shK_#PIHSl=4*O zoO)#vL`qIvklw9(*u+iDSo*lT-V>i$9N<>uS+C=k3(Ae3f_%9@ddHFY{EyZfgYC@q z31{4{Rog5jW0=r(mp(y>z@9|O(Ggl%E@xMUQ8X`dn;{`&5q0(Z?uebF)%u$t`0W^B z#>o0@EvFklz#DDKf9cb&HMhb=-AuM%fI~Y+axhFyI8dwHtKyY%cA$lakf8dobtqqS z75sYxcaJ5ipY;wqjV_0VQQc<^L@sC92> zx>^!OS(L@b0G_nBJmK4MpOY{4*wB_SqI6Q1)Xi0#!%w^m?F1lYxZUJ^{29H$`MYMi5kn&lI-g z1OB>cA-IG_*ZwSqQ|#c$-fHQvjh7Dow0avs<6iNlL8$)-`kqE1UhBF!h{!>R*$((#~6eAUN}ezx8rZ+J09rKyzId# zg8Sd!qcQB$4ymWoAS(cjmTncPFdEc|G(i+=n-KyAiu7*>Mkj5gkT<7_#GvsQ=V4xY z)c?&$FEBlKH<@A&rqvkzx%!aOSd1VR*NLo{cX?1h!)3f9JMlV(VmnUTnx4QI!^UY$ z<+Q_)k|WCp11y3HFd~DmDnmIsfPxq{VF&3a%$%uQ+g}Yu{n!L@H1Texh_sF&Ex06t zH!mWV|8M!jcniVW0j8t?Fh}7^IY6BZpk1C$hnqWA!`T|;_G&F!wVZz9)gTryZBqAoxE>TR}_uN>ZG#Ci7aKtF99 zgIO7tW2-$H66r*rS-^gOEl~#tb5{67^HEp{Q-UEj{9ld+EgYvIH(UvW7_i&Dt1<;> zzjqY++E?da;>-C1%u%jRM(s)AVg3YB|7{7wk=ChMl=L$*Z)aKw>}DO8qu>2a`U^7S zl40&v2KjPP%H&N8QT)WP-9uNZSem3AK@q~HpA(RfqQx64Mn*Bb`@S6H$1qsF<}4x} zPRoZ67Aq2pL#>(cMsKZ^8(V3Z=m;{xnw&9m>}6e*gpA9J7zKKTw{r6V&8AfjbaLAh z9C@ni4lTy}K7=2Y{g~P2;`m2WpRX`Y%jT4Sk)M_|ph(Gw92gY*rN8 zCh(-EP-;tmCH@o;WS{8p-{_teFMLGdVIJDPVZ=dQ`*cblAn}Pen6Vgcem|#!+b*(; zsaAfZ_c&myNpo+srr}V3b_?+FJ4yg|{#X~q%3Jql2@6{{wyXbd8lcVJ${^mG7bHuj z$0-G4aClgDm116(si5X85cuf_vCj&Pnd#AA(QPK2|;#@ z7QzOpb0_PN)5n}Y5sVppydq1BODmsHBheZ2kl$b%ISw1jCBMU|WcSAIhO0S+7XOER zLdIZoMe(LTZ~E)*`8CM8>ZwC$eDi71|=R_Z#`G>Ry;E_ijr z{emrp*Vw44F+=b{8t!59KokU{oy0#P>8qh#CSbbfL0EvDGAxJjw9CJho0eiE+=hYx zN1~t?HdEBL#>ohGb{<%uJn5Q%qy0*^eV<$x;po!XFn6kjv8!RP(nv#2f5Vm(V}!FK z*7Rz^)wXB99K-|AuA}OKA0I&UFqw^;m(p$>A=4)TAmt)-fs%!NsPQ-;F zxD|sP-K{3iyV3sAF)uLu8{}`L0q#*sc93Bhp$JC9S&y^jipR~>F_}KybRR;_lGu8_G# ztxHG5fWyAABs8U?Se{m^NFl*!D^jVVsK!!y5(u?$eL=h4SD)MlxS)p!_1tzEjx~lM zvyd4Y!uK+4?TfZrl}2S*z%~*S#Ns-RhV^eKIl?dzr{fDzYdZLG9%;BjyK;}NuIM^s z%UtE(tw79fVHlS=)Gb-oor8L({z}xYjl(2iy8b)I(>cyajYgfz@`gS~!!e1Gw)>|A zD65eV?%;1a3EVW_++(lQYapuC%dy|&n0o!2OX+=x(u0ov6Z4csva{*Ts5xZZH6tC%ZD_dnjoyh`s`S$97U*}k|A z0HrM43^5TBF5B9s*&|Fd)cPR4o+I$qoIf|_vqsV^6tx>gw9SIs@Q)J0EFEUS>C($o9{dls?#z?QkHe`%%Y|k1T2CSdF$1;c2#I zM^q&JjUd@!XtS)kNzgBtr8oP0pms^CL4Reh4HA=SDSStL^ryFQ0SDcObGWhKsvu7| z8z4Rd7j)56Ob|+#9u2Kq`93U0U`ke0P5qKoG!PTZ987yAFhKv#pZ;M?aDrGbJp@;f z@Wh>+hP|Ng>3}TEQBgvtJ5yjHBlmw z0*1HK^Gz;*V3b%hZIV~v4j23R3q(Bx^E%&O6L|hQSqw`YzPTC}zFbAp{)iHsGt{n- zxdJMs-W)>fi>pejSc`!Up&dNlqvCgiZPE8UI^U(i$o0N625SMYL)Xr7C6w3e%zdpU z-Qg$0?V>N}d=b+IfnV=+4~@`93FGPRQO)OUt~|7>%bYLPtd~Iq;QyCnT5xrqQ8U@1 zpn6i>C1?`jAJ%3+-R`+Hkwy|Ady8b@B2 zMqD5OmEb!PVX}z%37cxKA?uIOnb!zS;imyrQ{yFZVnR3r<5wK1i}mUEMSD*EZ+$GM z{h0o#F}~*_#VH}uD2Cq%anFDG?5V-5J8ibIu^v#K%{};;OeC$IY*wnTM2~XZ>$-t0 zxHXs|?34A-s?nmDokMm*5A#;;tEGWFmC+-9K~`yKF{?$R zw{xi({GOiRozwg}(a(j04ojU3MKGGM2FHx#YioVnNW(MuTDAJ0U}b_Gzau9t93g9- zRt&eZbvk;Px5T!$vTaZX-)cPsQ`2SJ;{W*cG~g%EH7WSJX_-yhU@vy8 zmA!XHYES_a!1Ai}->rLj`^AUjH~k!r`&q{?TFJUsiD%e+G|dlgz2nHi-7mLkY2>?H zGHl*_twfDiAALSptv%$kGnBjfDQD$k&ySg->T62N-sTsNi?mrSgNz}Kgv|iLy{n5M zc*W(8jmN>FNQ2|MxL4miAcDA;`ou*RuJ zslUqx@&J#5>B*)TFen&DEb>V|DgUkFbOGgPx^Mvobv21zhi zQfr!a>H8EVmETRG_FdCn?Z`M4&~PCVEFZ9lW#HT*pKUd_a8AALnsYE+q-^_DB2SLm z&QFuLAdC8*_D!=@>-66(1Jx3@&4Li<<~+Ob*--t-HJ@#yd!^V{s)!y+xblt4SFV^j zXZ`*$YLkEdfi?70lua$#4np*f$oOvYYPTYTUtdvV}&urjo1=6QNuoI&V2iinM9 zW|uhGWtw}V*V+IsUO~m^>?W*`@k>Cl_jQH~n2J1xTq9CI;qe|b62ha;@hV)S0sP34 z>QZN@Rvt5Bs5FPiqgJ*(J6HBBZ*LA!3~#Z9aAcEn>nlk?zP2oEn|m$omC#lUB<=;0 zVc22Kby{P&>ozgek)xUX@TlgJA@#{YG0BkKQy&oTWON7prjg+<+2VcV6nWB}2&u3J zMxI|$4mqgEgAD=OgP#qmH*J6Y%h~v8P}lg#`1tvlAl1v4^_#nPJE|*G#0$038?hJ#WHwJY)ugGX&wAutZ;T5XE|%(=&D)H6o~Gdj9s7U^f~d zq=43lJXL#$@N-Vh_jPjaKr62vs>sa_qP-XkA0#;|J?>u}q3q2Gxy}$=`=mo3@u^Ad zL^qPT$qu{wevH{nGKp(>#2J`kLP{5|a4h=slDQq!(?;-c)`v~`j?fy#ch>Y28EUkM zKqD)4h{=2oiuAN!(r~Ql2hp0K?6SL)lR0kstm^q1SDr0#vT{~`6Xer zJyfOij5G*W89P?5hR5E%5}lG?yV%G(W-fp4xEX{ddc8J}g>rSwE>Qca9?TE*M|M@w zNaS<}H?Pv2rain^JHRS;t{O+3MPdI8thV1?4u5yqSX@kUKXVAj`R!ddCjQEZQ++8| zO!2a_Fuo|VFzMCFkqY>4>#?I>;Le(19mm_{ABX&uL!Eu=m?_JJd{slE9$vlLD+=dd z!M6j;?M%K6-naJLGpc>z{L7)zYZbESQp#JT-AisQ8lNeT_bLqJsP{hw{(2^JB@7>)g6eYJ)*6B;WBzFA?BXjG zFS8-9rZ~tFGvJ6F8!?@n*j{y@CM<6)R8N~LqagAfE2-Gk_C)pHt@yd^sKbj((d^X( zXCFVlkFy(AM5m&pj#YdV{Fio$gkMEXNAawMSWe@)-&t$VkMZ+Jm7H_c$f(%DXFr`J ziyOPTLzmxktv2)o**2I<<(>;JOfy^I@nh4c|GiiWs({+xS{v;VmShNm^qoQ7!J&n z<3k(Temt@K?I34=FFi8yI@N}l!C}}*6IdK|sP(HzW=phpocY92ms4Ry^i#vJ`vAJ? z7OCJx21S@r#+paf;0NO2j~lW`BKuDk_W5v|ixlfvcn2xeH9I5bWcoAGljvd?8pJuC zbFPW)`Wlg>@6v!5{2gTky7&CJ;RLi=0Mfg^*aefW?;`QPZH%yUfYdbv{fW^CYI;w^ zw(iZdJEgYxG%9gl{bg?%_8`svr|Ha&kJn_--7n0#@3L_^6 z`>D%%KHIZmL2YyI#u&68Slf1PeK2rF7F9N0A9MHpuLKvzvVF5^8ZeMr zU;vdT)Bxo#RycUZU}Ddo6ue^B&> z*VH4XWYv7F=T^k@W$`$?(P(lgTQBtAEn1sSC$Ox=E6F1mj*)x&jWM{sb@a40O6&~W^7^^9&ll6Yovl{9-v1@p}F zv0LZ308>=?s|}d&J=kaw@9*li9Uni{`}$$;MlT8iD1_MAzxZ}_%F>OX)XFZKeMU@S z34S5I#5wQmk|XueGN*;R#!**%#a(^H1sC+3YC+@G?Z@2vtDu8)|3lQX zpS=oUKe2l$f{owz=AMnM*0VVk)C)zSQ$BoXXWcxyWZj=K&LFttHXb?;)b~N$SPpyunZ8)jS;e|}9wC2xl{nsbek?0@ zs{P{6Q+&MqHJYKbD_F|4ErY=RQjKEu?#>s^<>$Sg>*+PC1S1S)eVgvVF(B-!uo-h4)`jA$oh+ z-)A*>a(+|Oh%R}eD6Ouz<<$a~iXBz@+x=iW;S5G&(6TDVWRZ#|7-z?=D~d@>BU5JU z`|b1c94pe$)3?pVjMf7wA9CHHfxM!vAqsXtKB zLpyOK>u%UIFfugpy&%ffE^&y|Kii6du}PK1%odKxbf(`OByAOoW~K@@(a1MR1EvhQ z6iV#-|J~|pJG0)|d)cwNNTrZd6Ls?!Z@1&6Dl7He@9Mt!&I^~CW6?9o#iQpl`9Ieh z&e61=L7{9zGi?XOF3xIEy@$c=7tsd~FOI4B%l*43enj~tvYs#Aqu(?UAZ!|e4f-ltApS#m@~D|r-<^i+{efqLUXMO?Tt-!*#3G~mqDzcNFSEVB9jX1V zaRgFHA(@K%9JkBOb{2WBGb%Z#k{YcKjJh`lX;!A!k^&fN>tYn-vIBamnbLJCo0Ath zBRD~&!daP<{u~27$iGZb4sVI}Lx`Su99j%55CiWox&mm_LIvKL(U3oPJbj4hh~r4% z^Y^y$qcF5oSBqTTX?pSI+oR9!mLujGM~R&bp0^ED2RargkpWXDWT2kTV{Qxha6})O z7q>yiRre;*91Xf}h5D^l(tqsx;ayAXf=Y5nXt*`t%obVHx;_Ov3)J--kX zRe8wVNVyZSYf7Jw;fkxsu5@?h;HQ{eyVU4q@UzipqkB=~A5P(Edh=W*Lv=Glc%Lr| zoDyG$+IM_*-q)Uh4BxMw9EqzC4_)VFs{h@J8y2aG1Li{n=UwRxj9zF-rb>J)Xd}KF z)6=|dN<;!^=A7k5iXD1J&A*K>1C{}^6}wJP>|2q~e6b;#AjP~O?){rLi*k-9rt@`Y zZ~fAd>z>nZ?Pl-4O4|$iorytO90Jo{{L_EHD;Y6Qb9c+QI1A%yt%$ffZL<#Z@__PB z7n#H&*wsT|8q~C>u-BL+Y0o}0*Ra>SWzdRy9U@q!p&>Q-j>!#W&N>2o9KS@7k9jbn z!oAVQ4Ww2>1EtGWM$vqN-zbb@hN zs~UGJc>W>N05togiT~B~`UH%7??qRt1n&zy#y%$=B&-s~`B?kh#F|mw96fOc?S-)i z_wFd&8z`32{)zyf=}<9z-wkbA2urkjfP*1(L*Qm zXAL;etre&W3$gDy#qjcFa|eudyC9bwXILvFV6IkXB1M`Q9p)1Swxp;X+^$iNR&05R zXem!)Zeu?@k-u+TOy+L3OyOC=PI`g(cPr}`!Al4Hr7058d3+{?kbLM9!=V+29P1Zn z3(tf2qI%v-^8Zjmr)Gbu1`EV7-;1O2PTXn}^2prJY`Y+Q?%h=mUzNeu;{&44v)5}Gx}vrjH$Svk zv;h7!8er!EE03pK%GH$P@x_|^8B$~mqA{7TfX!sx{T~kpQwx^TNTOvPh90?Sh&tca zFHxIvPs9UJl~nHuS$z>)DCf8F-K1;=dd(v&6}sC~5WlbXAFdnGMYP<%CbKUA+2?68 zD_{pM_(?nS3}t>-+xq?*KKwt{VuP+w+v>Zin|^fmxwieSA$5!Pr?lh;2yQDvVxxk# z8nLf@?X2uVyS&e~s4q)wr_?mDcwMI0xNy+cs1}h*y1d;hFQG20pnJe)q>^w!%h1-E zPcK44m;P+e-Jb$3%q2aRY9j0eZ>-Mz{%xS3D`vOegf~X=(L{N&y6-X5D4G3C2P2V~ zSmroXY-ToyWT`_j1jgk+xBe>}8G+CTGjvZU_c|db6=1Hu)3Cs3O2@j-zX}IK_|T31 z7KP+m5O3{Ik#}*G=|kNb|6_mNt8sU=d}}z|#)g8RP24`)1;9e>)R57^vVT%q1E1xchA_k%=rXJqj#5@-+C6!rAJ_b70>q&iuPmbjMS-O48HNIhSyX%k2& zUeC7W=R&jtPCbcvCb!UKeZaM=EIv8G< z2Lzd8g__4tzsMT2st=#XP}(|Rek0ZQmjjq9F!sM4luQo%7j09>7`U8c zb;|wFT1f{EHq||ld5i8NizLvW$UWbtsOZGk6zu(%>K1m&SoYhCfjB>1Fvq3Gas zPlx%!j8)PlD zoS^PCM2;#&;LO95{B;U-Y`5NYe@P%CFp578O6igO=Hu|=P$GIaQ7?t#&|PgQT$80D zFrcJZD|M$~C%tc+t0O6BlufCD(#Lo1@4s7`L#mt84n~ktgQb^fEvO@m$AV`$jYcKD zVdQt9Ji^2Oi*-2jo)v;pu08L#dzFL@UXqJ*0TDFrxM#Hj;)wE3*To!l+?4frt*#4eIC;DgVgUzVP zxOXb&Y?urRlDjShvP_r&4o03q3(*YZ;h;~yZj-*y~YF4fuTDMI%be*3&ybUP5|cC`q0b`VFVI&B#AY7Fj9QLt-| zeuEXaMYp;ACXmlP{gOG#H3Ajgv))(67(obf14R1hfF~YL>=Q)M_jW~*1t;{N8sqeE zpo~3%O5gO~Evbbfs+3`~iKRLIwCd{AJw?R;`P=I4Z2Z)E)Y>u~N|;DKxd&QoqWt|2 zTEiZ|CXDG-#@uVIXtqy!97jKuI@D+Z*$kXRZ5J~=z?|7QDRs$E&h0=Uzq7}rbIxS7 z={RFL*81XTxus)Rd;3x%UAMx>aPJR4&0m95x4r%AWmz$QV05uZHn!x%-y6_tR%52R zl{8&LarjRCY>w$@4Q6pMg@WVgNHU#kw_T)3;JB1zPYPu2N@_f#bKW{1 zX&gTUN;3PQo9?DOp+Ak+qe0ly*UQd9r?(7T3{Y=%Vc%NxHj) zST}_8q_>-3mS~mW#Lz7pI*n;vZ`TdKNQyQ)K)KmrbJCz2)7%}w`l#E|E+zS_x2rt& zgfl<;M`~VZKa0k89}%!)-22;S; zCOXd>PfDhY+^xtXKXK|~Xn=|=Km33Tg-#z@_*#W2A}8}F=@@?zs?pD)g(eO)oXtq- z2~g}W>0*Z(2$~A29ws6|mg2h}QpwEYKO|&!Nfs+_M#n$eBUjPCiDt8ljP)}vN3o1> zkw>`Y2;AYCjlswMFD{~gk++rM@m8)Wlwx9fvPefJpuaV_l73H#frjz`W^|srU{UN+ zIII#axIc_f7y)b(0Iybgz<)oV!Hs)BElleRhly>5-#)NP()omyZ;K*5OC{Z}O5G+4 zcZR+vrw)wrbmFvq(bXFtY2_e?9)|EMzPb!A*#vPE|byy~rzN+Tv4PI<5Q#@^Q9r2q|Dt=JzF2A>MdU~yn$`a(LVv6no(Km`et zKF+y&)X!bT42O{hZB9Ri>X)E$-Vxl0?XRQS)!#Vs+?TJ|5(M@u+3~kN&SoRT`$+Ty zY+&D!H^i~f@|z^X5J6eBJpLoSw*v4wr>((nzZ|z33@#=`@um~OkM|MX&C(tHdwq3f zVCOCjSGqRSN3E)glxM*)6mFT~wL8kp(X&I}u>?qt=>QmwfU%TgjAG_%lG@%JN;N6- zX*eeDa83M;*}-0TTI4=wEV7b`4EqCQ=hS$aNY{2J13{j^!7SalHaSa5j$@YQqM#KdAr@u?W zDE82LGPh#597~ut+bR+!A4P}LPjrlqaSN{`T^4|9^jp+LOdq&Gvk7JPpX;R_eM^@0 zP(h4%i#_6ZxG~Ln@`SMmiL6KNmuwt`Q+DU0K>WsgkDEu7_K;Zcl7)liI%5ktwug-% z>{c(1sl%NxRK`U@|B!{J9XY!ltZDkDd(rkOxC*r}$Z}Ain0uB)vD)>fNm>Oa#}ywT zc*qhSCmFHhAtDKd*Nl`?B;-Y;;MqF2Isz(BKwovj&G}5+cKEj^L(jMvrFs>7k%DvJ z<;ZEy7pT36H}Mphel1xs!jIA!!;`krNkrwcsw84d&*M2lnIOBgCvb@#kyFv<7H-vB z$Y!s2D>&`?vi|}DbFh9#RiPE^tiF}~u-*B^8AS#4zOX1YQ5%BN_gLwBn%JLQI~-`N zMR>sy!yhLo5X5a0SD5@GzZ~dgA&~m-R@q4A416PmORlcvD7<$uAh?n|VMdtfYfzou z`#DVgzud~{0SOpF9?~*6qXrU~ek`j;k44uITkxzO#w0EoI15+=R}htP=+iP~0*7w` zq-CDoBrZFagC4O2YUgX`6W?-uu1@>y2o!+07d?45Y4hDt^{L=nO8ko42uB`7AL9v` z$0ZvKRuIVEZqHKiy*4#HFr!p@awpisa4&gyLgO_qikdg92MsAx*AS&Ax=YhX#LaT| zEF1QPC!bB2Q||FOaFc&wP()EXvA%L$_HvaRCfd4XJ#e?8g9fQy;3#bX~> zxp-=~WUHzzN$~?%}(w29J5~VKPnt~OU1Aj@zt=w8-G1D z$oG}LD!!p*1O+h0X972_+vmS!;OWSirJOtEELJE~R@U`cyx;6Err-b3(Zz&s#0Xx{ zYXA;e0ODQEr>&&iE&&IJ*26!D$>6k02JNCmjiHfbw=2-}Yljk5S`tPtR?x5d`#Zp) z7C%OOQ20lM5(A+m%;2ZjJI1D`0T05B`|pDeAH3Pt^cKdSbFs6%#xG5n7R8xmBK)W8 z(Fr>xzF)X$#I?p~wrOYwd(@&oz4z_k{Z`FHeBbRBGt3cx4$0O?N~JEy7LZDSh+nb) zbw{wCQ|iT%rIa6iG54o{vS(r)Jxa5?V2Yl4gcNOU({+(;>;A=_m0%i`x8)UhD&O8! zOa6p8estTYNtpA5=AN`l*Q5>y@HquB;L}Dz*xOg{mT|2dYxKjs(1Ev5|1Z|vG-v$3hzQ0nmO+3^?b5v z_iZr3z`X@>m{VXKJG0;6W_tdQaN1*L)agaSDk6-{9J-rec=H~@A;6%AukDopMkdB9 z;J7?Jhlw*O7qr0t3(jTyz6VoJ`FI{@_?l9_gLeMI>aqM zh_<8jp82ItRAPM#6FaU3TC5E<9s-kfSps`xE=_*)q75ywLMjU~;boI~6Y0X6AEMxH zMg1L+1d{WxNIq>l$9?CgQYA~#jwq0;d&Sd-%%1*S<+H6!6zn*L5UztF^avZNMrJK3>UKjWR+pGhF`B)DWFok7OpP~Em^vgZwmn$?IP9J8X(Keg z_hROxB$n&*+tDXHcl5aa-GUsX7z;47ap6w1mkHOg##F(qBYdg#X+n7(VleE+ck;z& zF>`A`=%jT*-)*>H-T<_0{3e7vhMvByNrsU19KI#P*OGv!Q5X=nt+T59@ikjO3?S#2 zUGY)efJIO9EtC9?4SjVcu?FcRAY7M0wD;8+_C`505FAQfIe_}UEPg^XEZlq(vO!Yc zQjB26Pb?8K>cUd#)dJ~``7FB~d@M>Re{fK#|C`d}20v)(!k}}dhvf#xkngt}Q`Lm` zhx)F|<3k^z1055;ix)T>Z_r&ocM)2cEo)}pe#aa5$!w6Z%y-~@is;!L>2t!z`g?m*-f0!{r5ALM;!@W$w5 z8L&(8#uJ!q*!uweAmU5KMTfzHyn5_12J{(T3O&#|rGu)%vuD4hA{AKb-^5kQs4;fI zNgV=d$)>Ai`pl^TvW1+$2kzU>*tP;G1(IkhZ-IR`Qn!hNW^WPPi6svGF4KEyowBoe z>O_0pm-XW(5kI~fAnFVD)VTpy?~c+c5xv!gO2t7UYcQQ@O7!dM{8s)AwBblyab5bX zBs{mI`#JQZw?l|B1ALmBBM3AZJzIShn(Ew;l!C2o=Uqk2X3SdzIv#6=ye$qO! z99|8bRf+jzqxvLP&9SgkRJWk-`$2$+a97k{+LJS`hLm4wk_J*@O4Oq`cFk!W54#|# z02w_1L(y*LMn)oZ!c-9ZD=nkDYz3?H;x2V&NL6Iv2R#lJEqn!#p$(0w=0xA_E!uF)LWHyDW;x5g2tkUmFpaU8h{Me9 zBDNi^fHr>9BsDt?F6gd1*KSZ2e^-xNhNR!7rj`nja7)1mWsbb)`pH-Lx5>&e90giqR-?@j-*j z>8JRd({TY0plVL+tlZ@*`Z<2FA7|Y$+@);8#nCvEpq|Xk*fHlk!IFkO-FDm!Poyx7 zv5E9y`b-?yMfAF!kvY>xd#aN8yH5SNQr$n^-b46af)hHpdv-3Z^^p1-Z*KvSSvQ1*PuEDHtP-($>tMbj`T zC*|2(JB~&*zP30tQu;?3N+Q{|$nyo1*dd|TS!m$Vaz71=6h)KK!-NfdWF+fB_$uDrv_mtt)9k!+hiZ@Lh zBV>q}cn7xfTFSzDgTB(1e*SliqlQ#X@TX#GSPeP8D75j~Y?{s8%4ema{3=vaJ>Bgz zAC)jZ$eNGpx6@IV1<773m=RH|2ABPSz#zP2sR-ew4;wj#hRP1+C=u=(er1alQ37PbYnP4aPEm(f(a(`>j zg9o4J!OHBGRA`HgIHhnLwIQk4QH1lpg!V!{;c6*SkBz$C$wY1aUYHN!*S1bIe&vV9 z57Rfj<)ml+3j3kkOkCy$DR(whb}rK7>VmZo!zZp45tQAv6QgLf0xTm`bhy!<{I96C z_FdU3j08b0CW@(Eh(TGk8n|2Q2Q_!w9M2~i3D>d@&`5}Kt=8@5z zXy&h#vU7Zp=Jv^Z3pgCz3(TaBTd$P#`v38C7H&P{AOQaO!D?MN| zYzSlIXcU!{P?VBJDG?D7DFKxd5h;IrfA4X;|G;x>&vVCh-1l{UPIT^QnuW^Zfv0=I zJ%zma>+)R6K2N$wo{#;O~(3115xQELSE&yH`QrJksop@kZRaUDUbqw-WjBcT=Nln(ixw(#jq)$B!&{ zJ2P#e^+2qUapugfvsr)dyZ*(Pv04Qr-b0grqQ7}{-)&RIXzCsNePP^v8MN|N#dWxi zCxaxCNI&mIn@!&a^yvzyXT&$6Pj(YSZwMc*Ca2`R3lK+s0Y> zV`4XI-%auO{Hdey2v%{(fAu7w{ww1hknyok>Q0A{|D=N2G0ak;&vGbBINX_8Y_OfF zxs#RZw{M0-L*E_Kw;NnFr!(ZfND`*bOh{u4=fP4FCA+?w4RWe0)b(z?-!%If>_^Eb zKto)O9MftjbN4Q>d*@o)xb>oNmeAAcB6fyL31*TmGw*SHoE^#2&MHlhT*?`7Km1Gj zOe&#shL=3El+!xY6+bG(Cx`p&T z12Xb!!z~$y3~NJJ5Udz4li}5pEN(hg@tX&(c}bE$Ufy2C*#~*k9g!ia#s*EyUPyTq zc287A<_haN>__nuXCRN>`R`08;E7zAPY=I)S;Ny<{0%GWtScOPBm3QFd&3c8oB@SS z04?4kbJ|y*y4^zmhnHR_=;@bh$xPLE3{K=TpVtkrwe6%6tk z^p60H;Po2iI2*)W_mt2_#LA?0WyqLi{jw7MBYYrKtTaZAv3cY9ytySt)Amx)(@397 zhPy5$lgDc40q=sUbhuj>FAK)S&cHbSfE;G_L;IOWtDR9ns%zDX!;xZax~A)kG`2mo z?1#S`fy2^*K$*CQPhr#9E$DvyLiecw<*zk*dlGnznqk^SG3*{J)cgF>7 z)~m?f<5;%%jB?@@FLAiGy_%&vHUK^rJN1?MKY@T>DA(cYNc`x|@ygn5z z%zO}r>QGS;sH2+$mD4TbNn*!DaghHtLjjaJFG3}>KEGq5DExkHWgz*!)Q-&-Ek}kE zl`7!+AVm$1*(X18DRYUw7M7AyOZoU-dC}?AE}Zt;b>++wy6R({`6P+PdpqpEEO2>l zRl*D#ei39&ZwqI#Pdb;oJhrQOvYoh`SS{6FwG}R2d1g0Oe{KO0I6}7^8gD1TElA7L z_%Ug3&QD|EXE#OQ+^Xtgg~6{;5NAhtzWwmE`dg2b9z{9|4D^-YuYt^^A#=zx$ued( zYnd5fO>R}ePUVpknFfuo^UXRm*%Q(_>CrjQLQXFXmkouuji!~%eHZ!+CsTo7V3;`P#EhO)b+yPn1=A+5D0HrVa5Rn57zvWlQxuf}F3<0^}wl7tBU4O==1=c?tyr31_n}k9Q@@2ZZw@JjV7c+TriQZ?||KIGrdR!AG)k z==Jd7Lz2yh-GRH;0xJHRO*z$*kRXHb zZ(*#CPY2$ynKUYS?Ti?;KXm-w9*fC`Glw&=y_+wZPWv(K!&Iy+j7_qz;8zlVz$}X6 zI=bHy!09=(nD~m$`2%|_FA-;?%jTagv3OQBujIN{EcqXPhAT;__OI-JoP@{TTRAo& zza1HRGj$ckf?703tk35}Gl}tZ0+x(u-GcHnn+bH$BNLh4-Dgzu<>)E_^r{A$_pU30 z<>r(m9B0xTuko+~d6(GDv)QStMbH^gG=)bo&FJ%+3De&&yrC58Fq7^{{NfZ~^u+38 ztypuEEYc*3wF7>C-_=TKO7(B;&{4=O(+B(;QP;hf`?;=l)Mj=)Yd$9k)ZJB5oZ$%9 zz5c$NEd^a}VMXphU{_{A7aA{y{1TxrKVD7ksH@>Ak`45~$3H4-jM1(z&dJs0LDw)n z4Mx)_kH5aL)>c)`AwnHl7p#-^Aj66hyV?@EoeA}qO&5Cvx+c~>-r2n|Qxw!#EnU&S zXIQP7 z-tn>v$&7BA-5l)=y%g;leHw4hk~cJKKFR;Mb1XSrk}Y<-y$vD$4-l@Tz_B&o9g<#V z0jW9A{g^p@30di}>k%}1T(8LHKQtwf%qkQvXpa@@|1WRjirIjjoA96@OOQHv5eD??SpL|q|>bT44TrAN-|_Pemot84*o zj=eB-Ih(a2Ig#zy?CUHkJ3?nB*Q4aA7Zjqo$XAsv{>W*}Z{@An zT4x$2eok-K5tMd9P&DKOK&2N#j|_kN4xP#>E@lZxG;3&m5nEr9WR9W_F3>BpUG0$r zLBB5>4D7h}^s;kvtE3u>WQ03=gAJ~uTr5~uZz7b0OuDOYD#(z9l`GYznok0%v@&mh zf5I{AZw1E&NrlK#1Q@*YA4_wU<6eQkt5nKuI40m2ffeZRS{N8Anaeh5 z(@h1bdJ#ALW@xCMt18R8G>SG{yv)Oa_-)xzUHd`to41ClN(HQN+M{qKM?)EPS*z3f zVE!rUpIzU2SQevbJgAx2&BD~F>5{gSwi13>^!oKHEuz5AZNfjE*JVV@%68;Kwq=hC zw2cCXKIc5j3YPy&=`#v{#ff9`S}T(Eem>Z>Bo-jqJY~g?HKxkl;Sk2%tNjP?X09aG zd_Y>UU6k+;qlLW#c9NC^Xzooq6l*I|_O(~FUc?7=m#;mIU&{(RX5$iKX@W5Q16&(P zgsvVL9!aVv(OG1L`Up=rv1Y~B))hU;G#MlgG-@P*u)0EWUW*KVWb9hdwA1J~7LN39 zj|dx+4b?xD&o!{wFh6p&7)hFL7!$(>h-R0iNv;$gNI!B8zXM02LQv?WgPIpdDneRW z=#6-~XVZI<)8112>GffMISq0nD68GJXT?lZ*|QZw15T?$B(@8Ibr~7NJZJJSe86zu zc*EqmD@xy34Stsh0gW}!{R0$nqIq20pO74tTzxsvE$U)?x`SCVFjHJ%}D7|U^)LHA%O-d(sR}*-P zPi^C#uFr3wx2&;GwQYZ0F&w8~@K10#h}tss#el8_>Yt1R20O%`WE#X7HR*fOEOv9q z75M8S)t@HSrBR&<6Hjg_StxTIMer;y;w4uaONSWT@B9PETD9=?*NRn)>w`4BABv2Y zum7+b-BK3P=P?|K@d)Q-SZ@qTe zF1RLiNuS!D0cDq84MZ_hZ=s4w%vH)PB1b%5Y)ZWSGlJth2Q=|UAt~xgsMVyiV&a&9 zRGTnAw^uK3jamQmrzK1qD?P{6_q;J#sb+1Q%k-a*6>_{=l^%D$*ZtdUV=LRugF%@u zW*}N!&upDCqO@is@!dSshFPl_2iiRceDKZXTvH9t;vJknPH1CHTZYHxc)7?RR(>Y& zJ_EC64dGS0;URoZN`_;QcQM$)GdobhU;ZycYfwIy@C64_vo)SuNenDB7vU5=9W9oZ zJ+ip4C8GbhB;<-gtK<)N<$D$KI;JqBSC}$ok86NLSel&e=ADUlUZuaKo61r!vVZcD z_GMoeZ;|KMw_6(uOk%~{nzNUZklz}Q3V*lE=LD!XxhXH@H_a?aV5@gC0`*B5Tf>=0 z_k2zIy^0Jw&s0LV3+Ka!n8NyI8XjhDt!Y1ch06-owj_U9uxsUa&4r{Gq8_5^fwQjY*T-U*Xf0fzSiuE$NI@~& zJZ~yHI7;?G;SaT^1;=h=HyZsJBhG@=Xgia6w#`NF5Us=tEjLQivISWwFFrrcf127D zt})LGi)Cae-gtPGS6il5b-j5*XgV14LitbkatT^Qt+ocEzi}xQ(ErSMv97jL!FAg& z!wNLr)>!}9;_+zJxCm}m#1f++WLKWGFs&K{5&gVps(V38-Lt?sJ?-+AY8A61Vw^-r z#}@mJ=D(SKw%17H@rfwWy;m&jXLu}+aGHSqF?T3i_RC@Y`jKFhi}^s8 z-(&yfljI^=R!bf5G5Yurg4lpBN~vf@1O26Ou7jnw3xHi*0&zdfUwXe}B^p5W$cd(Xj)w>2v!x(AG$fgb@2<|SB1^y2O3`ptxj~uZhsy&d-VX@h(CD%{mDwN zPt^`5s;y+c^`FQR{+oCr7-k&S!4~|0{;L3Z87u02dxe-A_QEbk_ajF?xqMP&i*`e6 zVIyNFXpf2Hu9xg1%i)u{l+go+%fk6(v?0phQ?kYp76pYlr9YQhOo?9HsW+I(csAWx zznb-_ik`_}bZ4uWt7+_3R&b$P)x@PY&g3IoQ01eZ7R@NJ<@7fK#!Kno(c$L$2>P@~HQFCaAxY+Y3F*=A9ul$BP+-R&&c=W*O;Ro);*dUwn zPNa2ah4s&jQ|>_5*E9W6l+q3-&zK*-U;IGr8NK1=(1d7B9X9D@An%I`tT_bX9L!kN z!-jKwF!5NejmJz?e7E6gwF6s?52g}-<8N&z!cENxlf!BQ!QY|!bjf3}8JCszmut!W zNWHM%%43G{-KAZ^j*`D|`TXtK+h`VF69O-5nAhk=RDQ-6c+Ipyz~cBXn3()aw?Z#Zu8m9lu7Zf1w*91r;WUh>-b66SjtzA`{nRAqDj0XTwqk5B6(jid%y zej4B8aUToA!HJjoBDN$C6@QD-9(VknR_Uj3ZvNhhK#NAAwuVeU3h=qFQPa50d#!+z{uUabmdJh+ZWFx3Fq{r z^AnK+Bg1{;+9@WGCbLQRV!tmIu{gjjXFFW`%7SXOSQFe5eWWE@+AU4}&5d$9$oL-s z+ej@<^bc|_>~0J{E|X*Cev(e1hlGJprpbQ_2dLk>6uEnXvkpBm6k^VdYxMjATxu-maM@H#C5&O&X-h1M_ z5mONBJtemqS}5P6Nn8vfME>w%+E6JUOztSlDwn!Ysc~q>iG{cptrWFry?_58T9I_Wp~6kCKhe42xwV*j`m7z2J{0eWL!kjO>uPS{fOzf&qT_iYX;Adl2=`0;C z5Wb8jS|~p)iz(W<{&UyR5i5PmNup3eiSy1u_i8g%I@c?xB;!X2x>rGm>e{h>z;@)j zqfWNyzK6lOdc!)kGf#sW{grBD8FvFZJpV|~XCP(T{&0GGdisaDKc5eNbno`EU-9$d zv=`xb8Bq&E&@%|E7vc80ntFyY~>07?{X!x#(6`ki6+@F@auKX1LVusiyhs+T7Jc*EUw|}>dEckfe%0{?Bx9a zZi(j&_;Bg-Ia~iz>C36lsy}o;i?L^Zh&M(B->UZbqpSpTpg$M<1=h9CMpr4K%E9+V zggG581P{`#C?bw|)0!(%PjtTu{>o(*7noG!pVl0tb)=JI z*A2m+FGZ6^Zad`5LTBM0cz%2d?AAXB5)ojZEc{Uf4lR3AV}#y3W&76Elzt1C+^J)csD8hhrn4 zvlXlZilBL$eb0;T>E{6V8 z56xN~jdmHUIhvB>nWZmgty%86lvvGqM#<$cA8~9erp)xiO6uhb!HA>lgLe}uZ7YsG zD!-9qtBmkrA6f#^sR{2*`CD`GdTio^l`lw8hD`w^bDJT*XdU;7fh7A^{KuP*lj3$N zU18A5B+3u34b%Lya)=*XAKpg4I^-_ZghYruKPjAnr;A)0O;a&H4-mF^l8?9(l(jZ} z!o3_7fv?&3Ek*ypw%p)x(>E0K6v|@PTR2%@0OHpLiP{i{;+Pzo%Aav41|ORJ>U57I zt5&iF4)bIs9&Y0yj(MXDlhCF2nX2#+#7$`m^1pwi9|jCFb9aE5dBrCq^q!#uC4Jw0 zU9>+6)n0i&{VI3kz+m)q3Y#gsWX_JL# z`>O}LMKW$XhnLf*-+Hk-YE!=v`69F+@58B0pC>PCLE)=Dqdyb+6Sq@|ZPrd1d*xPG zgbj&wh%xQj?!d_z*P;`vlLzISUm8l?>Jrab3{vgXdpFE+`Ud$&RredDTmR5Dqn{u0 z03xte+6!?}%dJug}zCv)Kcn)4L)roZz4lTB3t5^us=gnCwxIu3~n$L%`S69YQ9Y*gks&2#-yF5iUcg z>Bq`_B1~T_#9D$kOy+u_#_=-yL7Q8BztXGYC?#>qD}5clYUareQve>~>wM&$V9o#^^uIuGjB^FvD^dN-f4q+PqN zXW0yD2om?`Um_o|!89(PDT)eL_JhXB=r{yrTk)H01m@kk)cpNt*l6lrn=mB7 zd1K+(q2EyL@syD#!s^j3s9|LuZSLXR`&sR$F#`SE*$x-#DCK9Yq}A7{(AF|KYuL>h zrsayefCOE4e^D4E^rKc+I_L{9Xtfnjz$q|J8e*EOC8EtS5?~o5iv&pcxarhn3ShUU` zIfhfU+6%%fbW6W{*zi2|MBw&fAn!%EqG_NgeRrE~(mk>CxtJ?Myk&Zoo$}rBtoxp8 zvV@b^0pa(_>vCiG*2e#eM0FMW@m(KEliCcfI+!VMGfgNTp_DaqHLZO|^iPi`ZX5LT z%;n$xX76}8Fx6$$3c2M0HuPw;f-@7B{o`y0NUXHS&Q(3&gUhb7KfAV%bt z%l5;gdMBVR&My|sS;#e5z1ls6>ms&Msh=kPG`%?4-a5yvI8_XXGZ1@6!rT`&=b4Ml z16?kYqYDEKZmJ8cxBhIg1@RBv;jI`LzbKo&fSBT2h5ssO*Oj<-S-RikxEbS%R=^KgrJ9Ni0-!{o(y}YnBfh?yvWFH6zR|Ng|&rE$N5954!7z z$In}HK{vLFctkS7Olmt|8HU;DH|gb>dkVWF$q}AEoCGdc2iWeczI4qGB8H(`o`M|8?BrS680wY*OD0H0_0 z*?ur6?icq%>h!o8w|L!2{@!#C_Cvew^#C2`b_*%!C@@>Hu`87v*LrVZQFW znX(1ODBMm^`Ibc(+P7c<(liF&lcEjy+cakzI;wQ9?pt6~O`f;^#O;lc0q-XW{YRR` zoA~A(+)@jPeJRsyCR{wzJH18(f12+b@CCkS(4qnL<5Fe+EPLk_c-i0d{S5&|i-x#0 zSyW!bnQcAQ>8{G%9$_j~_N=At#pio;8AbO59%NP=KVOj1p*l?GC<@T z6#}QWJC)xaryUxuFoEnMoD8(ktNlRX*N)9cQOeoWr*-Ym*Pj;5cPpnH1*+C6U)p}c z-P?}Dp&W|DTLN{4aNm`NQOU0jiKTO<|9$X8g&RJu7hW)LWgQo0oW~vS&vDASkE#AN zSw~9DN?+~85eEJ5pEtJ2nZMer7>Jxc+aX#G7=PSOL%pHuR1=g~HyD1{?bT1FyEEpq zGB-YWI?96bHOi5>ZD%Y2QXSCjaEmq$a=?Iq>yLs_b}imO-I>2C0`Z_diTV6qIj{b1 zrJs+gWe(yWarh;X5BZh~r|#AjOnL7OSCfR7=I%B8a&fcBf)x6f*hNbRSiR?N6n)(N zaW?T5o?fqnbdEdN%htF(xAu6eB+}xw3*^-mi}0 z#P`NXM0?%lQ}=T4%EbcfPZFQ-Cyp$R2mILz$e*jh#fG`l(-+`{M(6z*3|xmMoT^G} zei$#@v5yq3_G_AdW_j@+pC>bI0b|sTADd7i=#I40>3BA0WeU5Z$NrI z+Ood{WF(y@F#YPKLW)SSv8^A0LE+@b(U=9q`i)#L{2B2v9 zTa1DU8(JisW=n`_e8UEZwsV(K{A6nWf#MZ!CQumEs3kG6dU1)WJ{1?*FaC)tJ-yYH zC`3J?~&q*MVR#2PRgYtYhm6%uR|)sVzv2GtbxLn!zlRikD4HBGVNB=u9z*)I_POGF7f} z$%?pRn|ImjPL8zsL`2oLjS#f&&iQ`QgDa`he?vljA>GM@4_Hd^X_^YeN*lKTCNxJc zs~wvm3#kRcKK`OLU{h9Z8iIkMjsa2TX_Iu&cc<=%&<+_eW6O6z==De3Sc?$qs%ef% zj=5ImX|5$;=>XpWS|Q~RMS4rkgI(6iXuFq95BKCP)&`uHuK7N5w)z^LnJ;+=x05}FuNgU zNjG5`JbnoV2pUb1WliXHzl9n`FWaELQG>4>Al(q|N-1^xYY$`212f2zx4O)o`ufxJggGApRq(2cIX^b%U%)z59qw zZXh2>>tol^--1Ic#7)~MiA*DU8Kkg7@bbOK0;5p6-pFhav5@ZNy$lN&hT2z7u-JNI zxt+45a5Pc9YX=H+<}{+V4uvt~{#BeBx{tiU220h$1`{;7vm!H<$5PC!xiMOLA(V3W zTLoS8gk6(V)nWxrE^k(qIp(D&XcUT(Pw3}n?6qO0Yp)$KUO8BkT@XSIoQdH>0snC0 zG)c%bLhdVAYZ7~d9Gd@)?FOqgc%DwkMq4j)3!p_w7~2J^)Q75JnSE3S@``>mql}wh zd1;4H0394%hOHk=>b|5CWfz$o6_HG3iOTS}Nf&ZSz7C@Zeyh198k%hw zmjF`)xJZRDlxMPu>m`=zjiFAklLJ-RmJ*pjy37y;7$M4hLUIqrfgoC3G}YH z#l_1=E_#ZFa096jkQ}?>np-=`A<|EuhmNx5wec)##1jc1RRR`pVDo#Z$0|tyfWiX; zp>=&C0QwwM1RH=`YuucX^b0D`hk|~%l>pE6cif?16GW79y%Glu$>?I62bUWJo|8J< zxAhX9&ZRmy$@-*BzO{DPrv%WZaP%3f1Pq|EAcTB&$Si4y9=t{~1)kgrfk{yf%dYAn zhcDTnhueue+p!F9wyTmdA2tkEGWyUBS%aNz&!L=%UwR>0w+<=b)uZZ#JlF^cCw>xq z%bwYQ+MElXj-NN&FWzfgv|+GcR^_Wk|KfnxyJ%imC`8Ryfhrd?xBuI@Mr&}0&5*6 z?OUG|G2WRQvXRrj2J5nXW0V5~$g3wqEPd4TSOIAShy44Bc1_;nLY)+8tnJ>_?MNvLOTm<7nuD%Y$iqz<;MO83{hw)nTSB`k&QAzM`h4N4Rh7FsOO z_KcM9IN><@uQf(x%gaeFgwEDX^ zC_oP;WEl}7o&ZgQWZ_*ABbwpOh*0jCCt~gTrwP}@_Lote3|}0;p2A>yK88ukwcrvV zjOy*hT_1)_W}G};fq8Z0S#<0*00?0pEzhq%ZK@7+oQ7%}sS5M?90IXCDBz^otiK)g z`bK}%-;_7|s+PjL*=p|8_dcaEnK~AA6M8U1>c5+)|X@#aYQt zrzwx|E;TqhRsySM$1ux0F=Jg`6*#C#%WcrJx*|Ds+kOBPA)eSXGG|XfW#toWsm}%g z@a+_O{_euQ?+C=^`1K24m|@p@B;$cEhZUmHBGp~>*k8}cS7JgFJHU?2IusZihEdo2 z9MZ#bcj;08VP@ZyO%mM}LCoyWG~CfC*FR?H*<2f%@)GNvlX#TI$p@ zvr6W|2gsuc2z(R|;MSQg0|5h96|HeX$z8!%x)g^&pI`LmeylV#iUK*)oCt0XCX*3L zYgX%gDMG)L7`poh%8Kw-5nQSlJeMf32GoBHZSiXoIZJ41EFH{+MkY_1L8OHn#PrV8 zV2L48mDyWdSpZIBg{i(+K2T;F3mx0O1debh5v_M_Gy>(sQ`?Llo`*5sKNq}5nEdpO zslj$a_vL`dDWm)3FFq3Yarl}*svJ_SP%MSU8!?c5$VlsKM4@tQ^$$P^$2&mgu*3r+et2@W3JqS^dreY29|EKd}WgvM{*8vHn!} zroWqp;j1 zL;Q|DS-2E{NF}AUWS{Y3lu6GJeOaXhYKeOCHNvb-+lnn>>rI2D2yae3J0nnKI|?IV zor*YA{tX-g9o5g%)MHf-mA|l3`A{jYA^IDRH`f{IfM%b$KmhOPTk+$5-#!*V?BQG;o!T!FU|g0VLV`eI&+ z&@>L=fY2mn%|ydVICHKMTZq!;xX21UHRGjA4CR<*t=cG8Q1Boy8fhl>n~qz&U5lDD z+S0Q_L)!(p*Jbhj6ei>rniu|vYoozo88SZP@t=B_N$fSY^vsBRLh6YT+nxLpqeGsh z0#C1>b!|Dv9J|DF#aO605XMWRGpEmblGIAwt^XtxS4gn>-dUoQ!^w}(1=4mbPYEYjH6g%_CKU4_0U?up!j`@~NtIRhLLe@k%KwnFVsMKX zmc84@IUq{wsWYa_w9)R_n-e1P0j>**+RT>_^JAgRg9rT0*@vlMpnEwfQ# zk*_?a$N^V54a;gp^skK=dub{`XAPI&lZ1Tk*Ea>PmP6^86d_-^UAeP9G_Y&xJ$eed z#zQH;t)El3R&3Nio`|AW@mNg)%2uJXWnIO*xk<^-G1u~W_{M{gAwUufR%7pnwPk@dOFivRdZIy6b*zP(zz2>o&A;Wnx%hyDt$zH-@oT+%g z4PkjyUqY7m+Xm>Q``c}^$rh=b{AYU(@}CFp07Pr)bzYhtE2TUkxO4qt;IdAnjMkCQ zGJc5|7JcIq|9hFUif+E(nVzLkCbNMNJ$wdH0)7sPGjs3A_I^7bCn zUqIP%JAI!=67+@ID*rT&?N&kxR}K!n%b<&N_ctf${?(ISe(DtsEz6%3aCbXXx9;Ok zAZutBDO0*-K%a+04?$E|e>KX2^tdc8@EWxh_P_Wazht)5z;7=Dz}IZ}$)+S~cQr*s zf4(($oM<3M_)w;jUXu($5dQ(z0Ji5jLLE%(6xJ-Wt6>!U6hCwi*8c%UQe;mOoWyi# z(8MotVOja`%QyQj?kpKMbWRWIKkMPU&yGVVDauedn_dov5%*YCKwJ+i!f4BHCMEYG zb23c3QrMczMR}GCQO$O zC&Cg5IT8s2hy)Kk-!II(DVhlspd!hny$sq!*{^(TPrx@MwDw?3RBXkVEMI6{tOkkN zYu<#h*^SHMV7px{-%E7p5cZ9r#`qHy*^^V zQpDIOTBngslg95O0H&;puzq^(G(`N`>VRU2)EU)MifJxTdXw_-V~FPndDcKbS%ED%~aiR$@ZbP-=5d* z45N?Y*g=+rfnB_&V~&^2tPPj~Jo>EadabS^wpmzoQY!QcTs$e&vgy7QNS{+mE)|OW zW$6oUkvL7Es+a@|SPX1_n{iXv{0G?Zy}{-0I@)zdabsE0Z!(iwmLm3@a7Fr`ZI2wt zEG4M_)B7zaR#3bZpg--@?AQ*$t+%>9t>YyhL#TY5-9EM9k3g(2aU^u1pIwAfr{?x|$xa%pTK}aAWgi4w-te3C8*s{JlVT_Wh%i98r zUiPYx@Yf(I{Wn0HXF%ycaQDL&OuRAh(3--$Y0vq!z8+}4=6>2tj4%oeDp7?9VxeLc zBwx%rIr;kFlG@kpVrDYbT3=e2r7$6F@jNA1R{hneBl2b>wCI}j!x#k->>I5AnfrJq zS&#M)u-?uj@XR{ni9RsV@Cnz{%IZHr-mYW(@U@oEhOCZBm>zZ)UN#Z9X+eHC5WrQL zL?`fz1N15JTwZefK>QOh;K2BU^|`yKcGm6Y>CxX#zvmmmXx9|!w=3mG1W9M`Ea=IX z>a4*Eqe{>+Y%=EVag=J9dA+|8uTJF|7R4Suqn7!bO1(asipfwQ&{-YxPNxjNWDTQI zL)lniA56Rg-AaD%4A;ym zT7_W`aPbGh>d?E#^W^%+Awx@Vi3TL0!dO7{s0e|J=r~LGE&mUY-~SJgJXW&LChtC* zp!b5M6OfpvHwYAO7c*GqItbf|zi`}gk^r0nt{NJ^Pay@Ym!-}Uv|ik$Tuhh8T#Y#^ zMb_?uR$oXtArGuR7XJf$?oJlQK%ZG&B_Uvwe1ksZxoJ6fP((8U=9y$d=gJxQ8?xD$ zX(Smtz{Zjhd1ls{A+V|{vkX>7^A7S(Lm$NmVpQFof9sJUgYprV2LJ_6D%;X%toLRA zrZDi0yz)^(Qooeg8i2o`FNdkla=rGT9~;)v-Wy7hClv7Ept;~h zs!ZQ3!V9FGWf4Mh-h9IcKyjTjnPCe;r4Rt_?j;rGA&P1$XRa`||LBpwL)y#fh#_wpmd`YCZ$vj&P(-wDP?V5-k zkx$}MR@BWpD-)cIOSU4w_P5VR_(CPt1z%rPZmpGrT6T6S^uJ@E(`C~HFKeRKq{mzn zuyVegG|AMi=kDB^R94D=&9RT=I!L~Oqbft>kUN7b7IafuQ&67)_RL~{1ktNa<|@qC zqiKi#189@_7+qe3(E9&p8XDFUhG9*GK46lzWOmH)5RWStH|e#;9Bzy8=$>lZ@FaJ4 zKF<9I5dQ}N0>Bh^#W<>{W7%9EZl5$x;wkcES@eOt@-ezbBoW(QX?5&ZxqpDNn50-~ zX6Sr16Ed;ERD~$UZ$fgoc0#SqK3U6^c@4_21BcfYl@w$`#lsu;Y_JIZRaJ z^9X$mqlh=F*H7+n?l@vv_$m`t_e2k(#9>7l`eZr&FV-wfj^N+Nvj!nyIj?|@4_Ua5 zL)-((-ne?SNCHH8SZR~n8C$aG;xKqFQA>AonMW!9dZrv$pQwavdL-sEQi;4;0?zyN zr}A^aR#$9lfQ{9#KlDN;x%S(t>}g5PCUZ-iI@aJcJsv(!4z*ukf8$Qgw;yBGK=EJu zujyV$UsiW^nQ$dp1r)ri-jll|?@Wob33j1QQ04?DaYQw%Z|~L|kC2>6!JY@1bN8Tt z4&A8S>GE^vL28iy?LWW~v%aUYb=B&6*dxqo=$wpMK3jOd*Dv0W&R~NJSpIsuq}ns` zl&WZNVJ4jQGv3`{^q`lKuzZzAVSA}DK4>g>!A8uBF&6Op?V4dPLjERF1YbZO%FdO% za`7BPw#^dxz!qU@0v_#L&K^2m?l1LNC%3Bd0-b;(c-hB4v4+0ixPSn{CMpXh_QQCc z4>>d+X&AH7aEa15c5QS!G?J-+QZ&<03xDnpOa8g1$^woimN{Vt9L1o<*DB~j0s7|M z2@{!!M8I81OX)BF05Cm3?#$VCsv4DA)&Kkm2fdUxKrZ7H>S-K|e()EV(ZeK5h|~Hm zGS(g-A%V=fCiN)cdyT#|t-%Vo2QrT|ekAF3fdR~e5RC)LFDt4FAHINK zUyXUsPpU(jse%`RmKd6);1Ro#!{o3wP2;X;Oz;)#Tn{a;#_|erSrJ#9m38KJ#U)XG z=ZNLR$g1*KCE_0dcX`b-HJ4!j#|@fHjvW`YLWOO2Y8g&aRL06b#y;@mW#-e1;L||S zU{TF#hA{)u^kQg74E544E=QgM+tQ)c?L-jc2|XwC0N+_sU?nT<*EbEuwGHlRGgOng z4%V$Ij~u{oi>eSdmhHNPGzWHWPHB}Jn~AQlh7!Jk%Y2Y&5w;-i@jpNar2g3j;eebn zjxZyRD!00iv-q4s2{CWT?Sz}Nak=wwTAHO0DyXXYWH01+zV9K{mdt2etLEbTqdZX@ z3I%|MIbg@w0s;M1y^Irm3|$Zw6hCUuG@=#%l{~CWedX;!dyHU49+*T4K&U81bDSu& zWgh|&FVt4vjX$(5eO*0ox*S(NvksMs>A(r*F~6!9zwwGR3?R15DjK)9jXTqkR!w~d zB0*jYjR-+sm!UJN=MOMJ+QF#V-0gNi=hC^Z^o!K8$#f?b9>_9@}1UVZP! zDS8hn!13z8t z)YGO7Lfy~Gj8&6asTdEMA`=(JbfH^}zk#A0K5n2JGJ_R2OvcKcDIl7Y`su!`{{Ss# z;|V2-@$f6EFaQS1AInCtcci6TVNAE2_%HpBga&c`-mV zVV?q1WX+F}jjiWWvHdB&pn8!}I`qnv^;*J0Ao~{8B%{3vu%bIObn;z!vp%YRmK7le z(xX2}hTKB#S`W-ip*&9!MF>EEGb8sqXzJRS<*hM3Gn*LCOGa3F3^lY;xIvFQ;U`z$ zt%Be0j1~17&^B~7{HoN?iU$Q=RgxnvL~b1KVP0DnSCFH3y|wca#l~1wUyPsk+gmGn z_j_TGlhMaeup!}hfqLqw7hyqP)_pbM7B$nBOS_2@RfQqv)w5};M9ugOYgj(-^!gHK z^)1Yvu0e6}x9P^1Br0gw5R5lLDXS`io66=7K*_#c=OCxvnJeGHrArUw21Sd#md~*8 zn9tOy3fT*ORRa2F3GuEC|1Uz=ip=|q!AXp@84R*c&gse9i-SM*qNStDx2`QDG zN!VscW$Gm8&ybH14r+kheodY#bnI?FfV8we?vcrNdEpcv^#m(*a_7g3G)Dj)>^c6? z3E;m8bo!G_lZ@AaRMf`p*Nz9V!+L6zEQ|UC{!Ud?23gNS zr;crftjC9{7qM%{1X8f;^)iingH@yrVIMv<5F7vS4FNVZo%Mo_FrQ6JBYsj=%lyes z5Idyoxii8_;TuT#XP48f$>La(`{8Qx`g}QFUUmECGylg32$7I5!tAq#crnG5hMGR1|H0n-B&_0CLnuM{P-CX?Rn8 z(@cRrNFizPqxOVpm%4*dzQNCgwb79^0E=S`bO2ZqVBnOHAR+`x1pWd4_%?0-t%<4% z0FmDZ4MvG(|Q_U2oOvKa8QCF}bA4cxGSq zkU%}E(Zz2QrY*9xrfMh3)IR+raD5xOav zgOqAglxODS>T-u-BXAf)_T<#LE^2*uBml1bc#{Ue8NBv71A1@(gi?~r&V(3+uQ`gV zN!0ZFEDmx*N^V1wipdgpmDTL;Qd0h;DGErOPEbf?bZZny{?KThfC4OCAJ89$&4wkW zI^i2=eyYn$*swASI(?fTLbN1bSqP3eSTHB|ntN`ZMW{1xO{-NFi1fhFkE%vq!YDwz z%>9~CnHOfD0od7blsT#MAnWyuf#6F5dG(tX`7P}$c%iEoYz*{E4;CS$57@o*2J>2n z?lG67(~DXQp!B=o|E<~63<+_6gF0+c^Tc%9KmuXgkFx1yXV49&xod1|^WC6k&v2F< z>2X;KxKt4xBEhSUg+dPtGEwP%%sP5kiOqXf~V$y*UbCx9MQw|%$9CFBMa>${B%C{($q6~9hPN^i-NJ4Um za`w4*{{hzz*LA(G$Mt+YPp>JpekdW{X`>~%&{n+0AF-K@dZ%z0sCZs{uKSGMhmS#E zjaf|`+>t~u;mq9(PJw{UnQMu*+WB1(Sny!ClDoJ zZ6bDxW|=R^(0Q)%U4nq9k=VV{cunbC?)tx{^6OK<;jCa&&s0blRFqQBzNZ|b9_>uH$Hg%EAH_x_b z&3#OkTDQ~l-t#@S@hrO^peX*oc{Sx8akuV>zdGRnI3}KQzqzjtc)~{$4-Gt?HC83* zNPAq0!G*PT+3Y$>$|Jcs&i@j#tEyk^fw# z=!0^9jzk=Cdz#J_k!qUH)JD2}+ zLqeIo=rJW)|3RX$LNe6%1vY#^fkMBSrK{O?5_5#dh*?cicxqI+YuI7SW1=~8999s1 z6dI3w2Yyt(NEAsY^QJ#HVrRx*9S|h=ahtZ^|(K zoP{g4$e@ZxA)%2RFRt95PB|9~c&z(u!ay;?cd5E#m&uaO67&Lw^fRvQVQKAz#+ zzUre$xYu z9zS^cLLgDgpsb^aHTq5%_`ntsvcpa&G|VGAjj<0f^R6eCz#_aeFGs)Ojtj(F>-2Au z-#L(4$I5=oF)=#S;Fp)Lui-qe48g3P3c`$%koR0@uPC(UHHTufOLxeW-KSqn{#FFH z*7>lO4QZRB$8548At*t4mrsDGPV>0*7Y*EUjka+{jg}B=fGYGrPBH6MMn5&S3@CEd zT#-G`gxF){&lnH0`LM*MoB6`3r-LkgJ_UJMcb8UzT$e`86?+C;ri zIlaRItbGp^Ky(O=dKBK{mP5azGA1!*wTG;1gmFVp%~t)SQJ1q*)VmwO$fqJM;S2ek zQ>KW_a;4X`7HBX5MKce)3{O>~b(C;i59!))rHWE5ykNrfVEeemb<)rf57W2Rsl#ZxIB$3zx<;o2{>5-ARnfW zijjjDtcV3ts4#l}ggDC1haHq?i6q~{d$=_1mAmAw{J;Zq2t*eou^GikWAy%De9=Ih z0Arp;a^s_D!ZzJVFE=!P#1jL#SqnlG*^m1CfIO5Be}*hP$Gza7rbaVRn4?7!pSBf4 zpPX4F=qhuDC2Y=-vHP(yD)4@`jONL=3_Grj38bGq7O658PoWq>;gmQ9m)1`ee^C+y z77A{;!_|2yqNiZ~cq2yJ-#u3hmQ>bB?<(}-lwqPDtt;7kNAYGy}aVq1X4Lg#t6s7Fhv@8t2jyY!0Ns3LJLWoWRP7*yQ~KSQHMcKZ7j$5JNMuc;sVDT)VS=G*Vc!^^J>+ zV@{q)*dstZR}td5dGfwZbQayQ-(>SqVWN1*b~Qn?_t~gG#0Gm%e_%eR1XXQ~CyrTOZDUgQOXALd{}y_U`F#_??`gkD8hU zNZp`07a7r$12dyL12vG6OD5PxU=3o_b+;PKS!4T<;x|oKmj6?`KMk^{D7k? zuy^IwkaN9|F(y?mN&b*WJdIBUs?V(>?(Ucbl_LkH(XgE|`Hmb5$zN#J7s0!Ao9cn# z9>0XOkx(a2O;`S?SVCv9{wgj4H_H}-_*mNi>ZDH$0M<$sO7wf%KesN~`OV@?dDWRg zv`xTxec@x)hv-9;3(m(d-lv6teKRi**j;$;y5)$ZFZ`e z!keg@6|9uC1mr6w^WIJgYWRo1v_t9ig|M+O8+^=NnaetdzV^cZNKW%_#`Goe!SVlc zwbZDH01f4oqBvnNig>@@>r|QOX?JS$#1ejZH+xrLp}afjf9?%AJKTVFfR-Hh^Uk$`+dPG6w$TBo}T?zbh(4c>;!-BWw`zZ zX9{Tj_$dSVNPMh6bU~p%EK6j{Iu)pzSE)Rq5p{NUD?3W^&Mfb$L^&gS73Fb}bRsRN zHUIW@Z@+@j+HvbGgI(e2&*W&TT&&i|8IUz@UBcf+So{9lp}>}CwE%Y)Rj=~I9>AR% zE@)T#gGSuh3>&$WE^sNXY~ifPNnsky=lcihXvi!P^T9o9zb_fJ^gM~bOtn#^c_#y| zB@my^d;}WT@BVt0?4m3J)#vd;kx_T_G9N~Y$slixuIu1mHq+t4B9R}k(*_VCys5`a z^RDlmWMW-hno+Jt&jPu^H0|eZ^X_{Wb(7fmY5Sp$QvH zAZ4co0_Ue&ALpAtK{JOMIEhHw+q51C=Zw}ZihvGne+!fGKl->uq-CsOx9sY1#oq-- zZD+lohX1)lgCo^YVqX(de#??nn{b_8W5rJ1NWYnnFW7i(kS@e{Qee1ewH3oLfLy;*@U+c+iMs&LzQXeII z5Lo)RR+|1e1Yix<#uZbu_5Ki1X9zRU^YTrmkWOE=`|zmEhHCMxA;*_N_jg z$EEHFGJF+o(?EH-om^{Y9nK7M{3JKlSz0hp`A1We#=Q5iEBsKatY?y|mwM9J zMFB_8uoLA`%qvPIf3_i?B~BRVHRgVu%pg_r({kvp7_)#YqM>TWC?xMhc#F~|xxm{z zK$Zc3HzbU$JHvo8R-F6m%y9pc;noUqIIG_Z5A~GkQ0sC58jS!rU=myE;+V4L1F;Wx z%@pi#3Mi|QHHT;uT&^V?J*hq}xc^P~uedfL$&tfFK>ar^wa!aU-}Ml@RLD0vY_cQZ zi#v?sL@CiaVY*V1x-{LrG}P!3GsG<#{$!y->K6~2BHD@US>SlHoKbq!#FB6SY)Txt zZFuI%_5T15#MI?RyrA4ITBoosqqqWM>QLi9*9;mDa{;1c7yty~!hc8p_z*Ic1v50; zb0A;0b;&&Hi|boFUoU>dv!wh3$fU<^%jA0zV_NAp0H?Kd=e@}*+mT?8bVVq}l~UFs z)rZkDZ$~EJ)8D51SYw$=Q(;+Ua>EL&?M+46E#HL;jW1Z!)_pBbIfI@{)F4|;rwCn*sB%nRQ}dk=n01%5BcB!0ahzw7PD!&%W*aQG26`)+Mc|Shp^k``u-HY?R)o zQh(%ja0+4)s~UzXXL(QNH`<>mf3KNdjb*Z{8*VMF_r7`TyPqnpUzp)wda-L{NZ@rPGf!z*sNyKkb_{!QJ3>XpK zf3Y6Tf^6qjc5?}Q`-0cHrmbuN9g{^nrudvEV81!j$G)`$AaRll1~Y{=~p&9&x^c~?RRk5vKwK4C)GC?3Cmy`hBz`k}CPlKs!v9<0Ity8~wKLuGk3$WKwQUgD#9(G1wCi$WfEN0fJe&(34k1+s4=M?3gLzLo%1 z=IluP#MGsIO>t?y8JDlxj!2d1WY;}r9i-frZ#zKk zB^LfeXK%03ljCrmN%if7aq^34`)u0zhQbjnSjG94bKGBD=zQ2?qSss+TSF=OYs7sHDa=jCa<=4f50)|2Ynf^RU*Z$E}KDMb>C z7eqI6i4LIO4Px3~PwkKwixKl~FT12O`0CE0VFH?Ks`$hEt_0kNOLBi|$b69!QK$xy z$!Bj09`#}Q)J&3R5SErG%?zQ+0E;n_tW$o(7}0KRj-xroSN79-a)u#)G0jtgD4LEIuqc_%>(wD+X8AD zhTH`|qbLAPn`I}@~+EEZQE}JQ1N)AUXb}x&oCHyZK8U$4% zu7DP;yCiCG3KPV@=zxou$3fa$1iYi4?ksAs>KH*B@P6(jVl$yoq{;Cv`So7I^WO`S zL*|}d&@D6CJR|PTl-OJj4&a&6(TFV-h7HQ4@8aI^1|rh}nZq-gV|~j3qFDj{1KC|2 zMf?;#@58%z+~IwkndoNb`4umg*Z3_u!dmDG@&aHnSzMM@nTv*Hm&T$0l|5}c7+Plb z!{u>&thDm#&ak&81nw@O9 zjjbxwzPF5QVo?!ODF@O@7M)-;-{5;seg1hB($|pFMpKdn1-;Ba>{`PUWPMq3-`lxJ z0J))sQ_vl-;;v$-Ge$q7Mtee%q9xjzKS8gj@ziE{R}SMrjyHu1V)QMHkWd}XgmbiE zUuRWXwtH*T^~#@?|ID36VH06K7~!JNbF-dfW(U2{2bn3J8+vh~D0Z4SyQhHLLH-iR zc_|GvCErPtQL}s~3V&FjZs2=fa9T~p%x5_cdqbDr&Hb_Vp0#P;1J=@>qZnHj9WHTD zqkNOHifYb2mpEi%htQzI6%tZO|&{El{a64+(GR zqHuZN{W~kQ3QapTX^k~_$5dK6E02pB+ytfS63#-Pr-K-Av!@1L7Rc}02l0vo(lNj2 zGTHygHkaS~cy27-a%R;oae1{!P^Jv1hIuRIKD%9sUf4TVVoGn5_;MNOerkOk#Q+(P zy`DAl&(Yib`rf?Ig}Iv&2REddVN45_PiM*?axJ`mzrm1l?Vvji97aM15`3}AHoP+O zKloUJq~j$_4>SCTu6}`|nYW*#AbjFjMu{QUWhA}zcxHEwi}PI7iK^&nL}<;GL4NfQ zGeqyl)9B0-sZ{mC3rs1K6g9Xwf-)XT zdEA0($G;Q@W*15v#MLOUwORK2*+#ovAfaIecOtd*e)|<|nM8GdU0vS8AM3qf@(X@*R0$UQ;&YO}tZOTlj5ENBZ_)iqjg8hn z@Ew!Av^|Y~m-BeygGM8aJTKjF%h&?J^D4I&VnmHPy^_!wa&<)kcIQ?0@ntH@-(Fk` zvYu6>9?eop5AZVgZn*Sv>PO^P(vpJKGkKQ=Uuo9=5(gy_OqZpk8qc5&IGXDh-EL== zOGhi>A$`0Ca)Bz~Y`F`*(L^>X%@DvqUH@kj6uBVRNo~KSGn-qRN_if;C5nf@BaC?Z z)XDrgN(%G>;f5_8bJP2Q(Bqp`ggbeus<66EkIuqVH)i8189d>_lv6`lC#nO(Mc#}; zS`>0$hJ&c*fmx`eJYDV8y!Y}B{=GZ_k6M)mFj~U&r>Of^S?V_rXq?zSjJL$ADqkbHQ5`{K-V{r%^(VdNS{L7!>Cnuqac?^M72ie! zVZtIdfFsKaGs-5!r@QRf+hy=k0zoq-Yeu1vB6p!=^8OcNC+ZO#^h^j(fY|RD@3&TW z-3NI>pBJ3_clv@icqkbA7F4o|FcEHioiFr<84Mf)J>_LX&nwL)Vz^NF%Wgl`B}6W{ z!N8GvR)1s3T!{+%c1atikK!i)H~Gd=XX7ZrY4($l*gXm2*N2bjyTYGGEax4$S$J?; zZSfe9h9E&ol3M+{i$2akuE1(w)I2m-lWDdRR=Q(3%uqe!RCL$7jOU z=NI9-5a@!c^FTF+c`XANkIX5dXCrqY_AQO-&T-WE%=EbQ$GMD{fWVvNDL0ml1~%k+ zmu*tta=kHGLqW#b2`2hAig5VDAD%VgiR6z&;uKlh88h4gS4T?&#(IdIC0)WwwgB<( z5DttSjULz~Up?e_l~Vgc6_mBH0qAZ4eH730+D4fMxQ`By+?=|xuKOA}7$mV$D5l8n zL2k;`>FSFkPjN}Q$<`d=%9(OGjc0vZb7Xy$0##(33chV&FOKMDi}xK|&sUq{{zO%r z(XphXW%P}-!jI^xPQx?06dLv{DMycxbU$gm&CoGt!6PrfkXsM8X8)aJuHL!+A*n{M zAM)a2eCXBs?Vjb1cPzuQQ-?cQlT95XEhO{@5PwIWHg4c|kH;Z<*66LxiLQw?B$8_< zH@ww_W)&?hqK}RC!VxXN#3NYo39GD$6FO|cf4+T&t^PCtQ4vE;aG-$OHzh%qgTIh- zg`M2#jLFBoS6G%pCodhIeBuhN@Arjk=X!ujKrPP>DisM6eIe6KS? zvb+%D-iOLluz!k3O&&%habcdF;@V949PN-dYsX1(U$ zumPHXTTMSz_C&037c#mk2`g$VHn{fjA=?+iWA(2NiCY1l}!iLn*2N^~Ope&K!FbST^ zXiJx&XkAz9S&vEwvDUv_;<6z4akIhA4P^Ny|1O}H1jS<&8WZx0OiL}pBxH@CtwG=0 z5rhda?slSBLEjXRgXGIqOjVbg(XoRe*J15T_Z_yeYs&T|pTn;=z9ESoT*!6uTi(2W z@>hNM!!aAHw9nuFp}E(-$evY3iiyS;uT}C#c4nrP4Wc78oN3`y!olx8@tF^FJWTGV_jkZHjcR)&sE9UYBeV!(|QqGAzG8t_-)%{NE}H zZvMT|Uy6^Bp;f;Ef9xJLZ=}a3R5$Kk{yN#`8cX56%L$QMLDJadxnrF-*AfXq;7+nw z5A!+Y<30B_WeDcOX$9_qA=;x?XkvF=2yK-6p`yho&Pq(StDvh>m2T)4^8Gd*HDGw1 zwSwd6ctUPEMaob$>%wR^%u?>trY4?nZ=Y@z@$i5Y-*G))3C=tZ$ZVvZb5{JT>n<{) zowv)zlgkLWzAdmU_nwR8))F`hr1Ve75<(=2&#oqIE`m%X82gL`J|nMxv0#y-dUnU& zZQMWM+p+PPUVdSzFLp}h^1zq%iG7}rlY;phU&Gu0cQb)o`SD??&%z{x>pIHa&tI!3 zFU-5*BA(7GEVrQ;9t*JW9wVjm4GF@#RM6tLulx(*f9jB1orA}tYxilvH$K)ppYd5j zeo%O;Z}JTO0;y1cpb8EkWk%!_o3z^J2O%;%hXWqp_n5E`LT(T%H25*dCcqRYMK{<2rOHZwmi1jT481eZcPIi_hS4VAUlL)_IUAI*^kD)F~W18-Nz8Yg7wq(aMjY0d*VWSD0WC zgc0l^g{@!s80Nsd|sJ-j!MJ0k&bFlh2PHP7HG>+FKOuI*xS!3W|u>wYSK(^|@_ z9uM7RBw`b5@tuoBQhsgd^n?aqdeeVveRPFqJb$KPDCk`Njuc1TpzH>K1MFLH(%iIU zbd8)UjokT2LEt>dzFpfir)U<{xJmP9QG}5XRrGvx`rr7Iz)t}`KRqqgz#T&C(aXf= z!%o~^*M3wZB~Fzak`z zPwqf|OAUA)HxiaSJ2W6~^@t=B52+YiiZ#u9Exy;9?|DyhziP9z$M8OS(?Uvwwdy!GXt*l-khBQq&=9c z_Fm@2S<-rs_Y1iAM1LyPpR{!k1K%3T>2>V%^!bA-+<@!9W)i0BY0+||Q<}<*R08)8 zyXBM!-=M)=YiMIx7|F(S_7`@Y-(Tor?aV>LMZ7-ho5b}-8~@CdPx)VuvLp4UyChkk z_XnjB4l|rJYy}iRHEAckUm%-&I{SZi(hBTkk`g1zkl(y>mnxu&-tPk=Be{}m_}4)< zGV5D?Q>FXUn=%9>-nyqp8XqA~mdXAQy=4f=57JcrfJ!p|v2enS=j`y0bwgU6#k#ld z>ca9%3bF$agJGk-DmRaFH0kfdHojw^T)pqNE-}wA_!%3wd%XwSsLZbkLl@cIhOEb9 zu1VXvwaE07_q=%K)P`FIV; zGq~YF)@ie@@bj)zV!4-YOsMF!TfInz3^Soz))kI0P4CcGtLb7sz;HWghr^pdzYXvV zA-IRP*##8*c+z=ornCn#9OyS<^MeMcEkRQLy^Tln&laV71xBk4P^+JDnHjBBkgxorx9HTd?IncL*Ndv__j*mb zE^AstBJT76=8kBNg`v=j(RE6iN(MbF^-h4Al_PUitrND=yc~d zI@5c_yH1yx3AijqaZdnv`i^K)XIAC#{48@lfn4@|R21LS^cr<}>S|*EhqPKZ!q`Xb zTU{=`sR7KomGV#Ah0*Z1SZfQ1{{SqRpIj{ydDIK0D%p7&j)@|m*T0_Mis-`T#HllK zI>=|~^8&Xe5Q6*?74Ib-j5%|&szRL2Y$HOy|=u+p@i%U!oYSB{M z_bk%iG!#zXzr1%^NtLMmKuXrXUH`?9Qgp5Ym#>axpa0hccr0KSOnkF5; z9Lb*~m)OPW1+z_0@>qi8JjNQ?s=`ueM@P>+`Iz6T$%FArb`*)f>jMy$Pv(!97AlSz&Y>cdF(l5$GkkpRdSwthY3Uv~Pno zW72*DGNC%>@HMQbqVYFTyb=F&O;@7-rO8LKZ2j={>3zpGmf7I(F}*^vs(8i0pS#fcfu)IRc}U)Z;nf50OtN;s=M zIfbKje2LMidB9V4ZyM;rAAw%i#Z7OfciCS@^`{>Mc=W4>u8TANMW~*QydXoviU|`h zyeJIu-!2X&`3E^q05WumuaaEyYCS5gw9-urjGs}r_>sp*;V|x#eQ?~eRQm4)OXPK@ zuuH!Y!Q9D=?qm1olA$n6|5?(`@VO*{1lM)p{FTx7n4!g1Px!)|rE8uOHK1LFQC9PK z{j08>q+tCY+>;k>F4N>x-?Hg9KXK=PoSGEu? z;f*EMyZ}&Y?B-TVWyf%%KT<&r>nLTZ`ED9G0xz;}%~6m%lt@>sjzwinqf^Hil1oQT zSy?N=mM?2R=~r(kN&RnE5p-b=@+5MfV5VViKO4X{qKEVDd!h1z;w!S*1dG`@{8bX8 zboji<;UaIcd77NC$_*k=jl_>oMqY=KN0$LhWr$bSf8U1Us?tXcQM4Pd=abAa0BrgHq4O7K92j z>ue3LzXkgawd)n09slY+rar4^-I=o!*gx)Z!Qu80(`BfpK+gb`VW`@^z+AIjmo^!s8#|!P4#4h zl(Y#Y*Tu0_+0YnO?*p@sgc2p8=WdU=05Mq6d3-= zdur5hinpXPjyb*T;~TVpd z?)x}r0}2wP)X#zwg7!@%C)@?n@{tn|3DEF~MJQV2qI;rh(9tQai=i$)YjvY?*Bq_! zSKbFQiPe?+-pBR-X#0sB(305_F5?}VcTe!8bnA3lL7>Q(WYPu7koG$&Nq`2UA}{_3 z(FH)@Z*2^OgQES=`Y}h!=rWA0fQK!$&{cO$Q&X>>F9ZIW^WvMpy;~6_meoegIfEJII1V z=8dn0gZ%0SN-nb`!x#x~b0&P+I*TvBsvOR(G651t=5c1mi{HXFkDkL#-1XArdY#Wi z&s>j9(eZV38ZuQqyygf?yVITbEiA7}TnQ0FY2T+!%2)D3kmWWAf z|6tbppFo^r6Vm)HH_S`#LbARWY|!Co`yjWE&I%^v8g8*iH?D`i(7EpGbVLucSg$Vm z%~E$u2;`e-r0CB&VmYT^l)locW5{(^w+>dbF&`LBYX(3+;#1Z7tM8eBG6$>{8Q``K z8tt{cQlmq=y9$f5%3R>DZ)TS79IMsL)Lp@0 z7-Vaq++$69H4sRdcR$&wh6XFp@F8xwu;BtL!l%(V%5R_`Vi68))|c?iF_<-ObZf<;m8v`tGz;p=%fIWSUpRlGp?S zYkw|AXDe6Eci`(`=AVjo{EL(F4Z}8%TLpu2NQH07ix#Sqw|OGu%NyQH+_hi(YXp+3J`B=sGU8Z7D)sd zT05D0`eKhw>gj^S{KkZc3hn@p+J(FyXJ#bBLmeXPkH9)fWD{Q-d6)-F<=~>%s%!-p;%9^dmhtuI8zmYUvx@cvG0w_&fLALAZpvZ~af^j3I@C zP#zpNFe%&?%BoCB_MPNAu%;3~mb%`&S|7KB2C;cAT3hs!0w=OFgCt$73OEaD$hwDJ zu;;ViO&SKgb}VWDXllvw6-3RPywLUN#iPwKGw0%omPi%07xpQHH>~TZ@DKYpE3yKG z@8o~VCf7(l`hAV@cLq3H(eNiP(pZ=9OwESB(k`9~AMEw@f$=0z5woJcuI;y7QYqx~ zbgG`ns}rvU8{8{bS}V&PC}k(@ghm~0h&8AiwK~8V=mm+0yAK4;V1)#|)j92Npm0BB zhHQR$6F9dpw=dE?XZ4+;T-qa*>dK{;zq1a){G2xa8l1@gIVyH1)%c5?G}BM;H%G-o zb4Lg0UVM8k;X-)Y_0DS)PsMA#jXb}l19CF{ReJ1q=UG+Q{EB?BpPRRe`)D9v{)uab z7M(yNO@0fR!0>0sLLkE5{QdT3J#{&G4nP5GfdMKk;;M|$NryHPPe=0-7{nMs_96Og zf^W*YF?61ED?I>K&f0XD}JFOWSqcSr?G ziiWIBO9=WJE{BSB{X#-(B+q00x;(&j_fuQ$O_(En2fn4jB_iiJs%Lkeu{FsCe+7Wu zPg5irR37Os%m|!zm>0K=WdeXu^X2VWu)ycr`;NWwWhBal&j#;(S2W;J=T$vN3mhu{ zU}Icr1*n{Wz%Pu9C6;G+M6*jt{lomJ`fZzs6Rm>!s4sMITMC@K^s@Mt>cUym z6QCHzD1lGuns3KHRUg42W5q_3LRwToet%+hzhIN)NO1%s@S@;q5+<$4C$>(V6iDH~k1-(iM~L5n+5YN5_|y@(vAM5+qm;kAns5OMLJuEAW^%d@U+_FqF> z_X%jLhTdVQC5kUReWsTWN=Kg4-qN{vwUHZrUpD`b7vOZWNz=yU)K51;AH6AH%~3W*u1=2RS`(m^*vmO--uz+rKKRf&@XRa0 z$+0@-!NPP(B!uTH;tmMBuA`pVkdyMBM-qCdoL{=)rei%?sr(le)8uP5=60oX;H{(h zWJ4Jz=eOdEdT4kA36>_tk&I}$RrYNyY=B!pU+PTz!EQ48qqA+9E;Xq<l=Wttv&ikoO%ew{rvpVc=Xqo1(fZ$>`a~JagudzW(XsAV0hUx> zb2`riqa;QoquKaeAqM>8h1#!xyG-G=L%5m5AB{Kc#DXJA?<-haWyt$${!)O9S~bkVyG@uk(S@-J$pre7q3AOw>-tXl@b%r= zyM09FlceI8+TD@Y4;TAhx96-IoSfEXN?9FDX;QVm{Rz|RO+h+SwFhtM8+5K-*l(ExY6~HDCS^iaqyCQ7wDFgc+_rNLK69tFr$M`ed7VM z(FcVN7|So_LmI0C7`atnE<&G9IQ@Ejun2Pq+8PO;SkoqT$SVrXovJ5Ce=$0m=m~pX zS1AD3W=W({gVvCNjA;CCS)!}#Zs&o^xY!H1;TpYtTavvq>+yGhe?qZ+Bde}&gL*Vw z!XSdMfz4)GG`%F+GltM&fF366<6TnINa9c~=!OKXUFfXq$QG6W=H7I!q{9pF#%-$i zsk|v&GuL1)&z&@jI;=}{ayzG9aPzz=s8~2;8%!O)0c)}C8=9W^*rM3D%Dpb&Jeki%FcNY7#&Z#P3$(<#CCsH& zD9=RV+*Rz2NwvznCNZ}jhEs2b;&aT^8@_77CL`jD|GqQ5XX_zb8kTXk=*S=uShHF% zkLdU7Lr2dtVWAQ=z|Hr$%?P#n(E}T5JqxU~JgVnWT`00mT-sxr3(A}yn2fz@?4jXukj)IF*39No^N@^EvIHz_&?Nvq)QE$ z=3GQ?Cp1Bur?77~w(h>e2y(4Or9^CseYXD#A1&au^h&dX^v}sewAF#1z%SbG!9x+r zAr2mgbFlRWJRoucv)qaX-r`L8p-;jj6+qwEn6WQorhh`V3A!+s$8G;8;2I-{IX-gU zeIb3cgVX%Sb0Q{bP)_kv*B2Pe8}!M!(vH!s&=H}zQ+m62SALT$P6fq1cO(ku{T#)N zp&y`rFRsG{4XpOsq2Of@8}=0|&6g3D=>dIC_?%SQW<@Qk)mGuEXiARWc339&>`^}* z$&C&5*2DR5!dGmMJ#IzFoS-Ud0v17BAtFbA*=0s@fiabGKe%rxTj=;4mG4BORR~ux z3qlVeMK8HEL)|S>s8DxJ#4R4XI0>Ebad7JGe>9+ zlj|i0s1)Lf!}NJ-c_ zlB+~Q$s2vIN4>LeM?i;~IKj__+8xd-He6&FI_oEJOT<_5Om+ROOZ4q;>alP+w2Zs90y=%23pT({ z5Q1h+a>?#sc{@j6{)ib`(tkP3*k zAY;Xm1WHw#UtX|2OV#{zAg3}VX4Uiy;%Xgz?NF_W$|z9(Q~UI@7tWYW>!4Q=U&gD; z%6FEN1k4^T&kNh-kZZqFVK&p(K$Hz$80?_7_4s)hG_oM%C)4D=8&YX%iH@U{-!6O7iJLaDCds>kf4ux3AlVa{m zi6?Y$qp)cx?o({OzeORwP%*VB3=7pJgNk;M)(#K=ZvSsqjS^)~NaTR-yr(VC_7pbx zti2v%pH&9gMA&;b*zp7=GW>M~EPh_BI};-t80UL$MVMId$nq6~?OR3rxCc(%?0S@G z!z(f#o+qo2270phSelH02Jn8IbMigmP4hIiWDQzH>8-(9gz(sQ2#3;owDU9U`@3(6 zte@IoUUN6ugh7Xd}?~y@rVN8mkJpp*hsVyG77%T44C;qBANIgvpf<}h_ASg zY!iVI;E^JrDzrSfl5$0!O6Jps&vfyXkdjy(;uQy})F}SfMV?a`g)x&CX)V#|-fIM` z{};~Z%Ess%{0D^1G;$|gGl|bt_kA%HE+vx@xmR`SQxZ4@emUS@|Dmj-%7?hJXnim#kUE;AVu(a?i2cv-c---OxLh8;K+BM*wGd$gJ(Y^W#qet`+>2$Z+_mc zp`cx!?EYD}-Z_B(r{z=HM7JSXQ8GAE_y2F)lI zubuBtK->Evcq#c-$KywnR`i^bjWY-PoMQnfv$CSL16y{vqssA4WyCe;7%ncqo_C#~2066T>}%VYm`0w9U*6x zkbl(+pbQwTTXfYt4}*1jrVBHZxmrG!%vACperjhR=d)bzpQ3iYFBWz5P#Ki2^D8VP ze7n4ge^Ac7@~K=kB5r?rXjoN>CVm>-bc^=1dM(vWwf#^+f)=ePfCP`RF^~#+FnrF7 zUeNt+ll)t+Tbre?Z92hh`-SAM-jMo}T8fT%AXl{fu?2TrB=_7zLiF&R-cn8@T-ajo z#^digR@GHTRa0a8^1Q51X3Pd?onO?xZq2Gr-^$8+k-zNW$gsCe46|UfWi1LPR+k{o&0ePsvKfJE zugE}UIi~vUPSyUjEH&z)X~VkS!HvYKm|{`PT$HZw??0pyq^_pr81p;Wf1=;-1re3a zGxJUxy>~2{=mHchzOUw{UU*2^kEi7Dt;ns)-Tfg}Xry7WvqEk4?fpkeb|f4bz2r1x zKd-j{8_Ihe2|yBv3_;7T!0V?T&$Rb=FM%3=2e38Vu_|!Sl~jW#})KjIMhdx?i>u6#s}h%n%GWL`nK$!>N@XRR$Vx)!_q%_?=kfkLUa#{y=Xq`d zv1Rcnc)k0A=-G*gduY0(1}CU`xGrI6!!)Bf#mb_WWH2f~+r=rLEmKQA8+t-Kor^}0 z*g&P+e^=76&2MniFC@X?pPcT%29jLoMvmaF4twE1nHHt(^ z*gTNwTDhWw|3`@KFJz7JiEd7YfwHkSd;;m>?f(OU&-}L%Bu(QDi-Pkt8AeCraPcSk z#Z$SHu9ld8!D^dKLtmGv%M?zjZ>7%)*pg0OS-*g-#xlI#mL=K(fc{dRCgUOsD$%)t z>+4c%od$vRn){;A$`I8=l(crUPGwM>zv%WJIc>G; zTWNUQ$D`8+?{LO^(Lo02V>53W);PZZJUp@{fJRyW1NczK$~o>peqoSz{0G^wMAC6c zyp(4_E|xNEn16K~BwO2ZEqy-d^f$*%D#xeSNAJv{A3NzrCAN}ac|65G*<&Z59>?95 zWq!_3b3OX6<#d*^5d*A)bw2g}$TFfaWb+1gyzlGp3){foH=N`kQ>a~?Zy9F+?4=vseyMx_4)y7j2%~Sl6QOpw)^H06 zMYg6qT@-KNnS1>g1?9S^b+IQJkn+NC-Sy--x^~68Xoma1!c5bT&XrUZfg1GO2N>z+*gYp(&GhN z%gr?5M3lr|VcM;o<6fTWjbPBORwdNBPGi~AUQP)CKpx`TPlgY(QRcaRPUDp1LNeB0 zPv}D$;(}Ik&WXC-yLC*)vrIYWS@#0Ra-h|qz}O8c@ox!Gc_y6k`HRf8-Bgq0j65&p zMr$UW0*0)q2i|tvPJfzhS}tI~gC~OJQ`s=LjBFNv8O+FyyB*?dE7W1&WA&j^39CcM zEw2a5Jxh+WX|ckEdlSNT`DC%g^ibT8NPd#En z=+&FWh-cR<(kT$ww0WzeU+Z3a0m@OaeE7`dLFrx!ABl69oB6Jh+i5xaAUzVw!NHe! zGN;zFA&c?~@@t}&o;yd?Yy*c8My)zCP{e=N=-HICWcJph?~RR@64xArsej$sI-aY6 zK~*1_R6H)vky610JbvIf)EFC7uI()i2D@0m;8E?RU}O@c40Ykbk?SWsVKB=*nhZRP zxN<@wY7@ObFuJ#olMB`6{r)s4N91#&odv8uVJ8y&NpwO!g(a)q_shxd&jR+?N3^&) zUgO^E8C8;U!Ab5mYOa-?|$}hz@wZ!Q3!Y>Nbt&in@(rIPJ+qy!Zn2w3l z(NSv#s{DJ!BMv(Ss#ki}joRYsiKl)9L>3*Zka%9ShMDAm$`{y1ZgZx_Q0w^N8CJod zyE)C*!IEVhP^j=5n_cBqEw*RWGPw8;UF<$LvufIdynImycHQDW=A?K5gr=fF=TgCj zKo}s%h`j1>v?*oOWf#TFp0kBOozn+p?me%_B^}GIrD->arX*JL2gCPQ{KcFww`4i* z*sVfHp=UxkUS+eN?Tn>biSo%tuR%M+X`vlW2WAHm0PY4qJ|Ez0?b;FkBrZHzEQFL=}`&Run6PoF8jWwH0 zmUcd%fzUUzYa&Y1auK51w7}tM*L4RlY+8ERFT!+KWYS}u#QkMJV}6JV<;P0N942@P ziq@0!w*KLS`QPOZ-KL7gPu*bKirjAwI#(*+vqNbogtN+>8{W)4IY+&BX%lYJWx_y| z6=hs&CmSvR`zQMD2^RFt2J6Ww@TNY^})5M z>HMJ4gg5U$iuONf1_90i3Qm&YCW|trKW~)5X8U@sCYEDC4~c(`evkCu107-fYBKQ4 zhQCN^=q3vYqoSp5-NlEw_!Hc=qf+IbxYa^uUh>%K1mtD6No*T5B4H|?^e-heoY9GycMWN z*tuRyB47h(Ied4{-zISCAKwjNqLBSw7ZkBAn=^LD1+ozzv=ub{~d<4|XZfQfz- z<|gDl?F1Cn4h2K)Aq){t3UJ*Vo4DpAw^vTA1F}pmzy5kDQ?{r0$pSBK znkw7NgEJYCzaNA3CO@X*eo1%nB1zxwyM~VBoWZcjfIYm!|pwEUlARfD`n1DbIh|{s?k=)jmZml z;dI?d2sGqhrwf(kg{f!387|b*U``r|<3=`prZ5yeP$Wui*HT{;p!01^)w3 zuaMmloaC~}8J3p~Jz~~4rtnrOM(kg68>&{?<10ORBgJJ7LUU*DGIf`>s=DtLgkH^r zET0MZ*Y3fRc?l+St6pjUk>ZpmNAR+x_;aSKq-1C}jL5S!$Dzil$RGCOpbzBnX$1J5 z7`sM3$%M4L5Xt1NU{C_}3w>lO9BJEErN&&U ze~CqM*J(aY7^6_)UqYWg8f78D13KvAzs!EVF@BQd#&Is*O?nZfFnYDvMW|&m)(v2} z1?qZtrPba{m$c!zmcMokr;o}Vht+?dtka|Un{TV|mdnXWR@5GgrOIQ(A|!H+NV4U< ziIGSJu}h_m{q^bd?POU*^N|Emg_ApcigP9IoyqfWn$s7$mrQ?#k9L?I60had{M$p8 z?kJsx%YBMLU9UCFc(mlrXBdjjz& z&W!V$b287tgb@PVoC;O{WB^R-=GaFkhRj*M@Ry3$*8cY1^mhqMuS>b2Ui#A%s7{GW zHlyb-{RPHMD$E2pE-D&L_Cb=vTL2+#dY*|=cN-Ap<^j461jJost4kqpXnx9AC%HUw;yCFxjkgVf9cppux zp@)J%&$9C4b21XX^M4Bq+Z`F}tSyr--qPVyhk9`s{4iM4fP;mlK8Sf%%jhfi3q_pZ zoQoq*%;>&5i!wWRSxX8GfDLaBnf;N#23~$T^fn)3bpHwGjZM>N`YZ#>>u|9YCAE(L z;W3=HN-Z+9FP50lca=ZxnEo9+%Utyj{h7ZbU~c$yEdDNO0Jl#Ek&s`B*tZjz!hBkM zQJnar9-2wImwonfW=X_8)z$;K%KU|)LpS#A(Jo}wuHZ=Y1DT+{M3(^=4Dr5GM|Lgj zQhmEDH`F%r`~h)8y=PxC?kXm#x2vs~aRGd-w+fsPm+YS2>(1vjX4nlU}ym*hQ!TdaDW7%{-@v_PRax*xTQ;bWpE8 z)&HvpuhyXgf!ZjoxrlT}jW@jpRiP8iKVz>*u|YLXW9F(RWlJBxZ1p}jSou=)CN{VA zk{{f5W>gkkJ2{k&j{lBc6-6c_O9w4lj|c)?8$0@_mVpYc%@{b z71<#P67;nAcRC^^EvM?ga7CwEtB~Gn8S;GS0@6dc(chGkudfPHYH-!>SYxkCikZr$ zKdf@8=a8Nr)ZyD7hipHEgbb6%hXePE@|)^r;UA5nYACD9{w%HhUIVHkLVfWl==Jj^hpa%sH=D2LmTE zGo!wMm7o91!z(a8s+6#9p+VQZAN_bnw)8?!d-{QJ3IB7#Cko=yyY8+cQaIZ(-iy0O zh`ts&ZkCd~5E4!cg1G_SJiBRH4mXAo2I7%(r!%5XumFYJr^Zj;^3IGg9d5r%WGhUO zqCND%X@7(I*1IF)d46E2xLH!ubLwa_-GLlFsv`99U<+pQeKX5D`!4m2P>2@gXq@*X z@Yf2G<1a?3?GnBJj%>lT!jb#HfeTR@2ktZF%;YED+f)Jfa!j7n*Q|Ko`P^^1hMvfM zD%)fU+u|>_mK`)>gSa4tPlr_=xYgc{j^9#!h=ueV7WK*iylE#W=f_6yokIRoe>c{E zs5R9)GAVMY@w3cKzA(zVxa z+E^T9RU2vL-bbW!`VQ6(w2w_&I$NO@9JlnVj-qLo1GiM%<77%n)Nd8f5_pK9BJlP1 zZoRRF^Ev?II1!iDP#1;Dc3jzw!4`zk*-lc_yyzMHIcQBoK&QjL+y_YNmj0bZ~ zZEGDq<~<+Mf|Opr{i<=uBRIM(LZNn)Ep=E1Fkab$?F6BUo(InVvE31t?ZLvSeCwpL zGZi(uewvtyZS9t^)TX0M`#q;$CX3gfrCYvAznqeH>?7k0x6O=zm!&=#*|``#t8$5e zX|4Oi?0AYGXya~U9E|GzD7^e$Y|oYXDT*tVZ}NQPxqB{OqX3!Dgb4U1wN4;EA}@QV zx#uNAVU6ZAr2|-Gv!AAFcC98}a_%Ta(76N_&;q^ILrd+~zQS`ovY*P?Mk5QVR$+xc zAS|b_Y|AWdL>p@jwy|(}KAMLyR)|3ga*dz6co1=Vn`;ND>x;^J?1&;6rsi{H&G$dQ zQE=+MrQ8R<mhpNU5^#JvxPGV9TD=Eb+tvBP@d>+q!(l42cYnAF>RIAv>_5Tw0Z_c(V)Vk<^&Cy@2#hdCWdAKMJf8Cic z4D(lYo#!LJFh#$zzXIxA$nfP6^A?4%pG9-^8gvh^hGAMABE1kMf`9JqH$D6-X-Amg zUrXh!rrT~ljFm>bp`{+kMOcs}|s!AAZC+W#jCjTSq> zwjlGIoLq-J=9fO;$bln4pW$&2FA4m!Mo1_hb@sZZcyCQ}Tk(iJQLz*W)(PCHfH~E%!Bo?!FnqEmXvVjtG0qo?JaxjU6&09^s8M`|9`^e)DR(!N zLv!++dErhLY|ZVV1(*4E`iFqYpxb7ymAXX- zT0P6m+GD%fjyD1-oP^^>jN5*Cph%~gc2BhSN-(JwR|O`OwWN+C8`#rgZ7iRxI|x@& z(pUCyf$-2^1hsw>@WJ} z(e;4BRz7VRxgVG)PG~fqX7AMr^%Mb7Ry1fYbY69KWSXvE9AOoT-AydcA1U@Lp`_!p z5*55=k^Hjh#=-6e7FD?8qs?~Yq&_=^KN;ZC2U{{Y;b0MW`ESCt7 zQu&is`)y%M^RC}jBIt)v*9ilCm4#Gj3@x+$;R}9*47P|A9adf_nz*t%4)T(4yMt1u> zYrZ^i*V9a+Pu&bvh8ax<%qMnh^BISw#eU^ET|XDdupqShM2W>THS|QXv>X59(fdk^ zJg#F}fx$_tW&Sk+)k(OA+|X9jvh0{MetdivDQV&|Nxh3FHV^IluvjgrGZFfzQwAwIK_kAHC%SFLkgSGV^pgQ%PO1 zv-Em7YrwCGdw#MWK&|XO;KA?U^@SBu%BU9kmAr3f)Sz&QZ3Df?Cm&n^^=&C|;<};y zSmR}aEQpg(ZdvwRYSWw}yDZn%g);U+)FVoRH?otWY71JQXSU?KUerIdA=lO~@~N#j zIYN(!Wa&hx?P$B%XYS-TPHZ+&vVZdiNL&8b@}l*FObNVqnT%zgR@mnB`wO=wa7Z+6 zy=Yf60^0uOM@B2vb7A<&=M4b(T3nA>Awm3pJx-r;f6-iy@=}u3GgG{bjyGGcljdQ!yLD(IH^5uB)*CpQBGwc zH`}s5bhN9AG^%MwnkfiheDic$HX-&v&KK1XzVd-%90^3Lm?xh`2cOZ4mLb@|aF(@D zkdrWTEI?)9{K89(2uFbZzSxu&E%3Y=HnV{8Khy`O@nJ+Hwx+%A-#zB>t z!J&nvM0ckVO&lqDQ3iYsh-tQUhcFK8+HQapyWUAdxx!i;fQjnTKj4e^sAsdkg*@2I zZ02t_Znk0nPGlyAlWM{23XA$5k*J1zWk5GgTpoOVQc3=S($cK+@cSv{J107^BTH=f!y!PP&u9VKLBe@&_XmGR_D@HX+I_9nq>=W^}#4$ zR!9Vioau}WAfILZCz)k)MmFywaq*JqqvnX)Sj*YlFXeg|^}{4PVP1w=1?yQj9}70t zFxu%lx8jSiDoj#_Wdv+!w#X>pUqqHo-&iQwi8{^-|Hc$xdntdm?cp>*36|h<8v5xn z_bSW^u#21{m=y`8*s6h*CgHoS3s3yOeRt*N*wn${lfS8vWb!(`Pt&!7lE=QrO9$@B zt-G|8qR)C}Cb{fp*T&;V^P5i?ZGoS^?*@W*VT{vn(nUx3ipx0A``xFqTl5`az9>Q6&~^erq2lCPhO^I)SzOim{2i1wII}4J98!ghko~-`{P@wH$}~0&d3}{P zespHI^9d(^fiFE8jfCFG?2--C)c2PMo!>n^MhxO%!#!DplQQdtB#F>&F^;oNPG)UP zuT*EA`I_?~35OklJQM7tj0^O(;cqh8Q=TW&`ZS+nOsV;Pdiyul8||L^G>Gt(s-!-* z@40_&^6+WUF3DbesO!X+^o;~6nyXFj*|FxVqlJc5bDe0#s?|7Mf{nTk<5F4MLc5Rs(TAe@k(kv+ zJ;F%^{L?_Iw>Hx;nYWmFDX^Z1<9R+?+Lb?okb)OrKTV6!xm|ai3co0ez9{2NF+aMktR&$7?ppXY&BLF%h!>VGs#9ol!e7GI*%dddX;dk2mS;4*xn5%%-mLb3_ zfBg8$GLg-p%9!Osm17Yw4d|bMyC#1qc>-SUVNQ?pHr6mcF9otv+5=j6E+fLlzmoiF;l%xVb%$j~lyQ0z zi?zPU-+#v9p;r{5LG>xcl!bA3Yrp9u8;#OkM`TH{W?0-4MTv zVnh+l>UcO>eJtlIYNiz9ZUlq&BZ^X_4CWCo2DI~=rHiyqVWW){p+lrWLa$bpaUVrrpnuU z!RNX_44i+RD!S0sMJC(&N#%0P7VM_>A9SOd%=f8DR@=yS=g){`L+jfB_iD{-4mgFg z?8frx9Nb7CoAt4YPqTJCf%E&&oAgjp`Pqc?_k~XNZJbW~oeV-rr`G&ShiOdujvJP8 zj(z)@;yKTA0s52P>@gh>%{$jO_B1T-!vKap$5K$lt$WZKysic)LL9=5W3GpRure;! zaXlOta1KJrWkT=**rUaBVxkfDsw(9$|CvYqfQ(+oeWfzDIANsp9Mp>PxwBhXP!nF< zA%AwTd`S}9fND%2!0L{H*;k$-%!aoU{W);`^AG_hr}-CNjUihg}2be9+l3& zw(dVq56=EDV{Q0ROzGmvFl`}YOgB7IPz;+`0EmS@)NgKr@gMe!##$r$27}eaFI}OU z-|h?YnYW7HmS>Dw2yL+0#G#xGobd9ZQMyW(>}E5^wNrrkd)l+url;=2kS##*OOgb! z>sls$5J|Q79Yo;}BJRqaDGXuP;!AbAh&rqPSQ>55$eo+WY`Tm#W3GxzRmWmIRDwqt zW4H*gwzot+tRTRMtn#-r^TL^Pvc8QHZ*jZiiZU3l--YijT&EPXIP_B{iTc>GN{*%n zq>n!T1Ku5<((m|#!)WK&X`!XkavCkwu0oY<1&uN8(MRpNm`0wx!Zdqh1;Yb{e8>B| z(4e3jELGxHX8pIoLu=%`7jQOD*G-1Xwd8c0OX}?`D?q(Ll*#ymlCf7`#g%zLpP?a3 z<2tf{ao24Zuz47ux+TiS+TvTc0Q={~FE|L7Cmm9|sY8E)A#~)`pEG3$)NSs@6W()W zW|@yYXF=!LKGgc<{MOAG+VA%V?PY5sZwF8=Fn}+9OC!JADQ(dC9GU1lwTT@&3yhL> zZ&AJk<~g^Yh!D7!?WLrO{_hJ%kL-y_D_-RR&XdNu`icH>vSC=VW2n1OD#G^c$a=)nYq}95yQ@++PQ0Qd1hD%RRG2wRHPw! zCjxSer9d%COQl)a=>VBrA6G%gxz61K`?|~JUbhg$LiGl@_3G|-I$?6J2W&%Y`Aai0 zl=7z}%uu%?i{(g;7o~H_UW;#A6H&Ve;bTe%TB4 zRW?kCJ~#WJ(n4PqC=xC&AEtdWTXoZ$ZGnnr_grTGlkvGE#-&eN$y5^hbM~i_vV#$q z>lB`L-sSKYgpTbELWjR7<=6Bwi8gJ3b%5lZ3bqFmGa!D-rv`M_J!Sc;Ub!3iLmm!8 zsyaE|IBpof|6u$ya1AFJfL_InFbE&mhIFOcsSt(Zoayz4gO}cU zGaansdESkcD9@(qU7(c<{?`ZAwkPvC(v%~P-|CE zwh;xk2D_mIjp<4U8rHFfdm8r&zT-?XM+cXg_ul8BNb(a?5kP$BFJJs!$s04gqfyOr zHF(M?OJC0(9Z|#f5z_RuCrXJCq*TvQP&zL-dYoYRXLy6@6 z5MQJ87Qs$Ed`BCNP@Pk0api@3O%bF&cM@XV9%z?uo2R3|{9 z#6j55x?uN+HnDmo3torZyHYb|llYwzlx`7`8};#G&TEn;<0jrPEvr*WGuKE_1)dPe zBap~en4n-Ce2fKIYFC@g_KxM&I*HKiIXZToG+OrBMbwZz z`Q*^AEY%m?+#S^=i-Ds(?l#N|P9>IC_Bfdd6;=;w??sF;#Xs$Gf@5fwrUVNp+okIT z4J>KrzSW*mX}{!Ly*P6|Z-XgoK-6B7y#45heoW$HsS84q4zodSTGB2LR|b3X;JGym zAg6XKAp=p}D<^O=Gd3;qM-Dg@5VQ(&tglx7N((W&K_VK$G}4BJ-75L7yRT?Xy(tC% zgQyaE%qCTN>>zzxRC{b-DKa-xx%b&XZ$I~V+HjRJqW7bKgnUCjh##ZG%ZXKf8^u$0 zPnFigC|I}H#+0@uE*(RzfE>&shrK&SX%QSpgkF7_%mE|Cttr3L!IDVKA1m?|ng0Qj z3VK+xd`N(tXXdxI7NY3YQ8ogf;8|mqv*UwxNp64bbcETb` zN(Cd;KK;PGJh3QJEXmVPa!XR4>@rBg1uKVGyU-Gd92zJo*oRAI4cz2*9J$1;T~4un zBx&9%NwY`nKQ|RMlQ~pW`T!!$u~`Aa4w8R6Y4q zCKIxY&RboSdEZ=@b*=E_FGkqG3!X3F#0suriZLOn;Dtp6%jcp&CLc%a({7IAOg?B` zlO^%IP6b5$@_Mw=%@I-;n{Ux=#B$AHVeLXIDNzIgx+`mnlUvqOnyw++gVqWYzA7NV zNl1#)C7tc;DsJTj7LjIsr$2AcW9SA49&G4*dV2RGS zFr_SX7A)lBSvl8oS?eR<^KE8v@352DYRQ%Sv2}Q9>7Nas<||{f5o_yAKbM}5oY|X^ zOdkC#tac*+okff!NircD-}h~RT}!Z`$N}bsg=;{|*?->tX&@y&2Du1IzR)%bj5t6I z5=k-k-B8T=(GC+)%lSuJ7#J5MJC5q(yvLY+e%CbnL;8V#2q?c32#rHgtbBK@>f$iA z$DeXH2iYBFs@>iO>~UZ8jSAjHUa2Dy+=*Ac%S^~pn+c1x=*t(gtsBz(`ueqQ7fx$PBrwozf4>-%J1#hR3*2+tqWX|I&7ag;fuW_@dd zZwruA|9h2e@89Dv0+hQ;m^)a|&QfS-85TEk?%|W7He1U<+kLL2zFCwiJ{N3EKYpb~ zQnuIIJo2&*BK{N3cb4DWY$FtOfg=+`K7%Wwd#v-}k8_vK;W((`R2`5|CcC;#?IJAz zY^X3|yOtEeyE-kj=~ry7(L`!O6e{f+@7QUdnT{~Y^np(gCdG(kHtQc3?sw{_l4)Q% zp?V|`WW~cuj&$RXtHKYXKjLol9_Q0!)S?a3Q9hN{wVto~mfl-)IYgAu$yHI)_#1o6X8Fe8}w*(hTOr7%QJ5MffL*1KuA6H!a*9=^U zH;+61K#x;UK>Ek;I<2y|acP^Dd=C_5x7A`tUVcT*^>6uW?p)xa?07Gn^5bSs!evrW zP!(WwB;rCVLx9Z`!gHf_JDi9&7ankFsod61hB=Nv?AD|%Ux(eZ_Qt1!uhUFoI_;M) z=%*Cxi@<~EBhQtu(Le~v)FvCKQwn9K67#hNM>HR2Vi48t$VB~g+Je(>I}V!NzNm*C z?rh`Rde!%Vu#P~Qq}(O#a|}&_Is%a(rX## zZ~RKTctaXyKyf3{k0@|bpjCz^EJz05HaS+y-bwNUr62rgMRBs{N0JkB3MnD=j+dkeMj;B?~25WY7m%wD1PVY%r+@MFYn~ z^0)!H+;o5(J>-h?Fd1a969NL6P2+)TiQPgWu4k5nGm1lU=JaD%g>nL@=Tu+MqI?2a ztk(f81AogcxM-asHFQLK!6jbNeRAN<;Qs-yGGG6)x20W{rex>8x)&d_%csg54L(c1 z86{(jEJAKM0UFr&&Uu?HlYA@(?Iipo8Yq<9G<&2QCKv=#| zVH}P0mj&%I@VZHzzJ+XAifTd|R)~<87H#`5UJkR*gKM=(axhkoy8PJml?tY~5Hz#o})u00G;=HCB zHm*nSjau|E-sRHShkJX)6&KGy_oE*Eh}G02)p(@ z^pX-_gQsoaqlP>#)@aE+DYtc|LnL0Gg!uuJav+rRL71+Ht??ht?l`oQ*w~LBIq;|r z;2nP*PEz+(rVH~=28-8c%crWYc_3z#D)8pNf!u>ZCK1j;%SIr9__?Fx@unirUKN4eZQjQj@%e!G=?%NB~kXplLjVfI+X@M>SC{ovaSWcodF-u z{~7FP;;;8j+3?*xf%2h!bYrTdO^q%ZA@ZB=8tGfd^Mw3UDA5<4RPJGb?W>Ba^}~^_ z{I$th0$F!~;orF5(1gWZk1z0o@2&^;nTBV)G;^t@l+>4*v&mxte{vq0IJzw8AhKZ< z>(Flq}4$5Fg42xZ$wQ@)>lun1efqoL)3}JpeW)W;r%n z%&(N`f`+b2ozFHR7$$Vxj?nue4m`KK`FsT)zM}b`ykc*rrp$6P3s@$YXo|bTE4f%+LXp>mB>lIBIxh2Xk2Nd56K#=Z{|c%o{^)Cg=ppEUou1`gO_X0jUovuIS__O0D-RYkb<;h7M zUX34y_~jpi?FFfKrsr_cIpzAmhrK;UAvr+8nFM3e>*Jq?>b z!2kPQN@SW$#NWBKu(KttgL!tc>3%n$e-@sU`QAqsIo3amd%1p@_g9@G>+!fUF+%ze z+7S(BIy8xY3g$nb-%k}D%ShQ4o{)H#RD4hODss%|@yjsoe7R?87Jd>J8{JNdEGpPM zSUpFXBzKnAzoN8*S6c$OKEdMcQh`&n_f>R~?*W|?Qu&Hp?4#hO11;Q3`RtF4tD%db z88Dd36c+NJ+t=L`>q6)OWQ5J^X2HFD{EOXkU@fOTx-H1Ylk9@QfMbmIQ>8IUSp>5k zO;{b;5{8EpEgm?E9Q(W+*a0T%dzgFrZG%^NlFe|TWvLh!pHD)7)!7yacs8lwt827! zpy}gK??Se9vyM%#h}U<-yZH-gPb1G8iKj_yv^1Qy7r0H7UVU)UHJ@X&-$d>LpSA-R zs6}}_v(u)U`=ayPg(Ur$w)qOqO$L8xwx6L)%>Bik1BzsG4lhhZy4(Gm!{AD>z;&kY z7!~Xgg-EFog6C*1H+(%xPp1f+{kQ-rZC2l-z2xSbpwa&1!Ss43ohwjkf>9lJYaX&>xe}>Qyd3B-Rjr11|EQ|R*^oB{W`<& zCIm1w0p8Qx-5Z`(uIFM&mbayLTxQ;I&TtU_&7cYKY_P>eI*-X_S<(|TcUMbeiZ(88 zyZMHpb=yzt*w1*ix|~pFM16+mG!I>5(#))yVO$#LK3ZVae||ANt}wa7uH#b{2Xmcg z65CeCkw**-6VM>CeA1gdQh*4#vzCZ(uy@fL*HWK7^6rht+GYY8;p+iaF#;84PFa~D zm-3i#uWClr=2vIW3%TZiLUyG)wdRUPT$2^AY`i-4FW`x-v;lc4p4dAc>k!@@Vsx+h zq^59y=vma=5T?Ru)Z;N^#9E%jWoDKGJTB~RgOsxP&OMC zLGT8SV7CAX2_3js!%{@8(!DJ~sx0LXRf8!QChm2y1%xj^n7w8LOKSO=IXaamRiS*1 z98(`EpY~5g)B?fhQHU?IhAhPjkjmd~nxyHBNy$uD3G2CliJsFf_p|0=IK~p|67Mr# zx8b&t%qlf(|7#ApM=pZ`AexSF?M9gnrm$%Zao7H?^bK zEFxN=(*2}!%9NOeKPa#AFYP3Ha}xSlfF)Mqw)|aDbsN^zsQQYkJ}b>XP$e6{s9~!9 ztk2R-kJ7 zygLHZw11j{{Xq;ZsjVoZN&0EWT^Tj;b_ko<-_1FA_^YP!uRlKfx z_h#habW9rP`$NG3m=;)TmrMQ|fy~>wd1n?6wF<4Avc$lWu3N;j)h2dD2J=GrI=3df zQv*eCgD)IVJD%(M=|~wbLbCA?J&H35$n0VVfY3n!4?gy=beg30oh~a${HDrg4F7P# zLSe^=26X^nsK+UAJw;R!Y9_$z>30gm*&t>y@oNEYemCG)0vm49p3ES;oUAP$@=dTmduor&Map~dmhm+4 zL0)lriS*_750_0B6gztVrlS&JIWXWI+I@60jh|#2mABsN^t0$3Zr9QrR3}=AoJG4) zCOPtiwUqO|=Yh(G4)3+mLU>60Byu9)@JvKP^B|*S)M0b;CeRLp9s?|5Xh&WBhC<2G z0UQ&$Rjlsmq(4yYm`P0AB$61p|{U$7E| z#Uz8B{xzu-8z7RrNS>#IQQ-Df7U&LGh=0mp7UWlLrYMW_+t85uP|@Up5{wrj^iY}! zA=5V$e`PqMBBiN;A#CKAo~Bj_UG#IuRFzv(;$rIJYXY1p|7j$S#!}&z@&bi}{VvHu zi2G_?s~@>l$0c6hw@AJU9;nXUGp;&48JKUOJQ|rg5_+j5 zH?G7HC^0B;xA0<1Z7{Q*^WFuCZa~%;zX%u{*@s7P6p1TE2!oaNDG3Bc<=_|4XDGdh zd(x+wVcO4KF>yU~$(1Jp-zqa9b&`(@Tnwmp&Zzz6PJ@E{rM9MWd4A8LQ>?j*q%`Q( z8;G)JSutyzy}>ORfmXlJKBr$%8XtX_bxQ9@ZkKE10HsyKg7Ut=@QbG3Wckw$nH*cF zPD&N4NO1%1qdhXkGp74i)=i{wP`Nt`b zwzO41_pCX5ozlDYDF-_#6$R!T!G|l79%9B_=?cEzu!7VeC$b?15?oPsmbP8m%q#BA zPE*yPblC&&%N+M}K*_wtmwz%Qa~TZ&&-d`d%4&3-zeg6>i4doHy$^WrJd>sJ4_i;W zsySLpXJtX^tD_`^$jv_zozkcIdTA6CPjLF~I2uvQ1}J-hx?h|7Kdf!k z`zzNw>W3ZU+Hznj>)UWrC8yE!f9u?$FNT=)--Tz8!a3^lY8MV1zJ~Bt-ruK4~4wsi(M{z~= zW>#=)+?rPN@r=c0iU7>V4l19p1%W3kIT)cdRInEx&iH0LNo#bO1~ne0YEgZY#EJ_{)IRp-8=Y@MAq4jJRT-N zP(W4<9n!eDb>i;I)x>E7-*pn~KrvJ0P#{4rP3}L9pLQ25{|Dsd^_nNe&`}35sgus! zoqzQ|EVpH{rghsyiw$k|+HHMPnc!HvwCD6pg`sqmvvMpA{+<~VCJxFDH@Fi+tMG`D zo+aIeFY-Ys;aaI@N38Zwy~^pJavt_%@Yb}2mQR$RD;;fn)QI}zni1JGAck;C^+b&HoHbqA1D3dp+_-=u2retS=BNaQWC%E=+CVWaX5AhrLy{HjU@Q<~n@mKfu+3H^2|k_5NFtFHoTFOLg2JTa)pbKNy&Z6ifos10c8;>6(OSr18x zP7T~M^M6lHJD4gWg8360q`9w}vz!>Jh*8Y4jD3}G6b*Rts((i|PQWlV%4hhtp#WJ* zrZXbtzpj3h!|t{Qvp4qg^Y1q;WejW9N&0JIiRN64CqB6)B!7!pob~j0b!~dd(2S;X z`$=@2z8p$O?)SlcDLvF9Q2}3i*a?V=p8cr^TYUlAlN%1^wO>A@xUa*R!rf_`*HG?f zyVjjN3!fkOyK4>l{N5$wMdX!lMno*XeP?fDafFaX4p=PfYhhaZfmwdb0Fc}YNK%*; z*V=`Y5kXiB35V8Np{zSnm6a=rhA1~cLbTe%k}Gv{NGatn&b{#D(wkjz-5H^efmlr( zOY5=Wd-rn(R1)m%qpf6?@HaO%#Y%n3yo-daoIws~xJS}|k8kp=OKS4m+wUeh>w0omn<*t4 zo{!lg77d0!G5idl^pl_S^+QFF%lVIeplp#cH`!Y2ZFjO;!gE0y^Y2EA|Cy#;%vUf9 z_>{iIuAI8X7I#J?iC7Xj5X=?C4iZvpCUT`z=^2GTMv#H`V{2GE!WuNpRPCSM7$$%V zssILuq)WZb4*KDg24CPO0Zr_y@$Kagk)Ox>$oQVhHk?Oq+)gVRdu(bWNQSQ`Sis6Kr{^L{>6iO;}rK>ZOsjjm!q-WHNM}#j0T5Ld&@ZiR=^K>}CxDaM@NR!Sa)?vS4QM_XzfJ-7o0oOkr+@i``XqNZIB zG;%)7w{-gq8%P`!(BLAHQu>%!{ZxReFSrFQoJz!mp{dxa+aBs>b0YDp4qY@S4)k$O zI$xEt+yuMTO~!VMIN7t*Bs09L^*7Lv&|TvsDOooyxtIJ>pc?0cKm85-3e?C-dx#>X zHY=q>s(n=PUT?bmAAoR;kbPK=m)S&8Q;9&;o?rBvEqA6^WM)2GASP>z&ehaCV3lT` zg3BMb2toIQVyiG>7{Mh34(?QQN)1n+ihNpSSn~d}G&l2lSa29|eE7H-X1g9muA6)Y z-=HlRw1?m2o&>#<6Ipa3DrSK^S=nuW3f-aaW&}pO9CJH!Vs=id7sNq^U$8AFDUHU( ze(<`Bscwd>o1jf!Zt}6qa}4O>S$NjXg8NZ=oY-WcewXT}W4T7ie<^($f;{ycUy^)5 zac7vFOP4dHLpk@*8$O>KFs$6&evia}x>rtX|Htrc;?bDh6M)&c6Fe`U`T$uZ!3U|R zdj(dr5S>d)8VOx?O>TY-((QI#@kF^1vDA`p0LC|608xB)RJwTvT7bApEP6^6>sgs| zAwbE86-WiIV|(IZI-sGH@ZEZAESo?XlGy?V3NV49{28>YaP~ z-u>aReS=!S&r!{J{%U|k{Ah6Fq0ZDmVf~$rF;AtE7vO`;jKT4eCvO)ryT8@OXkBph z4bC|nI3YOmJQ}}F_Q2Wj4$90$h9e(8TjU$jFJ5vsy`%m^EEOj#V?x6SVHR#NUqOFs zqhhA8pz)LWTbwfvv_aQYA7ls6_Yu1IG@FLTahQoz-!Vg6;L8x=Cn7i)7z?^WV+ZIX z>R~+Lj2o24V`aPu9bupyFS#E;vD>*iW(hBG56hLn@PXqieDmbZTH{r@@j|uE>dUw)eZt@s{^RY`N6K@*;SKi$1q;Jjr`}>P$ritKAK(2 z?Yy~sDzpAhzaU&jLFciL?z)2T6GcpbFzuH@LKNGY@ut7eEkhOOAiyRzz?6a^dYSc8IZ1gc)C3iMYvx6?Dc_}#;ljIF0>Ji{^X7FM;MXlBu= zEe?9|7xt=_(vC(1wz*~Yxk|VZNi=nJc=L<*@&LDN`;ZW3PzLDu;X;JStzT{6rtEFF z&22ohL-;lMte>0L)P+aJKZO=YA5}RQORmQ7cNs0LO~6&cldD?xwR1Yq(&r$NPcuim zD5jre(DCoS(~#h9>B3naX~9>bYALKZ#U7O40KYIkaZ&qJk2t~V;xIBWADF?nj+zGJA)d<&;^9dEedaU zBhXh6l7}Tozt0(fbNCd?7OY`OC!3(TGAevznFg$8*_^!!jR(r~@0j+@aJE zV*`@-HhbRPe4xPpRIC}`EbS!F&96T}>AF_~ud28ga81wCkh1pJ!F{d#Lk7}MU47ju zbuwJl?E<&q=@RgD&+Nt;u^;ojO4MQxkH3QUbNG217K;51#TL`lS+0)qWe6qe_XPC2 zeM!U52a9cxd%ee+!!4( zoPCwKUuhlEFzMkh^1mOBsV8ZTn_tkTfTC0p~(vH8&pQvqMM9lV$ULHd;vmhY{R zPcRw976aRdzZ}j0eEQkW%Mi}86kv52&`Ma8RB*mhXvH!MR4+-G(u{Uue4YApS_QZj z>IW~4m57+&vz8EF!m0bpyv!cB){S!t3K!CU50pS(=m%R&xJit>{Jo#yQ;t<*yX%Q# zeKH6+>b-ofPvi4`sYv*h@U*)n`3cQRqMp*O!Iuqn@l}}3MculID?UJp6sC=i&v~lR z|AJFH4`o)P`5=c0Wnu{CIr^IkVP*qfY^yvVx?gXPdlW~qtKol^dRq&u6TM|fJi{1; zLtiB6b}TPJJO{3s|B6&_M}NX;1H_N9k5pS>=D!*gx76j9Bpx=_f0o5mKo|OG*KySU z<@nNok5MaDzH%)A>Dz3%!7THF;3bEC&=pBmzC`fJoTF>k#7+Xop-t{858bmmFX7E zpW-n>;Oc|(w+H?D119!Y?!5uQ8E2E@-covGTWXurit| z^u*zhqHbU(M+bf(s6fa%WNCdpX^=Nt2iU`vMj=k&5{@rnin!fN!X3Pn4QZ zV|#hOi;iPCT6mkd%B(1Asp#r4^I}|S7(2JzxxQUbMp>C>WZhH8jA zqstxyY^eMzt#neSQFu_oq}NYAx+>Lb9(-29!BnjT55N}>X=p3h{XX-sM-a=^p9*!~ z<;K6PITON5h74@o>%@MNUgKe0?Q!IuAfo?0#CLC9ALdJI8svRNZCLWvniKn+Xm1h0 zw*luZ>)3@+FSN0I*7EyQ1Kg<0#51&1S_0-z%Y5XN^9q$T`)38RQVy5`W|N29{0!ge z)UX7DrH}p>_8XwKLC2I5SUwyiKev*HYkY|+vC~p8`{wW~nz!4i`iuE)#g!Efzc#;< zBHiNTIVqSz=Ku6Z+xM4y4v)-o`i;KbjF_V0p}_j^!JtL60>oY@X;$pFQ}~eQ<9ObJ zCZzle3{ZODCq*dbfKu|~=EXM*Kqc4tltg5p7`=*@f2>yInnT&spLM@9Lf)AMa8fwG zB;V)Q%ec;#_LnkPB2zp{6yjy=loHv0v>fwg-2%hNP}mdMzZaJz>HF`&9x7^l#cXM; zNK@s=Ix68pgzmXP%FBVS1(1k5(g@Z&BGVY`7(E5QozA5%lSyKuhePGsSNYGG%IfBT zmC<*vw^tcI0s3sf3{C?zDWXmX1iOGWSH!wWn&#~U554jqB7)f_u_TePHI1nGql+J& zzTD(}r`X315?jlqPX?Lxj#t))30`K36@Eg8E-b*LJpfEaJzRqC?y7dg_zZ`IBv(C< zdi_yrN|$A!Q1UT`y)ZNqWc-h)*BTMZR#jeB#rE<+ivaYq&0xV2q#yO-4+{r6<^r$a zhzPz0W%%~b7{YmeMnmMUUeTIKIHb1|%U|O})V9_qcHg^xE@uofhLJhns~SZ-w;|EO z(5N>>bNd`bve`u`u#C~JgH!2fE4ZL`d>|aPL;ekYBx5xv$S&YBUqb$uh?Ivh8u!sk zLb|HP0dO_uA}2d>(%adCB)=e4&0uB8!&rURdO8=W+nwi-halcQngTc)#^Ot%xk-DW zI)67J7IXs$Pepdm<6ZPo6YWzT6m{MvbAxtm5!u0PfKc z(3p<10vm@ckMv&_y~cX-z1mLg!=kF>-_IN5e(YvAN#DJn_vBTd2)Nah{H_XZ1dy$k zL}l;db4??S^9UE|5@h>aH<+kqkm*!(n4qM$!h}HPt%)mxDi3Hk%jb|v>ro}kH<*|p zoF|`UmF&hazUj6U!%(iCI0_FHA+C^gc15@!I$k4z`~pGp1=477xIpjh70Au~GVJ*` z8Ic_sj~}Oe(%lx@7hMVxFRC2L0BoL9n%3W9)$W$Ime|$O7Zmj0J;ACrO!=09jstqX zI()X7hdV!=&yicn2VNT<_+cK~N?!B)^j$e`R<}X8YDDUub;ag_ZTBQNnI%ogV@$Wx zGIvRZ4+B9O1VDq6a;v7{c>z%d_6no1=3YS&Gh)m(;Jl1I-!}9;HwK4_$ZNdhl`*h+R&9}M++eZ(wai%$!%Uu%QbR28Ys4rf-5y1bCOw-9XTczL6yWo-*5(T&Q!Fr( z|I{2(=~}<^hPhGZe~^>UiEWzlX_Ebrf)ot6*N>_d@ls&9ZHbG2Q!^Pb0$BM(SjYf6 ze0m1A`L%xj64P;_sq>m#5r{7IUiS`^YjvZH=rkj~hjR?9!W~sedco0(IZ5yo>vr#9 zVc&@F=&b>Udh@3+?^|TK?`6b?4*tVwIU(>ccoFi!rxTDu*-houL0<9OHh6d}QFov# zge~^+g6_Gyc$?;1>TqdRsp)M^8=S5eRkDton(!WcVEIRz`5xl&xc}#_=NBpj3fKK$ z-JJ-C9uivttfO{2^8RdcLP-8KIKEWs&9SNFG7 zq)k$2oFFJD?_AEJNkNa_ydzw&*BX0fCITttdvi<_UFnm&_DBP;12UjG0+Q<$jJ)o( zncgk~A>FsQ70iOZlb=G~t8qs9yxsuHl$A4hWj+$)$wW|!z32|h5phc9-L@bXV>Ax? zzujFn&%$lZi=L4i>h|MrJ5tz&3_nz-=oD*lxg|rM_4H;cba$c4HpcfKu zJ7m{T>vbWt_xf-1OK&L{f4VF<)uqLNsysT3U$>iIativQe{PYc(;6aeTUT>Oqa*Em zru9C%bfc^(%@1$CoKb4%k0-^!ln-y7CA3%Fv81ivnd z*kbeI##tjxkNe+Aa=)oMo~%7&S_3tnQ0nKH%5DT!DCcY>1C6?WRN_nf;!mUKWX`6n zFS>9j@*3^J4ibOjD{%7N=Ar?dOkC$zdM&8)!-=^$3!&NW1po90j4Su%*((VYu2rm4rUGi}+*#POnk9Nj-v>&%IG0dW>psfUxBy=_ zSHXJA0PTaRGb&q7!O!+`Ro1v!)$W1*$CRkeB!k}aSG?_ z>+e1my0#0CPI~E?iR_LLZ?r_M{jc6WMkN`7<`8pUPy}F<#Yjw?v2au19#?#M?v!3E z)N0P}IkoTk&Fvlv*wZaruq;&9%8jo?oYaK^FY&FLdHUQ%>+X2ie(nN~_XfkH2!IWk ztKqwSh#+m@;Yr>7P-jW$t}n+Qs+%VddP+4UOAT`mA!b3HEd7^xY>U`EjB5Xmc46*c zpAmTX)=^ryTgF(B$f9yV@-9ul=wA%U$Pw{F7R*vfcZerz4IcMoY_&eMO5&s%x4%Wd z+3d0dwci?8p_`LT#M9rVjp)YHCOa%YjX&#x->ip)!tNQ(-XtJ!HHMo`I zD$w7BcW>+MthpzfyL9#?HSB$zhW5zAMc8Y%3ISv*JnD!!2(t;|1(5MAM zx%*gVw%9^<@a=R3I}iu_;9@(X{-VMuQIGR|OC_Pdlondad&>mg;<{O&`qqa-I_b-N$4 z4ZC|qtaKf;<8jnKa@_eo*;FjZCV&E;M-1OOZ3e8mEPkg|FZ2wbB3C_;OV7h)Zp2E~ zX=9k1R+JlRRooEI{at6$7{}8nL0#Sd%_HNH>E{*0W}zCto7Q5UTG6ou{$(UL8OCLa zKdv_S4-D9H(q|xU9AVWP+qVhqqu=@l+JLokX8^eaL~CXUZjG%rGfZR2JC?y(j^c1J z`f2Gc9W;GQEijKycOTxs^@}IC2ji)w62?MI5%2&#rh$|aOr|dej=p@|1Fhn{k|Tvd3t14 z=SRo=J=^RoP>MluY2H$*3(H-Nj67z)pLepH1Ys1-$S8-Mq9{dI98Op%L5N* zmRRd@+Tq6Y5t1LlY6>>rEJfVtEIy4T@A@*4JcrF3mB13Xm79s;S-0i_6r=|IjV7%M zv;XoRU|?~%CTrY+ro3Iz%O=`qy9VS?|7iJK19%xKQo`rSZVjBseqAD+=3jeQ5S8Cg zdn+4*XYE~r%a&hnbTjZ%Q0&FDj&@gvvQD5~%qz+~ZeKock`zkEW*KEl@K^~wYw8L6r_pWq zyq@zD^6en!MKjq9xh(O%!I|kdZ^{`4fojSms_|rol}$3)9h@wB4&n-jFJ2M^w})%J z%jN5dl-bmnf|IxrZ35&pL{x_kZ`JFP!LzDnQ%ri?HoWx?P{gzuFFbP%O}j4UVtpUb zGa-%c(9e+WvPi)SZz2WND5tak)A>*>49YyE8AU>LW^UbaM7L^xkl1f~|84Y_T22T4 z)o+M8g5vN*XhG*FLtq;`^WBOU{xYlqW}N>6>>>~PHKR9losNnbaQC@?sl1gfZ{pX* z9f{SO$JTNPyh#n zzm}w03${yHxl-O!weP2Nrt8&u00!pnAw1#iNF)hxl~Zy%9M3id82Xaot-2z_!kfTl zZW-ichB7%2TSxLHF_Pvu*5bWT9lZzwO-5 zsw-zxHUb4i8skrEzkI(}kC6zR1vUGGNZ`G+*k>BiDb}s`BH%O6q^W+6Z?buW)oQEG zZyaJ-)FrIEFL6Yuhb;Zb<;^Xzh$Av%0miXK9I0TK`>K7l!jiNE!+){lzJ(YKWqN9a zjEy-cU>Epo<6PdKCwfY;k~MEFI_d|AYN)2`AE4z&x?nlDFXI#e9)7Ztv$GX_a1p}& z(qoo~V$Kn$-tT!=vC}d#%%RFUZc~s`Fo}6tE!A4E5UX@<` zvlbL{b+%5+8=mIcB_Ij+{en3kSz9tS6yjU4S^x)*(@WC(>M>&K{0*$cf4*EF6)!o^ zerz*T*`~l|WOo(9bZaHb*^~i8l?h*SmR2ec+0Q&-jV>d~#%KBix)sFa(Mk-^pKsLL zch-HITwm0K?RgACqz`+c7Z0cG{+1WcTs&G+b4EXB9NlASZgb0b7bMQ@-B)7Tiix+^M)RLlDQD1ch` zFIiQD68tc|>XRqREpje`((<_zCcjYt#l$XE*F67%Pq9wn6?*0!QCdlBJCjq$1^4&M z`NJe$eN*Im^IEUTII+6bC~!hx??1rMv8nU*kAGZ2?48sO-dn*ZqtDq`_n|Z(G|lNT zNyGt=2VN0j52@Qa=kJV9Ha*{$*6@jssLC?~XB9H8t2qv(a>;q%LMkP&M`tvqef`7U zE?s>-cP915u;Xax`FVI~ET?zsT57KjkNG6h)}R`ONH`MR!Zl8`9)Js?05 zqX7=to>PPSQEPg5a+H1Tnb&wG!N>_uxWFPE_Q(}*kKkUb}RW z^a{8%-##i*6!PnRJ__Ne`wze`AAW~;Y421+_J^*Bs<9uu!*$9hXZjYwQ8t)(cG=do zZbT8)M7WmDX|+&jKb;9r&MH-O!8J|m3U$0;u*+|cUx@NFG#5Lc(7Wgz8^Yy#)9GSb zrLI?hDIy=;wbAGVh=ii6rIM-YvPtV-(fZs0fv&lKaM$i7$9|*a>zAp7^XhbV2aewI zfg8V#+?JgD!0PfpDMYNk4BM^98F)gvctmrx<9zdP%}e~;&55{{ZZi2=Uc%zH$pIUW zUyl4S4J8(|x8@G^0AR`<#Hc262l6y5T3;1$q=Vyk zM7|m&?RO^s7LkrZL}-Xhq?<%MadJJm;yjjPj&$^C%=W@AFLS0yhsf; zMWA2mWMoFdA|DRY^*(RQ#!T-5tp`(^pb{zHIJlRARC!8VIUv?HiC)$>Gj)3A82aS= zHh0>~wYClN!CmUTpZjNubv`+1Bu;cHhsq!R7Rl)qFpAB{knVlL|C4+b^|cKpxFz~- zhPdJ^A6ycSZV*j?{<-*v#&{5@IBN0@35%+yU47cU&mp})yY97aAj5jhc0;QZ+eE--q+5u5sU zBaZSwvP?j%^PL!ln&}l$N6E1P@-4DP*N`Q1IEghKl(AnS8;ajjHAQ zI@y9qh*xTlSnN^x_OW`&W3Py^P;;FZcP*7nV*x5q5I+FVGM_k+(%Nado6C{A$kx|9 z%R{SrZn{_Wsm8Ull=M4xh3hR!<9B2|wP5RGAY4&=I@3E=ELaM;;`bgnarP>u4_Izd zcS>bV{SFIh(Pih<>AZJU(o4{xSeJy>P7?hVfi0PpcX-TNK3>XlzLg+j^+&TRw9>>$ zJESZDcT^qyt3g7_gcMfFVp+v$^J7)42AV`4kU%)qzt14i%w(~K&-Xb3&qVU-UnWds(Wp;VKV)FV@$(YMZE>+AHba}_u6(i_;mA_~>$|U@T zrZ5>0{e{+*-jTDM+72mlEXR8AFwG@5fdyC7?{SwfExq1wd^Ec~!3gxzjLZ)|+7|8k z4sDR_)`wY}V3FzYy!U?eHQRvJ1CrSd$>H^F3cod}zv(4PL4s=ax6schpOrU6^WgYB zYDO|gAZ!a7e@#;hJ=pZ;)QD3OLZ4)Nn9ym@Ni{z(fs;SmxHAl-0Z_!SuxKE=p8=^^ zfaTHg$Z8>V)nCD|FQSHPSM!Q)X@_qn?dws4)TT>H5?Ym{EttB^*`J*l8B=Pil^OLSBcbNk)K5hD(0`V2`oOYgY79jxe&!Z zdW=U;meQ-ZZhJ}h1Z7;TnlqMR&HwQ01kG{#?)iI<|Mlff>ZK;1j%IXK^<=cF$j7vc zzKcx`*ZR!V8eAFD$Sg;dJA}0-U2Xr&0I!V~m+3V?d*atXE}nBGL1ZDd!T90E!3^l+ z5_&`C$T2UD-Er}#l=>f_wIC{?niJpSZIH^XY7%TF(oV#&`IIC>d&=8TtfeXSakDTZ zPG2!T8fOB%keZ%Dc*S0p!FB{)H^cC&ADEwaq(f)Wf>rU`Y|^^HRO{feOuLa;UFQK? z_?&Uw`G-5q+|;Is=I-T%vm8cWYiYU`s^4?ut=bc}7jO-)@MMY07MYelMQ`~_2i#QM z4W3YxGGknHSkVVF8m-h`3Bi}<`VdhEKnwI=nulTp?}f7dDm~L*ZLA$bCs^%>WVQ!9 z2{21EH)4~pnds+p8o~CcN(!f#l>zxwb^B*OAVE@DN}yFdf4bMOtY1OwA7O*xI2cEU zsOIqpUAm+g@LGfa<=BsQL%kon1>?=aiLIa!I0xi>z1{kBRuKF)78)%X{g03oF5!g| zS0Gz0RnvQbQ-W^|?aqk{Ly@1SL?shgffRB`lEfY3*b;6(S#09T!i`?Rl)GfUrgKR- z;|Z&4hz&9I zszue5PQgu&XL>hmkIz9b@lXzij__npoEq?ukCgg)O5`Q}J;`s58Kn^s@s$TDVoBOqe>ovh6a2L43-+N4qVhcsBh9sO9Sx=fA2yX zUZ2CI0_StaP_iowfUX1w2yRjKr1D|fIcuJbrmU2&_*o&1nZ#eQ6QU%lWJ3SYn(8^P zaX@FS_cxzb7Iwrb2b`^7cBfBPF5^QQ8R7 zD1=Y&aWgOb&}l%wJ3h>e!9g(5Xg1c`*HiDy43Q(7Qq5A?o_lINCsZ+-b&2{Ze#rmr zCwpJfYDV9<5RnXXe5BxVNGqQ@&2Xml?{EHaikKcIx1-x6 zQkr*RvK2)?Av)W|WA!|(fWFtBsNm`lZGLTrujYco@jG7QQrH|G{0TWa)nuC4)2|L! z-6`MH&#_hvetGU7LvL{3S=tJLc2AS>l$T!U`EURShwVE~%L>!p0zO~sU7#^1SNk4W zjR)Z!@Qt_bfmz-pj9t>!6?}nugWRim+CN&)_pw`1UFHd(plz6Pc<1{c%HaI#OMae7e8g$<@hcgr#ep~P0ftSF zEiCX_unsj}62%i?rH;KXnwGRr5T^q#GfVD(ntXWIBB*khGg;ewECh(DbModdhMMHRep0E4 zv7c?_R}!maKo*9{w9@&PY&-flZNnkwkf>LY(;Ugso6&X^ed_qA^%9o1$PNrHWc8v> z)%IoLQcx2n@@k>fwg|01)rxuyRlo)_vY{}fSY{a!SDgmqwj*B&(LJ+9cM(rEi~!yT z2X3hV+u4%pzY2Bx$d>*#m~i?a&~)Qh7p~w%h-!S5C=~q_9&s#di0A*2Pbb0Pqi2Y!z>Hd=V z10qh+#oQ+7s9}%;72k`$I`5RCsgdIYu$2x7%x#bt&nv-)a6>=>KU@GTXM%m9v$NB` zP4~)u;VrYkcAguvtQcPx0IXS4xNQWj^2udd(&r)?U|V9;dmWfBA$MYH!#R!T|9b&0 zuxV7Iy*=s-_%Z?y@01+YUxo$~f8QhA6}**+a;%cBYS_bbF7O`9t4ONMpm?-b*r7(i zd75@Gcoye2b_&mE_CGl87XrW$%eH(y$ZQ7hw^0x>H}rd3m0*c^`<*|&j#O7b7;Yf7 z=K%jnsU({_iT~Q$jy#HxGz)MV4@Y1adI&FpX%eEiXI2{dlUabVl{ZvV`Y)f)-iJDFpPkuLpA3~I{nmYB zi#TBw!;ANHX9RQMAv(%(9fA9ENWa1*(4Dl`(S-sgUoZ+fW?eGO+Gpjuig6SFkBfuU018rre70&4EGB;K5COO4Cz?|L;lpHOU)qQ zS`9tatW!&#HK|mo(z3mWMY2{40qZvtUn*yrkP*ZJrb%uo(WHQSG`jIu6xy4S&J6fJ zWcV|(xgW@<_V9wfRjh`Z#5!H(OK-R+_%zSb8riG&ngM2sb$Sx<0Dg6}3SsJ2)((zD zB1MIDi~@ZeLUjB9=>GtNT-`~$ucgMKKDrpGUQ`3|3{gUAWlXe=NB4j+;0teh*>Z+a z?Jpxas4iDjT`y$%!`~+c@W>o>`Hq4otI1ci6PPGQWuO^c7!!K>Go8=22E^Wv1C9@_ z(3)CQpU}UQH`$%`+8`691*H;oGyMyIKXaN0l6Lc%b}T^&`dId@4nQ(DVQrj?xd{y)ZK4lY0(wt($C!|i}VhTR+X%=PRf2J>iL$5g$`@F zxBuH&H$Ybma%15hq75X>W%hE9XM8CJ3g6-Q_A436BA;jDdFY(2Ih$j~NRx6~yh4}Z zokr$L{;;?%r5^_~>l%h|#5kcZK3p0@yLvm8dd)`V2oqy_{Wj{|jq2nwYzg0I+WGyX z4^nFnI}-Hi=lqhr{BCepuA8Mu-^gh?3uy;sntcMj*j@rN5RvFD3jAP>D1m4g0as30 znegQ}z=f9jJgbpz!#e0Yn?DUcED5we(D1RQ*bQ#60w9aVLuMWnvXq4ZHf3}(kfv1p z;*KtI1&o*gUpDNU(E0`#Fhbe~m-lNMQtn2 zZmYIL7n7@=>p#rnOf-e_2=re#gz0HVj%sv%Hb>&_AHO%dLj%Blm?-ERZGt%b{UaQaNB{TYReL)Q{6rynJy+`hAr;u+--NEj*4O zZ%GV!CI;Fus?HSs0-IisWD{w+U@kL^SQvdG>yk)kso5?tkn;h~7x3O{COXYY|Wrr%gvZ86!(AQ4m0dObSbMG>F) z^bJ-N80M;37@I}~vDXqyv?meu7F9Zaa18O8$r5sCN&)bk_30%d3c`|uSdEKKQ!k)y zOw<9mFb28N+Dt*L$%kh?qR@@}F;^?^5lcAC+blN4B!=t3vdhQ`huOXJoA~ST zW&p&G?|)!G6UThdy4tyj@%K6A5_@R9BB1!xC|vx^1m8Ch|Eo`abxkLPsFOd^YKIU1 z#9mB3EhHZiS<52 zZL9*2#qCS-U<>2TG=Zl~Q4a_B^QTS`HMjxNOhv>xzV=-hv!AOscc-T2`b#vAc=J}C z{SUAX6gXb!x19f>m|9k!c~mhjE;(FZVb9rEAN=5sGOY923zr3cN1}9@?JH;DPvGmS zsdT`%44#sgwfu#j$I^Ru_w_YRHkQP}%wt?oe5q+UMo7K6Flg7nt#Z9Q0}1*0BTg-E zHw|yWuAqUvg-P-SWXKDBlA2Og6ff+1@fX*U%v@TYa(g?TU}|1A-H&$N7>&I)d!Y() z`C3QA-9J(TbE=)*5iH>PAjw5)@NVj7jilf9unYeIg#QB+FNcGWqImi9y-w#COf&B7 za=RjE&nIoApxjy3S*F!jl4HZ(9 z|9c4$`mD1EwPg3l6XsVeHRLLR1=enW4@uDlQCjhlTeKepx%wGQdD2Y;Q@%ntEn4Nz+;KCW*Wh%MP&`c+JngX1;y#N!!<7aT6Gu(S)HVm?-Fq<>)sao>)+hP` zKCJw#rrv~iTbuy+DEyn_6Sc(FKzH6IC0HQ0y6HG89$dY`xNY;#Lo^ksD|%E8TLR(@ zml-TKTIxjH5D{6m|L|v?@8i*YTR(ap*u?K7r*}q`#)gW#Y|Q))omp7W9T{zVJ$n8i zov6Mz;`pYk*YBP`>eXR7H7W3__d2rT?{D7n?xi@yGM4k8dcck9FhjeX8l20lN;R$3 zxk~ZDSi{uY5~hRE8hx~=TYJbc}Z)?fw%8FK~ z^wpN9lZ|nS5z9XMDK2f|{{bWvCF0V1tI0xv3y$RxV&)z-B-l?N)=XO6iY@m)0CmTP zr{oc6?~p7?xhn-wl=+fymTY&$WXgs9VP>pellqdKd zCb8J7GMMY0jKXqHcGmY30wm1x?Mp`>iTUTJ@a*6|Gg}TJQFw_}<`?zwtbV_#lMmg* zNKL0CGH}#A@h&Em3EK3|r5s}d9;v=|z$_WQ;&|Uv0CLdp9p3=ov4V3E4o)NG_9)8^ zTHu*hK~Leo#J+~Rv^`-q`$t@gpX466O7?4bCa6Heb@u8>NmTs(G|i*^d*8rzSP$2T z*JFgtO!|~>412Xg!s0VI%)i+x`PYeZo$p0{us&xX@y1~qLbMx44!_GHffw@@@VdC% z*caDS@hUZroW)U4n?heh3SU#K!iWENcTLH0r`%@14vtRSPqzI?HA(@toTYqyWd z?9NM|h1o%EYrk-^oC?Vi<>lDgcVR02N?*vm)xQx_22EwHr>mSUmeRZ@Szfl_Uk+T6ow3#7(eHZOupa1z0(heIGaCK;fU%Z+o>-fE$7R%S- zgH?neh}T&gmgb!`fV})YpLsd-mRot8a2}fjh&}ni_mc%U&05fblV>N^M*H7aLPEsy zWsO`kKPob!4&9h($(pr_49VaHD3{Wy6Ep3QYwhIBKi7TXi=l^<_dDeYc4L^Q9UuR} z&QG|ihByspd7|EHoduQ1WK^^swLd8>Id<4}T&fBe?rzGhAw7bY>4uL!TjJ&No4YP4 zc-A-TWphQLpVM8D5CnQZM*)cO1Br1P=JRSm0Ug={$p^0B|r4s1*xf#d^IfBa|Uwh9pbE& zPs7tLoYJPQy8$x< z-fkR2!7eRt%Z})Jt3^Jcf>kDEXWt2yCdzyjLgR;^j||*p;D9SHzs^EF4A{{4)IsS; z6r`2?edVmcx&&g8F38p9Axd{IE1E)DXy$)-Z09avlXInO4Zy1T3uV)dC!&ID3@(8=7;ZJKNCmxX-U~4B62A(|K zW6hkCUgP7xj!ACmQ9?f6H%;zI1-df;ujOHEF;ZGE1uKxHgmr$;4jcEoX;G~Z$YDlS zSFB6;e}IGGoVT}z`oj5GjC2Wx884dwgZeM;AF|x?G1xeKpVn0Ap1O)`b5%Gg;yx7Z zYrIw6`g~5B2e+S!3QC$O%S{(aUWTb3VpW0a5AoWj<4w>m5O7S|fknC>wVx+YQ`hh( zo<|4v-YI2e>6VORI4g|#7OzcT*vx7_A+X7LNR-|GyxaTcKY%DEnuWp&!_Yt1(mfv= zY0!u0Gi6?5F(;N~M!km0ywKTb%=5v+)Vd|bX1-{O+q@I{m6KsExzF5y%7IDBk=Wbj zyG3W^@zYC0RT(_vd`rv>6#D0YThi$$c3~;DGFlgs#p4HMnn?^atHi^iP5eOH8Tw6K zibz2W%R(SCc?{jo5XuiAViJ}7Zyl9@c!>2cm@KHhOMq4Nh4W7y8hFNI&x%D8IP)u; zW`)41n|`KGPwu&h@t_h>SzUKCtQ_ZEIc7ZRssO3whQDbgUATmv`-O*Va9eOU?+Of3 z?K{}GHKMY52XD>#9274`Ds~Xv=2(e2N@cNIpY?g$@t`qEL7y{*aYdOzs4}Hc!Sf|z$4ee<*l^ld}Z7)f7jH4RCL?qIH{}y0!|2o}_VGWkK`q0d>6Iyo zw-7-2s%$S?W2P=KKi`GZcZdaT{rWixAiv!J$eICYvIzM z$+$-1JAA`D!!d<5dg|=uZlnvx$sgW0(N>1fuf||gxZvh9y_j>~pFBAa-z+lzA;Wa-ioOMR#5bDZ8 zVxxNj#b0Tm%EQ^;zqYkb_ZxB-~bpyOmvl!W5e%|(|s?jZ9oNkllSp&azt=;=eA|Ck)kthrc$z;_R(qSL(MG1kD% z4Nz^_T&<%Kr26z>xlIq1L}9<+$ObQ862A}rnvLF#U!o@k-7&B!ccW>`sTD(A>qmS;}zJ9NId zjIP-wlz{fXA{MwYZtDybi-4yNE1YZ#-dJM)wF(;8?-JS;SL*Gh0e#&8$;Kv~R`yVU zqC=*feru|(d+}r=8Hh0{EQF`qB6xGmW|3=yPdIp3pLXnUB+1ir5y2 zPTaOI_5*ehwD*ZMw=C=8uD;D;l6>+T(>S1py209##dLt%@V^Y`D0$gip?mR}JN^|H z?GMD#g~f^R*(<+(MOh{u%NDR!kq@^3wjMFfd0=XFGAnp_r59lG%+Ga*EPSvH!g29? zI9wS^E)p8Xmv)~l*7G9YP*RnRXw%O;3)t2_n#w-Fy~b$j!%*GjQ1m8fG%V3?5SmWs zlvIr8`7!#1{ExM4umQ?Yh5rYzz@%30`R-(7wxQ*%#q|pxv56KLz}&O0q`eMhn!fGr zo*WYJM61*4Gv#*gL(kkDw#(^NQFS=S7)moTns1Z+O|4KW6-KJBi)_nEj$I4+o(|( zugrUGryw5R&+Hv!os4&3JWpDC*mJ{WGIS~Q&yhWzGpdBx=4)I-%-;eetxpXXxNcT{ z3m88v@4{9YH|g)~^ieXl(eaXpBFdX99r01vT{1b-fPBPzZB<``Yr%8Rd!S%@dhsP1n@Vgge9+gcNvVVUOo_rrHkKyJ$Y{yBOm90oF!P#UX#!5|UV)fg z6Rp&^<>Uv4Xl?rzaQv87mh5*UWhaC)#KuM54l9Juc;@ti!>@rqKYY40oT3WT-yiN z9^)Sz4h+bw{=|0QJ;gGymilaVy6xw0$7>m(51>nj7c5J=P~iYe7^m^)(bDA|@|;Cd0iFIVaaG zdUNuse8hQIP6yy$(=`2}+GDfpr=|4m#kwaIqhnKlyv&ohN^k1loO;iG`5dGQpNRUZ zS#Xdkrt9D+VA%Txpp*URKY(Tw(-wSMuh%tl%FFUyQs&zp!I7Zi*pvn;zc^mf6Pd(E zLP#Qw@}H^0!Hs_ttIiUqPW1}u6Q$3`Cl-dqMas4AWxh?KHxx!pAXCr*g|*gVc-` zRkf;$s;XJF$M4CLlRtA#&L=15oloxj^}4R!Ny~7xCv%u1l5gSHfG@RU9i=qt)le;M zvWi9JNJUC$K!|2sFL@!6cwc{)`jj!{?>Q5Orjv!m%C(lP{wp*=*@8 zA)`|)N*D1y{YL~*{ipC>vMQ#{R>Y}J*Hj&&BvBnp!5ha___DZo+ph?j@#7CUs)rRR zUJ_Cpr4+B;1CDVBK&Gd=3wLr)_>-lue`9X*L$VNEi6|d(DMZS+=NjPcA2^OzazD9X zB|%!{411D}re(PNPQkC!&M}szkSXt5Ro6Gw@dDp*Sq2Gl^q3#{a)Re=X18QRmw@@_ z6aB=1{!d`Dv9P{G6fPT(bTn3OFKE2#d3N#B{=iVtkDZ)J&F&X&*Wdp`@|-FG z5y0vi=0Uz3kNVFkiO=zenzt+6;y=ZgF1v&>pT`Su;94(F81h`*cdFCy4&}JV-HjbF zwv&}0m+@l@CVgDv@5R(v5tXIjo4r>K$*o3TIE2wmr^fFY+LMASQ$Qm_&cVat9*;90 z#+Xag4;E^rAvM z=kDKdC`jvelfXa*8__>2huJhx6=Sb@)Y)B{7IJ-|d4B=^`J&qzcTe$xSALyh9tr?f zPMi7sBIuZjk8ezDC3phOUZH;go{v}a{2FWiUOEjZrWtb5YsLV5*ZeqZWz{w7SXt6f zLILN)k{is6vF5%a7A+A^Xia=aRCzF~1@gtUI);SsXu|BG1gs zyKBAFO_S8omDsLpGPwKd)Xmu%laf#|x@>lNO!1jHo3_lX7Kqmsr>lWhy7K_Z_2|^W z6Y>@H)oNBT$j<2p6%7qNk-G4A)w0DJbMG2)ym)QgnhH=?QUF%UHj* z0p;(JqP+Ved{6R+-71@dG&=nKx<5p{f;-lN6_mxU)+WIPCDoDWFXTyV=qM$LOpWeJ zJUBC=O7m_Eoo#H&EUx=4FlpjHVM7l$3u$D&<@JKbh?H5Ll;keLpQ899QRDhzR|g^&3g+4Wu)JvVnwfkKLt&7UHoVBgN6&Vi84m$jfB43(*3jHl6vknUQ~Oy{?3ew_&tZ4u0RmE$an!$i7%W(i zA$UO{sWkXTFO^dFpcI39b~7f`CvVIIyhs7NI2LXnd(y5_F~G`L42GAt!9LS3MY};pT5x%5cu$=-#XKtST1Zkq=9XI)E#);@G^gA*d>RzTGrG37)g! z>o>=%1}FZsIQzNL5XswV8;)ep+1r#qmy45+6Xng<4lz_YwQ9C8!sxP0;+Ri9TjFUonQ5| z<_Rw#RIn;z>=f5x;^9vMSmozg=%4hy)HI~j9hD(^w~HaRoL=hH!);hh?@o^c+X*q+ zcR$_XE<#6I`WMx4T1|u!_Pq!ytwLUecx#bAzeCRNX2Uvyir>TlrD)x#uSvOpE^x^XlC0lzPOo}$ z_H&ny9O7(bKfwK8&w%fa{h~fF6`1wYNMFX+x|TMPlftxrw`QF=>9YU2mAwH6_S~l` zZVeP~KS~eD*(3UM1p_;V-Dm`zilL;5bX0xKguboOWG-oOb$4FFAUs0#&mc}f>?o;8 z^UQS87p1DJZIS#G-f4w+1}jctj2V1%M357C z0{7EbKFJm`5hV*h3DtlgX5}ibKYom{7KNONU@WiVtQunpGr>r!7546VYM6grLVQY~@ zx={&We4g=NbRbcunB&F^AT5TO29^KD8Xlb-V>b9Z-tx*w+8whqyoM)Bd;O(c%)UCN}p6dbI{ zmSvKRp6<2cy7tsoZlRv5SjEDYle-x{k(pESLUtcC&oeI=)VEJLzy*qVaO76K$W^)KG~`OjAS)n?i01qM0_{<;t5ctc9586GXuVX=#^M{=(tNBAxgdI zHS!)DUugdypd&MO;Mg=XmG6KqttvzA3|o`(Hq?S53k__GD%m z`JZ!LIlCo?19;1w(iG1fFu5R^EZ4J*17c);nBJ&Q@Ra}FdhBo^I`wPx;^|8b&a@=Q zMJLUOd4t8ZctwjR9RI=jVa+2&rzSo|{C40~Pb>{7SI*5&o~~G$AW!@|qic%9=t!x0 zo$vcqpkeneS(Td|lyh!ezKe-$7z4Jed>g#u=B@5k%jLqn&;IP+N#qUv=0HrJsr^&4 zX7!9f4X`vGXc4gzQbrdtHa(ge0MSA zsJ>Q;Yd4*hD+*+g*5N7HBX68d3w9Cjc-|Ud$|d+37N5Bx$k7-*03Mw&a70mV*Bzw- zoTF3ySNNeeF=$ZZsSI0mv-VOD?uw}Jl0JLmkufu>_($cRlP;CJNLn?~j^)ay>W`?O zPjm5IU*sLMKv&81-ncqwBhmKKnb9k$H&Stkpi+=%|m(eZ;tYDe2s%7cO5Ilz6} z`Ps`u5&iP^sH~~yn`+bSKiyPRzXoxmeV>Z3{B<$cd6#VUd#vjkc<02{NOz{*rCDoF za`G0-3|BA8^GjFqW7o9hV~&3^ZiA`h*O=VrRSTzBu#=GG3#HYS*Q0LK^XLmJF+465 zZ_<(=dj4d_lu_gl_W&t*N>!sX95-TPAX zSSuZDbh$AL)7e0j}J%PtV^1xR9I0CC{^be*LXaBVam>Gn8%A2W-kD@wq_Bni!c z3ks**Y1EJx_9~JS6-{i6TOB)Ey2RT!avzrz)v-r&9?X}s1ftdd@!#Rp`flYf|L2Hm zQvp?F8EUt1b_Um?%7Xu6nTRU4iYWDEe>#_u<}!_P2ZJM|Ld#J5HtjCU&%}7)Lnb3K z40Y{`9eQS0`eSnNn5M(-pR0r1Kc?b4<~dM5W&=V~mxMwo|9Olxafo{yu=8?Xd|aG% zc-sI2R?yE5=1$+ICwu@%HirCA`TC;oiU9`%CK70Idjn<_Fvw1kTmg*yPT<5xU;?Vp zPh9QjQ2(-Gk9v04yK<;BQv{?5@{#|XktAj13&%4gsuc(2$rdk{;||*zO~<2ibw0kU zfn~uLhv=Z+t;%#LDdeME8QV#UT=PHQABwK2_mjvicA|s=K#4g!V09;5)2_i@ArbGH z8Xrmgj#?7;KPT{YjK;DC6H(LoFPwZ~&Mf+wG%)2wVNC8@iIVg24ylkoP^bxJf&L8q ztQ_0)bJKW2j`GPUf3*5idv}UK$-Xfm4R7#FZsL8E<_(1vo8U3MQtmh&O~l*kAESj4WPY60ENqKSDspPO8Jgaym9OJX-y5 zPd`fW#}Lcpfh)@MqHC*C8D9*dxA>A6ip^hl?|?p(-DK!^XN&{NH8pTIQpc|j^UNrH z{{ls_ylNr%WMEG{;|O~q!FuY71+Dyi20$)RQgUj|Hnb)wsbB;j`Y^wT3GH(>RdJTb z2avuj0aVqFe?#-9_ET(G3xAudtHsrG>zS9l8MrVJ@t~FP`#)nVli;(0bOyJZ2%STP z_~ihKC7WR$xEjvqu)M<#qg-85FD1xrwZ=Z?IrRocR0WQfpCFY8&Tr$OX+$e zd|l(R6hk4qm2f6gcrOJ&1nDpbr`Biv91HtHTbKkp^(RcIpBX_95?IGjq^*(2-dKqQ zlK_b{8hm5G3<+V)OK)a<}`*@zknfT%Wo+Gx; z@U;#rSnQQ}EVT-rmYgi6a~%)57<%+*+lq%m9ktw-IKIC?-nm5gGBI-73wOz}x|xfV ziB20?9MVazY8|e*u>>mAr{m=9SszlZi`*1mG2Ysowx*#|W^a6TH{NOB)C@akfMqzq zDbRS6b0F!#3SrtTR?LqtKRlL{Q5|vJ85wU<5GgPm&RfzFH7j#gnXK@)8d*7fmv-Yh zx2L85Az4`0BF8j8nrOL>uM-o;Hjh`c(Fv_FMBzTtOZL#F#Vv`n zokR6ZJ^AdWhq>#DOTtUMP1>ncl1E0tPhf4XT1Engo?|itVvBr+i;m0=u*r z`J*H~DQo?CeiB`)SPOhoB z6vC)`>aw*2XNzFUrDdjZW_&5U*9;;^o-NshF^jPmJW~!zgB6q9(6W&tIK-4ZU^=N0 zuE@T;!zW5rDG)N6D+DAu{iv83R0vo|e+a+H`Q+IbVZez=*ZFnA3g*0nfplJu1^E3W z@gRlUlze9MvIO}$!8dF46n;OiMc0F3&3FBsfh{s*<0P-)xXWb6w5UA}696$7LK@EX zjxSgYEnHS*@eJJ)UN9XEt34-(Y6JZ^lZW7M(lR+J-k+fYu5tJ|HUmk?UO*d#S>ENu z&G@_-U^ReL|> zjrBkv!M(Pnny3A@@n6l5VEW%bPrhoM;}_z+`X=ha#o~)A9%C{e)b1%S2(zp3_T8vw zpZbNkGk!qq5lIPchQw=}nvbFQs)6(uwpBH_KEvqtRv+83g28@3$ zYNU&bx)fXJXM9dyLIcav12DxVfA~leUMbhv?*T}Odn~Ngt+8))Y$iA)?t%6+o*O1j zm|@EQ-Pj}Z`uD0xi?}8UgcE>E&w_|vqM(KIBdnnn&|1m7|dLc_Y5BLfeUNWE*=Vms|0si`WWma zq0#n#Jr(&_Fc+CAFFwI>^>t6tK>{)Q55Ra#?u7+hgbg*t9##Otm)rqPYQ1k~lvua( zZyhgL>}|B>XWW}d0tOWxAmR>A?C)D+=zrd=UpW^Xw#2HH@_wHr3S8P=L!b`UW$-+g zX|WGR>;XO!dJjW3e~$yAR(@Rl$~do>Bti?>$L`Qv`o2=x(^JpH%<#uJZ5`#ciCb36z11zPR(=3?7crkGmFJ0cGk^L?a!cLsW^952 z>@rT`Iis2G+bsGbeHe065^p>9R($9m42|muIR*LFh~V~r(?}ymdj+G zw;4AGRV&7xK6XZV^Psc{?iept9P0)UM=6D`Z!_ek_zc$lf@eZ>Gex?o4Gm{aEKqh^ zOQIhg=IlYh-WN-dh^{4X&T&s*hL$2QqcG6Tq?Y58%YVnv=81tmXHTW%m5*(Qr5N$5 zjR3o4e#jCphV30g^lvS);u&%#bYwrn{!cz%{--QIadD?@bszrMdw`6j9a_YjO;16S z@q1|K*~M+NqJeRUm0P6O?W8`3iqW^Bg1x6b`=SWWhPN1GXP0!dz*NLPwV7C`WM8m3 zE&Y+kABz5%mr?Q(xsgpb6gXLzO%weq>F1TIc$46`OBAeoy@PoqdWI;G({r_`-0aNl zp&+rpIM0ghvo_id=d_WXGnb(ySo|{T-Pj8g8dN)YiuE7<^ggY=*3e|TNtzw;{s-@q z$}&w>fQK#)SZp1KVaq6qmIc5@wnN?jX5CJ|@om=U9Fu%>FQS)<9k>0~S#UD1k={ov zn~IcbZ75f-MCwEiACwc72kW)L)2}beK=ayb5{F4G7h>)dEFn!9Dj*h)YYVg5%k$z} zCk;-{j?3A(!FG@BCjW2=&;<_%501)A1tou7M+P;}*fb3d&z#svp}jhQpv& z_;{%#nqjqQ(qCmFw{JHyJ*N?b}ZDzf#{@7=HO_3$;~Ps&Y=L9I z)=4QD=O!}|vLOKomR-KZ2ck_FZAyOXjX8{`VYb)}TJg6ZU3~-bl5~m3l6L2jO~_5` z#TCxn&Hvn`!yZ2Gs_@kf<%+ZYvvjHe1)GinFe=MXSCPj&p+6aw7On~NfF{`c01Nv4 zKBP%aZ^kW}kVFJQyxH=fk&L5j$`xC{QTh-pC zQQ(DEYhB}g$%dCXG}UlvMUZfo)ma*pZL1+hzU@wro}IS!oJyCo!{Ish;y}N5b>R5w z%aHD%=L#lewxNKh|MifOku1*HV$(UnI~%a(PcF2zQrd^%7rer!+yFS|ROXCylh`E{ zj5gFO8~QZ!90SabjKWbwc*uSk)o&2SG@+^h*U1~q^OF5ce$icgu+NHsNlL&@LtSm} z^YgM!hud6QVRTbc{@3%|JT-M_bAUzgldOkU(C;U?4l;GW;4HFgcefFc22Yt}zJ?Dg zr!f`dH$}Wo;jdz0i`-G_9J7!XYZ0=2;xE7R8mC|Sp zc}RPW_oJK7<-2aX*Clu2+w1V7S7zZED;;=PWP=$<8+wtt`9lC)p=f8pcE)Xx56`+B zPQHR!MeIpL1Z_DPGMZQ4b!keQs%QePvCg%neH`X#crN7DF3pdL%rADi&p}MR!4Ojf zzJ4Lb_9f|Zz=+di@e}TzfHQ_HIv4I(oQLpR>{^^_BN4*CiY9)&w9k>+^m0cX*tsIJ ze28N;UD#7RZn&Pc>}O|mZCkUop#7+m@vHs94AlG!=EClS?-BYnqY{AXJjTsb8vz@Y z0T{Kn7MmNUsr7u4s)|%ONayGpUiRBCZtbbA%Ush+kOqh(Sk8K%|Hu~uT^N4n={i+tN&dK}iTMgXB zE044k(yMMQZLk^ySH_a&m%vSRaQ%VEc9B_iYSP25fIVg09Ofkw{8XROW%-%@R}KUT@n` zaH6mijO{-OJCuC)MCSm~N(OyZ2VAT~cNDH2gNpPMBg)bRLd0gd;sNUOrNo z;XIY*?)gTkU>WvC5CgvM3|0^Ro(-uHkcv|87lK6#+s}YV-;>aeQr`@)fIoak?Q9ne z*6g^GN=as_;M#bTNoc$b$BCxdN8{Bd@au9xSM?G@WNd@_`}^9qS{=x7?lkyKgLSr&0KK& zM2%+gO!Tu(f6F+IAhVzfGR%y;hkfXfqa7-gTUE+Sqc;Gb2QNt3l9HMx^W-&*O%2ie z|LLQiXur8C&y66$f`DI9$0=K^(e$;u)NUKKJ2n`O^CX5t`{{j1MmUR4XYOC2M)l#< zI6#Y>I_C|~gJm_tAZ>&trQi;^!AT+=ZCqwr&jp~YWVm&O`z__tWYvuBC3OL}n%%T5 z;)mA3pc|xeJ{!hMm&POHi?z+)ZvYB40Zvar?Jw*Wk_N$}ODeTJ+1ECThi zv(p6ufzzWPgXdnBd#%*0k_@FM|yYbh!F|& zSm8ZG@RDMritVTILedW%V#5r45jt+g^%8f6SO5rD;MzYQ_c3A1EH4>H4QEL(l$x;ov%*`!e^7Vt!xD z=H7L472w&&y9}XfaK6EeGK9Ol8ZhvE?DM``bAEqfgrq!nCP_h_t|#QXnFDvO3^^NMZxHwap?v>$v0V*tOMMFx2r`QMOkVc#FFQEk7`us zdIiAb>C#NJpd}e8mAIF4(nAh+9s>hHGsV28LbndO>g1oPx?KxHMkGovgL&hJrIECA z8@$D4dttipw@-MxMhJBNE0yhS>JGOYJCd^T-MiP+)xe{1cJJ9_{XzOYKjd}NWkA+CTdPn zT0;1wD`OsUCHx2T=CJ|CQ3FaDh(G`R!DeeQrY>~!Uh14*FLiFI!6d(Ozj9Bnb&$@4 zFi#xj%_TL}gD_9cO}gMe>6ynJ!|c;STZl`~+OmV)1o=-}D=;G5;usbO>^oh*Tn8_w z=ZQ(`BW9;ET+B?^rg-U?-A70a6?ncxucRfY|I=#($m;Q|(m7{|n7o1}p{*2^AA{(3 zUw3P@tGodDsBK-1pI*q4AM(l;Ln#et4vHWk z%>Jr*1+0jkE+KuBJp$aDPblYM^?D|Ev(u;GE-QOg1kj%|-W#k9k&z@Ky6c;*f}xuX z=~GBse%!GDLhfp*ZFB$cDr!JVu?PuZiGf|6J+$j;{vt|R#Cx&YqNe!#3`FC~lftRO ze5B)_B*eA!P8+_zM2*lkx)C-dQRa)vt&%McBzo+Av-nSDS`m8y@6H)JysA!g5n#ia zoy_*c#0QCc4plBYSXmz-pDQJ`elRQO!p*M^=RBQTQ37%a7L0wJztA#+<#JwW&1riX zdHudZ!Fx2NoNwuBX-YJvrxxe3yUxc{b@7;d-x#jyOo8l0RF1By1yNI~Ua0I1H{Xvn zSzXzN`Iwf`IbMjGGEkCewI^@6#jZN=9BG6ZPiAj9{wodA3)vAKy__-XgCjR5yNl_$ zlzfqIM2Ag@i)QfOyNZG)R*8r~2}81!tUz1cbVZKlDnRj8)KegNz@bT- zoQ82Hm>q8N*&}f`YEuC( zfd&F1S55O4#~@XI`+Q2er3$1xP=;<`sJv{7irM)>_bc!-HVKUL0lGfc7iX(|CY+p=F4F8W(a zF`e$bwE=1r;u|Uqic&1%%xJe@kBK4M~@k4EVoGZW75x$H4l_J zztzE5SrLUpNtb;fMk{=N-KiR!(j_mrSiaDs0U$sqEADmQ))sVIwK4*Yb^b+V+jcl- zgSoE%$-j<^XQ%t_d06~~1r=g^+)x?0LBj3u1oPy-hQ4vbYDMSUf0LK>9MUWKSwqLe zGyWP%qPVL8ADUYCS)%)x6a$(60ov~qIN4iIcXn-FjCS3*mAs`6?5z*gsDpebfX4Gd zNgBnqH9_USh8M;~@2-CwW*GbL6IJ`?8)tM;aGf*|YstVIS}Bv+hzf-gG>V5k7pmHK1l&PrORq zIYu1I0k%aiysm0+mHB>wj+d=vQi?JRnce_Tew8RgYDuIq)V9boa2<5JG_FVEX2Xr1 z&j|+RGbrXB*1<7((_xPXwXh9j6FQ~~=DqpWV`4`tI=Q%!DXE|_WQ3P9NqOIwslwZf zH52BAk}`dJQK0=yqRk7{3O~&z?CU+}oyuHI<2h zr~Bk{YwQwTiFghs$k08o2*P{6)<>l@SzPwXr+T~SwtwHLeW^sKjOTzFn|ra^>NXwb6|tHmJ#DcBiVPls8Q8 zT0#%=sESO{)&5HcS$xaU9c|_1MF&gKOk5`8IwL+uQCo!$_A{||H`!$$by&e4Vp=*& zKDa4f=!;w8rq}wbbYGC_5qzm*#n$+OQ|7xK3C&R2ri4OFgD%;b>5u6nN>kjCv8DXC zRP8KUAI6=Al~MrBzNURlP26CN=6)B?3u`bsp-xCXIyU*RoSlb`$`9uk7b?xW10eF4 z+q)7*j%X`Yd&B7H;@^q*a~W)v`SY?E)x-sL@&JF&8K7a`#XdWuZ(jFJK8?NFUhcfc zS40t3Tcs(o`2jE^a|N1^aiC!h2fMJh`smxGE1M5A1Du)0Ahv)u9pRt$K1*DNX0Y37 zf2fem=fEj;$0t4?CuaFrkJbT$dsIb@_Wv>u+!%8EpTFfyBZ~0HT`OjU%+yns4 z^-e}PUC;8AWMfx25_HRsjU?>&lV;4iMt11{Hf>zgT`|LEx&#@P@B<|a*nG2!x&EE% zY*hvjzCp)(6iHvAO)p&v>E3C>!ztJy z-86T$9H`YPxann?u|0H*wN735auGiG2+)&v9R+;WhmD*t`h2c1|Ie&U*EwwSHhbV3 z1%`JssNebZD{u$cle@eB?%$4rU&<9WY^|yqE0aIx7&&n0-hz~R@yC*I1*IoL@7}zh zv1m(0HFl;x_frhUQp5lNvheSD-pB@m^jS#451%F8^mFRZ(Gl#)LPq{z)Nvl8-k~S< zgT1HQ*iqZG;ySu*Xj<_ZoV&WMtoPu$?L%A$`tQXPLC#pNA%}3 z&xuipRyQwWGr&0lKub9jezSqI5O#6YsQ84Jfcf1M#!CC(Wlz@zt8iH7ojC_P^Ir7g zmb$xiOFjOw`~A7v>$UoHr_8`F!oueZo_0nzMNdre~{O3 z=I?*^B_0+diotTPGmgc(F3+hFw9e=pjP(eaYKOT0+G0SIhI7av1m~j1hRPI{lWOq! z`gAM3h!1qGOOFGrSj%b01$Vg;!n3|pEb*5)0nYe@ep>SCLjCYo?zZ&m5mYC?7 z@1+-1Po-gAET-G1_TbVEuofqGd=M*n6h0dqNhAUS{!uzD$a<^IUayS}!&}9xscF?Xssfad{|03~i)yJv!0jm+k$}>^jiq1Hl9ysE>V}_ZF4$tIA3qk?V!sH1K(T->TOk|G_^AA%Q=*E)%>y6u3Exi_at;dZXn)Ei zuD4^)au=0Jz5k1dYkUn4_C%SU2S`ztJNaWso7rMsbXvBONF0S8vL23p@MOgXGfCcq zRWP{_W1nV}^-dcG_Yr3mKNA}-6an9Sb*bj zZhY!0dU3sO&Yz-nQ2U)(^K`Zc&3AJ!Hy6<3~W@3t8qjQdgiFcnN5vG~HdF*77 zDjXMIAM@?cbvA`6{oZ3`GxVVMMkrc}%=>~%SX5ebA6FtRYk;{0ulzo>Uxe1|=iYd% z2|rZ_l-PLkl=x4a?aNXE|0W+R4hc1XuScap<=O2M|K>LO>|}f~vvl5@>-AEn@U)z)!LD=oWe>+d zRv>Q*Af!becryQBM-%Hk@tZ@ktY&CJBX>OZW%X4mpvh=YmHsc9>YDV1rtPu&gB|EG zCRf4x3kh*(R;!luKF0D~ok;HXdV@i=JwqYl;NizZ;K}du$fULLYVDB|+Lhe1c_CD| z%(7n6-(Za=@Bg0nu7^0m(NzprE<8sI5AnMd5dE!eFJ1QT5^jSu>gvXMFcZ*(EdW0V zPmOB3u0JoG0bRxSZriHf2Gs?LNl(ury64&K-4!Z}P<@xBERJj$4!a%6dXY=bUivkqd-+9Owo_Zd$WBLS}&iWuBY3PLI{ z@E1j|ROBD5ds=s#72}Bo0Q^r)&5wU}`j;kDy<>Rp0-{8ol^_I-SQqKx}!9 z0*oQNR2ZwCqVm=q@@{T~@w>?2cajh9RiA?-wP>_l-+ojjfwzd*rh8JK_^~!`!+oRv z2T;)7hs#{QLhXKp4po$1J1X9r2?A`$iY?c$EZl12oo0oL6<^6i1aoAJc+YEQ$x|5^ z&4_{wi|GDPc#Pjui>`h4U%WqA^3?yLoG8CGa$d z|31dfEIyM$!)vWW-!^uMUdp;-SO?UCvnunbnBTnnybr{(**GTpRjI_fz-=S6pgB){ z=Ss>x>G{_g?DK{ll8{|9NU$~?<0f7q{xkeevGY(ULRibNEtSy9yzl}VfU;2dZE*S; zi!FhFa`uzeJ!kpkO=ALp?#M_rRtV<-l#foEW*=3ae z#+%=9&)a{XAZa9jGclhxDIUA2Te>jT!$XQ^Uc|kNCkyW|QWw_MlTE|D9=hcSbG3vU zQ0+mQZ2i3^!g$sokV)b&zYkczVJU20+W#dcbUYi>8)&jDG85^SDl(vJ9zGV{X~{0C zbVjmUeafdl(Nh!K#%$LqRI@cG{>0cLhiJGh28+9?x_H5q&ePuS$K-)2TJfV38bG&L z;(@yKd*M{bC#sk>kqCTPlrOD=xqg32xMyxIYRU0Dv+({Xoqj_lywV+|Cy)EO9#Ste zK>IW(Mb&rBSmWgux=NN_Nod&j`*n?0Bhtd|EO$cp`s^ooA&K{Mbt``^+Y+BRNd4=& z7O&PK@u!$X7EV zI;Vn7ux54Sk08m!IRR>|xKNL$C_F(L&S?#35gY1PaQ3U~iS zvD2=W`ewHZA(+R}w%^AidVHF1MRc*p%O$4|w=vld3CqS|vKL4vc3g2oSfpbVz|*g$ zg$M2~DZHkIgN);>VNRXx{C-d^8)gHbp!r4^SXFI*$V>!=NT;1^Zci1m~4{Wq`KXy*RfR0f8MIuL2Si8IbU?@pCKxCytM#jcUghlO;n=u@cLdx zcVy;{?KYv2#eheDMj_$4U0cjV=h+0yM-U+OkaR-%)K?~Cp;S- zQMxDglp$xC@Uw|)@m9QXtFV+xgQ8L8@W}bgAy1lgI$9|sDv33gZ{4~C2n2nHKgG*` z+F4W3Oq^8I#0ia8k1TV2gL-RNpn<^SN_|U%HtGzU4ew{y>U^j0fH995=lbq@zG?`% z)$Br*^;uKQFf>VxxYHR$LWA=pJX`#Q0H)JEo3EjGft z)QsS?+1eq&lj=7UJrV6^9n7$OsAo^<0s17u#|!Fh2}4hL0zK;ptRAH?mcpr&`CtHa zgsuY1LkgEU9bu#=S<5kcqP$ramb|UmkQ!wf!*VmWErA_~B->3$^9z{j4H;r zI%PUr9p#f%xNWGs+entLw46q~MVK(XdQ{xR0J>7>UhS8=F5l(%jKf`4K>AS|I}^CYT~6YDSOtm)4Wl6SssYixhl%ThOp z;B#hUMB-SqukGf}=$PbZOw0t=p#{Q@a%A9*F-4Z!Kz=b$wKXsex%gx(I6Ro^LPdg8 zKM{Z6T@T>W@Q_y&{!8Y9Xm*lgqICa*3d7#iG?+Y2@YVSIKLF)PF0vZH0Y3F4Y;3cC zcy;Yg1ah%pN6|9zy=L|4E~z}ZXm>t<)t(e3ECIV+gK-8@vf8Wtum@Ax01WqNYI)XA zh7u}ih7MqC~36xXaHT~^g`a~2}c<#8zR?7}m z{u08h2POzp9YeuQ4E;HI_S(er0>s37Bz${o_@mH}*9`23mUECl`*uW=cA71XKpKQ( zpTpCxb#DRJLS_X6-Fr+7>$tU2@`a+aeQ&hmi=7OQECxtL_vMQ#s-Tkj01(@(7bop= zBJ?T#>5%^sy7qOUUeYoC z-tKV^5A2rY9Sd_d_a9PsmpF7sC~s?ZO>Q7F2`ukStpVCo$bK$NHJe`9+W@%B}Qf^(1ul?72wD|6jR zls*Bmo;!m-2EpMK2Nd|DW<>ae|2FzUdQ!lL$kPUEmOSH!$wp(~pP%R9H8i6W87-j* zJyDh^M?l~(gs8uC&nylXHV!vqJ&D$`+lYHVK26hU&<9D1Q%6?z6drNU?o-U(d z)#lk6qw=vk`=A&YK|Ql4kvA`@Wmw%OLV^g43W!x#IdD1w^BU53F8q%UIEMg$fFM?u z|9?VQ0RR60B0z9nq%>Nc#l%qq!zbezN&;hX{FEckYE2&ELAWO zbNp@!eU_yLKa}~%Fr8etzJDd)?c3x~-gezp+5=+4a;LKmJVY4|Z-PCXXL3C2KTK-t2r9GZFW(Vf8#} z`l}ua)qi;R?}ME|TRz$Qt?|F3WvnKTg$X4{dD#fRO zU7wXbE4ffG`unDllM8dIw3L}R{YfjMs7zD75$tN1eW0b)(a~j;Jowo)$C_wsy5r87 zWN7eYG-!bTV#(*2C;RbJw}x&Ta@$`W`_&9`_#BN=NJtIT)zWF75RXsKJ@=`L-#~^Wo z?3Ekt9=E}%hp|E9b%%*12N!-fd@yYHkK>E>Da-6Hw5~aG+eXkHJ}T{gU1R>-z-;7D z+}a@aalf)wzDl9Bd05^1OAqbO9K7RSx0Wrm|15S+>qYLNN3ylU=&YpwHPU(xpydk-b7S3{3iX3?1iln*uAtk7DeFw9zB z-b-CL+d`99T_#XV8csRPCP^!I3V!X^e(W}ivSr9PZ-7J@`w02_^;X5DPL>GYBh>o1{w+rx z?qT=+1cY`@@W5-{`|{6pP{pIJKhx(rucmNpy_1(VnH|v%+RQQc>3HCM?Wvkd)lFW! zf486DTb{!30kVwR%;W!}P-X*l7hYD0Tr4;%^u2|z_F-ns4xioIOLi;!I;#bz3n#yZ zPQ8~l?5a?Q0=RL;Vi7{I@aE9GMYUhC&omYRTKp<|1g_5vlz%qB^UiW`M{9LrT+Xof z{{Xl^N57dsxyt#nQ(xezJQNitrk#m#P?S=#w6biQ!JO^pErZ1{YpELRurRxsuWH6 zFr=+ap;nnksCm~T^sR)X!1gf;tI4KlB%IUiDCF~-xn8uVu(Voubt9jiVju$O*r#g>jEI_K<8bc_&TDx*yDZ&Xx@kdObkhmGWR@k@O<1Dv|Umogn_6 zzbO8luihm^9p#*>`ksTOtdw^kc^0g1<@@YWrR1$@L82X77>q>OxEmMPjmX?uIl852 z&ZkXQzv-Iyy=~~k+gj~R4j(7Nqp|Qm`e(wff4F=q{{Z$M3VE%Ir_-mgHssL_;$un# zlN~w~s8@GfGOB;$VGgvpA6dQ%T}*6mZb`IA;?QVm?dW=ait52jsOeVgr&M)m#Y0C) z)am)TSF2iBy|U2c!Ai{xh24q<+t{V1j-@z1CrhVJ8D=@r5vKBEWGhd%r4Wi*NYV0< zDH1m~TCc%QtL?tGxG_IA;+OW>rl@5}S3yNJRiRH#JC$$1hn@{A$5(?9CNWhzUB`FW zhM6QIf*PtxL=g65n<7QEgmFoUlDryuEA;+$Yp1#;rODFZ#SE{V^mf>iQ7C%ldT_;5 z?BdeRMW9U&Q3?^o~nH4LNJg+HSuW3wjhB~B})tm%PQo;j3yRW#Vcw> zI@*1L&g|S8+LV_icpW-hr$Ty|7lOKB=qIqO*~x%-X;M|yN%~KUkBdoHQkUGTsX9_o zUMWngkN6!abmzO^`KT5zBA(-_7^+N^iC#@D1Q2OyUPqvswN0-FNn+MzA=I$^r(4rY zDdc>S<(R2oQyXmRdbW{^SRQH@ar$1`Cp*=ccsR*Ri**Ig;$$stf`{OJ72tgoxGSa7 zbZF(3@}yO#J_S8W(D0?G`d_&-smV)*bvs^{G}?|Ty&6*4LXxK}W++UipNo!P2kC07 zr(JYZI#o((EB@M^#VvLnbzYxREA>BOl#jASZ??>mC!O#ib5GcMyojY0!1Q|^h%noc z$~Cg8rQG#We7)I;c1Kam6+TXnt!Pz?xz(!=NiO9psnnLLG1W_osnoBjL()X2gW<01 zraKcviwrm;ru&bk`xt8YaRSnqD^QFuseH*@3l<*C>3`$KoN=JK%B(WJVveO$JqoL) zzLijP`{bk4ioH(I%q)%CR4F)9-+x0xLqemmOaB0&9nn`yz^Exyl~)CBS?WUAR*a6{ z;pP25t^JOzbfxtxZOdbHQPZZHyZAADPNH$5I&>VRy%FydsZLR;~o1vad@i?qbJEVwFvLb|>buQYlp_w2u~l7{wPie^lV#NMXvaZ?B_ih~PAs}{$gK?gXQR?9K^JON6r{RP ztz8ZET16OXrwk2iq0?Q;xfIn1bgAT`x@cu@{&PR2H3q{5#J z!=xBlft1q)ZWMcZM`;s2$Hb_0H<7fBNGIissU(s~oRrlFr0hDUFYIDc%He(8`hCi| zDAA(7RhLq%Dw4IaSD|HGqp8lyrbvO*%yoGwbw1QKnY*lR08p(mBf- z9yTdbRQp~EJCB=oBGf*`N*-i2CO-x@;L-gKErksYFC=)k9Emn6QoT=0P>S3r_A>J~ zl6Dqpv=5u}en*)h=%Hn&KdEM=mG)LjEhd&@T9IfLgx;=2slqC9{ktC8%!QwnBCbiu zklb;ZaA}`Pkr^rF6?2t|jHqnszS-zQe&y|}E^;fob~{;cPPtMW&)a8`BJ}cy&R2iA zQr17{Q$2hEr5h z&Ye2WMF%B(`-hnc+2vVQM`VuM&u(o;EPB-Jt7Ov1%G)uET(3foNmX5z!`+>fEvEh59F=pk z4-(F@VWf0;x=o6bs-t0A7H7w>Oo9h&M2U&*q(`x%jh#E1JnJ(_m=Oc3#>z)5hk3Un z_BxYybTzdWZ`g58!*EM%>C9TE0_wC=UMoh+Z)$BLW-BU$K6e((5J3bGGIH`IYPj|- z=6Vjl1j#~1CN2h~eKV&{-CXRWL9YU(k0RagvldzsO_>=m`N>xv-9YKk!RGWn+tuu> zjciA>l1V2jR*TifOia!b#am(oOq&$DpOT6pJL7q-3#P zzT2=ok0Qv2XwbxrXjLrC(=6F)ah?0h`4fRm3xZ92kVDF>+`YM0fuam19O!{w28a^f zN>8>({sGcq;>%?Zjj0<}PS8D-crg0~NXYimPq~r2lb=EeYbzFNccU#T&_M)$6*q4| zOLx~{TM>)tm6WJoQGD7n0a92rwJJoiJJS+X$cn8T6u_#MHL0cxA!U+D7|H$*giX0Z z;Dfs{F*#-?HL1YV3L-@0?mIyUjIcjRPd0^zky9*nYY15#>EwLcq6lWJPmKz>KWnl= zDc_P1d{*zk8`{R(*#Ok9wPxJT_5Cvo-_^vthQ((8grv)1Xr;fQTJrv%#<(>J)q#qM`zAm}rDTgzGwo-w<4QLi6rF zj6CG_x)mxFvp>m~RpI@rH~|lYelhcr^@h-FiiBGu?^+avKL&N7J6&gJ<~$M6HNnje z8ypW%qUU;!tS0;#*yIJ%cj8;nXm-Z|z~dyHW}kywbimV^uyOF2J(Ptg-)jE=`E}(= zpMqL8TYxJZtl9cRz)o;zlC3{1&yX+m7TwVNhY<%O@q0@+%wJ_>=eQ1??mHFv7L__W z3#Cf4bdQUh3Xhj{XJE@ev)<`M`AT=F{_t6PzAyOg+@cgHQIQGWqk_n8coKdO`2Lgo zx)dN4u!o!4(&PjP#u0B8edY?VBsvdV2Q1UuP*l6JZISAL18p9N@})|UvQVK$yz=0x zO7kgllB}%*$KCeshR1y|uRnj${{YtpRYg^%J%XaDDx-m)k^F-3?BPA-?vsubBHO|` zo@s{fx&(p%B*+_&dlA)nRY6t2DsBs+H%F8EQ$Nxlv_{FJ{D5LHQT=5h7QLiPw)Cn9 z5_vGAP7&dTszRhA90IytFn`ki&>6bt0XA@({{R)p?}3aB{ij8wjh0OUtjJDzcP4I~ zDdEypB~!x&7hDeB;hyD3+Q~R|;405(7% z=8OLTA-`x_%X72M3ueY(h2yjM6h9z4Ya~sh{+ol+GeOgI-D$Abj36mQ=)#X+bLnnf zRgJG-_fCm1uOu%K%f|W(#BcBm#BcBm#q7|F+YN%B zBd9fiL7_mryHTvH!nF-<`zseP9F?$WB0BPnoz}{?a2x6gz=D`+R*y3QQ`D)?T6F{} zzLiR)Qk@{FP*9Q8Ikkk#I#fPP?d^kbi)si<9-ihmG;JrMJg^Bu&$^^`RQ&{O!A@jr zBg$4sqO~GgM31RQA4jz!HVPN&@3O6KkCAdzDAJW27ra#(E9g1xQPbA!cKAY>#!}JL!0C zO|$+=*3OtASa1I5Jex=x1X{~UBXmR<;KH;eum7+SZJ0R6!4K9ROM0ZYjv0)uyHnQxH zv>?*qp0H_l!FWVOqG*T<@>nez*96Cw7Q2lajWU zgGyj^q>}xSX=B@*+|x;v@Elr3#TFK_)3R%eOoZ2mj!xRc5O#MvYu?_W1JN{sGeAYp z+Y4FU8$Q=)@QN5A zP}CX)$9tODLI6^uIk;@HP^nRY$O%r-lZEb^=oMASbAwZhOE*jq-d{k5g`n!1+KPx5 z7NTw%8?K1+BT~`SqUiuaYf0(LV`DucBqj<%Hynl;#W|tZ*x;fFx|7))w@$PtWM`^0 zQI_h+nBv<0;qlykYCj zoME%oGy$@d=F5V#IY&9Zp`tLpRGY@g+&z|Xid6~ObKB|{4MI_k0eDB)X7ReJ3VWxX z&F%=GrASrzzOWnJhE!q*>JCJs5$qOYJR}uEkZ2gH(wq8wH2Vdf+GP`-u=q1}8i#v@ z*(lHmCwvfUJ8Q+q3@q;#JJ`fg>@4+aI}G0nl@~?_ZJ`{f=hAPp>!|$zh2;y0N7!1@ z;;8^9?{h@22o&ZP@&hFX0dtush)neAIAxVmX!ycj~!T04*i z2cfp9(3L3QX+(?>%ak3OHi}8+gMu}kzX%Uv4WH6+8xDLZ(kDTqB^u|L(IsRAA_gep z4MK(8SB6hj;U4I)j7{6yEXGKL{2~H44!o2Kl~Su2!yyB4PK+{9Gp#((zyHJlC=dYw00IL50s;d8 z0RaI40RaIK03k6!QDJd`k)g4{(Gc+A@gOk&+5iXv0RRC%5QhyonkP@r3d4*PLun+& zM0tbn1?+XHjS+LDaEK$22Sd)W&Kh|y^xzQlZ{s_&cLc9f0Xl1({E)$N0~NLgu}wkB z>m`y>qu;yh6Am!s<~zV7ZLLG_{A1Oy(P*CvVnbm+e^^wp6#K~{gI&7CmXtwR`Nax4 zxP~kfShZCF_~Q^A8rrq^%8{sYnN_hxZnx%ewdeL>^gNn6xv*--t{L(3nvf~W#P;QJ z9ftn^A9x^*?yi%G@VEs{IsX7~No3l`jS?TGa1&4$UGYqB!-5KqCEHJsrUzaRjhK#! z(`+rl>N&?_XA6rAkR^G*L4?*EO%r^N#&WoDO79;G&{Z#aeG%k%%;rU|QJ3IvgCLG%=8%+4qkkT8sYxSu`CQIm9PmLH^Sa zcBR_^Iu{QNBG-`#Fcv#zO^nFJ4JTQ&>B+VgzBl!V;YV{Svril26^Hg_iCEK}3Gtf& zaf8wIgByOFL#+w{jJK62G>Id*tz*SS za@>@vZT;eup}uQ_8gdYeqYXqIb&L(0DaJ`2+?)e~I)UYPCx4t6)|N4>!I893W36^y zu*>$CTW~{d`C#NYHEz7uco;e~?US=E!n|Yvh@6!%L-Uck!THU!ao#8kvst;vb;hoS z@ZdU*H*mCPSV{|sG+pu{d|(1V{{UUgVWKkQ)F=Xvyp{I&mPb`H+Y{{WmP0-YrVcrc|h1teD=r#z$| zGL#8Tj-6n0@~|n^++qI!VXv(*N>!n#a3`5?s8fmR=8&ytCg#=9;Dfs>ac8q4E*dv4 zec?seqpO1V3Jf>!+$DSTNB-ePBM%pk&N#qZ%Ka`rqTMR*79}hR!Qb(O0RgW@e0Par z^m@b8XxTS8$$8-9{MRIzZB?&K9} zxEfA_c+@Q-0Te`TuIXhjPWbmm$Bq87`JsK%H1tth*Sal`ejL-qRr5H)&k9O z#vPv+VRqYd5rR~I4iJwfLxEK_3y}f(ayG$3TSJWw-#Wz#SX`^=aBN)4ayX`p$w$EB z2NZpM`IjakT?e|tNVw8$!4RqW!|+wKW0%4b3XWEai7CEIi(dX7o=fQCsA0zkBvY#e z#a~5#7#oi)!!e6ZC*BUbRdB@sZE7tNhvz^qW4 zubEKA9H_1i8sF9F#L!G$p4{388kr8@?{LK$w;TfjZ4RyqYVEhoO{#3cdZ;)ZFkmqb zkhbk7T_MLgYyF_J#6yjfJ6~ zoKf^{UV?n<0(2|6f;-TL=ssb(i53~WB|`M==OzX#n@_lJn_W&5A^GE&PmD1X;Bg_@ zjt!6M^tq*rfz;Pd9{7p`8~})%-<(|mbT7X-e%X29aWaGrp)f_LQ%^eVm`wnN?!TNr z-E%lgye(FdRx_O9FvKA=cZ!#tzT9_pfR$|8cYs~&DZm(N%Q3Kw(pIn#vBxeti!fR^ zh;Cjm!Z8fqZma8f;))c&dP+rpGEWX~IJ^ZE;S5Nf?`htTK5*l5b}0O0Mg;46XZ4LB zcqK?3Idvgcsrs0mg-7Ime|Rr4aRYr!sZPtz@i9Txrcmn*PIrbvg9hgQOpD%rP3LcU zWE}xFH>ZgK`f_w&b_GqK@tPu?FKzFfS!&80H<7^#%m7UXse|z8%kpwbZWDWOLT?ZO z1ODT3KMS`LR`zp(m=Y;=Q?j0~GhLH*4i5=~LLKO|d7CPkRb6tX0Fn@O zxrkJyrF7ukmqDX${_&eG%yqbMyVh@Tb${F$r6;WnbTV^sB{~EkGk<^>5m(n67I-n+ zS~mO{A*D^L-fvfvHrlswTqbDi7WLi?y1>EllvA8iVb(XyhUIvA!7lMqdc+)Ynp}-$ z3$Nz~lbzzgdvaPpeQDQb2qgfzshq-SQ|B1xZACih#nT*Gw`PxwgKnJ}sZ2peZoGiT zG;(RdE)LK=Gi-?*7;guSV36(S0FW4Yg!InP+;;f3DS%pl0xWr#)@lbYteS926D1+H3)l#}U>u-1 zO%dJ5$?B#WQ=_t(1T80yKW2Fz#LjdP4`;PcaFz6rPg_ zhGrtGF3vGGHN)CaH@h%<_c7u@$LXZgBAED z7Hs}Q8B>aPGkq*^M=tx`A`RnobbhmAUtdEukhIHkPY%kU92i`Hu-$#&QXqSY;T#Po z1_Ej}()?Mhc0-PEqYZFPBwo1x@mY9yPKX5kvuruM6RwMoI)A^g?-aD z3*@Ho6ZF*0OV))<05j#L4s)xE3`51T1Np*2K$Kg)6vZgt9oybO?G6Z?{NVzYLGEZ?zg6fk zZ3kttGCf>X zU|nF7%(CNqIm2m}+}dtn{KF;QL!otX2TRrj-!KX;y~n`mc+Zx0FIbjox6=bDDef`B zI2NbtH|TPNh54APUH@E5zR zu;Z*nA);^VoYs+F;|QaDU<9_iBa4vz;v!j_!tD|mGpcOLOe=zM@?=F8N<5iVwL3M7 zy2IhF?pdI;?tXEI2B!*txLO@edA}I$67vl`iL3;4Mm5f@>?Slpci)NDGfTa#?~USy zgjWwbSYgmQDb?*T?qmk9=K_KJm`~Swx=lFOirXCrj$S0Nc>Fo84?1lfWG{&g*kX;) z0&#Fh;m!W;+*7KRSl>;^goxN6t96Zf6rM442E9*=kz?GyJ{cpdH;g*mC%~0VkZ}=Y zD8?}!;`K2Sh}UH4#%-E)i5L;C$%PPN*P(H-H+LMVZmbNFod|3RvnVdg$GmnHLa=O- zb2pqZ-Sj_rfLnFIF_H&TaWMi3;K%^kGcK$i%)L!N<|kpvuZ-K)y~{q?4lQ$lR9NLW z6|~!lX$|+6DdHAk0l` zxJ=mWS6HJGQ*Bq=v=V`fv%PN;)%;~Na=hY+8tE|9`y-81gEfw=O=;f#F_b#wGCfFO z7RGC{SV2&frxnQ!C~-~2cs-c5z~IE}ch+nQ?3`PY&ywYf!I6hDxOl@imQkE0&1+ykiG0032Q7Ik;7W#bG-pF%5tQFJj~MeZ#ETJzA{IMK zOUu=4m)tYpxFRG@_q-u=GNK4ED{U^<%!5jkUYL=(Q|}sQC}2|w5hF2pfk(Gv+;{v973e{rY;gWK}Q~1`pWAgcEmlzp1;Eb%Y;N5%Hl6q z2YboJ@4UOz<;n@2L3_pXJ5lQ%u!5WZaHtlE;)YeU+BV}yhO5pXQ8|=hj4UBFjaSv7 zm5Sh|alE6H-5&knHV?dK(0O@2@DY$|8vd~)m)R@cyvAkbocWL}&}34V098F=C`T@N ze7K80o)E!y+hzq$vNRXdCDFzUwg&ezp(YcwM-nnu@rPWYhbFfJJcA7H^N1&O?_q(B zS5bP#8GD6(OSzInZN6$^!coG2A{P;59U9v$Db%6E4(b!fP{ooK?Kr}YgQDEDynRid$cm})0EO)JTbP8teF-duJgw#S1T5E2h%bucX=&8zgLT*D9vt#sh* z2l<4`C@;zJjF19Z`Ns`tSCAgu5g|ogQT zrbVwUVjSySVI>CoaCTvQZfy5|$-Url+oF+$xgA%k{1{m#Em@adBsAbqZ>_Ty3<1Bq zq>gdCq5HtFm8KuO64tRQt>9pmZvYLY35j+33{+---)~dn0Uaf)%jR_7=PSeO7YgiN z5z71+Oxs=yxBVXEqPm)|q4nF0%u^GGfqpS2oVLvQu?t;fb!S7ryiS*$w>aLq)+CKJ zjyh}D7#9a{V4({^#@5Voq^R?6+l_T9zn3c%1+83EH+X)skQc5o4K9#H#9f|_BA09# zMx^dy2_dxYuQLEZ1r9t^qSIOB%TDJRw3ngF400gWyFT%DIX3Z$q2y{haizSQI>@`Z z&hesxbEopT1aR)@&PY|j2nz3V^`jsYa6|c zkf9@wYv8=*?!e2pi{v?*Oyt zpy$3ad#U04)MZ4vlx8IDRnV{8CRZeIzt%;Q=ZtzzPn?3fOU5^LaR)mlUavCOdB1tX zOcmc52ue&2j!LQPk*_j~#mXfZ0dY{WBGFE!1X1Ifcj1i5Fs=qt>jJpXJOzP?pyq5$ zQn)p#fbg>5rPh0HG_2yCIdq3>yiP%cZwlp(fmx_UjW{k)|954N$0`M6miMN*LMX0XFK;z z&zt64-F~r4xP9*!RYkajvN4wd(9$DD5boSaBUHB!guefhWop@5MA!$ zO2qE~%@T$TvDx34ND#D8(i_bk<%6RuX^ejGYXrxIhvO*?A5$O0E`l}X-C1fv@!v}Fc`1$I|>=EDiYS1V%(<<6>M-`4?wDZH&i0uATfCOj8=!iP|Y zOfhsrX~rqlb$ z8L?ctw*y)DW`#;Kc^K=Q*e>!E-2VW;-tsazz=|CCj_R3iJ9@{se67XW<=Kjw`x*BM zz4bY`a?p$1!v6r=*O=fin!9?{KWzgx50A<9h@o&tpwpE!+YMqsqvbi6@nko=2bj9f zDa1hL_Z{lu6HBZcPOt$6jL^9ezA>V(mSuHhNm+xnN$JQRwT&ZyPzYMJzl=vhAl2R) zU4yj!V6h+2-d@!oec-PszF<#lhQsX1v8i}ZaZNfCuzaQfJ2Z6u6EBo&`7vwM{HIO= zqexRIye5?QE{xx7nb2q~_YZNf^nS8)4HH{FoG43q`ojVM;~&h8W2d~Wk4_<@%oX9@ z4XO|>cj=7L$nP)5vnwWi+(FJ7#Onc+S583_tTqaL<0I&<9De)F6x0?p+vZV`_mJ!> zNB;oYGw3_tPBKG~ZI2fz- z;kpHE#>K0mXTk0TM_I9VM$oGL5^-9Er{^slBs@EMgos;(C8sK|!;efRO#<_}i3FIU|>p z`gE8Ep7f3!0uL0!U_P3TE#pcJev>0b(rY-7vcF~~i>THzG}>-u$PvA)I}0|nhZkc+ zhkC|VC?=daI1EHilcurNvgKn9Kwt z%`{)tarFu+_VVE7%`2Us_m4jqDk&Y`IKU7%yAOu~dJN7W-!WvXU+Q(!!-ZXRUSHJZ&t_{G+&>&#T9GxW&bF*JOfdxrb2VKhA+Q9P0;c%3@3CyTDx^oU4We5k}tTsUdtvc$aoL z*`KB}XAd&ArbH8s;P=ytZRB-}voWY`g-Xh&2LJ@bd@-#}p>uc_qw6HN(Gc@3hiIn{ z-W5b3R}-AvFavXg9s-Ma{vPo21atVpP4FAuyO+%0NCNZA;e$vIpHs&d2zDQOZd}AM z6NGlmu!=_Tz%4@q*6E3ajWB%4ffpSaQ66w`8nZ@lvsiyyaqunoakObC#~Z@}@YAT0uK&7roQkuFob3Wl^eFh7KiK^aaUK8^X6)Yj{Y+ApR8MOyG%A7X3_KB zP?f<&VrvEpV@1Ebg|>et2!{?P&sn^p{+AN(_IiFXxd8rf+?`19V>oc=C>Jq57*uJf z@{hS6o(LWGb&5B+?cBuM>#j5A&*7QKo8w*Ky~uT)=7LwtfofPZ8!#zIgZn+lXHU*s z_mbV@Ma~brRq2!ofWLW2m~*UptvmY1$|W-WG^z7=-XwBD+Y{#o;)VHOc^w+fxSv62 zKSozm1(M*>Z~HK4o%XH|BK#ko3`&!Oye3FT@XQ~}`MTo_Cbs=LzSA!TB0zJHcDgxM z;mYGlJbBu|zmou!UUA}AFkh1+37n5H14IKDWG5*L^ei$_fQ zqi!XXtB#I1;}+}S&JAYDIy0CtaCwsdN`_TxHxS$Ce79^6r;;%l?U&FQ(cIrxX&%|TPOM*^&PHHwD- zw!q>i81nxB;2Y}*he?|w3bpXWi zkqoDgK5hMAV_B>p;5gx$er6DW$Y|ah#lyguqeI&>hDH-+J5lEwb5LGb9yWFiz|<~t z0ri4`rj8828EcmW&_e9lF~nh_N9%d8f;t|IRX1>}U38i3zyL)I0j34Le3$?$u)HwK z=tFwEVdgR&UW3J74( z#P1$1%DCal>kvE2sm2R9)%N762@Vb5jQGl@$Cz2IT&r@oKRJIai-kOexty6#anvy6 z#Xmd$0L^{B`L8+0L>#)-JYsRi2DUi5n{Y)OV90bbH;M5Nq z2;^45*1_{@-YHft+&bG|&Bj(H+;+b>P2Bf5xomLy!F!o_8!tFb>+>+B4S6;_WduNT zH@sfgoN_&uEy18^VHZw^0C8Zs<^KT6Pj(YFKWcN8rB{ynca^1cqgXxQn;ZNci9c(F z>zbEAdEa?9804)@@tXoFlh$jAt|;FJH?X5KtO<{=ER`F9CfknT%`VpD)CXUzO#r*~ z!I?QRfj!DdjeoN(3xf!9YU>{zBO6p}6 z8pX%1IxBH>6DfGq&L&n01i%K&>&ulC-ti+oIKigzsqPZGaf~*0))cv+?~ZRv_G3C* z=Vm`fT+r0+nx+Z(&GLDJZ4i>pU>lGhhH;Y9l*Tfey<)A+#(a;CHqf|eHofF6DEhe? zrtuIHZ^jo(r-rijAYKOm+$lOpa%TuIh1fg(Fefm0MKPRBAg=tGp-A5c)idunZKHwB z?mInl@4}eUm^n>llbsk;o#koPS|%)U6|mp_OeZcjC-Z_+<_d6za7dgTYX!_2vG-E!pZ!<_tggi(Ha!K;_a!4`!~3;Vzx zu@M}*PO!dUWpC#-){$w*nEDkit|2!=4MPN5kZ{~!4iXW@f*RZ~PTk;xedfA4%Xnd> z*EmPKw_nb9b%9X}*7Mt&pBNZZ*^bAZVku5LFtgSo4)=Hr)^^jc91@0YSfiv!&Mt8> z5PL6=#v`&I_4L4yYR#@35G`Y^&0+NaNRBJZEk zlCQSp{{SX%OxCl%{C&u4ZVIfrI{CRRO+3z=5@9B9xp|VUW`aSuPzQ`u*~=9X*j|z5 zPy}lJYmly*aKFBB5*s&TzIQS>z2(x!y?^OVaSi1JXlfo4gnhVGJ^%pML7R2&ST3FZ zmE#$-J2Q0GPeyGMK<>>8ka)ow1u7%S@s(dl^V+A*^Yxr)aoM?*R9>=lIL!bpHIc2l zc40O|IyU6}0N^oMPWOn>{Niu?;|*XqU10#Ys`W6CVC8@CXeJ8vqm1Tnry+8f(96zy zmN4hqV9F*-(wuMt4(*ol(){rrR-pmv}iN zIB%>DmpkL!H*rMzaawzk#D%$Ep4dF>wM=fv}cyg=JkP;iDcZq4zI`5RLjs;SG+oVW=P^S=HJT%Z#4nJW+-pFZNdVs5jv$bxf~ zy<+v7K4j6CeD4%rj&Pz8eI_6cePN(P4-1JxRaR2h!OCsNKC^r|v}RR4Fu}OZSG?8) zL;nCdOrU#-#ni>3bBFDl%8!@<2YC+DSxXeB#yGr$t1FB0^knHt6nMC-i~O!t6e8w? zee(u`Z*By6o#D#I+(2r#+^B|B3|J5x9hd}<>mZIK+nnAe0uj#k;LRJt7X(Q8-T*jh zjk_i_alJVLt^x`;=>9Qec0;TSliu25i*>QOXNsUbzX}=uc zOVJlikNv}~D^K|A4tG&QV}QSDQP}zP=VN2f`-(ic-dC#TnbEl7_s*vu_=mZy)Lsk# z*So}C0|gNGDM47Hp{{gi=u`SrJ|8*H=935f3`;iXUDc=bfrPmGtzlQpJy2>n^?6ot2w~k#n*xpAP>vQV{z2KeVAj+zZ zzlz^aa9#fU+q^k;{ks`cXpP<#b+-ZYd8B2?i-}FT!7Ud3xw{-Q4`x6?Y|84b!)|k? zZ#!=|vG`i!rJvC;mz;R~XItmqG7m{K<9J4?A@OngNVn7R zo3fXU1(U1|=|_5U82+32##thX)pd;MiTlOTl81Z8p$8gO+ZMx*`+%%5*eus?_+oE< zbCNGtCz;;49OU^kWSFnRoVgWuFj^;B+Oh@!hVo|cakh=)2CczrdR-TdzjNP(_OV4xcp za}*N0BJW>#2162Gk5qv-P~T6EE&ynNFq~ z-V{6><&5HGsqSq{m0p+?i#o+dI4h%>lp9m)+zMjjW&uy9rwF>&o*yT^jMb^zO}+da z38_bY@p5ds(emRcQ%#tSv>09Bi%yR^;Xd#y0o@)WkD_l!#xB^&h*LE)r~d#6tlt@G zovtkiUO;Kra~CmpUGwGBW)B?9?oau--m-Gf`I0Ac zX8Ge-0f@=o9<-cXD%R6Z0E~+BxZ?^WI{VE8m?P-o+So7}X09Otz#L+34nq)&fczNo zyq_FkK$jTmf8eTRI@StMO5j0-cqW_#9bbdIBh{||02u;rOYTEv_4>rn-q%wYN_PxJ zZthOpVw^50U)%06j)n6bAe`i)KGvLD33kOF{3z!kP4dkfdP|UK-Hx}7Enxm`DaLz@ z$9>}|u?Oo6Q{MFA9m^9q3BOIoQvw0gaDf!?V@L~!lO?koY#uObuyb-MTelos@h-&k z?=NN8!KzZs+fzxz#kfFDt}51=(BH=KHcuuA9bNc3_`-sc(}si(k&d?L({}kXKvmgX zzPoeTfdN*=(;AwLn_!LNNNRuJY;Oj-)WJ)_U@4N8-Av!Txg0e&>SD5@^@!Gia`G28 zb+E-x{ObbV^W?=j{{X=*@f%F~H;~SZ6RiF@vBiFj`(p3+fh)GLsQxoUdNFO>)>P>E z<{=qw4=Riootav17b4T0OObYMnDgM^6IFHW>HJ_c+;iLYiLbCbh$@$Bo;_&Nn6iwiT3ShIHPyagu0Pu59eV0EVHneqm_> zUQAY19%2|4>0LOn=WCjdG-A7)zW{WY^6waOT%_vgaw_5*%M%&8Y|4Ic zL+U<96;1$3X$K}P=)qIAvRX;3APz>^m7zO)_%Ox2G?2N&ffrkbiSDzY`f#|bmoBa; zOG8?f#cNOWy<&kkx0ssCsMStx7j}Whu`4*%4xB7X-Z2{EBeK7pkYNC&P;@Si-?+~2e^Qn)vRV(N9)-=6AWuH2@avFvnbC-%&TJ6ifHHdZd_ZoA*afYV1 ztrv1`eP-N5etqT51Mu(uIb47dn!nallgCl-d0XEvH(Ykq+xL|8wfWvOJU@E;;YlyS zmj3{}egMw#E_XJGK;B9ju6k-#68OTKxvzz&1VY)y2;hkI}w zA+rNaN*@LR&)~%O1WY%4NvVwm;%2}vW)y0U!TvcGiBY{`FNs?Yek0}*L(_;&U1M@B z`M~Kp*0QEIR~h!hNl~mQLXN$~0EllSXVg#V*WF+m??MG4uI_ba*;TLF(nS%KNjR~ z;<-Gz$`Q_M1jC#(EW1u-G-+us?TaBVaHj(w#L=f;j54qsc8|sk1#Es!@EQUd5d#Sf zs@vJ^>j($(EN@S^rG%-=!&sA$;sSm#k8QH%tEQ*hn}A{;43A=k$wf2hz#EN5@?x66 zgjvQ!sqAQA!5xkl%pT>qG1)O;XgW6>f*oM18b6!>q09Z>{8E|gFMi`;lIHRaOr+L7 zc|Taq))9_vt?2`n2utH{+nb5T8-3>+Eo$maNLZ!HU~Qzwlr zA2`-NNfzCix1gLiOcR$pv`i5{C)Ni<>mK^VNv9t0nEwFhd}la9{i%p+MKzrhQArt`Gv<3>pa269|1YWD!^;+<0qG% zjCPdUml&U1e#>-!+-9}J#a>+<|QQ^WzsI-U{=2!m~WK z{Njz{#RJDTB8uqXE;a|ZU(Qjdj(Eg^aKBpT6~W5QIagXvCUQNxImvAu9Zb8Vmwo2( zA#u20O<)QeX20A=G-mD>1*RU*jyuVrqx3TA-cN=ds{)f>Lx%yTx0?-y+-$dx{3r32 z0kr;%LLPW$Ul{U3u*E9%joz`RJ5A^37o5AnE{^q^)_&%{84LinKuNzX;u|}_tk&HZ z8zOPr5UnUuKA+Ao_pA!#2KwtaoXmXB{+X*>L3OOt3K737%_U!4T}KKr z;Bo`!5b4X8gx_-Hl)tP4qjM9f{{Wmn`Jw*+&hhIT?q5(1{J1y?OQD7~fq%T35e)Ai zK&9iBA6Qfn?Hn00-JisH#e(LDT)y;eP#n+fS%6W3v3X<>hg*fXp zs-9S*AJ>?ThVQ(&PqJ?z!1&Hjr~d#67kV%`4a}Nz@%aA$$~kiDoZb1$jfu_(NI4wS z7Ax%64haE8-NNiBo#RkT$;NF^*?`&vPLbl665e(OoGdT}_7AKA#0+6>1uQkaKV~gN zqhIxK{DsFy*1mBaQ>-yMb&pxU{GGgirU5wgbnf^_SJdw+(leWCvrTydE=qaD0EmH5}pi&5l=dBQ8Qx)69V$vYGA3^YO1- zPEDL;h(_i2rvNIH-YbK2m?uINlQ?*7)}4LgRTisRdI&d@3klT4*zHfF4ko@X{>+=5 zchZJDuV79uP2tB;lnSZddJfh2=90(Z2fB>VX2qy%uPD~0Ljohz;MvTZV)xymYp)Xn&547>~M$d%vEyxXie)yCo4Xv>OK&{;7SbjPPR zR6zls969nfyw^D%?=I6KEeIp-8cD8SuG}bOO=T=bC-glz!!W(=#BIfn{nyR_He(An zzx<@F$G$)8&IsXj9UU5%3QjKLpy#<6%QX63qj^& z(CD=I_Ga?UBI6D!4d7^OKaYjT5Sx6QAkpInnN#S&rNF0BxWPok*@MfSxS(h`g!A?2V$)B6GfT;0>>YQUKqtEZ`Ea2_^Pu71 z4jm;RG(=bv2E;exIMMb5daT?qD zWT9+JgO?FR)=rv_JIAD5Z@igcjw};mZVhV?b3zms;E{CjY^MYTTGrqFP_Qh^1w>iz+I@P%@Hn2$N z5bo~{(rCD#13%m$oEX1Z05_dxuCJD{!6RMV;5zRBrSfcM4#5|m76B{~Bg2JC?tbx5 zbsvMHHf6ZqUk^@Ej3-Gci|b50ay?{wxWT4!HtfaCCy)JQ`+qT%$Xz#qDOBg-$Wx46 z{{Wde${g2xVOJt1v7;>#ViHtbXSZ0Qr8lkIa8BGjafGOOWN^Bpb3_*^!aKK5Oc^bK zE~~lLE)<0mSFM>ULX2iY+#U5di_TM~^O{PgK*2YYoa2`rZ}r{+#0Av9nbZ2gn&9(B zDJvbFV&cm?(}EziPu51Ncfla_j`qWpIsD~s8V?xsF@F;EjuV9*FlcL?{{W_aeg58c zo=EK6X}`Qsw!bUJhAlPo(%da<5LHe|cXvg;GY&ywgcWD#xhqqv2;?LoExV1+=)^4h zp!@;AbYdPnW{qc=tV~q{0f(5b`Z9}540ZX&rn+;yV5mW|XL0w1B{J*e^5QB=Zxc=Y zV110y9tnj4FA+V&S|Ncn^~4n^HsKZEm>Nw4E9-=szUR&k(hcK$5=Rrj3Khs9{$6~T zB(Eoxm8JuWao&*vo6Yo&#=yP&*6*mWl4sI+HG*otn77wFAls^S_m@W-InO9&9aksuQ+HpYn(3vis z^=3@rc*j#}{;}a&aoYEBD`&*Pi@lk&-X$*hnWnBx9FCr`4H#*xaOgVgeX|&KR?ghC zIYiew^BEqU@EcR-5(v5l$Zngdu+78{jm`Yx98O{0_`^&&Z(>Z;2H>Bt9GG_Y?*0crmL&NZE4xPRwa-m_UOdG@eW$?+KAhC>iuEq13k zhfOO&n${8Y#&6661viRafyct-ygTD9aNhG_Xyxk|8nm7#c%dM3J96mXfB4IuVbso7 zCaxL3{40aZ72@y}dFvM|mWVMLUi-M~6Khq+R5)$hh7%y~5i~h*Gi^9h9W$Rq$7Bx7 z4R%u|B^>V+cWuJa(2huf#h+MDMTye+81x`iA8=&Utt19iv;v%+qaPG(;GZ1PN~(xQ z(S~KpXzK*@K-%S2DoimTQgFf+I%3|*Y z5)nER0=la~Ic^&%JatZSw$Em4CuKN}`^zGAhS1#7aU9<*M!eqg_9+^Y`xs`I0aw+xZaOTw)fw%;Wps-^T~Nf+ zX$(DaaJ1Fs;nwg^A75kg-u@g`p+!c~ySN?X)>I8~kTV2V%u`uwJ`81(o~}s~DRySD z)2x$kd&B0|pR8ho;>_$FpWVr^p`-~hw} z^LlZBM%`~0G$MxXv2B1yUYuY?P*{+ga2mIVHx{w^~++V

%62_oYjZ~jku7GgvEQRamG1Vz+IZ)!|fneza-5mQ`vRh$jbm8 z69VTISaUHTUo+E=DAYP{nuQujA5r3;zIW>FsGb7V)Y*e3|q!ddpX5l z(>N=cs-B(f?a8<#KD-Y-af1%lo$s8sD_c-+ zkKRB0;k{=2JIa<8{B+}8UtOH2ka7)SJ;qHsJKQi-LC6_zK`)sSyvDCLc%nlco-t}* zZ+X01tm9|6s5hyTZpGX!MslNKYJ(sFEYX+J?eOdBNnT(%Cf0-kCi}yOkSQ@BKb-XRAe7%kL1ym||E)s0>_0X98A|dlEKh6S;!WQ+A zBx)Fj=o`HmW3Kb^%j1_uNm(zB9Io8^GeFlk7HHaGCIeoT$m|$`F)ok6fh%OnY+C!~ zMN?A+XKV(BA?_Nc#=6^%;fr^LFo3&yFw)L;yD>Wqfx{cD>`vx5T71?J1Xl%US9oed z2w>5kG~gp=KUufrts3y-2u>djxiJLDuh8CDSxR+tUI2X5oBnV+A3j_Xxlg|E?+x&G z>mK5gZ5xA$$_P7sa7xh_m+&&~NYpi4feY2+md81(Yq(*~u^-M?FE1ml z;3~9=>YQ9mT!RvuFav)sAu4btlyJ2m5Pi7z7BckYuv2c&69<>c`_5<( zqlCOJAS>AnUQou>UR=Cy9pJL-E|&!w$koJijI9ib2i|5hL=>T|E+3;-R60<5a&ejl zg}@m8icLxLi^2kSGI~UU2RnPP5)B1f>83nKiJ-%kIEExn_F3i{>bIicyQ7W<8&|jv zqV#6)3us1{Q!a%A+GKtOky^H6@K*(P;80v5+!Gvc4Hi7<{NOt8C^0(7*5rc`TnHj> z-aytMATKYk0K*}y+fHHc07jS$Q2gNjVWeED?~9ji0Q{I4bBwo1myBQiF^UcftB0L1 z;3ke7tvBP0bvqw=&UxHe&-nR4-|r#?fNS@GQVqp+CN>ITFPPF{{YSrxKBRQD`TTZAQ77X06&ZX>rEIsygcUU z=Z}VWh>&g?M_+J3=UQbKs!q(kb>^oSV2K{`b}tFX7zy^ zY)SK-Yx9do_{~X_?<#ihC!)D$9iexf_BVz?-B`I5Hlt4l15Pyqq{@P#U%s&*ZAV>W zwAD9{dFuDKHFgcDtl$x#)6NkJ;KIut1k(VdDer>ej8W(qsL;@OZ-LEkhkS{~L!X(< z&0DmURC>7Zj8Z!d>fyu~Rd?0PZbT&OT(=riik%0M*y2tJH=;inOi+PFS+Fx(4o-sj zW}uok$I{;MwsIB~%0pJe9eaV=f~Tq3lvX55Aozwjp1=VmX~34n%C~UPdBvR)+Z~1) zEKb*0>_-6Y!=dASWv$!N@Z<<>oH*Af`WeB_ZzFlf!ryS@97fAuHG!yN&L7q&S_AB_ z4r=fiaH*rLVz>=-{kWuLj{w4nu@3Sk`{}^Qg??*_bp#RP?9Ox!@mp$b(;PwU`K-N5 zzdJI@t3~O9p;R1+3|wA5i*qz;{=FLYN+JBrys1IBqzT@hXBzm$9m#0Zm&<|E7>xxH>hA)kq`QDk z`;X!0tZ*TH%VB}~nX+=1F1I)g9kSg#of^xRT$U!7xu))G-? zG&d554#E*a>&#OefUoEI#Y-g-@1J=?osT8gSpBws%$E-R6HMI?O?9-pDW@@Dvy*x|zv zls(`TgL%msL|gRZ16sNIIl$597=ofNSd=tVIf&Kc579GX@|ZU$-6tzJLwysUBmFQ9 zg=2MQL0B%$m(8}xQ;%f4(bjmbwSpaStSB1vHH)GYtN3)ykln%G_BdOv*Wr!*VF0^2Fvtm@67DR%?Wg!k<&Q| zu+T(ZTm4`KkO8iYfS4BGsW&bZK%3#jp}aUH-W?bsEx|&qi7dJV6Q;5;Vw@LHnja^v7y1SPZKtn*0*?1Mi}xQ6O1TOMeRJvDleaDo7FTa z!7>MdqY#ox(2?rp^NXH`>kRN3m>+Z)DD#At;vFjZ&FMaZuiJ{|&RY<^7~~5T!Cy0M z>7!vj@zokt69c4)wYqU2Wq915c!t0XUa+>^N5(#e@>>JbHBge(hku-x1)z{yAZ24b zFDT%bQG=EJV^i!FxRNA9E(?*Dc_i=#0hEq8!)BqJ2nW9Yv1mHEq_LwBhbm^9rVjrA znPt<1>P&Lbs_16Cwb7?b7~@3&ZUt$qC=xJYc!3inog&6ePOFLvh9a>l(q$hugUfi+ zfEoeb83cjZcsRiI2)rkpMt+jX^sQerzbdV}#nIR%!|Za4ylIE_;A|sGL}&Af+k^tQ z{bgl_0!UPkj5FEd7!N)2t_5G63PeoY0scS_fTVO*`FVEf{Y|j=udcP%0|);(3S#qd`%wMI$9}Ypm52kVD@JW6I~Z zN8Syar>N7c-y|KsK;Z~&!@-1cGTC~_Pn_G1Ge;S6jUD0^7-F_yZHFE9V6t#BvlhoA z&+6`u7KFV#E=A|(?+vGkQ@Ma2Ex2K{z4T&+ht5l2kP80*<}m~`NhW@tZ~@*9 zt0mXYutWh0?Kp<6)@vXFh=7R48nD1)S`F9^ueef3rqvgo_{wR(Fo4CNnA#D;*h{y4 zeBr~-0lkg<4l{IYR4Cf@_t}&{(}CfR#v*c0yb@Z*)qBnTqSfVW%2u@Z=lE z2P^u>W+*Pq${XvP>a4GC$F~TQewD;_7Tr#M@}ZrNwZg+p-nGZMQ&>}3W`UnW7hYJV zB{>AJaaQqi=tG-$CNqdTS8qq>84edl$KJ3rQV#@hh6_VbUvS3%0Jjojunx?>v#4Dh zE?8aDL3PHAC_FR?yCusqajnc;>t0mXhIf{)1OAL13%9n+Isby`}8OLZW(L+dA+ zHYhm;H6c&{E)x`>0M{_o3~5K;a`0lzoQu@S9Rk8c>E0TvoEK@JFeMYNAC^s`H#H-Y zFgu-x6DS@X{qLf>7jX(6uPsEnucjzTXHZ&cc=ktoTn#x zm?BPLc>Ne3{+U(^z5HM&ZW6o;S6NWnoN-)1poOau!Vc`d;kmyY zP!iz0zpRLCx?EQU@zzi$)-897bEj+Zi>+a^fJX1Pz=|nZR^|GiYd@35wwZeW#Zhu_Di1OuZ?hgfYr4r6N;`W9Tnov#%T$ zFguusxxHY@F~QpRnzCdHaf{zin6DVNBjL*KxZiMpc!Y1v2sD~^lS;8>3LsS+J1fG0 zN8rIlPF@p*_{no}1_Bp(qVt-O-$y3s`7bj#jRSgfM8V+Zru#U~YByns6T-KF7$wg5 zucI}Az$eyJG_o9SX5);c?~E`cwT2OY{l*B}LFtvhVq}28;axv0>lx0X1?tLf9AMjC zR_k5rU%s$N-PR!x?ctHc*sk3K2j>eKj>>FxYEJRlD^`TDm3jLyZi{RZ_K?lRTCp@X zZ)x+B9fDV^5xPzP02#(j;yBlt6Yu)KNsyb~1)BTk4b4BWz?;rUcuw)XfxJ1O)15eh z@qrHkg50zC!>nEs;6PoAxq*QTdpqwmYDTJkb%3B9G@f8Vm8Z@SFzngilziwTqJK7l+&ECm?DcU0tF+e7}p>T;e7i_XR8mL_h0`Gf)(9N^Uhn#0>p?<1sHK{5Oh2E&ybw z{Ek**p*T0jT)g^ka5Mz%qmZ;jDlsPVk$6+ljDF)<|$HG%mHj zF;}C2IK^&`fz<44{meFBh-Z#Oj1W6Z_}NhH=Inrqhx znIvXESPus?DKS%?_4A&;lN2S*T{!VQ&WwYQ0to3bbqGI`9RM!yYoi1ZJ+_{LX7*z!~u4n-v0oscI<=3A({M7 z?*+a`p3E;v*X_YI`en2qtX8{aF@hdD{XdMfQZzcF1N3VKj^O%!KX|X9ZpysH?3e-A zf+Q-3pLl?TJGzXh@%Q(R#7a=-#{JA|K+&_XsK0oy7VZtU%d<6FCCV{9YNx6VA=?aNj>K(SSp3N3LAZLiffK!1fyMYo@9c_u!| zH0!Jyi7)MDFjdH|^lv$c>wk-LUk}E2(LZhg0VB0{@YZW_vcb*_69`088s)?b z0_zGCxD$62>$>7J)YtwF1OaAk0_#V3!l3I>gs~?(c{0Qr1LotJk@8)2fe5a)tBloR zvw6r9*@d>BZXCCrG{QH)f|Wdu2X(>#SECUn7fUs?HZCvB=77%Lt~-u{hvX}PP@&e6 zW#|Aa_Lr3wk5z&+qxT=$_}3fc&;RS zQwH*9aOMs8m?#8S;jUG|JmRB)oD)VJ`4+iKE#yzZh#qX!y9Xys1)6?v_2TW?vXYlt zBr-v+7U(l_DLA(-QQ_V%7c>R*>83{YtXrH38r(1s4l(<&+k6P`Ez`cQIU7V990D{TF#u?JcW~Htjl%6w zE>7Y7>*pfT%3LzYg?>kZLF-s88#>kvoNU3X`kM&<0Nhkvsng5Tmp~ah^5DSk%U`^B z&^37iIpNX1wqOh~fT&B#7o=Oi1rT6N8P)+J-0Zmn=&#?JWLmQhhfyMsJ)mY!wkOZ(5Lu&VxPh}A{A z&jC4II501--V(Q-%tf6e83OdX4Bx$~@AU3ZE|@|wXS@?9}F=O-^Y03G~yg7LiB zGlZq<;L2Snj^sG=4kT6WFtp+$=gtUy7T_X~_ES1H?B`qQ(MG<<*jYZ4vRk?G4r zt?P~A0=SVf4*e)^=MEfOMkI=CLSflXGk+{hCN1~zjU5}qJPL=I5_7EQmkYh<$;R9jHE}|I zG3o_f7$Em|5BZD&W!eYc7>_XTtgFX-e0pyLkjDT&&Ih2pUh`ua!c1NdN|l+bPJ)dM zVWDQ4VUa2aDats;QV3{VyJ(t*w>x{7G>wKSUI!mH2D^Q0DJHHOomIj{RNAAh%a)Ib z-QqZmmJ@Hz1U(Az!6{R?^kA8YLE*p79&$KbqG;&s#z#}e$HVVBxo%xAtJ{K}NNJ<2 z^RR2G@2TDntSI<-Ik*QWVb442WI+Vm=2ocL@rV&aki(~TUF|YsSXn?rH!9wn{o-h= z?i60l9N>XcvFUu`6+oYEO^9UCkTc=NL%tX&2Rp`;YgsaUVReJNP5b`j3FCtECIn2g zjX)8G9Qak;cHZj1GCkH?w8m1jG1e zB6w%yQ~)h^zRnl8OhM`gRetx z#s*fU!BD$9;d4>wMq|Q^+EZC>@fTQUYCb<9aUOtan5d}SbTl6wzc{A%I=ImsQHS7R zPqsA}OHhh#P7bfRU2SDky`Wh!KxNn~>!TvXk{eS?jo}3v=`s>hX>*C4R*_#{$(C!C zMx9i}QZhR?hvv3`rcKF5mFEO)MK}fkXokVoGIxJ9#ix?}_-7Rg?;|NZ34dl?ym4?Z zR037&TYj;rVTU}Jh<)+?xEo5~J;87299*rLuSB>FJZ#$_!*pD71b2vMYsUr#+nNt+jOE3c;<9z?|Xy*VA=GpW{W&e^%W z;+wp8gS}_H+9Np=i;s84` z0|Tdce#4qPqQKJC!{R=>kT#X{w@SZSpaCWtzexRa8akuQgRe?wB?A;)G%*o3tU|eJ3@boT%~yC z`eiy#`Gi{WeQ{Ygxr4p+z4PW(qpCPB2-Y+S6g&FB+LYF{d2nTOXmEBg_k8PoCa>N~ z67NU#&Mi}dt7aq&;oqnAonb}m=I=X~m}|$7$xZ+oUE#qIf%%{AHeMIAjqYZn3#fd# z2FnxtkH$dk+wOf}fzwuGI0Aoz6G-HFOD-igfEykD+1+#I40dksVXK20I`Q&d<3vsy zV^#}=1z~sz+2esbVRv{5i6?B4xb0qK^=%rbJKZpTh$8bN9HwpBOSBvWY=)i(1E;i2 z&%ef0(Z%oo0JVTM=d2(upSzJ)_X0PA>j6O{e=Cg|>4iFPuKvGx^j`i>He2j%!-|lT zqPW4*Efvm4+t1@?cm=Ni0Fx4=L_GfhaL^l?)zR)9uROeFpAkGfB3;A7sLCv3jBI&ziuj&pimp5+vY$OKRonhhJ?m; zxC7T@(R$8VuM5`|xmQ47MRGOXCWy|y8X3+TPW{Ee4L$2;dcJ zxWopI<$CM+$rABf-ti6r-{D+^+VH+_+!&O-?sKg0+EDWucMV-v6kpjgP*1nE%q75e z_4It=;Y~UfJ~GErz-tFFP`lQi&hc%1II8^fw;7?TK?3`7Q0(6WB!uZy#4Vfjz>Qov zl1FnB31Q^NG?6%cW=}`~Z*E&qTG7)DvzbK@dT`KSq*%4a5Ti=uAZecrauIZJ7ztfF zcDVQ-c2ll0okeWd;Kb^RvL5Q-uvQHN{sI;J%6TQ+MIF#7`01T>Xb^YZCQ|B~y<|6Q0 z)(#Q6!l^ZUiT-7#s@ele6m{{Y7EH4l?_9e957_}Cx3Z*k`5 z2clwyocxy(<&A`YIc9>{@;SgI2imT8)QZV5)uy!mt$5`Yp?3Fu$B(3Ba+j*K+mtBN zHgi>9-e_&y#+^+c8K!n>J68wfEI#gD)h3$Xzj)1c6r)6Bm<1GHG@lyp5CEDD>kzMJ zyZJIAi=x0Tu}Y4b9EbcfbDO;;4-T%ATE%pYXF|ecxdQJxsnXsaC z5QZW&$b;#RCv0#)HFI*f5^(-ATeBCS-em`vVnAEHIJy>U9bi{{8{*$7gH)yY5$fRM zL_|za9Gndq%-C%k@qrB>iOZiN$u&HtA)qy~xF8sS*E{1HK;U=A5NQ*G*msxhBm{uqm(+ds9))O^9?()6tC5 z`K1B=Ns(vdxVMxF!`wuukyo?voHql#WrESThVE8C*J$sb>o+a5WADZyfY4gYsM>S$ zY{C)`7LMG+hL zb#UD_Yq#@=L~d1a8jIlINZ61)b&n`&FDr?nYs}lJFhUo7@ATpEk;s-C3xL@tt|u9$ zwk&PLu)}un!+~L@N$}j?_EJ6dFb^POxMEP$kP}nuBCU*jr*63Pyx#~(K_4L*@mUN&|^SGw&?hnT85vjOP;yIL%R^o1PKX* zC3~aJTF*kfJ|)D^w=1LLB4DZ3@IZ<81`3p63}jIr%u*Tmo7+BdI(LLf3gtqP)nkBk zag$DP+H()qI*&Qd1}DZrc*xKM?>d{nZOE9X4LNDH;((7i_s9}%zJKOl2O~AFeH`zn2JKE1&c-^3=bZFw9>0zLaZ_y5 z9bsg*T%Kjtul25Qm%<{0E?=Zv9B&{9Zn zpI8HF5imidQh}oc0*Y4PUPRy9Tw)?Nu7(Z4zIkw2#qW5dSetf^@o__BRL|q-!+zSN zZ^qj92-_4SMP5;-Zc*Tp&3 zEa=Dh`TNMAUA*r4n53lUg=@Y{L9FMeJ66m=zf?7mrsKIXZ;@!+a@=)nO{qpJI?WT~&6eHZ{m8ZKQ3L#=BUJ>YPCVG76st=sXB3O}X!an@~d zBADXXO-y@agcpmo4Xq2f{^;(IG~DlTtbRcuY%^f0pCCKBCIyX%Ylat(}b9|d7Sgd zSVRb;*ko*KR>zm~mkx|uA^OIoM>#ZQ`~Lvj{{V+ycoin|bO1>ln*k=nlK`aU-;9h= z)*K8JRjA-sShV|KupK#{ywgdB%#yNT2x_696;6+iE+k8hVb@On3=aPF^M^J4=JZP7 za_qlP1~$A5cauacSH7Ku$1!aj9;dBxFw^lNx^uKw&0T+=wuzgowHTF_7L2!A~+-S~17o|~+Md5*|+@C+uzymtK zPDfv}3f=rKIC5nd;fguaHzC#moIj1Jm3WfSjXlAf1?{5fK0^+HtFu)m0k0^(TDY)H z9;hSUZZFWU@64!CO>6lt4pMMeU(WBD1$nvm1wc3*JHb%s!hW^h3!|hu4jBjrit;44 zTpu_YE)DU$e3;?Nc64>DW@(#0Mrq!zzF8&-p@I$fhv2`r5UESFD?3y891U(;Nojoe z#LbEucip?l1r*8+eU?mNNc74y;+YXj?9o}TP?+V2d#Le(?`g+7y2dfF->C84KpS=U zI_DAz4e9Xg!T~nmFs&-nr}vwXw}S<$^p)q%@nAEJy%|$ZLsOW3+~vm{e{&_^?Mi%c z97zt|+z>^xkkj-y@;H45DG~((9iB>VJJvxEcGBAXVrV_6d}P@Xbnk=b9#jlZUmQXd zRld8JU;+|_0MV=k@&mrcm+KH`!?Sh=Zbpg70kq~}q!D^q)^bAh>OV#mpatRW$Oi}7 z>gy(<*-TUtN7=l5wAhcc3c;ZZ8DhlKSQ4oO`tJ*}79R%+O&cSZ1~Ax~NXgDvWRG2) zIE8jg2LAv!I1#(qG1=MDd}0y_0`y2QR z-qXBnfSVB#w1~=t=!dI@Oy00Fw#qCyf^%s+qYkBgV6ki|EZ~&1J6`mroZ< z-_~*zQXjo88haYva;E6gKfECZqJRqRF*F6~q$h8mSlXh|)Ax;1{01Ws@td`vtAZNa zdgQ>&>$kkVHC^q%&%Q?&`p#<}{xArH(Cl1QtNv#LAS>}Ou5BS+E(t3T2Dc7?JIVp5 z7RuXub7f}q#RWAmx*L^Mf*nwd!)Ps&M+s=43;GO!tB$263wqORl*$I(>He^)n{?+4 z%@FJL-YB{|)qCSK044R_%m%bCf$N2fl#^R4h-@4JsHcXw!y=Hz&;ml)aP^TS>GvC4 z0ytw}m9fj0(yBaZ$$d*x5f_2qCRCh)bm6-@-dHDH$%gV|Mthx0`^vy^4tW0n!ys|I zZmuS72+da%73c(P_Y5IKa0~!=Z=40Mn{q^2N%+AVFc1xvCjS86GP*1ulhX!qa0G2N z)&`5r-bIzO0&Jcx=xaV6^&A}orn5MER|=1m))3jR_ZMYHCiTv;ts2>^7%-a$=)gnC z@6mvO0^lh2A=k~E;)n?*r2S;Esb)qhtBvTzQl`%)1mZ;r)FKhr+4>5RMExVy;4k^cbmlMuQG z2J=Q5s$K}<^#CnJ(+}ST5wcssfbq`fM`zr?!=l@JFj_@*u;bv(r_gY5xs{*?0gc6E zf#+4&+yq>Pzm604n;T+J-c%({pI!{rNcC|9*@w~!8WI;+ZO&A_zwM8{5ul*lc8`O0#l~{0O5ly5krOUb@P<)N#=`?kijkJJ25xM1^(O>A}Qx^ z&VW&$FBuk#dtUHxM@I$3-=p`MmtT$Vxr&%rt=RtnSrX8ipM7H6vELoBKzY+HmkYKm&^NW^L+m&eN_UhrFK-#(}WJpJ~F)cv;A)9KjHgIFzy&;2tu#v$i z*2ApP4MjiR2~oSkn(@hzyW5+2#b`;hLkR{F>ieIcjCP&sbzQ@ZXc24MtfNpg3fzr; z;7p#tv^`_RCFA(FiZyklljVy2i>kw24mpQyCnhP{q3}Jq08_7z%ZwOP3^ldJ@YrUN zImJ(A(z(^x7km(?fiu#BKaC z1n74mj7MSSK?_31SWKpe?Lk9C8i_{29c-~I%7#77G^oSyO{V|&3u zU&D@$ov#l+IXc*FG9|u-4Z(QZfUv%@7)7rRtB0Ep_kL1_fm*XmAZ)6YQSlvO2ncvPXkYo%xQR zMq5pn;+UudB2i)2cYv8-BzxX$yH%>W4*SB>%vzv=m$1Zz0CkGUbH1KB`M^!tPlM|q zeF%E{yr6xL@g|?V;mv`wTvwhk288bF7&Q8E+_(@{=^mCYG@X$p{xI5eP8{)kDWGgHW z?BSF=aM{MLatYXV3}sTb&rd^<0g7ooI3bk8L^Zl9dKz)>DN;g$Hq23ZMCRvVJm)H% znBc=y`29#)Mj&yw5ozRLg4QCYoT3rk<+yU= zcr+X{tQ3=*%!!48&sXDkoyShKuTD+q&_aAX+ zR2+?OzOg=({o)L(o!K#2P9*E{WrDaN<-FxW8pTJJ zuUavt!Qy@n3CmY!M_7cDW~Mhnqum^C+MV{ep{i1WsXJVNrumqQM>AX2D6%Ia8s3A0 z6$Z3-&B~>MH(@Z?mH|4r-;5I#yM->MRN=1oj|vF!44}@HQ=jd}Sb@Q_<;Rn@uRnNk zT4RD;D0G>~Z(YllLg*Yq2a$UOz+2X+m+_P>xyF$DUbB4ga0Vqs2pA)3zZKRfXf1Q` zmmNNE7h|>~tMEKB&kWrGzAhq&iO7(c=$m=DpnDd`T2HGU9 zJJ{chvKx+EMjAPwZp-+`qQ3E$W-Bgfy8i&lyx8Fea6HB0VP~9U4a_LD;+P#=fz{Bn zBk0De{qAL|fDQ;8V?&;>6=?^Jo&0Bq1S{KcPOjY@xiI+6C~xjgdDbv zeLBj0s0{xAnGAPDy0m4@2#g>*))YvQU0#QNu)ts!fmO$1*ci2)In)5CJd+BPFzZuh z{znaZRN5=EZbcxq>|V=mPQ-)5p-@z=pW=>|A5qJWGNjY)<&+HoOFvXX_Gl z40uibX9pI^0QE5(jR6wQgYx02+CCOm6eJ`)-%tqlYYFS$ZQHby7&c)^a| z9HG{CFvKDTkT&psxiWDShYQWTO@Hdn6f2FSsmjvGcn!QOv&j*Xrjr%JR(~ zkIwK;%BxRYOMIR291Y$DX40DH2IQqZhO}MaqCuslpC?&bCAw&4A_I+`>rXn$a)%L9 zQoV2y(bqg3;|=0uyV$@7y*CuSD+%^JnDi%X8!^Q-VMjBJr8^^m2oq=%Av>V1E;e{~ zg__Z!dB0hqbgTO@ptegd`;gH`2j>FdRtB#c{O16|&z{x6$3@5B+nUO)X0If`+OYR& z=Y_-wb9m)se>W5X*76u@K>XwE3(rr#S-1zJQPLmoSZ|(0;l<_bU8}?#u6t9%z4|zW zj5J^$GGwV+WY3&x-1O34dcqVO1smJ-ia{sbUXP5gRQFH$j-=Iz{Vo_IJY9a2#o@=> z{5WToEk2#_Fr2k+uc9!cb!fH7H4g>mUzFk^3EAFo>$D2DpT@ z2?ake(Swi5MtC?@C2doFCrR0YkvY#E*_+1C4%~K^;ElM$@wFp^I~wBW^Nz_X^CZuUH#QI$b8Fv0=4wzfOk~ z->~hs;}ZbF0vT8;<6~gM{{Sh-XHR$u*ZvYGW*J9XKZ7QrbdNJwr&r0C$)5Q)lcA($ zNzBEYJfXZc{2J!CiqkgbH|Lma%Usd_0QBanLsu++9czqDtp>T6JT~}dt(w2TtkG#X zu35f+E&Egr+GmQ4n{Q~AE@0L5@8g~Bxk%}af>!kNaO6<4m9MtTHPO($? zE)Su|KnIhExDI+EVct5=$Kqzh4OFD;YZ=+fAiV|2O>8{f&T{!3F<7Z@n5E*q#@6(? z9!a9{QGSOxu?10P%g(T*QFT^wqURUx6@eAF%&i4SO&;-VylF>sAW28#UO5iLJ>wv@ zO;wt}6*%F#zd2}to58-P^XYStUqL*_yEMOJ&Nk3WUf6Oei_Wbjq{ez2iddbFO-LW8 zk6d&mRP8yR{{U7jQG<%!h4{s5ewW|I4Os`qrZQ~Pv!G>{V3xd>xCV`sRQ|Vur4EU> zbQ20~x(}gFhv34sF^?deuZ9OAG-w_PD~3jZ z3!36jV*da*fQma!I?z`MigqI}0pY@Wq5Su(lHi3xd5gb^!_7d>^{IyUxHVMq%@UJP zF$?(#SRsTsQJ!@zt)k zj%r5L<5{m!B~{e;#1%e<4OeJ4^WGsTq4(Y~9K&67-tZyv=-ZD0D1b@TZWa$6-h0P} z{G60N!_40()p$9yG;4Q2!9tNax+kt(b~L-LC8+~NgXWGj0|nFo!J8ekYlF@gi$9gk z1!$1u=D`gwE(}2q40IF24q@pVtal2v7#gKtMSdJKI5bdU4BR}89sOghMb5AE;c8KJ z&d)Ga(~urm;TNei{OcG;SDoRwC!yO6Jyz4_(Ohely*r?KW#y zTny3M4U{-G{AQ8S&Io5F*{3I3%R=D9?e0)5=yOwPYwHjJ5Ko?_Z#B+*$j1Yrd3hhlZY%am5Yn zJ$24Rvt(OKc4F$rwZoQb!O7d@adaK7ZW>aHLADm5;;U)l@$-qXrnP&nu%f+^;Ofv4 z?~3#otw!#e{;@)?(ZAr}8`1&iP6^0zg}=rN!ycag-N1S7*M9}ZNT4+hzRQ530bjyo zEXV0@pG*L%QO_^l0~9e<56&Cv6xx34o69Us%svBU?ur}P(!NY^mxueuSO;2B-@Q1b z5wstYf09F6}0iWs704`$&h7FSRafBrDYv`?*MSb=1@DBZ%MD? z2;jEsXXh@Oy;S`fD6wdhhP*J{y#X*6qZ)QwtT7vM+GeUX#D>YnJy0Vyo@Spyt!$-d zF9SjSY1@SY0u*tIdAzZV`1};lyyTkDF($U)O0TItX^l7Z$5Nby=%@F*L`aOu#rtI8$t+ zwLsQiXc0sAxsgInP6#(<2>cv>U|^F^kbgU5g4SWVz7cTh@D+Yis zmqf#6gLSWtcZF2vruktVp$}WL29`R#_ls_Xh<;b3{TTwO4OmB3xV(5PoSDHKr;v0t zn*_BkfydXEwV8Nz1O6U6cIyr*GMzBVpU2?A4(X|~J6=v-!HtO|Cupem+t!&h(&;f# zsCXTE%xZPlLiplymCCxCxpP8FHHkDs&TCZIm;=bN;Iv=@)pgT{$T*9K zT%BXauJuATO)-W;b@DzBmk7wHRqf@ zeH<%MH;U$!rzug6>l1Y7932B(XMJF*{{Z40;07mL&u#&w5$-Pf#6UYw!7_+YG~3~k zZ%Q1j!MHH4^Fe^?ePr3TQ&IGFg(Wp0>kwYkcnE@x{fzShZiVk7dh3IpCK5VpP9{M3 zgu8oYBE=E%+tkNk))BjUUHgL_Gl>|zVt52=OOAZ@?B_y2UB35-QhEzokk5e(I%X^) zrR^z0`!c4RUYu_%AIp-ME3DlcJw6PJ3=Q+)ysJP??m@@0U~Zob;W4NNt6x8y5Y*Un zElIFl{6lZUj6xNx`X;0HGPng&`rDOOWpQi(0x=#m% zdI$54(jh@b_;-YED=JVl_B>*O8OI@8=zk0fOZ+Q6rJSKeJaK?iSd$hJJZ-Z!~p%@=O!pAuG8xudoYSU>HB%W z5&>cU0qz?5;Nd&yo6WZ(D$SMSr{^IxHf(YI<&C=hkk;@a9UP1$$tAKn)sqJo25uqw z_<7DcRNpAmw*r`_dJusAT$7`swjlGoBRc`E-kc!t1^Wgt=nE|C{KPaqlVMFcYw*#$ zs1|*XGP4785c?+=0_xjqYAW_%F}>?a{9+YrLQ!b}cZs1o3pvyhpMpzw#zz=dog5~c zCi3t$;5A62#S3r>STSd$}i(76-ftcdii&~ z80wQMTSor?(D}UMTBHMm7jNXg;G#kt*?SMGMj*3ogiXM^{{XpqF7o+&zkj?E`B$1f z8RtIq%B5I4Ha{g?qX0v;oC){GgLt72ox1@)l+pRXNuxDo%~ze{QZNM-UjXfl#Y$^; zyi(8%V1Q8t8)nCWeE$IHj}BIkzgv31SDioCUyS!oL)kRP1-%Kx-YI@wa2d9a9P;9Z za9RC>8@e8e9D@P9@m%Z=yZ-=s$b-h={{SX%v;P1Xo!3>td~(Qb&ba4NHaW2uJst6FZ{rgqcA{NsRk9t=VnHp62< zp&M6b9q-CCV)e}*y0ceo5dZ8$4(kc$X-qNG(jTk>GPAXT=T!ffQhhJnFQ1)Tg1}5LjVdc zAm)JhQvz(VR84EF4z%+(937kw98EyfX)~J}gUxbSE}wll0yPep?RRq+?eFBmgI2gZ z!>J@D+&yDJu(4+T42&v;TgLJ4taO-qxuyFP!eN4;VmjVXV2%E8lwExFfU9^-@_v6g zGJ@@TdtA2CjjMM2T%ninQvMLYR=EQ$;r`sL2V#Q3=5cb)W4NfN;}HS@YvV;=^%$*} z;C%BvFlvAkqrx5ydd=RFfE(jtyq;kQM2&Pc%;`fulViI-vLhx6A7{K?PVKClrs5EEhP+mPoAe;9;aSe)VR7NM-BGmw4J z973j!woh;g8cpxwVhh_q}llsn(n^fVUF5H+4P!3&d9ON{#+qix)lmltCDD~%A7IN$kx_xmx$l3%hl?l8J zu+VP7s%Nd%28aAQQnV^!&^CLjZ( z8dUW$YP8b51aS`B(@@f)o_R9cih|S1oK^P%(z-1vp*HWVGKM+2>zorsDNT-I;oC!0 z+QW$6@RuJwQY}`;g8>u25`E!Xq`WBfdmMmy6J*3%NSwA0#vwpG@V^=GdN%83cZ0fG z(+@~kw^Zu5Uh*L_jON}iOx0qo$wP}tTtibnnJ?YO#@K(w|woaw3z#oh}$rxgI zif+7v;AdAbd|QkV%6Y}FhDqf79_h{A^Zonn;{+q*f`a)Hj>UUi|!#PaRR z>sb&2NiZcspQ(xnx$WTk!2rjAqn1lj-A;*cV-S%#a+8DG_QD6Q?^wW~f|jcDB5u zE?TPz2Wj$Q4eakY93ivdN{jC8oaT|nxa58?EKXrEk{q0x{N{tO{)`#%f4m7oU$k&2tLMBz>?Wkv z9ETh49*76LnlFG>MfV^iqcqAILNNnn))%rH-`*oZPCeEP5Du`5@2s_CMI`*V!lm07 zH}nRflTwO4IpED5;x&q1HRcAeryQY#=c$M4Q+7hzDG>j+5Zc zp~0`&fvRytb<~D;Z7^`RMo1bR;lsnKl}a&g9=bK>`o+gOriEYDHN~U6y^qb~1}A$g zMtuEb>NmP^Pb~G>>6EHY&!t~oVo#z_@ok@OF@TEHd3ZlgP`nC52<#WGSWrZOBi9Z3 z?S%x|-&8fNo4>APqd81|Yi;SJDvr!4Ah7{kqDLrisbU zDvefqZI6x4IM9kM7V+|B>b5Uxu#?NJlH1JWIy$VC6gT?`75#0=M>YTu&iYy!!G1VLpub3A%D5M+@ z2N=>iJ2y#POubQ8qI3Cj64bMj?0hDiVjXW?M;e+qYtWxqBkKyUUVd_o+G~5005`Q2 zTu93m7%PkkDxY3}=PB&KF(3DQ5>T}=+UQu zxJ9>wM#mGzb#`N2CnKEz@0rg{a5-FCM0ekyb%X>jXS8bkb>=Vxq!Cd)IPAJIBs8_Q zGIa3rUcVN_PiSEZ010k6*xumC?kPn{pU zTPg(1LT-ek}1-ELx2^*J(||Bcnnqgn1e>NHOBBHI=g#joC~%A z&+iaYZZAAtIsCE1f8YY3@dFtV8sE^saq;=oo-QvQYJgdAk7T@9-2F~4a{Eld(PF}UGTwtF% zT{zi2)ak$h3%k=i$rVa#!{@wY7}lLWa-&-?WIixeB@Rf7%*8>>WK6Ao6EDX5au=fa zims3#AA>L8638Vo_m#eZJI(6d zw+m1_h7h0e8+tAQy=w0Y=-bPO5s=p=i<34@%xqJEywW^yFL*3y-Fbr+tl}89ffhZE z5(baXS*OzlbqK7-0Qwy8dBtq`Mq_pxS@n(RyF&;)X;<3yfP>g^td5JwbLImnsu()z zjlkgLgohY3*Z%;?fu;Fv!)Pl@3TfpJynvM}CzwEe-jsM;Oa$3`)*5?HT{u)z<@LnG zG$Q3La>jS7K6Qq)dd0e5UK^y8g8n>IUNPOu&3yJ^qA zlb;heLFVUY@k@p7bj#mm?BvcvQCDqOlzMt`(Cu&7=~wlDk<27?b}xn)05io|oo%KE zfH)pPeM2p!fL*NqaFBBVdVKyc1P(=sx1894z~T77q3Ut7UghYLZ{pnNCg{~uys5B( z1!%Lp1p>`-(qPnVPFLy$2>rbkqA}hBXj4##DvV znv>%hVj%+b_4gw2Jri4+!|h2P z>p8`_%+8KCD>%k20nz}t1sTI;$GoIUOROzI1zvxG<(yNX$Arsd9M*>t008>l8zEPy zgo&LMx%PvM)XoPZ_To2FV>@v6N*W~!K8HB0>G{o*uuH;25}k5 ziQF9V3;|FCMol$dgKwi*NT9E64nUn8y%5TXDXrRF((~ElOev-$kPZOoLw;r{ExTwM zlXH$ClZ^n9sx)KycQLl1RTE#JSf7j}D6muyQR`YftlmU+9qI?Y97PZSg}n#IW&kKj zrD57L@_u4ck)i@h{{SE6vD1h6m(t_b#|i@1CZD$X!K%YAVz7j!Y(dVX@qBKr;*^sF6G&T zHGdfGs@qGZ;A)RN(&9*PJhgjpS1u=lcKRLQ0TPH``+{cz-b8&x#+CMh3-yovhWUCg z)5a}TLw0Ki21gixy5w`0!EXms6a=2iTgC$y-@yEw0j=$bTFb}IJ4X+B8S1_p_VZ;=XA%& z*A{8S-H3(tj^jgIafvtr?^?#2O-3A22*n}Cm zg%J`6muE93X>S)uLOwY=T0>wNyc78dXx5!Luk8E`YqXS4Ly!gGJR0ISfEgx=WGx#} z*hz;!4hBo|WYWUGJ~K^MTIUEF(4&L^qntF+OVe%-Zo~|t0OR+D&C!iG-ppt_*ILL< zI!)^+184B!4rtC5_;-MkZ=&5>hbPxH+c4R9Jn_qm=t`quC>jM{df3}Rgj_0|EW=X_e`ng-D;5OcX zy=Ku}>4wX7Q@_l64Bv5lXyBHLha4Jm2g%jQ<4=>J)*JKH(c#>Sx2ehax^SVQChe_9 z@2BG=0d-#o@{AOCAUOCJui=VCV>l~7pLZaYSrF1Y^OGP5awso40RI5-aZVqhgH@24 zFr3B#@3#;jY}G&fe&kw0#@Y^r{?75C6m16^Pvno56m;BnEQX)6m-mdIAZX5R6BgRP zv-ooIN&qi<#g(C>Mjj(0N%LH>G%d+rcb1B{Lr_1=Y-?*>E)Iaag1WOLEn@$2* z9E7T|Zw@a$L@BND^kU;#EAeniUb~!mF;LMPtW&=5k>#(nw^)VM0M1nI9T_NEsM(3i zFGaPN2~GM{>5oPkZ@8=nb(o--{Tp?e0RjZFw+@sD z6~(8sE9AU3E`aMR)nqSa+fQqlqX73AvGYJ)X2i#drYOchWoFY{J0EyNJ zlB-icW)CByXybIOQSu>soaXj$esL*XESUsr`aaK`8>pt&KA(}7EzC^$VnHfRtbDhV zBb5y&4%Wo=+Y)Dg>Pg9PJP-X?SW4X$X@al32Z3naNbaZ4y17MaV)6ROuy1F62_WY3y zM6{4{_?z{-L1D8-+tqcV@G*ecs5Naudf}m)q7|i2kI%S}F+7@f{`amD*S!_dEc^br z4`R~(AAr_ZUDkc!#2P>gx^0FjP^BZ98d{GN^Cm516(_Uz#ymwgwht?u6^c3B4qOqV z>eFv?0H_Uav+}t@7Qp-U#|VmuxozVEasc0baZIN?-zzPo~+MXzsP zj9dPmt_mm8leUgI{;nN)qiZ*a4aAL7@M-rFpj5g}+IrSUi+VimR`}x+s{ob<)-L{q zU(PzOCVG?Ovmi}8cacWNo34H_Lg%UQm}_X(7!h=_KU&6Bay&r17$^ZREAPD4Q%bGW za_|xgboWfCAp|=Nj;)&q3k0{{o~9vL$ajM!B$aoMIwxLT=L)YlE*K0rP4kEi;nSJO zhOmeoP=WN#k$nZ@B}EjRYvUS#<`l1-2{%s`<$Lp|`^=bIcJ*?R$a1O&z?sHSbVz26 z-L8xdXqbi`Zm$WJxm_{K9w|&OBlD&Pqpb%&w=`&$^Kp&ov!h(At+EzsdycIeZLxX` z7786e=ZTBlI>H`O;Wsa_#w+KP?-YqeFXYC_C@TEnjj+(ml?(=j0r_~|Rb4#37{OF* zv#mAj0#aU^n#pIf=-1rBJAAh2^DGomrSI?K2#&6%4a{&_;6fzc`k8(mKZoEl#)kZG z;$WQ3y8i&{g>fA7@N{4f(edsfu3FJ|Jxl?qXz}%$64Il&?lWcHsf8i=m4^Dcv9C0| zVra7V6IjyO(rh$frQ zL+qJD9Rrhg?<00N5aWtup)WT>CptaAF8`q2ECu@qmv z=ifQ7MOU^pEAZhdvcYov$EG0cCR{K12L>jU)VoNW$Gw@b7R-T09G`#74JxV)nlH}2 z6BskZ4JxX?c_kTL$GCoJoQj{Mjwgrd!R)xxgI)eeIAYiVF3uzL@rqbLMxp{ff|-{1 zy$*4A%++!<@jvCwOgF~xV2fb;1!uvO6w$-7=A4p4(R9}ozyV^mkeJrp3-WMghCd)! zIPkoV;QXvK6;ky)Wq|1Jhcv(Q0V)a~BeG7Mq}P>&EY-c4U~9yWR^bI>fDrmiyieOG z>A#d6o~KSm+^yX1fsQRz53$d z1xf{o5bgBFM~bf4VS29dg55b|#|zU6JIb6vv0T07Y;vhIaIdQ`9ZH-Rw0|JzWcLz_ zr`1o9On0=B1vy!xm)EWn@%g3=~J`A3o(}tL)CkK zw;Ixt#YEyoo_>pr29O7|(x11CX->XepM7xj^9?*ijhxf}0E>>$J0-RVY;>KyxYkTc z-j-L(we9o{HE&fZ^Km~vd9)KX`yN`5Z#%I7@!~9IljRnQiwe0wi@45K_e9lagC2;1VV_ z9B*dKToJWNmy(%5!vRI7ohOMn6>7j(nIoXwM`Spd;IZF8L~7PIkOkgZVsyj(WXH9HaSH#~Fx%z@}^ zw@$x|m~gZD;$^CtInyRx{^kpG*~tB}<@RgLT;6MT=jzM?bDv+{@mWBqbnfBA z5uh2YoR*MSaisvJ$Z;XalF(<_?K#MxIMc6=H{NMXQ$Xkw0E&aHGUKH66N@=!ATXfo z(D&~OgGh{YxQ5&N94TlvPNU7h7;3O@ulbIRku5y-e>pVhouTu6WmU9KN?dxU#3F1w zU&bZvfx?$n_sN_Rkey#-1vP-ZhCW+R?4~V}5aBcULf{#w5ftzC zWx`SD&}w5e3uJm-T!kwObuiGK)`6}e&(<2pV+Vyf*QO;O6a^@MSe?#6?$dYlV5}9? z?MQLn7mxK9;>?;ba!t=4jYPp11=#EA${3@EP)C{8uX_D!BA8It3DywAUPV-bbXIC2;XT2KJ#;{$HHi_Y-z$ow`Yi;=VC<8|h zj<_DO=o>E9{{Y84WJWtJf~?_yPD4xXMbLR%t+JtY+!IQPqHHxQNVC{G?f-0Nc z3CB2{YBxLM%(?>3c%i+1x^hK$c0&AFgBkQag~y@qn@O<75Tk?yDFFF+@NLAjKxoc& z{W=c$ac7#oqt1(`lIs*L5p6ZmgM*>bi3lwM>3;njuK3D;fob!a@m?{i=R#MeuQ!a4 zY!DFn50Q2IVkjVpTOq*nmUf$ps*O1C^YA7}qT4hzO?>bj8^-WMOD2a_>!a6svVj2I zas2`Na%ZWZ2dI1wFplXP@pkJHg0#`VPv;RqHw5F{*r_@Fbf!sNb_W6XgHrGt>qWlD z7+NI$nG8`Emq;r(z$5z{IY4c@Cb(`m7;Ai9bnhQ)TNln(?+9uqRX8}QfjBhj3(j)U z)3bTN4II&sy;Bh6jZdv??Z{aw83&=d_nK9FiPT`-suDT_ZYoC#ZC96g#)G5-c!yRv zt|O>M@1~@{iXFV0u3TY2Z;97Xz#YHgeVl6s5?T&U5+Y%vvZ9ckxRLynWw7>b3`}Gp z)1@rG81e{=4!$FTGzDRM%8~Lc4;%r}zQ4y==3DH&_m4Z$s)uRTC0hdCi`KGq(7el> zx^GUiU}tK2=Achyb$PhSYu_Uac@?aEk-?Z=aa%RfJ3neD2{{ZQRCqN0hY}5Df zrXLwnb_zYl#~+q74G~p8!x137jna=@lPWHH*|Pm?aMt3IZdp5=eE-Lw4O}7 zL<(>sH}L-4fP_b4s~^kGGV0F)#{ODflNIGbMtMy=^F!nNM@#)L8b@idF@dVLrui4s@uLB?FN z4yk>!@u`s};786KeeL&%IF~hNOa%UnlO$bLuC*1tF~J@|O2OztAAfxTHa`o&=%zaAU$G&l>7N z@2n;oB6oT-bcb9zG){~_&Hh^>qq*)0fkj%Eznn+`E9uG3lqBfi-;Pno!jwJK#)wcf(d<@utzz2nYysbBc zQ%;^&&I<51k8Y;FSyf|TU|b#m{NXF51XCQHBe-uMVsVSu7oWE)cw-t4NNe$u`q)tG zE1Z(rRObqI4uo3A9tb(JlHpU3?;cy-#tG~7ld$B)X)i$5yx+LH+!tMz93cGgf#>34 zE!dUl$8Q)JO=pvq-aE$x=s6A3g)p~nlx^$M8Uv0HfEc#4`42tZV@NQRi8vF_(Taqc zG;mr6U)v08s|REOd>p;FDZe6wZ2m6+<|;t;pag;s5)K0Z2#6h89TU~Ow}GXl%92md zn8k!a9H`NIeQ^SahT$4}@rQW@0x3U@F2Vfcu4!)z#=iy2o1>_|PF#N@M zF(!zRafELn4PE+Vyn?IHrUkneiQJsb1MLJMURN8Dm3ue?Np|)7d>FNk>3r6-V5-_E zlkj?A;$hc>4YL$O?;f1SWP5eNlF&m;1k(7$5wuW0UAR4te|$NwHIo}wd7=mn*}eMQ(S#KGkI&vA!w!ay z8-jmtag|hix+R7+1E6%@S}-?!Mvib$BL|0WQ<`}iez$~y-YgD2GN#6yJ5A2Bg1ooV z9V%k-TH-qUFhmBxW&F8keFdkNsq2yDB$)(A1rA1q?a4DoY|Q)B1G3dJ2!51HO3 zpr*w;F!PM`r`N_%O(~}is?)T64pfLWKqS`QG7!;QTs<@*C(Dv_HnGxTGn9j@2Q`K9 zn}t!Yhpo+RLL3$Ch6GZAkf%QTgH2d);X$xJP6rwU=M-bfFEi%_hsWc+7_qRc%K{)a zu1|C-q@>C=B43hwg($BthVKERAfowmNvElwd>-oL1qXsm1KRBfafB+;S^JvYqg}<$*+(n|HhOkL-mA z>N&?XN;n+5=pa7n&Jc73qVuc{0K9e6$HoY)TiE{b$|#K6aoPFaQ$nwF53H*qy3wcB z02d*?X_P7fUXM?cEkkNhcJGOwSP|~-NTQnTLlL5r!yX1=#`Fq3Uz4l=HgS5N_YO;8 zM@2#5*O+Wv^qufAphnE_+WT;~no>mX`9D|!H!Qn!{S7Wlp>cYfsCrk?kDDy|0n_+n zh6iV5vz9%1fF(kyM?)~h3X|mz;leS7KOzL-1_Wv7=Y(^?xFQV{-J%fx0P)4@LqR8i zL&2;rpU3^JCbi4LaEh9tynP-f(k0YnTxza%>H2b#?Q2fXFq4mzUkj?! zEBkB%R1<#m59N4qa&;hl%~=S8+jD*JV(4z;r_op*`EhGX1W&-Z`WFB}ZFj!7$Ib$z z<6c{LYwHTCsix0Kzlbn|Varcy*Dh|j-0A`cp94 zEhVkxnitF^4Z_OR-!JmwISLb{&~sgxO(|L?0)WDs(NZSo4jp=mzQraaba4b&hcagE zVQZIQ8Y*#&wNQ;$rX7txfwteSg%zDD4^iGW93NyI5N01w% zJ}!iUJlTe_x{;4!CeN&4Xb;)U8xszN$w;=OJ?=P$XjG+53GT7Zs`^8z{w@+jOf`CH zIKEb@g9gnaj4@?e-$yI#cxv-;;J-P~9@OYJje_Q(UiSx_9k{l`b}fA2zXJ}t7Xs|M zmF%3Dg?Zkvd%GXy%P}>WD8xCCH;_UiP`l!W5D=KWV44IWBYJnezU2c|e5a3{`S&If5GqE8(G9-bRE3dq^C$2+ zFt9ySsvG)wc)~cF77dC%3SWbG5HQr>HO!rIn1vEMFW-Nk!#LPMdhQ-iSf1)N^>;(J zoCU;7-T3|{Ucjj9SHFxlR$z{Z{{UEgBnW5=&JdvISA>@qz*lG*-e~Pd!Ft8EGz4a? z=fe>Nm*HgRc!8j<-C^bfsoDlMDvjq@t5)c6+~avjOHOL-rRq^4o!MAxcUO9?)yiL9umDlCMRAobRSc7Ql{2zW` z=vMec)9J|ON-qOVd@d;vt*QXxIzI0pIdVqqat$N(h7PR-c1|DgYYJCV#)EfR#R#=T zM^XO(nCqcRw^e_Oh=ngS3`$4z{_sKBP#%cjKBR-P_p|2+A^N!qwmkUjmBAES3D~_j zt(r$~w>i?rNw_^{CTlvJgj**ChFml~ZdnXN!X4N72L z_7y8jx$}q$f^FZ~&5wfC$%+uVS=fhntWqMBOX245Q{hOU=wZadMNlbu=3}8sI}yMf zQlQjt&MsXf2#9pOWE-2JZ&(bqlDa2pkxuxY@J}bh4!UrJMXvAn^_%+WPWF<_Vw_0t zNZ4X|iRj}84iU%R1*j3TcUY~_yZ7=rWv|OqSkP?m1`^ts?Hv#C4>GDLqh5>G=QS}D zCkb&%FtyKEx_MD&Ul`gvy@N(m%{7R{o4vaHYXWZ4jfPR!WyekFgJG#Oy&<`hLQ|gi z(~kcDSe|-2Nf5`dQzCn6394QRX|MJ;oESM9)AfS#yum4 z&5sb#vJLZzihw4fDzmr9ti}Zh5yR;IborC?8DB?!*Fbb*WD@DIWsJ39bhSkwLWONEN7~5N9!t)+zlZiX!4M zVBUb2qX(t0=A32;Of8B&@T~KO?Sv}g?|tY@L+BfKB=44JbXukUPscE2EV>co*8G_x zbFfFeM&wnb%YS32II+WAI`iWEvl9|W>r#GMD@ds_KFDE*Qdl7S_F%ne1+(qNhwzIL zOFbM&1`U5M?Rc-A;Dov<8JcGY7(fKzB?*7$ ztCrX|u$}LRx1$WM?k6wsO4cccSV0l*8WwGS2bI56HsCFQ-2=GRyly%1OoBf5bb+&c@sw*7p&Z1Cn4rbHvpLb36Lhim-aTnN(D-qCWLM+-l^CfMJf=3p(L+Rhr;e72oqNIs4$$YJ;K zfR#NBb$|ic0+5-U74rFvKM#P0tDQ?`>EH#gp*Y? zjNr(UH|fqOG(5c?T&Y4P%?x>tpTbSt60S z%x&L1ljj9D?Yz(cfKzFRnyT5e@AHwtL}=H~9T-kSFi3#%160S2Bpc&!qsov<7`a>l zt*!Vm7YPdzFUyJI2(xhMonnz7itj_!9InmLuZ)VjP+kX&(Io7YVQ=NUejD0=D-_IO`OcVZBvHjmo$Kd;t9cnTLCsaYm6XB z(4>_$9`R{JjTO+3SVh>|PoVN;nm5VN6Rgs;1v`P!%{~sL<(i&@d~#;7Xm~D<;ld?L z0u@7P`!EW?HiNW$;QV3Jpc@eeGQCV`(ZTvNtlyfy+13z2+k*7SWE}kruhGpnphLdf z9~|L~wX4rveFoqHn2&2-KN~VAZPqoRNWJL%Vjp#I(ZGDzOb+OL+IIcm9Y6tHc4Wlo zb5*<}>s~LUt+SBKG>h0(Uq1NhG2B>PZ%Te+3TknU*6rm;MG$vqak#`k3|8D9@;u>o z$aptihtGj16s>R7v+Hpl0_Xqoo#-yRVJvA8;!nck>dRW;Z3r^CH$A%NerC&CKDFxM;0+bb!qML$v#ZA);YO=t3BNJgnluGR3?tU| zd9?OSqySF2y^Qb131k`J-Q&006xGVF|;FgYBlGe#HN$uqTb#1 zy<@U2v|*4-&}YK9CTY(y6+2E9%LylX44e}brSKfN2@>Y6|VkL%{qZe@7$LM=LZKEWx)`gkhgGgo7n0ZM?zt;KT^;?VRU1XSk4$iTB1&Dm z#4Qy#d543DpFi(Aa!?0=>wcFtI|!iDjA}(u;>{O$u~D4v@%&-LVP99Q;C^Ilw9vme zac8&T17MR0Yh!^(Sv zyd@0)B5wqi4be(!esbVc*scC9An-g)O|e6{tHjBXNQYa^kvI$nf_1&%oa|kBzcF3w z&~Q~w9f&o+jis*1lOZ9_*XII_yvK7nMMUx1VkxwE+misc4=gT>*C=p(`OZeZUZ+cv zy=wG3=9#7lj*hjzF_r_Q-WVv=(OQP|eb~ zEJbt2L7*kdaU!oYYhoIV_ZcAnj5KU9MH!}``0V|V6(r@ z4kM}2h2VGAQ5Hq0_gu3)U!VFPEmA5={&zi(4>p?{20o2LX=svOrQSVx|}@zb7C9U+jE32t}^Cm$e)9mblNK)JD3l~ z096Pu$G(0Hk=DvtQ^_ELfovY?tgEgpgUwg3l zhAfDjA7)#e7*SJ4>BGkdHphhQhWP6qQ-{$a?Z+1ieV`xtK3oW*#nbZ9HT4H;jNRLG z2_WoQ(0pR5_6@1Y@%C`Y5<7+Qzq`w<8z-<}?7<#02GIqmNB4K+)@WK@QSj5F`*BG6 zj#1XUz2fQ^NUC>4E3`Jn;VU2!nREz^nFt z2xAnhdD5um@fgeVj~~lHdO9*cJ<_^unz(f5ypUP5hBQfq44skY37C-4;l{Cvn+S%2 zz`}$`RTbJz7(ujfCk9Fse+^ry^kvX$D)IG@Vg(`Ky?pODi%#7!`^8p>hZo}hZ+RII zI^5IIk*o3!nI=T!dL6Cf@r$A*5%Z0F=ULhsSx++6YB$qc-dHLYH<*|#F-UqX)Z*dB z482|(+3@EyXhy;6%x6Rxa`=_P%)3MfvG*OK!Y5~apGGEFL}Ul+gZKC_z>;>_QRB<< z+)0f^-E)L5+!8|Vz)dX$dBzk-Y22I$U{T(iZsFMHA;K*l&imFzWfy~Omz;Bc0pK`NW4?8g;8W$6>T^dgB*0I~CJLRodZ75UIh9 zB6rrr<5-;SFV|Sie#JWuFy+!g8>1;=T6CoA7MT|={{R7$Y_pB)>j@dHl9TMEb7E}Hd%)JiQkF3E@_=+n4{rdl+Q6!z@P6;aEp-t%Kj2tv7i4y(hH zTKLbW2jom>(g5cMvsHGOu|#&t@0+!yZvgXBibbFdOXY;k`c;)|q|8aO|a)!93~VMHYv_tPj< zXi9PC?-fRir=jGyBpe2O@w`-W7slK*hPqxEV=-Va2F9{`GeSM|YpfOHjy_)ed^oFX zT$foI@d`TSuJR_^TR7vab!t>M#G^cLSZ?2u(qtj)NE+JYi&N&F z#K0=8L#gw4Oms6+`hR(N$jdu69?6<3JBcor;{;ZiF6!bgYgjc!aITX1k~Cp#4&Pye zi;yTSO$WtH8v#Eqf|I^Xbo~qG&&+Xc4%b>T&O~3oT;ThdKw3gqPj9)xs-TUP@OuxP zVhyD19+)4*H#r8T+xw;j+%X@f;cnc4GWRFKugV4{;-L&MBJve8@OTt7&<#OvHiH~&{PifbPOp#b$1R|kF%E4 z^$}mx>|#puH38$a@Ta)p#m6V$fj%fO2I+8R{uDiagOPlcCVVe}<|9w2OW6dK-@iC; zkF?)FPrwzzfdx@*6ochBN+gQ{ojo{6d&7^ibo}j%^;+mg+Yr}p85I`d3Km1h*{?C~ z1LSFr#q;u)5K%T%N)NY}yE!^&6f!?S+v}DNB>>YTA?Q&aOs%HiZoRd^wjHEYAX2s=S45V|&YYY=^ds2%-|0mLy8 zct)39gYLSxseAg)3sY! zyWEiOD{v0 zAa?Zk9|H3cZ`e7L%q12cJ198b%>nZNf*fBfwjUA;7@ZeB)mxF0E}3JRG?# zqayIBw;>zThcLpj<^4Bt*E9hrLE>YZAdb2Nw@)~HCqjDn!r>okm>YcX_d%sCZS-I6III?@ce>gV!D%_@VpN% z26y==j`L$I0{YOWcu#4$Yvx@95VPy71C8;Fz5`>w)*7~|U`SH+1jnom&Q}Ce?w*($ zc*EY#ILL#gqUZyy;*#5^id)t^aA0zHt}-y?@4M68m^q_Iz<%Cl;j77u{+Qn^Lwfm# z^yGdEP+9vwI8uz-(175yH4v+z^~4jf*4jOvSXnT}4Mkk=!mU*NTu>bwINN3W&NK&% ztc60*<6MjBf*m;4S=qy=J1FuivU`S*tHGq-T=$l5g<$tJBDtZy0vG_$lX}xI0j7|phR!tgya1ZeDtiuChnG>gbm-iIuJK4gy**55B3Mm0KF;B5xE*)o6;Nw`UhE7wQCy zQ{fzs1I4VZ6X*TSK~D`z(Ko|ijL>A+{(axAnbABo2I99 zS@c{$LqP`|@P`>%BCkPz=W_rccOzHRM`4gnA!5E(&)ajEh6OewaZU-HzK?tje&zrm+y<_}`|cTd&l+9n%zjnHK_nAOs11EJ zgi1SB->bv9U|n1`^3UnQsxB616gTwY5kw;@{ciXM+?S|YWmoV%a5}3EO0#35@xvEQ z*?jisV#30aa<4SE@R!_7*s7$akUO}k62(NE34dC_ky8YBL&!2&`6%DjHNY0FntW6z z&9BC87e>-Mv=IH14njsZ(MUf#auPVind~Y3&%u|3n=U>){jK=Hu{%HHr$=iE5tOS@ z1Z2I=Wu7MZj?mheZSd&BxP2l>u zSuz08q^CD$te{Q-$leFS_Qg;#r~(Zh=Y!lZkf7$Crz>kJLsVKEugJ8`Q^HdhFTn2* zHhF8CK?wc z=9pC_;1Ruid5bXF)*W9Y^!TT97)wbWfpxE36m1I!hTptRs!*y$jF=$@3HrD~Ng>S?+N$yR)-{75s9&$%GNoJ*Qhkk0O-G%yaCBhw&~AXUz59ab z1NyEur#WAenB#IqP5hnphU^@>?F^j9SdM>RSdE=QTOx6bLL+%^SCq&|jgb4*a+9Is zm38h4Q0u*~EL0*e1IIkLkfw*ydKq>Q+A~{n@do2=fx(-z9)Z&m0r8VI+FS^aMRWxvP!Y<_3jXMgiFKqn_Ozy!1iTO zpxjnL4*tJcN@<{1_cfH#AD9LkoV%GFG;b@yZ}`oS*1~E!Tt|OPXB=DuiKKS^99e-F z0y!jamZ@?8FN{#*fW0_G5_1g-d7BbNWQ;k#Sk-xa6WmF%slZ_glb)h>V`Z~L**d|> zZu{sGznh8%*3@BHi%N^gYG9V~B={aMTX!FOe|P{utD@P>#ND;fHlC5|EYZWz2KHh$ z@@x)4Nrumpcc1?N3>}K>hta3$?;5s=l={XVYB1${ZUi*D(G*UxLNj^;N2I?wp^Ky& z%hkh^P@LBH7a)?{Cf9m$C;~x;`GM9T02&wFYqT&!yL5wbv-y}67_QMgZ=(xX?G-N< zTgIh11~1L&FtGu!;&^fA_JYy3MD38OC4Sk5p_;X^eLCwUsFG_<7UqF2_OUh-G~kC$ zDwlh>C6HCSUGdgv4${i5>xtddXiOWXv2OxjAt)r$?*h}!plL4|PS6;waO~U~a@0e$ z%iTYXKAC2dPe)LG)-1N!anUdA%|QvzP`Rtc2{q8)gS=6bhmKFLMU6*(jjZe#GC+p0 zLEj`{3IZxL9-<$0%?AKcO}eYu^MHUOt{y}9U~n5v4p)Lxjgjz0=xa{ACC2{%A@;j9 zd|3L%8n&tJz%kyi6b^Ng_q(^}0;q3;;B+q&6%$R*d(m-FBSX)=_rGP9L3e z-xoKfXQ;jaVPd2cYdgiq8JV*mUM%mNQ$PT={Ds;1z|bd*pUEFP#n3tc97KTl0f#7P z^0%JF@}?~-xT@3B3HiW_p=jB35*3ONYy=_<()Eq(hZW*A%lRvU#2K_% z!XLp{EzwBc-vn4a8}}fF@|zcrF8**)&4T#PbNu*WVoxetXzTH}8Lo4N$Ih4S;MZX6 zCvb4|%XxbcCtDqdxNjxwPcz=WZpY9t-`?>q`M@b18ib1J$o0I-nrk5L4B_rZ@6OC; zt?BPAu?HyWfvM&FpMwz#!f%J9U^>Yw43>}v_~82I5=a5Y4MKhMyf=HgA?l_>P=p_@ z9G@5&GkDFT$nY>OfKUeA-FPstQW2abAm4+Weu_g0<8V7W%8vmlen(T4fi|QH^gRV} z%OYz9M+v-_ILHcs*?1c9>T{4KG3;iRGCZZ`8#T~*kE=SYJ%LqcDvnE|4{QllpnCOca%q0;#;uo#;{sVGabnR0e?%7D01CucFI8>C6U)5 z*@6I9k54ZdFIkAHqhW>w1AX;taoHhZZ97Sc=rk|xVnBl%)R+Sd5qI(#4GP=_qQ^H& zI5T+!jdI_(+cjOF4TlCCKaHmFT1hlUI0B#dU zdvI&tr_Ag@!MN+G&LVw5bTKN3dg;iTo-5P$fK@m4uNuOO*Fo%cf+ZRqg-YXim8{m@ zi)IGC9`KHIX}^%$}K=H=yi*x)0wXEMh9$3{{VY&ik1Nvd*>+FN3>bzZgHtT zxxTm*0?MKHthuS+J;|(vEtSU3H~Pf`%9xw^@GwZ#J&Xq~MXnjSa8CJYH?EwPoVWyr z6oX^A$2-8;;@2TEoE>~1TE!s9*jgP!139=ve4dI(Tysgn|ktKhzN~in_GzY^G6*NDgsR|yK{e% z*i93;%ZMn6q5Iqi7Y%xiAyb#E9BM(?Ex_LN1+r%lrm>)p5yA&UfM^frA&iQ_%>4Y| zE(fO1*4^Z*m~`B_S>6Iak5%tpeC2FwuZm%*LaW#@ZVMa)6AdjzT`vSq6FJ}^@|SM2 zWyF*o-VkbGvQ&DS*DuyR7VHN4N8_UkG#=L&g3-CS73Acj6bVM*qf7DLA@|tnzr@Ee zWYQISZy`4-4AtOI=4TBv14j9r;*_=8Ce6knitf55KAtem9f@~6K*oreK0({``O}Dw zoPdCAFkPG}UM$z;%U>AUfPBsBnmT%@%EAq>yufzr`C?&E8)H_?z3> z@jrYKh>2yMgHiGMIR+q+n$Hl>@wNg;Q*!ep$K?OC)&j3&o3-Zkl@>y<9h`T*M2K{q*%`6wuUj@y?`Fak3*Y0uartNIy zt%JTFoP;*Zt9cV`eaS;wA$0F?ceKQg{*g7?_+gj|03}D*K6bFdM(>2oc4*BG+ky+B_t|gjH=^CK4Rwlxp}RJm ze7UKS9j3(p0G;67R3!&{GDy0+H_3>5&csMwA? z!J+}#{;}&(0bMt(IUrSA8-n5ZivIwZ>XAWPaiJ2XykzicrgPj4VqE|j#iaiL_YU2- z9BMY3hYmO>oDRNn7byP#;~Ih#IYu0;^G&5xG1zw%N}72yXL(S!Yosz0skC+UtW-Sc z7s;>IXl;_|(E?T;<&QuQNKcH8*+RchSg5T?>dTy^I2~)C!Lm`^UA#}M-n9;cj7@jK zyP9!77#(sI>G{Esrr_oLTu_ER5wyWZ%Q>8%I?64uY8sup#aYk{0^+=jAC<>K5WL&* zTsPV-*IjQ3o7CCUghg)6@@BLaQ|q5_8o{mgB*5Q*G`@UfLMF6$I)*qmDXXUiBnxt7 zg4UHVpb*8;zvSW!522UtNxl_od`uB6B-?50_VK`^~GjZ}EfVEL6CWS3I5b@zxPlq8{%GUyWY_QxT;i zxBA6P?wtqV$sre`xtkYTMEu}ohyz|Re(7d@j&gfHB0=;S#}d#COF6DAuX|PH>ny{` z=HfaUX`D|nk-;YP+}jBFUHNlV-Ir)O>@n+|ZP&`TH#Od#01L1VTu-nQCm0 z9p7#cM5Xc#?-44g*|8pY+l%86#OmpK%4jl-AlCq*pld^eKv7Mr0XgBpk#56^-ShF9 z(DqtK*Cpm|M)WPo3W|qJ{u{%zCt{j5{$AiTRZ>~2yY-L80aZ2Hc021fl7LQL5aScB zuO|K@3Vb!dHoQ%7*03`XP7*l1tEDk>aCMYdT*nN2>4o@dgsgCrzf9bja%eVRu5!8W zZ)1hTck@5fi*k-Mtk6lJ#+#JHO^~pq>ty`ofiVD#GS{2pF{8X4I70A3z?jw$ zA72vzy-BTcp!WkxFZ}LL18Azfw8>=96;S$6QwFy+WAcHk_kxgtaoQgf z7>00~%S(6Rg;jY;7iR_6%aNL58#%r8#c(z>pTVHwcX)2zzV%_+=f+(x3T}=}1QWT0 zzXvhB5QFwyQe5;DPLAEXt}5AfNK3c-pR0?G6MK&opEEuTC6bjOaY&Dp!6>6C?vVcg z;4lk>bx@tIwf=@_6QobV-P-aQVHr&H#0*~;3vF5<=-~&KC|F<+SEI=Hj9_iCXVm6x z!tDS<7f~06;ur&%$&a+t`Ep%;3LC@o{{Z-4loBoyIF0@}V$v|WfRk%;;QhJ8YPU0R zsV9?Iqk5_ZJ08bim|EPoLV0u(%lgOzN2)AWr2hbnDAZ0g?L+cj@q4PRE7tF*Idg5| z2ckM1fBIM_lY|Iee2=#REu`Pq1dcb&8<(P;A0vnh);krZo<-kgaImg`>b(YWjp6}v z$4zzE#jvSc0p+oM9ErIaV0ZR=S9p+^

auhnSdat~2>?>BSLsI>4l-y{d}f4vkF<_MG5&3WmJ9)X1m= zNv_)Zz;v~6cDZp%-YK^db{K+GgnZrO?%>w31lvv4nYRoB5b%CUkuyaD3;6REfOhFS zlaH*m$Xc3TTm!ff0dHDyTXvJ4U5T0*I~pFv7tf;?xkOlN zTVpPe6B%#GlhW%Sa+yI!=`s022%ICym+hj7Z5F&v@v1iX_Uy^Cs#OB~+?F`k0>A+s zpQXsDvuK9;PW{A+)`w^wpMK&Zjhi5&pzje2D5ZXR)+v+art|O5nLt_DTlzBNG+Q)a zgm^{QdO^*xP}a$Q52F^1n|V=hIm(B(c4n&{!K|C;&Yo(G-as6aPbGbw;>;}bSL-O1 z*z_9snO2fln7#k+V)Xdx5aRpxMY9H^*p@Y{v#P#ccn+ks1AINX<608~2OS9{{lQ`v|Y!0zEL z4K9l78zNVu3|&yssDD_p$FFxY<~vzKgEZw26n~k`QOSdyeB&1EV^JqR-ttYS@?o#d z#DU$hX9u_z1`S7(-we`svu?lmxw4$=?Qdau!$2jh+y-d2%%;#43-qG9fiJ)=K@uysdIX==d=@ z2o=!kO=}0^t|~r(F}eIm>=eUjx>M7opVj;r(~VUNHWt4x z!IYX>j?(U(PjL>Us8zkJztM>YdvWs)j~DpHaM(O&DciP_9WI^qeD>QrndS z%nUKA@uX{b?ErLP0R*d|H{SjpxzFvXIYY5J1}G#TEpEZThh4Fam&4zgMHr+J1KAq6 z?Sc3C10EWew+Q%&f;}nr!i9-($?(N$5IA>1(9I+u5P|f=A7w#BSLNp;QTcFL0qDu( z`m~4bUj_|xXCb;9VVG@+Y!-d_i;^K5&~m+RsfK0%sG*er-U#{{gqgl$iG*%kgx7%l zWc~HF(0K{CsvA#*4_HWo)iPPFus~COO7LYcI06ooxj=@BeOzjAA*VsI^^gIg7VV=d zNdW7Vy+0XL4Qj&GQf zDj>7IclG840t`XnApGXrRHC+r2+pwqPVePk(Ux6FK(Xij#Z)Gh=|B5vj@{Zdo1J}O zk>o)Y4;QQp)lH~Mn=RRn=?cW{$)yH)TUApS8dDLj*=8hYjUYrFNr2#ip0z_Q@Ke5T z@ZxGxH+_BM3zJLg*cd&dD|^2^oj9NX17P2-Fo_!VL!%aj9%&wH4FqbFo8)k!si8#F zZsveOR7z2iJHn|$MEG8i!gvmbFT}cciJQCc zYIVLcCxo3TdEPX^KP{UpIsiREI@PZ>gFz3(I;ase_W$PA@D- ztI1&jtYe0l^W=4Kqe{XO?IshDg8u+X#vlbGjjsS-jbPAnBsNZ*2pj`rYb?%Q%VIi7 z-X8uCcJJdYOQ1O?73&dH;yH(dueU91*deBY<;4ILBBW0oU8Z;kx(l%P1BKOSfZhyu z1ff25&axK$Z4tD*rYKFIyQ84>_Z3lrqSJ&OxC=}HC^JC7+C&9A@9Q%gECS;kdebR;elW z_23xmwu5ge&(1}tAnmub6}T@F+u5>VEGS3TMY_({eMgXbV$(SW{PqVHM|codkNz`w z+y`Q~oCM=(o52}7bubfy?0-pzX}?It7W#kFOc#AnOqAmEV%5k+qtzUnMSmWr8LqtD z-?Pf%UOJG6-MrvLdRj~GYzbgvXY7uw;MN!-OW%X}ICw};p==Lcxxm0AEzLVbSDhG%zej)XQU}0b^kY#`-9r7^ z#tAD6;&f2!h7Nh?Vfjw}7b$6Ck^03g{uAEh$360S`AQ)!4kjJ37?CILU0_(ip z-tGolq`j?vT(@KKbmK7(tX*2Y?|kEW&Gm_%ZX6!3N6VY3yV{?lpTVrr!W{JhHG(M) zi~5qRiMRqQbe#RPlLY%YM?W{dNO0WCZXhucrqBIMbvZS!FN0659p zpd<8<&UGO*AEQk_tZw7s9FMo)naU!Dib*!-Ukd#J zTx2M;09jn<b2E!+l9tVOPxe``~2WP|m;|r%}ES!!DK=o$R zusBg}$W7=PdUH@(uK0Xc1s2;PJ06TwtqOjiVBH0!>P_akM$%4xdox8s?2~^j<;YRM z*PE8IYOHo2(TLHaz1#iFxLL;fhASNyUa}s6FVUE*>gj8=;Zmix}v9|jBMz^2;Nf)ilik|`eV#BJUrx>0ttlU8}C4K z_i=)P-vgZ~h%rWxLc4|riqb6HYRYQw7G1ge{9snLdun?Bcf z;}{f0d120TqKCq*hE1c;Y*mx0;1*X&{utH{jytj#tq!WK_jx)2*Wc!4pr!bn@);%M zwZJ)X@gBl2$m&$z5x@N6tqKZQ$^UolyCP*O+RSsbsQY+>)5DM~bIaQCO#f`qgVNE=h7qxM}uJaP;485+)Ew1r+ z2Zf8SzRcW}u>MXltr-%5b`DN9A+=`t=0no+y#>zk05VyKgk!&p*7*c;aJ zo)AGQLuA%W{#&fTAfVu^uL1;Op*YEr=w+i&6w&S(To=T2X1<)AiKQY9Ys%pQ1#x=q zaGS{g0E~ozx^Q-%0kUs+?3JPdT2r-mBTw3@{ca#_0l;^pq`an!DNzFb^3`O3dJ(ZBauUh!W#&uM__ z1K{8w4H{A(%Z1Lbn?Iv25vY|h@T-K~lml1MhWlA->sxqxkRvTNS6L1rFh*6_1_zvI zhMRxr+4ph8s4}ltyst=);P2CTO`?w7H}Ci|6Q`}Ael}_NOe4lk_HgaPln#tPc(ro+ zD+3F?YoB^=9x!PE7e?H%;IN!*SX!PGdD-&dfkmj+j_BTpCv_n_iqx3eB`9m~UxyQC zZ6W1T2cEg5v9t4WH#H@rb*dPKQ8?h}{*JN(xSZ;R;`{yJk7_z4Hpc-9=_#{!RLQBJ z+m$qlj6G`aORZc=NUA7v@MG!91J_92yO`CSLt≻@4Y2JyXv4##lrk_BE%tA4!Uf zyLve3tW~Sh`Io_uY46HnT0gFEwG}zgwQy~GY25B%1TFoC=OvOZpVe@LXwlR$D)LpD zejF(~I68URle2M8Gwu4x1!$z(>HKhDA#!h%R~G@j0+716Q6o@YPIhGx1RV$PPB9TT zPdF`_%i9g2@5j~&bTB;I2{EeKBc8_@RaLuA=;8+(uY-O}W|Z-U0#}y}3Vifqv(&(# z4c1+O+CS+qQXyL*IJ&v6YM2t>vvQE_`gQNJ^o zc6?_*%}xN{I0?Dw;&x@1=-ztHDubZa{PmP3EkV@ANNmw|zZk7LZ?v}b;nYg?D8G2k z39c|5H?zOaTA91fXhN_506)AEgk!bTdYB`Ws}CH|Lm1aQ>io=2XqrvizA?6M$-YUa zG$36Cm#wcDHpCtTPJ|ue{{UdtbkqV^{L>|-0&3`aql%M)@pmC^rv!T<7CV(kSZsCU6t&pGdlO5hn@52FzG@pjyKn^H&Eqp;3Ng@K^OjIh0-Fg z7P-2%Bj;22b4r{2xqWg0fU19tC`G;7AJm)5qKXKM`})Mh(b&HZ?lzl^)BEKuF!3kC z_~d4`yivj!h>GnQM)<{f%4x3`&m7_8FDL`W7af~zwGZuaLN_*bWii&qk*Ycx^DLAS zAHwexJ*v&^+-lgX>K%U?nW?X^ukqa+C2qF&7?Nxp3V~s2JQr67IMV^tDg3*C zvkTD4Y<==`##dk8tX;4_I!9O>c!&mfu*(~*(*i0tw+PCVM`~2K%TTU|aqO5Fbv9dU z9b87#)PoHj~Gp&R7O>pD9gOK_Y3OHk32&}{6^VPs`vm{QD*Otis#3?Y3T>0Aw8gQ7VI zfcH<;F~np8qfPZ;#}NASMHY2yqOhh2mikAt_yZYT-yn>61RL;%Yf!3 zF8c1UH&MV|UKi`52C-_W2%N3+5Y2S*HrtCRA!kPtddxv+Rmkb!F()HIU4=syp|Pvy z>kb6u^rSb?b20Ci0%iWrHTh9pxLdL7MQ{rP5H`-PnBWlxB?722B@W%}!7<9py7+!@ zFPEp+_Ttt$5Xo-0XbSNlaYX_t>~bCfhQKnHW|y4oFP;ibpRM6L!(gedR}m0+PzCRQ z&|>SZ`|6u{!rC3Ph&^FBA{#kd{bEP*uPcYsK*tzA#&X2a;k0dWLCjK|C~p@+OHV_c zYi+AmqnwaRn2txGwK94UqHg&4>c( z$E!fcLt-}f#~6S_UPm~L(yQ2y*H~+!EmO{MC_ccv;4TeP%ZBjqf6d~8#z&i_$IYl; zzXD>~Lasz>JBZPgf3LhZR|$wN*QqYBiw!H6e3+876b_dLXcNGBINy!~-33vq;O_t~ zx4R9v^#P?~!NXlKG~o*v33%w**A#Ws>ZxwUpB8=7!oR0@o93^)N?Cfqf?G zBKAzU5z*Z6na*P*#Cpnu$=pHLA2=eL_G-R8Vv<@cEWE{h!sUcXtqi+hu7gsVFHSXf zFL+(dDUs0QsB(beZOV2SP3TmkF(D91(kZXrAr+w1Xu)5cN*b|l>cVD#UqQrAlW04g zuQ(z_5?b-=BcqdZQPuUq&}VSjl@D&RH8D@vZws_}y$7p;31b%ReoO!b>^8s`aB|=j z5*9w4b&rKq1*Fm+!)KI|h`?w$NOm>{zc?7?AQ;Yxd&eq40BK8KoHVO_ zedB7pxf+qIfWmXG&NwAL%&>fvsCP9K-az`>5xK1%sH!8=D` z;k=k&a^V5jt{)g%IIrYC)nnixz)+^&I51w^uSJYVA}I}*tP3iMoP@@S4`1-S%c~Sk zaB#uUCsB!UN4EP=c*GNDM&~HF@9dU@x_-VeHaE6dH?`<+#h+3ghr!7MfTvUQipsJA zuxR`@7SYGywYn6=tE=+l2vPpyJ}UJJj?4MMQEE1b3%(9dA2Qo$kW@7Y^Nb0F zXK)+a+s0jru{WUzj7QbE_S379Tno^$hSScB-1($BmKU{NSqr(VOi5-R+Cqq+OZj*-Xr@L)M|1~nWfR~4uLqoZh^CT!d^ z_#Gx&Ra$rcZ^?+s5Q|u@Sd`05+-cvu;_VuRTIRDDv?`2Wt};f_&MV>JhQLrI?Q|Jp z9;?Rvyt9J37zs2k2tbC5qP(y=yTbA*C<-;Jzu>`GbcVT6Z@3o=6}I@s6wy5OguOwl7#_Y1v7A%8UjG1H=L&@5 za7`Z!h<&TGX4>F}F7{5+5=$}?=~{9R>)e4%XaB3F`5Rv*OE&yzf8^+vOc7Rz2od;LkiYkE5+Dp7k z4#k5|(hd%3I_~!mjHpUcO8~)Kyd!9LGe zlq}e9q8V3&iZD^#`NW%?MbyLiChM=G;J`&jpiq9j&1euDg4xEq$d!Png?Vw7T%RG9=P=!x&UQ8^BELW{7f}sUOJ=P+k^IJU9 zIFNSXKW;v>6s=pXJjI0o_@mN#YZE*QKYGARKt$n6a_2Az*t~LY8)OO&z*{p(9Muk- zbZedF&bh9>R~A0vMSwv4%GUk)nmVa1uqvbngnn>{3P9est}(0RR1MC#>kR@;g|iHHNs7}NW`*F5xa$IwuwA1x8CGl}%WJ@U z-Xf~gc8hSCcXV)-oN30gXedYH7$n&xHZ`{^zlqZo8V4C94FZUc7uU{PqG3>W0boU1`zROuXmZH8a+Q#I~3{{V5WU-V)? zBp~8#o>`PDkj2|~ZpbeTfyHgp9 zOo}|tal?V66l?>v+l}7~0w%TIhZvbCG#e*(HY|3~k*S+i8N=(0sdKCfaqsId-YaON zHpgoQJ(4Zqd|~WaG=tU8JzNH4`35hm9621Wf9s6f`7zMw&zfOl<_F_=Nl++D69?-A zEfAYhn~Cll2mNTAFk;AOfV6O2+W z4;-Rqd3nvMnH7lA+(0i2N?A7Ib{cdz#QEHK9rzeWxWa3hsqu=)aTcmyTuI#V&q6`wnhA0Lj}<9=MH|{Fs}rp4_5PzjSEe0d#}&NYoMqO z8P9xvFbLg9(4&lE@+l6vK;cHhP5?Q#qd2iz=p!W!;w*>-`7LUaj_%UHyAeFbm{bGgLG{p=e^-UWqC*{tx8ZR}PK3{Qi)i8HBFDAb8MJa3a z_k}?~Y;4G=L1hDCmF7%XxD;(67Wqe=qdqI3AcMH1_KIdZE3?C4$Y zIYvX)&1)!@A`@C>t^^iRync(;0JBR==C@oTiQk5V*K3+{ryU5rzr0GKMS()Na_B9U zS6P45jSv$ObGGISUWDek!EQA-k7|8oU=c(gd=4U1LI-d=M;}-~bc}Qf+Ds;)qCBbP z#LXz}U1p<3?Rm3z4#)~B+~1R&NflEs4G2XgW?pU=d^@%Fxj}FzYlI z7nyO0sZj~_9GP5@-(EN(+DHremTy@+0cWV-rXWfm6cb8hEQ<}7e;A;)hkHrDJ^7pY zLb~YS*Uo9Rs%F8)v4G`&OvX7Ie2J~xsZCbdq{%8Py=`$Q6>Ng-$aFA_y7t~Suf}gN zz|@1mor<&;7v^JrNHG1bxbq-YWoU098aG$9HW9&7ngSKArzvt-N^z;BfsJi(=#z&g z8bw>@2J{vVhHn@vWM>ynjBn6M!PgTPtIM-x-u$_2jm;Bv?mU`a7>k@ZQaMr zBr-!%@M~S;V?&ynP&DOJWKuQ;Gfac%9`&4odsjf#+yD#Z9gDU!&;{jBQTH28P-~5U z{9I+&X5+0-%X3E*0Uf4?>o-jcYst?h+yh`is;<_kTdCw0;Ena>x?dk{+DivDXuBIYL1p>6D?S zq@CP?E(PAw!IB_)HTW7FdA)i60B2jk=g>FKupJ4}ccA?5cygQ$!QT~ekx(x8nb-dRV1g@Ke8gm>9o~Q0%XgE+jAzj7?-oPFlFiqw1O{&B z>rnGF(<%fvq%2J0=(rouHeHDSp&4Q=QYGDz9S7$M799+S-cHg z_-5_h_~!<(dtw4~n$3WlGEt_Sa%!K^xMau_cZlfva1x$`N^nCBv7m&7C7oe(Z7H|k zO~8%4aiUHx{{T4BV2Klpu9pSn~9D8kKcxsoBziX;C3aE=(fz=`L#IQ($P#_75MKT2h4 z28Qm9Rl%Va1T-;QV?nv5?#8%z(PV&zQh;f&g76bAgtJo%KTwL$O0S$W0DwO3yG<19UX($Tn;QUf~zs z0Th8op-rDRJ1Uw4TYP?TXdhH^;+R4Lnxu}6KUg6sDENhL_U8ozgj6)~iV470YR%k8 zXuzt6nRqNAcsC3c4xNLk$6jDTS`0nM3~ytU3r35;_Y-Oa<8ZdwfFQN8!}UFJS}=R& z97{kTcoE%W%@$Q2?c#J}8i80@JtK;gG~-}%taMd#pTN%}Eqk5g1u8EGI3=6SE{yvc zM@C6U-Knn=8rrLw45f!W?~e(I$gWs|8sWzXIYIBnVkzb=;_&dpWQPQiYk4wT;_2aatCq;AH1m z?a_N~EAGGjF?mqty1ZaOfvQ1M$Y9uQ?4x>#o_9%-*yUnCGjHdNqKLQ01sdz+_;Rkz z{{Z^?!Ky>W$vY?K5VZC-IyG=*D?$^JIxx3oD-^5u`@!%PR!tc=RUkOAdB~t$7j!(B z1Bh+Wxo$PK!LX(~LGRwjN-0+h^AXBAP-w)G>pkg8SQX%_1HHTE9jv zoAQ%@$8r;M{`GUZ?V)PF9~;(a3Xs8NuV3qdDtGV`se?guG}0bskb-ScI;)0rSPJMh z#M<(6ke(-=mKLh$mPE5lU=4ao=NPeAE;A#F0Q~)hA8O!RUypnxBlR0SG>B z$(7{Zdu@1_kflZO3AYMx0{k~atQk>(YKFO=)(V&k=q#w1ffmMxdpG;XU?Fa@^S2P) z$Wh!J{&1L@R+jYsoJ%lb?0Z}WD^yW^66M+;MXJp{VhF+t0%OJN?-WoL5ULo71r*yn zy2l8OXe;-L5j*%k-{de2`G>d?C0fTWF9^0KR_?0)aZ<#QE#lkIAhP3%4crje#9EkFis}64Ich(l6Zpg2 zfV+0G}CxI@sNGX~B6LTl6?K@RLj_v&O369FK+?>4{ZF8WPZ5 zx8|}1Mw9A0)bkZrOAkt9@w`|~K5JlbaAGOe;HM!x!BiLd9E(## zuD0cg;BJ~0-%NDt;}dDYuQQH<@J>E33aqqYa2-JgGdi`ZpqC=y&%pTD~61+^-*U}aGIKee}FFyuy15np_)$PHRiblCB^MOJL zC;tGxI7FK|J2CLdnUsnoi0P=_b0Nh1MfbVzY!Zna5XMB=#oSkwYye4UR zXjnt$xY@Jc6JK2hXhLhj@0G+-duglJIMaOuc0LA99?D%jnW>4j(i`sV!8+7eU3gye zhRG7Uy`JX|IgSfWbBLOXYpwi40HFy$S@dVdQfaQ7wsx3AZ1~o_+sM(4aq3O zm-F5#mC+T6x0)R|q$LytA+9Xo#@D(C^1Qzo)a;A2n`!c4UU2GA9rO2+uRveLxZS`J zP4m}jo8Z-yY0d(=be#O*S+xwO_(F&00LFt%AQfv zDy3$rhb0y#wSn;lKgI}*i9)=$S#JT$2QRUacEl>`=r~}*wxa%MF(D@t71s|g_pFOU zM*jeUWjQ0=?(QEe7}U~~lLo1kj=s(Ut>#rL6Ni1`n&z}g*}=wh4VxX_q$=P|sY643 z6nKgDanyXz1rbOAklZJzA$m|>V>>U0Al5vxYQgYNqh0F)@WH` zcTgjjNV<5@AB;=GBPr&`8FvR7l>LVqosBtC7vnUcO;*~r>6yoVZf z+gUd}!?Im&4oO)Sur@Y_cPRnkuhfTm!Q>agE;V4Q`2#Kq!#1!{-nYZJt^h8s5a1bM7h57bpS()K znh63_PZ%ws8sgPS!&x02z?(0B8NWw0es1R@8lgi}>8pn|kUDSJ21LdRE^*6)CGw#p zr;hOjJXJ1ut%eAD?#TLH?kk~kvDazqaByd?FF+0^#*%5VEuZcM)zHBU5B_lDU= zMCkCo%mbF8Cl04Ne9BCy?2`QA16qa#7lu0}M7f^5vG;qSf_0GfmP^BMHyjfRKVt zfwl+s<*khi$DLOqeM+nqdKiUAcby(4BvH0JBKHSIYEV1lYdNLo6083JbiCtJS^?9! zyfzx`eyzP2eWGVU(mTM3Gg=HBEOm`Hc=t8#g9n4HnY~P=s=B-! zV2eal-`jgKXym~N9{0%xnru;)r3|o9{rErD_xWV97+U&wuc7fZ{;;Q$r?!DY( ztJt5$^08}wmrMG^)z`gu#KIJ#iM-c2C8)amI>c2cD{@IMybO8UWy#=vRXt1Adqm1PQMEz)PXMnLWTMK<`rp zgI%|^=bWm28U_wBY@vcpaNLf)e5Z*y*0G_bhvC4Pu^|s)BFbWeQNX-!P86G?_;eYY zymk&CVTO?WpCypg#bQ&hlh&arRN25wV&dj1(qyV2r;Gun%2&UwC^=^gH|ug+N6g!m_K z!+9dK=JidXh%qV4Z3%v7cn*sEG;U$6y~0;gEX0>dR_oD_(K{C$+7A2m$pqfE-(^SCkW%dy~leZ z$7J|@aTEe8U2zPy>lLTKkH#$&MD9Y&5c%>;JzUWU7+uWNK>Y5o*due%(D8BK9s+1M znQC|g8a7FZc?|$W4B9}CWDcLaSJtJft_@xm)Vq`)pD{{6io)QIPs6f$d}XL|2W>lZ z*eVjfoEbCB4vyuT#CR&$@0->YR|Fx$ybVZhhluI?6CsLMK*{6tg~p^$ZcsEML2o)Q z#z6@J8?)%Y7?l($=;tX6J2|@41Fg&Mup|l{>8|_d7{}c|E?ln5t}Jy73Qr(8eK`ed zucPGG2}(5<9fe{9DOO#J-tvLkl_4fH(|e8W>5+mV{IX!^;NQSF$%APjhpaC5gh+)L zh#TF&s8eG$TyccaXadsC+(ZvK4WK&n_F^Gu_kW7wpfnPQ_gq(Q0NOmyzpP*jK!0vL zV@u2U_`>nTM}izA=8z64SmfKM>kC(~6P;mqQMz~RFka&BxH%2)FNDyi+qBP}cg9u; z%DDGzk4g|B?msvcySjH{ny03)HY#ZK%41#*mzX{bMIfMk5%Bh6I~W!bo$%?zsTpNJ z`7zp30C1=M!3RjY0(axavXF&WZlvuovY`|nKKInZXDJX@rp#0Kz?Q<6w+4hGOufi? zJIa8(3W#e<^^`m+?hpB=M->T3cUxftKUhx(xba;ClN8*2zBp~VeOeZ)`rs1UA zN1d5t6-jvG%aFW4C)~m73G@Y$V9<4}c%8T*Qzh$1I>jco)oan!$ns8k>M0L3;R@1R@yvd9%V3ahzrMd4kSYTstC&=5;cN4z%uNTcG)8U!CU4E#d{ks`X3>-aN#*n=eC&P})nqQMTl+-lNX!Fa%hc;M~(1 z3r9)Qoq@c%)#cjN$c}?|$9Cq-f_kwsNyfg+d^qUhPWUe9M7SxBewD+ShTH(MDC~&uY+CxE97?#{6exCF zOjSrinm6-+iljR)t^`41+`?V{esC0;8@l5Ykfc;l4simM3XRnz9Vf;PU8C}J4-Bl@ zY`n}at;__#?Ee50ry~juzk|luaVV?)=8(3EHumcQc{MBwua&?fhyZPU=U7P$ZM}#u z88(8_z)l(b7&>Z^tmZt9MkkIjKkJBG8krxp>&z@v&73@I z*>ME`G_?u-+}ogh zj8ImY5$-;Dm!auhN#0i9NS_3W^?_{(slr)o&!@N>*7U4)L z4sM)W0|+nNF`(rYdwRlyno;@QIspht`pLsn7rjb2pQF5G`?F>zTwv}kxl`|bW1#rB z2+?WRZUCu##Qm7wrFtH-Mpl!FI{i4a87db>tCI6Dhyw`PZN3ij2tCJY7q@pV@J(Y} z4Rkk+Y3lRFxZZDy6Px-lg2AQYA?wWK+^?tPeZ`3vG~u{PHVJ7ptBt~djj9gjEvg$N zd;(&ThXpj#)7?AE!nC&9%{}B%R1`Em;;ZAA!}EXzpz5kKR|Z_rbR@HX8^Pw+=pBsQ zau?`+KCogX^p50r-a7ruUli-ybl{2*&hs6Z07|6zF=$oK_~x#nl=MBuHPdzOn1mp9 zjmjo+q}H9o=ggL=G^T7NRhKyRnl z7?32iamuDlG>4ZGT|sr8W*FVQK^ORa+K?0dy}L@4lHrZ86eOF&H>o5A*jd+T+*m=(-l z3qvI)Hv`ugQ390E4Xd2&KyP= zuy~QuXj~No;bM7w&NmV!U>lHy~!iLazyyNQ53vjwJ1vLxR z*Uu9cL?Im-v-jpr4OPKUae<*bq68aznOKN~-yN8S-astltsZ88WgQ}Ixk0dWS9)oJ zq3RMqnuh zR1K#(SQ!TpsV%9+#GI39#Ag%Y;leA3Y7)D`GPsafy4Ayb+Hi7-`oP-aqO)d;g^vbK z&9r*r?(`FyTj%EjLxEfF4f=AO4Wqf))WU^Z1UYXhf?ftUc~l%ZgdyJ#@?`^ILtP1R zJy56|2?CEUX=o@dg`d+RR!v<ifQOo{WnQJJupZ zxl7qM_l(-PXM8kfm8D=6QWV0Q$lIa*le|=EhCE$sMjkDCq4$;acSKw9j(kiJERY8h zt`N3Zk%k;5ktmpuXws7;F}B3<#lZ4SnE-G#+*hJ03g-Qa$We<;OcvjXaEH3 z!+d38YT??ub4>(Wl7dvUqFy_|8#&ed^6wO2KpYCd+I?k6*s7kmc*G=1(IpIfJU+%4 zCg`Df1}@MHowunnDsiHww8Sh2TrXUU#xB4mbqk_nrbsn7jT&%dy9L~PE+s`9J6D!E zZX|+Xs6}5Rf)%5KrZ)2AN=3EO{{ZWR@VXRC8?Oxue!~Hlj!R40I=C%OhQ%22(;Qi% zx77FV+@=9g7(PrQuGY9`rhVX$=sCm!_XW4A%w+9IK2FJ!D@t)Yqxr%D^g#B{GQti& zUp2zpBj^L`jK~Jz+xL$mh~3r)e-LTHONs?y6AHoG{{WnhTLNSz;b^ye_{VB((w{8C z@iGZ%L?RJT#|JiIK2R|g z3eBgYS6`Rs;~;?06xtQV9^uNzK4Pn-TQpnNLeN4Nox0b?DWSu{(nR=jMzKpFwZWrp z)yS%W*+MTGbLK2C5-1G!f6JeC3Pu{+5_OgpS~%l}4BJ@^@llGpFl_m8h|p74;}Rni z`?3lIP?k4t1*2v1U5p{6#hIi{6Wk@;I3pRTvd?5X;JfBezxT#w$veUI6{B z2-e$Odk=$*u%0c}p~fFket_Uic!ZMCO&S>FCL;(Pc zMcg+30PdUw2xw2gYk-hxRSIBAQL8;p?i8G%rX%x`fjw5vLGPRvm%wlCV8tOz!8&!G zgaHe?Tg}E$DZ^k~S8d8Y*wNzdd}EEs$$MOC=mh<-Ppk@f!U(CvWE~D($^K;}TXVoh z;oFM^G--i*rGCv|N?7t$J{#uYAWR;r@{^2XnpT>R0ncg)?E4odtg<^CQu&a614rX7 z(9|Kuu)&D+y~zrI-7YylQv zTG%zHu5P26D>ncRoaD{Y!`^B_O-T1IZM(hm>w*n|>wy`46zuSVK zVisHu6LcY{0It0Bn<#_5tM-jiT@Jiv|EZ5wgI^$G7a!PLat2p&?-T#Gdq zzWTtcAY2;`K!5+>n9(u?s|@Zt2UeaUd2CjTv=@!Y+d7vwvrX z6dhio~1u|x6>3_JTXr$pJYD~69Zk_5+s-zu1XWd`LA(;bo^ zi(bDI9L(6$^Ma91#X?ZohFf`_FQ#)b33?7quXwF{2>E<@gc@uwTU-+qPYt`-zFjzu zEmHv3*kmWX6)bnK`G|vQFB%zvJ|HHp(tb=~2VFUsKZAH&3A)ML$C+_Yjxk#}(JSYA zzTr`=PRKBrO|T%8z3X^IC>`*>GpsW`PDUpqh#^N5wG!Kgw@n;zPIlUm?ZcLCt}eR3 zi-H49O>Y;whCpu2>M@>(yHnn+t*(oc)4cEXh)=B%2P;K_dPR1MvV(cTlXAdPl;q;T(i`77|mft z2XN?@waN*=;3=qctD7qS0AZ7Wyuic9d6z>uFbb#2PO$n-JQPC;le4Ci5UEE&>%4IY zHS@(Bc8T36Qdjf6VJV?M>3fbrWa`CCTq@b{-!9%Ucqs;1H3e_g~*)T#j ze>G+jrj1zBf&0&1QY|=eEa3UNIGiyMk&J&X1R0y4=;wPTt7NVyCG?h8m;{XUB z)B4Gz{Eeu$*{$IhI}||F##)xdt0UD7 zWFu*I*KV+*b^w6WNA1drkX9=u@Tm(M4z5Q)e0Y2~L_UM(+xT!VVp`#yGYx8oRcH6S zN1>cf^d=3li?W7Vi!l3rE1!#}**f5W5dz=(T(Fv5YvkWp3MCFdXBx$dwQDm1i1z*l z=CI5M5>_2$Xi2;kq2Ex$Lf(zIG#8>}0VsGY%;wB$roLpV3u4r+_I-*f20>;Y!~0Nao*g0&dH|qE1sA5|iSXf%Zx=gp)5Sc@T;d1y903}X}vS2i;%=CAcLeAG|@w`VGi4&G^CMl7? zcZbMPk0_Jq>n8_-9HEGBDQC`DBfK8rO(c!w#^69FTocP*I2s&7MvuQ-wcPzS3v z;GHA(mE#1fv$wB(g9f3hii2wjG^-MuT{l`VWQZZH0qt=c8L_ak`G&fd5rGrj2@0WL z?qQ61Ilma7#TkB#psB*Bf>G*Zh=eFL*&J>=VtwL$azT1;4KgWcg$)+r!iK18eTeHX zI=vmHr-#lo{{Vqw?D27UflyT#;9Bg_@;Mn2qnXgg!@UTgSlyT^7Msh8E&9y z4a3;=d%{7rr~dd3CcFXy+gZ#C!P`#B?kHiyr&PragrOdbw8h0IP}mnAcn7pp1sg=f zLv-zeet&qfHLVv%Hdee=X#=3O82-X2b zDn-Vwwr?F7MN(;@f4pL$f|G$f^vMCF*fj@MJEla0q1*$z$m6Q;Q+J5f;^N(qSIb3w zyzLoC7o~JuoHqA>?;+N&L)*`K#*0yoe$n1(aQl8dn1esuHBkCE;cJkc93!(Skr=ea=jmH zOq)#yo$vRohDCE_`vVjOaf`dq_+vusE6l=H4ZIyAf}HG^K;-)Q!+|2J%c~qXF{w3A z)<3LUO;jJ8NC2HHMb}nhjarVc+n10dqEm-|AC~gB#YmnsdxB>M+rj?;TpwEWJR8Q4 zf7VxBw+%D|M(NYz%wam9q(0vc4XpwcdQS0~RyKBdNyD4mRjSQ>TwpQtNs0O0wMsnV z3N;8`hcMQ#h(2f#TWa+%Edxkasf@=xm4wlcL%4GdeYlX;4$0w64aGsy95}eqz#H`Z z&eIuehuKHo387nQ=NAWHwu7lyj6RXMKRBkeWr_2DX_QfFMR+g2InN_iOH1ws!~CN$ zv9-0NFvwP>hR?q!R#00*H{I~nbYAY;%Ou@=LkkQ8Y&G@0tg_vrsUb-Z>WLP{w zhg%Mt_-h?v1Cri(OjCIm^Y-v!%t)@M7=(o5cn-5tj+Gt`&H}a+=y>Z5aF8MH)1RCz zs7`vk+&e%i*}(S>3^O-bNFbKi7DWv;**P%|<*;#G>BMGSm4?oO=`ZUg7A4S-{{Z(I zNLBn(ReIJ9rii6_08=J6#eG%(0CL$hz9_3y!9m8i@-}blc<8EAzBde7){QW8zJ7SS zE;Y&{vW8T834c1*nD?_zCbEIHt?qk?cUpN9<;J&38e4A6daSwN_l`HlAZuSKfExp{ zq`&mTDoXHCoMPbV0|ht+Irb6h*BCvWPLIw8?qrDGpD`=t<6d`gj1;&4yf+DeUbJ{& zEwE5n{hBdzM3gN=n{GvLCUI-Nd)^vABCoHmb4Lgs>hZ4dpeK@{qjtRGDP?pwha?i{ z?`bzNnn@d}FDsSBA-K}qrWHZb@GMMACW3EHiLb1HPSL-|K4Q=d+qZIlN8=DCJ=>a^ z=Nv#>U5Giu7$Txdi1)6tk;f%b$%j6{Bv*r7(b^;DAuSp-q1eYq63XqKIA$6K`r z9HlN6NKj1x{{Tsh!_~@yC%B>m8&d|rpxa`96zpHM#C(J?u;QDYV6JoZfW?XkOw{ivqg53h_`^B|UMehFq-<%OektUEvXJv6M*Ms|Fb8m873Xb!u~0`s zt^2sU^i7Meyp~Oc+%Khman=MV`PQ*V<>auZ6#_;AR(4|as%-?1=)sN81q%rYlWp2n zfi|-(%4#q~aWO7*oQCKMhi1`xk40Yv27DeBO{VX_64`ElwJhCy1x;~IfCCBA1&6UQXD zh(ZD1h4beLQBs(GS~BP_4;FHxlKP=hPw;tybjXcl(1nb^I7BKMlH&H#b*8Ae0jS@A zjM%OVYcfsGA8rzfBKM_t`pFC<_PpEi zmlsP^9k;v)4FUGk!}o$}q>I>0Eer&J3+a^QwghvwKK$WC z0VmI+^@u{fDWC=BpBY7vL@d|M#P3PWKgGm4S7mv9hFk_nT2OE!u}fyZ`1OPfWNJrF z0aYS^+}AJliepaoq>IeKM|%M)rF$_GOU)7d;7xSq8u($0X%Q)056PUaoU-;D--$1b5Xgb0$X(x-C_AV?*eG$ z+0Va&5+u=HhS`9vd_zfffD{lzN$*$zK(w6-U<@!}IA?#pv8cjx)EJ}gJ*1R8^|C(j z{YLSz1pPvjP;fI$Ln)+dxPU6Q>U#PEMbr1p{_xT(4o(n2H6dySyTZ!=OivXR566e3;S= za8%-H#jbKYKbeZufTqrlzA;tW)`xNOU!uEmSpl>t*}IY8@a`vB)ff zOTPKeadjO@n*1v@Vu-ufU(1(DmFRH+5)Sdlg90bVCP`~a`@~qQL|=iB8wNou>46BM zomJ)Ij6fZVAM2esJM5O6j_$D1CY$dn{h>8ICOc6&DT1>$f{2pvI@6N{2vxhi_GIGX zsassOokAiSKx02+dQeQ+{O$T76Id023Ap13^L;dfqU@wMYWE zyz`Pp7F2Y%K;lJIO72BDI>gHQ7VNuTdGW}kAzV;-_}ut8h@?#6Oa+~ zhP%g!4#p4^xl9mYOziy24;+OBP%R~zAH0dJohx0EoI@0?Q~_Y+LP8sChZumc zjrk^|_c)q?98D#{WYn|ixtnR50Gu1XCKw3x9SZW`QA$9(`D|sIn#9p6 z!X+JG1UrzSBegvE!w7~|2$Kvb`83)08e+-#4|>}9fvfP1iTxd8xABM|*{iAM8Yx^( zk;8F#=nUm?x99*n@WE=p?`;OwZq310LTZ^_fV3Pf<5)8Q+9!Sk0KCy51AAN6KrF6= z1#;NHBW`kW(N(8+SRI<9I5%-k%R&t~dils7T+PSe-5dtpz-Io_%ys0;sv0h?X<*xP zWe=AkP!L03t0RMmQk2&dp66H=1YxWdyLE}X7q>M~ayZLSzy{%?{^9@+61(3UI?ArPlLE|!p2D^&;=i0qP!Q=^ z#+R>ffdZjjgp2?PKnZlQFIzD{(u*5VCL+@f1z_v##sDt@GnZ-d6Zen}?1}4oQ+px?IzE|h7b*c9P-^b04Q!J z4`G8PJsr!#mKj`Lu1_3rXBQQs52G5;)seG|YLz|(qhW<35b(_yJ%EA#0A?BJn_VUf zU`x3Y{N*OzgK(c9IMIyC9EhaCt$I|IoUc<8S#`2&X9dAp~%AB zqVSYBUYXgBJD>r!KW;>_JRTgdpcMv}Ms8v6Xi!{%$iK;#_8=y$;a&XTqXY^53gk$X zaH7sBH;KvCYTRkyezJi!W7~J$DiCD&PjAL((NZ1lK5>!?25B|H?9P9a>NArf7Ueag zG$2*9!Fj}<(YlyO7_AQ<*7uKUHmG*6=NobM8b)iBM8N$kf_$=hPMOM4Ca8E8w&Qg}GE zH=+KWW#$e`TJwGD31}3FHfv9=@_tG>=a#v=AS`SQAUyGqWh2O|lj?Vnz1d52_nNK* zaE>B!ioXrO0UgmTrxC))BLhWGCL4t?*k~U(EvL9reqh-v!mnlH91;|w**Vj`4_r19LxJcw`_?E#jsa|rubH(YKv`6)Iwu$k>A3u# z9r>F6QEnaWxE=x4fq1WOJcGI|t2q5+Os~?z00|q@tCy};6Z>#yL1#+(xe6+a$&*e^fC;C7 z`@Cg#I9Qx4w)Zh`BXnvV3?ezxu-tWuijC=sKHji_7gN3R-T?qY_1Jde6;2B9X9M`k zGy&ZXy5j_Ce}Ipn$4UgOrM(;&-0CE~*}Ggx-$vvm+n0l2TGOK4XEX`h8~5>oNu{dX zM5F$L7#q?%IKPYyf#Ytzu^~-^L#xs8oi@UwP`vNFC95#lt48jZ#;j+%4W2% z*Snr(%Wcur35`LfpAd8O;b50uvz=jv(s6pjLog1Ncw->VV(GoPXH7*NA#Mq7LABg$ zel8VFZh;O9esGH2Yk=dA!;%1LmGST1Y+lY!To1+wK_^;@VFIceKS%K5sOZh~PH~zD zwN9-%%3X%TvMc;z1heuVshYktwwNxO>BcJK+;;bir`?2*ZMC?2VuJ+NF1p4b5GN>@ z%v4$r89{GLSYp)4&Wz?AUUdfvl=#GyHDq>@{{SSc;^JV$h8hXE^Q=(_rBn^A86w%+ zbt%u*B@J4ub@cRM=md)G)O7oB0T69hj_l{>56NV>ryBPsY*n_dc_f|UeW{Ix`18!2 z2L@_%1mVIlwHLW={rk=z@hwANzgR2Qlmns5@o+}no)N_r>MCd_P82Xy3COg58MOhs zRnw%HfJ7iZyyJ+eSJlNdC~b^5uBID|4rFJ1 z;G0dgzso~3pcY*lxi!Jll>Y!zHWhR?7t5N!+qTPi!Esj15e=BNepsNPBtV(D@%4?ZVF*`JR1Gx-RcN7=7ZN_wJ9y{;v5><8tx96M(z{bYSrZz5tKCoyx zLHo<(LJ?R=+FUsBv@54;&Kv58c5J5?7}|EzU&)0Kt>4(DTHc(c{*OJF&xKc4`q&6K)~2hheH-4j4GCL=|1g9pPjy(Q@w zZtrE1=pU>@cw%wR7|rP8bJ2)DoNNlVAar~^`7?h*@OYKx!1NO#@O{n%{-{=|h{e|q`RvwAG1VCK>@vPk1B0GBle|UN+b*u% z`;Bf=n&W!C-mx>aGR;a}~^DL}f=X@$XR z(pYpDgoTBJNQ&cKA*M=h7owgOhei^5Zo%B<9oT%j0oqS9ejJ%X(P?8q8&Tuc-_r_5NSJd<6{AI zXltKvCoi#~%nS_&WbTMO0{mkR$cfVxi&tQxuZ&=NDXMX&7zCa@Hc2puExKK-(O(=& z5QAPnCN@$_`FX5}*sn@u6>m-CNMTYTz%9d2PEn*C>UD|hq2icU)$R?w8131pS3Iis zl(FciP1$F&HZNVL0QCIel^`UxpPV*G7fGN^!y7HcgxyL2WR@68(!+7r&DZeBA{A5ZYvFaH`l_QXXhMK`Rf%F-p_I8Nq#KE4IjU80@RxiUSq9bQ$bQ3JbR5A$}aA>KNx2Kf!K7n z=N)5i)s`AR878R`ZYQp8mhG0JI0ymnw>m@boUTU8#;8Q1G@RhkXMfQyDA)lkOT_!Y5(LnZF=EShS({LG!W!=? z>94@nl6RB^15n`iVTCm51P#uo-YaQl&*|P+M+0`zhp%mKH;iNmYQ?}bnOQg=uQFg5 zzy`+h%orM4)24ec(M=u0HOQtW*v+p1JmIn9W%E99*pu*4-_eSJLbK5CspfOV{Iimt zOR-Ps!T8k!qZ>)ytAasxY5xEWv@#^7;g1AYvs?Rc;gYqo_#ZXw!BQw2JiTXzrC(|D zf)O1KvWYe?14a!sN6Sp&M<-Xmi~-2#$pYiS1l00!ZvokKddVh`*7yy6v0xyiy1IWH zVT!jxe86#a$d_iL@0gMTMN&a0e>%!@16ByGoEFAHguU(75EM76#reM&pc~-Qch0wc z%0Pr<3L5g@S}-)9r5r{BhsM3gn|!xs@grnWYNAI6RA~>d!Mv5Juyl(fuTOE=Jhg|* zAz@EvG`5Q5`%||ELS^XFhTZ*RYSKqMCsV>c&sA4`E)zz z?7@+zEImbi_~ep{>h5oX{7jr&K(EyY@VPLeveG=(KCvTFz$kgA_MA1tXdUx-avM+{ zR~L&Hz~$_jRim>lQU$0`!-odV(w#KML9GQMeop<)vI>1Llw5byR&ewOlNB6?N33F| zHqNCL?K`q<>&yhDLrU{L({FAEB+;s#=NCLRrS3S!Eimsy_cSsZ+8oHKGj+rvy7tF| z9XacD<<7B9b+SAhYwRH8`reXY!8|}=siAPU7HEUAvsh28Lt+K;xp=j>(Bx-3+msNn z(B$`g8OTaj8V5OP9O^q~8iz}VhVo)#KyVF@>GI&jji*dFK?VTbrQ<4sI+C*Gp>7~( z9^u$<7$jYS00myW%@T|UVSaHInro+x?Te_a+$aX@e^3>rqT9)FS!0pMqqt9?kn=Qj( z_k`QB=rvN{Rw|&!4*75fu1(2o$^jEWB~EX?Vu&zSzqq!<=n8XjiX9%cqKRe}{p}-JyQm z(Fj~mFSFwT4xlN$oR}{`R4ZNj;Wo2s@XhmgisB&0ugmj*W0X+`@Zx7XvDd~Hhey@= z-bWjTJ4v+b37|F7KU&5f8+k}u{bIOu;?kWt4&P-RJ^f{2wx>V`cqBBSE{@B87~lh< zq_^iFn%={b0TW7G0t z55e?a42wvzKeiP>v$nQo$?ujJS-LtLxwc1I_|0HGZtiH4hPsA3q6@GodgB=Q=?H9` zn5sDPqCI?lX4O5V+0k=a`#>sr5$bV@=?_P559GoP3ZynV=YKpI+Es#q-Aq`vt08a4 z^@1`1G|N7)L~mR1uOBe>27(2Lpei=YIePpjN2AVE-d#{Rx@Nf2}{ z1E8QIdTYiwV|q-ql@2ZGy13wvigw`}kv%uXvxgf?$vtJ~IZ{TB1jgNpf$ZjQ8e*lq z7YJ)dgYHd~mHfC4r?@}lbo62=!o^N64!&@`uHmz6xG}hDMpOR)-rR&Mq%eqtm3Meb?V@7`E(K&e3-80)GE6mit#Fb^0kJ8nBBa`er|avN@#dep}CPS3H%5K*1& zrU4;+Sc!O@a)Ym#OBk$Q9o}Id2fqi0CKY zoOXa+rw7cyx*`|^pR|~MCxuD)aMdwLrRI*$oMLorO8Yy?&Eh>S0FXd$zj5mv&Z<~N zzr&T)c|xZ>bdwkcs#*cMp6tm$Odvf-2Ndq+)_oKC`NW7cZoo8n6BmPeK(g_!@De&O zEQbeI%t%exgzx7M!Veld{NYyn{{S$NqCUGXtVP+PlEJ`!TyawN3WD;an8Lfz0=m#Y z<{k)ip=7&E9Nxhk{v3gl*%l2e^JQ6@MFw^bsXWAuG-&`{b{G)bYMs|}Cve=-9I#Bn zy{6L29C-$b!@m7YWj#_Q{HJK zQ9D-d;X2c^YA+KNCJ>fd+pHLl4J-@oaS|lB4@6AuU<*UDKV|{a=-OWB;t2Yoknet+ zm;@n0Lx(P^S)=c~&<984c^HHcQi4O|S*!(3x*cDvk!0EzLGR8t35M){ygK@aQ^Wqa z!DSV8cekL(0yK83SA69>vLR{c#+;o_NJKgPVjhVs(%0uEz`dw$&L#$jz;H8Z4%cA9 z?tAx$ia}C5uYDXY2W5H{))3wHXy{&R^@`YlT;85?vKy$MPoBNPfs3X@egnUpO&X$e ztPy!nuxmq*yF6OF_4w8^tN`e(`*AL*!;=v7beT}n18VC1dgJU9q;6O;G`_=sys6Jy z*7(;LZAA(i-+R`wks=nNb=>zjYYC>D;n*(@4yVk1(?5%U#}&~s_C7RV?v|2I2G%p?VQo` z;|iTkEQRd%51uGfQ2EIu2yMM&G2408b!_KFN%N0D)+s7H^WQSv(jnRDCz$0lFuh%4 zjI6T)G&}@5-Q)@)7 zm+Kw@z#6z(LfuQT;o5tqBxOLdU$T}zL z7CJj)WIE4>^bOe&+uk`OQ;?-yfp-xCyX zV!LUn#l$X^Z&0o+n;C`gd&aei5nX-0nF839d@no2t^3)@j74Bnf%4?G+8>kG01X#K z(LQ0F`lPcd{J0NE_Wb0F5wn+u-Svk;j#UL!hge^=c&Y<|6og=p*pq^agU!*MH4|r0-;(WO@6-n9$h#W+n!cWC`fwFLWw24Ee<% zRJ4fo-dNXmNcC<&*aBUk>4s1Uts8gF%of#X5AQ>VsHYgf{az0-ZMog0Z4URC%mLf%^$Of znpql-n97OaSrYMu7rYfByPW*x;d5YlHtQ6_269egEzQ8-&ZscsrsAPT?aJb?B;<3m zjemGV2!%j|a4|!&@Zu@Yi-}+sgV8aO5iPn&=>D;b8Y5ngS>-?uo;RW601mVqwHrnF z_c;z>;iS36UsEFEUo zC8wbC<@Lr8_3>O`G>AoU=wOf}qf9s}fV-U?KI(IgnHwidy@A}o3xktO&1aaTFo^Ls z@HYbDoR@cj3=um*BjenmITJ!`aOFKKg6((?xAU4J6|q)eOM5w9a8N1Egu&3mlBl)7 zjUnpD=;3p6sq+(F>W;OEYmyr=S`_2an)zB0L+}`SstTS)?ytDeq!NkBc1IXH%d2h! zQ|V>j)||Z;QWg36))s2*K+=tG-YJlQ7rp#^WlEyTv;n68a04$tKW0=2Ez%GF0B$@A zh&Nm2binVkTGn#Uqhp7S;_@PXjAkkuQgf0tpkLNsVCb)S@5MRQ0!J*1PF;07_Ync% zfJAapAM)G!#taMNzE?QaB-6a?J#w00UH#)sWeXR1RSQ`w9pt9MHf>IOF=-*GG7CmI zWKG_(E*Ip#Byf6NryQ``3ue<1@E4}B08T+1u!9obH2vTdfFq#|mnpK?Bg{TAwm+9i ziA!X=g&G{~iUKkfr#Q&b|^*H|I|0=+Jp@uMUy zsE%ab{hb(TOb)o0CSFatSbwKf&5Kg0lqjc-_T$-&EL?al|OH%qT#xrS++}XxED{M>E z;|y9=fuhlS)0Mq?);bl5YHvAZCc$zj? z?0@b6Qa&5u)=tC}+G39fMdIUmd=j^eT)C?Jgt(!t7LtVS;}1lgq3ZrHTGOEka%jZ z@rNCdCJMrO8eh%?jUm4iym1W)(P9^C*6|SW5od-a3e8RepW5KiT6w~BNtJGL&5zih ztU_H7PCLdD$S-GmrjBbi(@`}vCNwE8%55Afe80{(*d&+sVL31uufYy)m=r}?;nN5B z7)k^+%6$sQ7tNki_6pV=j=L>?B|ogNlZx!uS__AS0GBEGV8=%4-JLdnLmNA&{&0l- znYRLaT3?NSlMkh8tuyMFcl~)c$+$O$NU)dHIn9l!%=5is$d13Kmn63m%CE+IH9IHD zJz?{tLE-xM8E_)npMwGa08b-6vss8N@quvSbQ^1;KLz)^Ug^wgvI(Y-Thz#sgF=yZ4f6dMk;>+{)|JDIZYqYQxQX4P zpgM*QjdJy?mN;lGwRMXZU@XBUz8&J@8vgilik(T*xsAL~gpwyg)0M#h8i2YJ>v^=@ zh;F#yaZ^*eoI%yYVcWh{IR=`|Vz!n1zmplYQr$r1Jo?23^>eP5o}7RO@Ey;=-Z3F* zROgKx^@~UYN`YJR)=ky>1HJ&~1E9*OTQ9sGxlAuXs`FcLRlb028JGdT#{B+0z=9iI zEq0TSoX{9nxgj)sTnZ%~Pa(m)S%7E{r<$$+0JKMJ{>D@!xN?^7k8q8IZWUXaGMj1m zz+aqbN)<%jkNbi& z+;}RzWQNx}i;^i{Qwb84Ro5S^9=5L!FWkTp15vjHoPj1Ueezal8Svd)un0H25)VUGjk>&dS zajSU_slxY;*+H@1QT`q8hZJUqTk{$8zD-GvX{B|hJB5FP@ZjmozcI~$UWhiYj#V){1dTdU=-@ zMYFADh=^gxb4OoUZ4^i}Mbq(c15rXYMCTSh?3*R@&FRa!^Ku4-d)HgJ0mfK(_F^t_ zpSJ^I5FVP|MaguWMc&^tuIfA3{Nxf-rnEdyoCA}{cZ^6i!i#R4n%Ji8x6C%_Hc4bL zTRSV?*@7H`li$u!VsrFn;-FJpxC`U4VFp(IkODc#CNCv0Ip5sS!WLEvtVK{lQzEZ?|$Xsj;U~QKwxWrXMem)iGN7M zP?J^8j53@_yLt~!-m|HzuHSvJsrW2$_+t@88htfiCKPnu&sV;%wMeBfp=Dy zZE;vsD7AsM`@B-T74I=qbBn|9j|?g~S3=PP^5PC)jdKU_eldg=93(@&{+vLJ;?0j| zI2tJ03ud(f{xewXcZ98`=*0uTs+i%v`eY{aq7C@k7)MSkvyZvX4-iHVVdBm-f;?^F zAD%FKp$VFFqV@5RDZakV;#Uo; zs2j%lFcqDHW5%4kB?gT$WiVX@mObRzqhLP(X2V*dBwyJW+zC?fEd#On%Z}EDGafLN z<`8Kf$K}FEs!H-Rq2R`a0;?4LaNu1HiZIt0ZPnC595ulmi0!zY_knax0pBl5^^F%( zv9)&o`^NAaJbZoSkz&*h!va9=+1cxk8daos3H5Y)a_^apfjZw>%X0u*Kb%ZetH>ps+`9y(jk-^aVCC|=!mZ`t z&i*DIihSQU#!4r4x)aQmTdn)^=gv{8A1YF@>RbjI2p+obU{C?EVsUfU@in;wXtS`! zU`-8Z?Ti~xqHEyI4ble+CegTv4QXR9cz|e8`*NA5q3zy4Vaq$z!{tkl=Mq5VI(q*A zI>~8JYV}42RU5rS6NPSWJZX2&csA5hJUB@CoNHStIq9#CGU!#V(cTlV1(6@WGfPNy zI=)<=727zNO2-?(?F1g7f!ope{d<;EhYM~ZRHE!c4Q~JtFn|So^vi7$Jow%Zq)r9- zd~XrGVX|%e?8X;CF5&MG^BA|0v#H+jB^vo-iK@R3#v+k^@lh;M4If5qT!GVFDU8$_ zB20BI*TrIHl_98T?~FDjd3Z23ZZCRg%Y@Cn>`K02q3G-%!;CJD`aZRl&`C7l-aL}U zOV%O{UogA8VH;AlMuQa6qz|(OLqmT90f1E8*IZ#~?~!cd9Vp_{cytioB{Nbb*yyHj zOSHfC7$QxAJF|ZnqjR!>_nqW4*3;Uf%izd@Q-fXcf&msd)BgZ*Nl>UC9?Y7SoJ^^e zo$sr+3Kd^xMwhtI3HArRCJ+oJ%c173vz-LAZsGVr)3@z=$lO4yN~iAxQ5IY!)c%(V zD=w=11kcFfmLpaMUcZFqvM4CrL+|12V1^QH%JwhvYaA#AvnC}6IEb~aoa`8>6d_AH z{#>D`Y&&_Qx3!k2jh4Eo!NKJJGhl=#tKP|)4YEM zUwhUdO=2UTKatzk7iB$bk@&)&h$QV;xn;7uLUu(iIu6y*-z)cy8t@z?wI9LP+r|Yk z7snSt_|5r37=d>EEAmMuz_nRpI>g(>~sS@vETX8y~zHbE) z*nC_?2U-nuUxo}IB@Kh47ZX_uml3H@RnyhPAYg~1EjR4eRbuP~e`iw-!hlrGX-ra^ z;Nb@tKRANWcoU`Z#DD92DB0oo zOoDc$4c2a4wIMwsfMnbDNQFB ze2#S4uC6Z|>*J0!<_%&zJvI5p6nxlN_0|Qp8m6?)&18`{f2UsJY0T0557rnf;j$aX z6!ULS{{XHz4ZL<6*QOmbLFT-};BDYDaVL#4pUw`woh!eK&Bovl(HlKXDTsss3-;nd z1SKKg$2cyG(Kcb${&J@<9a3?4odA!qjA8*(HS=;|*T{qW#93yhnh**2e?zQggR52XD1Ufbl^TpF zF`;c$XGg~d1xTsHPk_tRmxaH%i|d3Je@q&kdU17hIB$2@B0$%7Btv1NtJ34f$Y>hWbMCVJ(n;y5z*&?->BJ67kL+Mhq-eLS3s|;zizvVK%p{pxCUY z8!_EHZ=-KKG6bVree`mlfhiTA9biBbMieE``G67A*7ni1IwnjYc395{CyqV#;Y6j3rPel~9@Z*8XWj+O$VCR2MRYA1NWR0W7} zEp_voJ`jZy4$3r_`NRRIXuMg7#5#Tm;5G9$A@wkaR)EBJ<{-3s8gLOJ9c02xqyZw% zFZqce0UkLo{lm3^<^t8?JQx?fRZTxya#W>2A@H1IQ*BfH6m^N=`H5}}hX6gTVGkIG zC9fPVXh8R$Zln9d3nT+NACaGg%T3f5@w*kD7(;R_i#@Gfw%_EIDNsCGQ&cV+$#EV{6BK8>P(jHpxF%7r)Hdp3E=_MUFzdF z4FTzVk-rx+tj!O3C33<;SY>^bVxwlu5iGOx=A(Bf>kRU;wwQ>57%~lV5?Kvq53;= zq|`Q|!_ql=8tpX`N^sz+q1jrC$KxMK&;eR*&T-mRQXndS!vQ572%F0;38`Gc4?X$! zizEstJPtJ=cLt!AmY<9PXctKQ8^CQ!B)v^Z^Mj#EZIhoA%L8f_{N*HoZ~}V!%|qjB zDh;=#%1wQC)PP=0FhD5rzc+cs)76omH&Eva6<7+OcpLoSaI$tv8`Z?MDWUrp1x#2F zN<7?RrpfoO341X#O4rCaaaa%Ds^A-?MMc-yO8=BVNhghsguSnjUSSMhkOrD-)E`$nh`G8*l>=6#NV~b8yMOC~F zjs*oFvWMg6;|eM^gEn`phiMeVP;m15j+!DKH8kWjY=yIUnRTN=oa<}6kcDmRJ2}Ke zj-RgA%;yrTPL7OBd|aHpIHxa`IkGD)T4?aZ2&@Lv5{U<|0K|z(unHHJWXZI;#B6rg zLPw^U;H?`4 zLJQFW&FKJX6~$#EYZwjrpKpP);k$L01cZpDpCu)+5n--KcH2)2L~HdIP7ty2x@TA{COZI{ouWzq z0LBY*hwO((m=8qwB1+`hY1Z@i+~YY*v*-60%pp*Bu3zoz3Q16OD)eQDadyDoY2C*7 z+-2$@Yhcy{gcq{@t^$M@j~qJ>##n}uZA8wf*@*V{Ll{7?X=D7vATHQg z3!yc@#%xifcu#}eCeBx#Jn?eI;do7$sjW_K)68K4_6Zje*R+aFtgyjAKTHgjmCn_; zQFtC)8A=5_xcM^rkaxJeADq~buDg6-@%d;&;kZHxc5so;11%-OCr#`p`#jg zXg2TM(FKIBD|*PCcca67ofsrxXf2P}d5{t?-CM3>=a|mo2|XAI3IYSIch)0zMVTb) z7#d61MRD4nf|KK=&7P^|@bcgk z?xXuKQsR*P8mO?OKxUgiN(TyQ#3Cj0b-B9-maDE-f0)mv;fb$DUx}NUCdk)Ne@(zs zn`dvUvBal%-H()vsk!CNI^AFIJf%n%kvhf(E1)6tV(dSQk1cNoV_CeAMvEZgPB7-p z`}n{^H}M4~uwn`Z%{0>d_~WKnE<`;u&9+Z*j$oQ{1}$>d(p*)*GR$)fDtHc8MiUoS zpOSQRIx#Xe6cj%De;F8!OY(hvV`wQxS@5C7zEw@GG8aQ@!u%L-OS1OdoVlXtI>xN9 zTPO~VOo*jlfd{bb0G1t_f&Py0l?e{!__HWI-RF{%p5_BG3P+~{T$08iEz`XTk);9f z(7V(7$dC;qMy`fo-O*t3^NfKIK#hRAnIb^vC&DqOP#Xau)o=wF0j<}e>SS<2(KtOH zi<&c#iXq|WQz`&@kOPW!kpmP&3omPs8`OB&4%y3y-Wr=x*FVj~NOo|d{iZ1rffeuf z+lpkgsSN}-ix3sllk{a!T|8v56MMa% z5BHoKRni)s-RBSp_*lFfXVFY8vhHD8Nt(aJ)Y!xlnf; z4W{^4cyS$uotBzB?as0lrvA{naCw0^4uppp5xD8Qcum9iGQ{5YbDC8GZT#NH68sDLJ}?aP4UV=SnX zcF*fOsTc&`Q*hfN?Ab_O=W{s1Q0C945v8%ino(zQ*878)TC$5G^*a;JE%6%S6N&oozOEuJc29=UFMi z!sD;5F&nP9$9?KyOWh>gp{QhQIF8&~ny7Nxj7Z7bwcJOpPx+M!hJm1GSNh7;Q%8~2 zGJ8lPA53=$H&zp!Fh@0B%QSs&0F~L<78pVUe6^9p1bFXz7ev-74*-Il0me0D!rgw8!m1{-B20hh@5CE>y)#+G~AIBP5%H{da4c*+qn)VH&vw$vv-tJWwdfZqg^!7_3ko4jut0n$<6b!J$`Aes)&^GtywPmJy( zp~=R#Ogj)p!NN9vTt4Jcxk?8lp$^MNWNX-uxBE2W4eq~s)f}eKa3~2z zwBd3og(TDp**>%wo5R62ae)9;plyDPF&JB0{bS(JZrXFPOGBh^0eFD~V~Ldt3%a$7 zr#q#qj|@Dcr!6uTr8CHb-VVL;Gvp#5q3OtVX^2hdeBY}0Ms9&LW` zwPNHtaB=}=j&G}Eh~3>o_FO`dNN~jvcZiktWcKLu9A==fb)@QJ#?JX%5E38`hZtTq z(1i8EaGV2b`^JjWsPWeF&Cv?uRR@u^_0oJc;mjg$YTJgpWB7wlO zzEdZqN}%hD_T)_is8|x~7{zK1`nPUwenX?4T5JqeEv2r(-j1J~0oia+(!GP$1%lp1 zHoA}ZibRf)?xoEFC3p?*yfLQszMMFqAdE&7?VDyG)GKt|`uXb)6b$X7;_6|7$?HvS zxG{v5B3^aAnWBQqz-+xI7}NrYzF{VtEbaEvIuT`z|-14+>J zCX3zg4K51#P0o`SRvSw-TpwO#nKfVsrUZiB0GAQ7?NHeW)XoVg}!#< z4jZfqI!VsTpgab>d15aGAa%=>Jz?=dUS6wa!wJ?;s&>5TstRRzSmY;Zt zsEi6d2%3-QCDGcY**7p3a*edSBZ?=oc_sp>Ap`qy)A+@j_}@|a&2AYSYd#u(CJK)# zhZ+z4dAl0yJvWU105OkVr`d)Zf@p+U&k=+Zhb}1U`Hztycq9&VqTq`ipMGI0WlWgr zzWn8J3K+30$?06>vaKYV3^7SXeM zR|Lqhxb)ksMr3Im)ZRKG0AvBhtzd3Vr$^C%8c@*f#o2>#qJuj|h;6Vv;r#*gsAQh=Yl|qHxVU6Q`pTn-D)dBwc55IyqOpn1hlL#M_+N zvKL!=PjIuCCEBDw))w2IjkkX|fD{|Ukodn@RT}~Xi@EH?)tPJ>;QDbXQX$!e=h=W} zj*x^2-ZBHzt#Pw94#gI?=PL}2cInm$f_5sZy(dO#M#Dyn_0#Te_Y^j1&di+rK+T$) z??0??L<-pvKrLx&i$;AIKv6SF;X;cnSM5ommy!dh$No zlf*U{t^qXBPsT;-wv@lKAV@U>2P&m1S9qzolPIGV7NftsFSnQMb=!)yprCPrr(fPE zXo9)h(d&sK)OioSuWns)ggGCXlhUD6Yu}@zCW|rSXXZy8PBdeo(MWf%8I8l+;$Iop zxo_<`H&wHKvMo^7^`@=B6>MWuUEh1anj3+DI7|!~jSfuL4ucYSS0m{L>h2rY*dB|v_-$1xKssP|pPB8%28%}lW z=guH3>j)?lYwuYEoSs=U@3#xP-74$H1}BvUvl@sqY2}LT|&H*G)`Tc z{a5ENgbklKMba8n?-m-~0Hf3I0Au5j53#(cBF2j!pgPT{W&m~++)bp-1WM511()v- z3bY;lx2?ATK}lUgsp;E|XcJ{lp2zPI08-FW^bcu_bs?gJAyd4Bh^asUEl& zhDKH!9w*y@6xi%>c>J+gc_-{|o4En(E!CB0;^BJT=rcj0Tz^vD9|r4MTpf`EYmuFS)+o;bioN|5_vt@ zwCfaNhihrj{TLY`)|{_@gD8TTIcRV9l?&I2-(UDi?9}5D*Z%-AWi8Z5JiZw*R27?6 zn9ViAd^?}KSEMC=!u?{=CnI~GBO=r`2s)GdF*chuKZzdk)9N|dt>`}BU5Of{y^qFv z15|&54(iEKu5}KCi`g(XAZ+A6eRg1UjgaA{BbZ&UPdmXh2Kv5l?8P{|i59Qx7&QP$ zL4LmRY#G2Vo}6KXdD-V^V*wK2R$q zJD&w|#x7L{o@OyOa3^0R!d^k5jZ>bpd{v+b(dVI^m=dYiQz5k@ZT-gc#sv#`Efn3J zVu3kT1a;2Nu$8QArw@z$;?-h(@8`E8-3T^?U_RiV-}e}}X#i@_-rX3cjT(6dhO%2g z-FpuX8;g4`knIj#X26VA_C6;%&1wxepOE7gp`scY9!z$~GA?X7IMAM9N55^_7=}TL z*onwsMTYnnN7>mi?>g`mb=!#&0idBL0gT*o6e7IG>SKhfPeOh5ghiuX4Z9oxWHlX# z%mmFyef6Y0E^bgDu!y~nxoUvHv`snS-h91^Y289|#y|-rU4zWsgNcix(2hA8X7mfqB;m%GPC{{T3yuz-%%XQNs| zRo^WK6RO!w2u>Le0vpA-zk{6Us!$W?rk4lH)YcbIx|Vkprg_F~**cOpFR{Ej=e zbkTlDAG{JtDNl3ef2I{4{gd@?_QlapP<}7}08=}IaMR+TlNW@rKaVfz#>jE7FuZBm zn-_QMO!pi8=`aNGT5J~bA{=T^9G8@6DBK^N^0r86I zbl`y3qukiv=)b%)^qrsgtSUe(4euQca)S-C``+)PDy9&D+qic<#0O9Snnt1t@C=QW z9hR^2Fd|yoT^u5pTGl9HAykiM1%NKhY;|ur3Pji(EvXbXsA3I77}z#O(@W+dBo{$6 z4HzN{M`NO}>x>Z8kOI`&Gp*XA5G4bXx2M)$BnrF(*d1j2wHn#oU^L#^0Bb;8Q2>E8 zZ(+$}kkMzwauyp6*YWa+pse{M4M8!p#$;hEn~hU~fwqk1*qz}q}s@iAH=+YoS+@&e@wLq^-mYkJ1^ z?z5Ho#bWKD_H~<;cd-fH`S{J(9TRUo>n?(T&Q3K)0bj={Fd?KaR2^cAP zKC!XYfQ?=M_yFJ95IcJGls+Pp)zx0Edo_)%n8@2)~yX zJE*^Zw<|`qa$riF6+Q?jw2g3EYN|`aJ;uzZ2-hj6 z5L!dAMD-r;nK4MzF6;G$XhW~D*(CH!YoXd$gV{Nk$tr5kCzca%gD zX;c3IoJ~2P71^9%Ne6I&`)ey$5|R{pJ(z@Y0`vhu?Y-evso9!Z?!H`U5QAV#!#flkoIRrN3kPZiBT#+81U9t#3Izadc+6$5;s=jlPTg+%BL}5gl{c=2`*` z09|r9(Y%bX0-+buLgd693s{S=^Ic})s;w#=Jh3pFPFNBG@18C}pp=(ktIfS(HpYm9 z6?DDpIGY32X453lAjebT9D<^nb(*y%I&Ia{zg5fgnqWW&>X>^CN*6q*_e?VGYpMGu z{2V=sCBMjo{znm`7DMYLn)`-dHbdkv0J!{u{bb5byP-P93ib5;WWAJ$Rj7Zj23!aL zK;VDZ+;WsTE)Y-4v;P1O{5L6pA93ASESM-$SVpno<6Ss*8^k!aF7Qh)Aj3gK@h7nL z$9d`SSlM!|9%LPews(mYq${^;r^DwXMN%-Ko!x1WTZY8t{{XoPfvUpl?+|ny^aFIs z263Voyc4U9tBRL#5l(mRZM3v4s!kKL5kt1+bK}X063QZIMWH@2l&TOK*VN}4Iu4JH z8|}iCwwNc2iMcIO*qd?0yTcP*0{hE}wPh95cM)}gJkg0Y3(|3s<4UF4lYU&KEdxkJ z@h}xSE`<&Upd8z(2ZRzV8a{K6NHC8P*^9+ZfvURaG!|;A0Mk@>#>kR1kN*IlaY735 z%#S6>Cn^@I{p8~C+6t+x{&~R$g`S5b2e-T%s9H(6sP^PL5PBxD%>Af=de>OU5gJwJ z+){xazv+gWfmVS#ADj;Ynri+R&$!V4# zbNtLqx8+_?*H}twtedA3D_43AZz~cXP9AT@E_qfQN}Mb47UH8;lY!PM4r$UHUEE&9 z4N>v@;$)Zw5)GvuWGj*snr{vbQ;j3{UV8HpqH7?A<{*0-#()o}`pyGs)TqslK_F2| z9?ZQWF?Xuuv_FSV3t1f-seh&_yDMpzi}8RdSBF$Lf>aw78$Rzjs#(S4!=z|JfZs

uP6NJV)Xby$+sPxlunZETX9(XDKEO`C@6-#AOimbO%y z7o#p2VRHqi$;*NxbVG?B=z@_9kkLfpa^i2N4R z8WtfRlVvErDU?Rt*p{AMDNxD3cv20v*(n{r;^cK2wD5wJbh?cMy?9dUKZGV~BZq-1 z(bIj1(<}b~(N;7Q-qS*^sj}&(p$MUTkA7k`Z@>KsjJF4Lo8@swc+)lKlZQcaYMRw( z-i^c7EB1)I5#Wymcpyn0m-J+n=?%D=Bb5o;X%K{O*gXea7EfV`ySo7awx>=wus#4 zM_IoFX9Y#|nX<1K-iEavpGJz^B~Q^RX;Ia2B5bxPno07|<)@Vm7Mj)k8t_Q8*o-=E zB3j|Vr?%I#iLV3}F4&m=0E9Giz5=?w5w1^5f)XW#rgL3{n3OdsZC3mX$(m7?!;Pfk zD|>kV0AzCC&S4az%q)Z?Bx!og68LQJ6nVhQji0!5m`=%lp!H+K6pF2mkqXgqF!8)4 zQx??NqU=N{>JpC#X9yZ;>Wwat$baZDWHy2~Dv7dr_F+TW|&+27Z8FfcnZnyq7pw_ zMP_kWalAfzn>n(T9(5dyUf>qgv^Sh$+$f(SV%GI(tdb>k(A(fb3~R=d*-;H%Nr~e_ zYs&M5$)ow{z7kd;i4m)8(?U?5?BIBVIrvT_AuG|=V>K2ukoA4-F&25YICL_9 zVk%D8cxfbRra|;*#ARZ`{iZh%lt|+V_g9CpA+-jNYCoZ5ptNsqE+Ngbrel-y(0TAi3#Qixp?F*n3AKN9%8;iV&6n#YpuLL zCo=XX(h^I9fY~>q59qh#H6uxh2xD@w7U1$JY^dL3@?4>u$D-3^GB+P4$#*~4k(muL zqBBjSH?ls7Iw16TVUClT4ouZqSh!8vgq~v)7Smtt9YCbf_Il4q#C}A`_WAg8i|CK} zj&pF48#Zj+H;6Hk(tA1KGQ!;6gcm0|C$zl%Tc7ptsk3*wlwL{8Eb*S;Eklv z$HMz9Yj1;RUn6p!hBr8bAd_EVAqhyAXXYWua)}U;y^z?NPoG5^V@Ap*pw#GV4?hfV zDSpH?RpA#m92+&{ccKz5k!GF)t>`xX#;dG<9h6UdHI)QXl=>tZ#VJv+C9H?Bjg1d3 z+8`;b7KSZ82GeycIj^Bd4E;7I*|XY(P?SIuhBuUe2wdZJ(!Z~V|*vzNei&C6%CD*jd~e(8D6lue`DtsY~-q8Nxnv!(L`)w zlSAkwXXwIYPL2{vI!o|#uOejB7Sd-9N@p8?Y)sJJ9Hl`dAM$fq);Yy3LqZeIjtvOZ zG|srhG-%(!Mm|f1{vO07pZp63Mua)CY8EjhgOJk$!XDoOG{OSLRCYKM zSZLUm{zRr2jtF5B!!=32>csb39aCfCmrQYwO0z@w%xf#)nXErzA@(nWR>rcND)2F) zVrwmhMHOQ0eF5BK8MLZ8$`-}PD!1Y6#O+Z z#xG;r!zvN6Y_PdG5YB93LK2hErTZ3bU88AiLNrH35N`Qp6dvI^B+ifcg}}-_Gi!qB z35%jsE0oB8z@IKjN`^LEp$TkaZ3+?^lsyUD3`=x%F#I8D4c0VRi%|{7!ozsES~5K0(Hmk!PXZ>&5U`$U2cnM5CA7S; z_4eK#%JqeY{go8D@Fpmr>yq!%bgMaC&`;ZsJ#LLoA!ma_zj zh$E738YMU@-p10!qU2AZnR*=VIydl%;}B+uYr(SNDq&Pq#->n|AQ9=Lm>!Nj8%b(Kqnf&5CBzgBGf3Vmv}I zeH$qx35lBUevRy0iaK0zlMvv$!;38qe2H>F6^rP8QxPP^zQndNG3cn#tZsvKrE%V| zalA5>jocx1@z{!QG){gH)yMQvHgrCO@Q1+*jJKXyX+BWh(8c65cZ6CIqU5dNG>RvS zsF4F&hjMthNQRgpG%>G~7h1&^C{&DCkgRmgu8olP41Lp8d>Z6~b>f3Aq4G0j#-3Bc z=<&FRroTN3P(HRyN{X5R9g9kpoQ%Z2e-XDBNU zgt2%o(3uk$kAWiwgJRWI%hP%u;K;?G*9cDuPRR-Jn4bib6BZL#tHvxVYuVNkrF8I) z7Yb8c8y*!dSyB`K02d_v9n)-l<+stZMz_pIrz8| zJf@L57~0_7*rCBhC+sh=CLLp0MTo3yXXId@V&8lXl%yF- zCE(wCDDhH{ZYA@J$q^FEEoTP>#~7@u5#zv+$!YCFvQ(x_z^Kw0B}T#8dwFe$Ohm}g z&59vCi^^iwAE1b930H)|SS_vyK`qCUHX}68TNv_OU1(223gnZpw_JcC?RCq{`#Eenzenq%Z3?y%jZv8$qoQ6_2QLVG!T5|nPUY+lVn zlA*YuRbs|9dqfb&zk=qmL36<;N0B#q!f0P(kz<1Ao(7+JTGKSNbIGTQ9ewVRNHR(f z31#99DD<*(o^Z9Yi=lcEYNL(@@Fhq+Rv!>?y%(D3cr zIU7r%ws2T4q88iWZvqhEVlttPaz{8BJ$y8gs(CajA&HG7OX5PxR z;v+(sr|IA$S9n^YCFp5~c!=26E(qw;n@ogd$0#L^4vI04lF~fyfikqN=X6Tj#+YA` zDWow?jjscL8;$I`E;d(B%vRJc8au+nx`*5n#Kb7Gbc1OMTSVA(RXMCURoMrzkeU>k4`dLp zZ(1R-EF^I7*$^U2OpgQElzuXci$|5^tUVVUWoTF6pp+9`8ZCl0ax_tL5cI^mYenMa zVp~=%GY03-t-?CJl`Mawn_LQ-HgFjdjmmLQLZSHK0xR(nEUGDMQK~9Q+nJ#>LUZA{nuL zkY89_d^j6LHH~2@)?W{^=`lzZ9+{Rk5Sg6}*VhhhB(mxMe8ShKAHh@H9c1MJ*$Q%WR`lk$oHRaPf3> zZM0IelY(z!;c@tR-Vv?hBa>$t{2y7}!V$65$Rd>c_%Ds6*MIYsDjxU8uOvB$`w>qguWlab-UrcDW{vZhp*18xcW8z_&# z9$~MxC`F60y`D%RY(!J+i;6-$o9`SPi8A7?oI)l(FqRoL*UkG!&3}% z6xB5k+?cUx=Ha#E{K6H+@d||Y%4&;)cV0&YO(gUOhQhrSE@*I&IT} zcRKHP$8EGsVd(>cD%rJZ^iB5BMx&UI(sWqnR`SiG4Fx^ z0|!|Ws&W#$l`F2^k(E99t=0Zzoct;~}$E+w}_28A7A zm^BvE+Id1CeL*EHh>i^}csY%)2bM8)4@@gn5W_MJ=29FT{>sw4@7PnQfH9($Exp3R zm@dUQ=fMcsfJ-xeVrQ_`L2OLEaMOBUs(m#SL}?M>6_`Po z^QAR>U1*hBQ`Kzn-{69OKyn-x<6ms##twZ&|!?jUpFQmUCmtV&>F3Q#8nA@1Ny!M&T6V&B{*+Urb*Gbo=P6XFBuPHzp9n zGV4<)sRw#*nCqov#0Pg{lbDcQ6uhBmf2$#UFnNG&3|J*-9ll86V8N|B2~69-*y1GJ z*%>H|dzUFyU4c6qe?ni`6c=Y%ss`PO!@m6e)#|BO(|%a(^=&u=KH# zPST(tE??TzQL&c6m4=q(g+PogF)XhdMd+Qdw3Kla!40@tj%F5asEM@f)|VFx6V!SM zSJtfLKsc46I5zH3T?c>B2c@zJaV@ebGKJ%#gT)j?!xCD!WoGd~EYkH%cEyj=NWCwH z6z$Y2lGvp&ZF{zs%k5FQD;4!C_CGOCXb$oEm2)j9k5w-P=I#nxwyJDH)+4Cbnz!3j zo{|^rjL-v@2P|v~5qW4(T-voj@Cib{;6W~`DO<;6X}QP97}_gPa@D}AxogrYlX(3l z4$`GTOVd4d+r4oS6?>ltHI$DbLRwOiN63hEXV#Xi$TlE8JZ2J2zCiE(yIs zBMT+^KsM=Z{3c5ju`Ma;SuB|Mph}eqOsPz%Qxg)+0Ng7es$x+#XsJv)O&^R;e>Abr zj)ICprXnZ?jaYB*5Ql7NqeEDMWKjy`x)^&HPUH!6W^=?u4MyjjenCF*@sD;f>H)D% z-oI%0RWGr4mp^pD zLraQfpn+V%R(lH?D=`~UCQQW%9pAl0vdL>IS+pKk`#2)Oa+PH_`z+GJKX-{tSH0pf zK?*|RSskG@&qivbwaO^GLgNG)f?oBHbl)|(7Q<%Ws9h#_W$;+Ido;@Kg|pMT3oPP% zbpHTE7r1s`5X%nd(k{_kR`Q~6#mim2hLyiD;l*qbjHiQofSDlWD(vHMIRA_%t78s*#z4#-1Wv zOPLhrTG~~Etr{W-WK-)nTx$POJ61h8+L30@N9Gq zBQAl)q2kYwXW4e&S5mX?e4tbbRRxNJqC~_sAKEn>8_G}=P2jmVj^l$%)jUMK$RD7e zBebR%GX-K00ID}WnD>t5OG>RC$^Z@dhe<1TC~&%8EWo#waVi`V`i+JU^@*8&M{c6S zeWD3yS(06hFq3)A6{5z6%(s|SsZeFv1}RCPrHwyP7jUSCNJN{p{f9L$j?ls4Q5sxZ zZ=pV1z@wnz`rV=x5*veDiFC%(2sU6#3>penDy~`O)D043mwQ62V5o;8I8s{>G9x36 zx`0e8EWWX$28STNAdwfPfg-T!jT_=|ysZsaVq&AAi=B4jvqg+?4as@ARz*cNc+W^) zm#GqNz2<5f0#JszlMuL#(ggwQL&zba2d?F5hOysO{{XnO{>U{RNadTg4c9}Q!jA79 zo9Q;`G;U;U-d<22W}Mx`flNBpMvEdkYgHkstyr2D$+og_+OP91*fr#>S1DHkX4G?ZzSBROF z-Fy*xNA?74N^sN;F?BS2sBNnCcgBPmvh=Puv09Ba7Odh?yekl?N{dmqM(4Oke-h9} z6ERU4HunO7$DVF@(XuS0z?iP(+%pMsv~Y_=RmP;;`z@l0ydgN-Bp92X=IoN zVAK-s&l2M5^vogwI2uDjJ)+<}fn#J3s0KR2I7`HLJwXV*KFJH0Z~y^B1}}K%iW2Zk z0K4%5I)G!KR|P{r>^hOzf|smP6GJgu(g;oZ2(4cnMCM9pGZba$r@fA{H61MILoqJ2 zvJL9Xm#wuodcss*@cFHl!*)~$9hB}5K~QyO`jr}&E?l{J)`@oY*$y3$t@^fki%}4~ zAsj>wdR7AJXC1?!VqoaB1SRQlkM3N!t5xie2|g*$4_;XB6t=p98LEofQ7|d3t z@oGFVYB?{Ud6whwh6#7P3A{Fj3XT=(S`E4rGX^45Hn=yVvD$V+K#-5vDZrD!qThKh#dX9TpIek?&#h7F|mFXT7U?i!B9jnUpa8q}_#m!`x)t0OA0 z9-~m+E~8Oy5p@}VG_#}XEz67ek(VzJYF;5OS?XS8%g&dA480VDCU}o#frW^q&A;MC YhPzt>6La`l>c7Mh5;ULV=F=qq*;s7V`Tzg` literal 0 HcmV?d00001 diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 3fca0d27b6b..2abdf724f61 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -1,6 +1,6 @@ """The tests for the go2rtc component.""" -from collections.abc import Callable, Generator +from collections.abc import Callable import logging from typing import NamedTuple from unittest.mock import AsyncMock, Mock, patch @@ -21,41 +21,32 @@ import pytest from webrtc_models import RTCIceCandidateInit from homeassistant.components.camera import ( - DOMAIN as CAMERA_DOMAIN, - Camera, - CameraEntityFeature, StreamType, WebRTCAnswer as HAWebRTCAnswer, WebRTCCandidate as HAWebRTCCandidate, WebRTCError, WebRTCMessage, WebRTCSendMessage, + async_get_image, ) from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN -from homeassistant.components.go2rtc import WebRTCProvider +from homeassistant.components.go2rtc import HomeAssistant, WebRTCProvider from homeassistant.components.go2rtc.const import ( CONF_DEBUG_UI, DEBUG_UI_URL_MESSAGE, DOMAIN, RECOMMENDED_VERSION, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow -from homeassistant.const import CONF_URL, Platform -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_URL +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - MockModule, - mock_config_flow, - mock_integration, - mock_platform, - setup_test_component_platform, -) +from . import MockCamera -TEST_DOMAIN = "test" +from tests.common import MockConfigEntry, load_fixture_bytes # The go2rtc provider does not inspect the details of the offer and answer, # and is only a pass through. @@ -63,54 +54,6 @@ OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 host.example.com\r\n..." -class MockCamera(Camera): - """Mock Camera Entity.""" - - _attr_name = "Test" - _attr_supported_features: CameraEntityFeature = CameraEntityFeature.STREAM - - def __init__(self) -> None: - """Initialize the mock entity.""" - super().__init__() - self._stream_source: str | None = "rtsp://stream" - - def set_stream_source(self, stream_source: str | None) -> None: - """Set the stream source.""" - self._stream_source = stream_source - - async def stream_source(self) -> str | None: - """Return the source of the stream. - - This is used by cameras with CameraEntityFeature.STREAM - and StreamType.HLS. - """ - return self._stream_source - - -@pytest.fixture -def integration_config_entry(hass: HomeAssistant) -> ConfigEntry: - """Test mock config entry.""" - entry = MockConfigEntry(domain=TEST_DOMAIN) - entry.add_to_hass(hass) - return entry - - -@pytest.fixture(name="go2rtc_binary") -def go2rtc_binary_fixture() -> str: - """Fixture to provide go2rtc binary name.""" - return "/usr/bin/go2rtc" - - -@pytest.fixture -def mock_get_binary(go2rtc_binary) -> Generator[Mock]: - """Mock _get_binary.""" - with patch( - "homeassistant.components.go2rtc.shutil.which", - return_value=go2rtc_binary, - ) as mock_which: - yield mock_which - - @pytest.fixture(name="has_go2rtc_entry") def has_go2rtc_entry_fixture() -> bool: """Fixture to control if a go2rtc config entry should be created.""" @@ -126,80 +69,6 @@ def mock_go2rtc_entry(hass: HomeAssistant, has_go2rtc_entry: bool) -> None: config_entry.add_to_hass(hass) -@pytest.fixture(name="is_docker_env") -def is_docker_env_fixture() -> bool: - """Fixture to provide is_docker_env return value.""" - return True - - -@pytest.fixture -def mock_is_docker_env(is_docker_env) -> Generator[Mock]: - """Mock is_docker_env.""" - with patch( - "homeassistant.components.go2rtc.is_docker_env", - return_value=is_docker_env, - ) as mock_is_docker_env: - yield mock_is_docker_env - - -@pytest.fixture -async def init_integration( - hass: HomeAssistant, - rest_client: AsyncMock, - mock_is_docker_env, - mock_get_binary, - server: Mock, -) -> None: - """Initialize the go2rtc integration.""" - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - - -@pytest.fixture -async def init_test_integration( - hass: HomeAssistant, - integration_config_entry: ConfigEntry, -) -> MockCamera: - """Initialize components.""" - - async def async_setup_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Set up test config entry.""" - await hass.config_entries.async_forward_entry_setups( - config_entry, [Platform.CAMERA] - ) - return True - - async def async_unload_entry_init( - hass: HomeAssistant, config_entry: ConfigEntry - ) -> bool: - """Unload test config entry.""" - await hass.config_entries.async_forward_entry_unload( - config_entry, Platform.CAMERA - ) - return True - - mock_integration( - hass, - MockModule( - TEST_DOMAIN, - async_setup_entry=async_setup_entry_init, - async_unload_entry=async_unload_entry_init, - ), - ) - test_camera = MockCamera() - setup_test_component_platform( - hass, CAMERA_DOMAIN, [test_camera], from_config_entry=True - ) - mock_platform(hass, f"{TEST_DOMAIN}.config_flow", Mock()) - - with mock_config_flow(TEST_DOMAIN, ConfigFlow): - assert await hass.config_entries.async_setup(integration_config_entry.entry_id) - await hass.async_block_till_done() - - return test_camera - - async def _test_setup_and_signaling( hass: HomeAssistant, issue_registry: ir.IssueRegistry, @@ -218,14 +87,18 @@ async def _test_setup_and_signaling( assert issue_registry.async_get_issue(DOMAIN, "recommended_version") is None config_entries = hass.config_entries.async_entries(DOMAIN) assert len(config_entries) == 1 - assert config_entries[0].state == ConfigEntryState.LOADED + config_entry = config_entries[0] + assert config_entry.state == ConfigEntryState.LOADED after_setup_fn() receive_message_callback = Mock(spec_set=WebRTCSendMessage) - async def test() -> None: + sessions = [] + + async def test(session: str) -> None: + sessions.append(session) await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback + OFFER_SDP, session, receive_message_callback ) ws_client.send.assert_called_once_with( WebRTCOffer( @@ -240,13 +113,14 @@ async def _test_setup_and_signaling( callback(WebRTCAnswer(ANSWER_SDP)) receive_message_callback.assert_called_once_with(HAWebRTCAnswer(ANSWER_SDP)) - await test() + await test("sesion_1") rest_client.streams.add.assert_called_once_with( entity_id, [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -258,13 +132,14 @@ async def _test_setup_and_signaling( receive_message_callback.reset_mock() ws_client.reset_mock() - await test() + await test("session_2") rest_client.streams.add.assert_called_once_with( entity_id, [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -276,25 +151,37 @@ async def _test_setup_and_signaling( receive_message_callback.reset_mock() ws_client.reset_mock() - await test() + await test("session_3") rest_client.streams.add.assert_not_called() assert isinstance(camera._webrtc_provider, WebRTCProvider) - # Set stream source to None and provider should be skipped - rest_client.streams.list.return_value = {} - receive_message_callback.reset_mock() - camera.set_stream_source(None) - await camera.async_handle_async_webrtc_offer( - OFFER_SDP, "session_id", receive_message_callback - ) - receive_message_callback.assert_called_once_with( - WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") - ) + provider = camera._webrtc_provider + for session in sessions: + assert session in provider._sessions + + with patch.object(provider, "teardown", wraps=provider.teardown) as teardown: + # Set stream source to None and provider should be skipped + rest_client.streams.list.return_value = {} + receive_message_callback.reset_mock() + camera.set_stream_source(None) + await camera.async_handle_async_webrtc_offer( + OFFER_SDP, "session_id", receive_message_callback + ) + receive_message_callback.assert_called_once_with( + WebRTCError("go2rtc_webrtc_offer_failed", "Camera has no stream source") + ) + teardown.assert_called_once() + # We use one ws_client mock for all sessions + assert ws_client.close.call_count == len(sessions) + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert teardown.call_count == 2 @pytest.mark.usefixtures( - "init_test_integration", "mock_get_binary", "mock_is_docker_env", "mock_go2rtc_entry", @@ -757,3 +644,29 @@ async def test_setup_with_recommended_version_repair( "recommended_version": RECOMMENDED_VERSION, "current_version": "1.9.5", } + + +@pytest.mark.usefixtures("init_integration") +async def test_async_get_image( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test getting snapshot from go2rtc.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + rest_client.get_jpeg_snapshot.return_value = image_bytes + assert await camera._webrtc_provider.async_get_image(camera) == image_bytes + + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + camera.set_stream_source("invalid://not_supported") + + with pytest.raises( + HomeAssistantError, match="Stream source is not supported by go2rtc" + ): + await async_get_image(hass, camera.entity_id) From 2859e7de9b40ee322d1f0cbc35d7558fc9606c52 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:38:01 +0200 Subject: [PATCH 0427/1664] Migrate kodi to use runtime_data (#147191) --- homeassistant/components/kodi/__init__.py | 30 +++++++++++-------- homeassistant/components/kodi/const.py | 3 -- homeassistant/components/kodi/media_player.py | 13 ++++---- tests/components/kodi/test_device_trigger.py | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/kodi/__init__.py b/homeassistant/components/kodi/__init__.py index 5400d142f28..5ffde76d313 100644 --- a/homeassistant/components/kodi/__init__.py +++ b/homeassistant/components/kodi/__init__.py @@ -1,8 +1,10 @@ """The kodi component.""" +from dataclasses import dataclass import logging from pykodi import CannotConnectError, InvalidAuthError, Kodi, get_kodi_connection +from pykodi.kodi import KodiHTTPConnection, KodiWSConnection from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,13 +19,23 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_WS_PORT, DATA_CONNECTION, DATA_KODI, DOMAIN +from .const import CONF_WS_PORT _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.MEDIA_PLAYER] +type KodiConfigEntry = ConfigEntry[KodiRuntimeData] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class KodiRuntimeData: + """Data class to hold Kodi runtime data.""" + + connection: KodiHTTPConnection | KodiWSConnection + kodi: Kodi + + +async def async_setup_entry(hass: HomeAssistant, entry: KodiConfigEntry) -> bool: """Set up Kodi from a config entry.""" conn = get_kodi_connection( entry.data[CONF_HOST], @@ -54,22 +66,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_CONNECTION: conn, - DATA_KODI: kodi, - } + entry.runtime_data = KodiRuntimeData(connection=conn, kodi=kodi) 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, entry: KodiConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - data = hass.data[DOMAIN].pop(entry.entry_id) - await data[DATA_CONNECTION].close() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await entry.runtime_data.connection.close() return unload_ok diff --git a/homeassistant/components/kodi/const.py b/homeassistant/components/kodi/const.py index 167ea2a4725..1ac439b27c3 100644 --- a/homeassistant/components/kodi/const.py +++ b/homeassistant/components/kodi/const.py @@ -4,9 +4,6 @@ DOMAIN = "kodi" CONF_WS_PORT = "ws_port" -DATA_CONNECTION = "connection" -DATA_KODI = "kodi" - DEFAULT_PORT = 8080 DEFAULT_SSL = False DEFAULT_TIMEOUT = 5 diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index c4a2436548a..2e32d969fce 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -24,7 +24,7 @@ from homeassistant.components.media_player import ( MediaType, async_process_play_media_url, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_ID, @@ -55,6 +55,7 @@ from homeassistant.helpers.network import is_internal_request from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType from homeassistant.util import dt as dt_util +from . import KodiConfigEntry from .browse_media import ( build_item_response, get_media_info, @@ -63,8 +64,6 @@ from .browse_media import ( ) from .const import ( CONF_WS_PORT, - DATA_CONNECTION, - DATA_KODI, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_TIMEOUT, @@ -208,7 +207,7 @@ async def async_setup_platform( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KodiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Kodi media player platform.""" @@ -220,14 +219,12 @@ async def async_setup_entry( SERVICE_CALL_METHOD, KODI_CALL_METHOD_SCHEMA, "async_call_method" ) - data = hass.data[DOMAIN][config_entry.entry_id] - connection = data[DATA_CONNECTION] - kodi = data[DATA_KODI] + data = config_entry.runtime_data name = config_entry.data[CONF_NAME] if (uid := config_entry.unique_id) is None: uid = config_entry.entry_id - entity = KodiEntity(connection, kodi, name, uid) + entity = KodiEntity(data.connection, data.kodi, name, uid) async_add_entities([entity]) diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index a54641a4234..541a9f781fd 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -4,7 +4,7 @@ import pytest from homeassistant.components import automation from homeassistant.components.device_automation import DeviceAutomationType -from homeassistant.components.kodi import DOMAIN +from homeassistant.components.kodi.const import DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er From 96e0d1f5c6c24ede2b1f9727ed710b75823774b2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 20 Jun 2025 18:39:43 +1000 Subject: [PATCH 0428/1664] Fix Charge Cable binary sensor in Teslemetry (#147136) --- homeassistant/components/teslemetry/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index a32c5fea40e..439df76c838 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -126,7 +126,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( polling=True, polling_value_fn=lambda x: x != "", streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value != "Unknown") + lambda value: callback(value is not None and value != "Unknown") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, From 3c91c78383973f77dc8f4968491ce2bf046415aa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 20 Jun 2025 10:41:25 +0200 Subject: [PATCH 0429/1664] Use PEP 695 TypeVar syntax for ecovacs (#147153) --- .../components/ecovacs/binary_sensor.py | 9 ++++----- homeassistant/components/ecovacs/entity.py | 20 ++++++++----------- homeassistant/components/ecovacs/number.py | 8 +++----- homeassistant/components/ecovacs/select.py | 10 +++++----- homeassistant/components/ecovacs/sensor.py | 6 ++---- 5 files changed, 22 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index 7c85a63cc78..32bf5d3ba15 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -2,9 +2,9 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from deebot_client.capabilities import CapabilityEvent +from deebot_client.events.base import Event from deebot_client.events.water_info import MopAttachedEvent from homeassistant.components.binary_sensor import ( @@ -16,15 +16,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsBinarySensorEntityDescription( +class EcovacsBinarySensorEntityDescription[EventT: Event]( BinarySensorEntityDescription, EcovacsCapabilityEntityDescription, - Generic[EventT], ): """Class describing Deebot binary sensor entity.""" @@ -55,7 +54,7 @@ async def async_setup_entry( ) -class EcovacsBinarySensor( +class EcovacsBinarySensor[EventT: Event]( EcovacsDescriptionEntity[CapabilityEvent[EventT]], BinarySensorEntity, ): diff --git a/homeassistant/components/ecovacs/entity.py b/homeassistant/components/ecovacs/entity.py index 36103be4d11..85a788d7afe 100644 --- a/homeassistant/components/ecovacs/entity.py +++ b/homeassistant/components/ecovacs/entity.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass -from typing import Any, Generic, TypeVar +from typing import Any from deebot_client.capabilities import Capabilities from deebot_client.device import Device @@ -18,11 +18,8 @@ from homeassistant.helpers.entity import Entity, EntityDescription from .const import DOMAIN -CapabilityEntity = TypeVar("CapabilityEntity") -EventT = TypeVar("EventT", bound=Event) - -class EcovacsEntity(Entity, Generic[CapabilityEntity]): +class EcovacsEntity[CapabilityEntityT](Entity): """Ecovacs entity.""" _attr_should_poll = False @@ -32,7 +29,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]): def __init__( self, device: Device, - capability: CapabilityEntity, + capability: CapabilityEntityT, **kwargs: Any, ) -> None: """Initialize entity.""" @@ -80,7 +77,7 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]): self._subscribe(AvailabilityEvent, on_available) - def _subscribe( + def _subscribe[EventT: Event]( self, event_type: type[EventT], callback: Callable[[EventT], Coroutine[Any, Any, None]], @@ -98,13 +95,13 @@ class EcovacsEntity(Entity, Generic[CapabilityEntity]): self._device.events.request_refresh(event_type) -class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]): +class EcovacsDescriptionEntity[CapabilityEntityT](EcovacsEntity[CapabilityEntityT]): """Ecovacs entity.""" def __init__( self, device: Device, - capability: CapabilityEntity, + capability: CapabilityEntityT, entity_description: EntityDescription, **kwargs: Any, ) -> None: @@ -114,13 +111,12 @@ class EcovacsDescriptionEntity(EcovacsEntity[CapabilityEntity]): @dataclass(kw_only=True, frozen=True) -class EcovacsCapabilityEntityDescription( +class EcovacsCapabilityEntityDescription[CapabilityEntityT]( EntityDescription, - Generic[CapabilityEntity], ): """Ecovacs entity description.""" - capability_fn: Callable[[Capabilities], CapabilityEntity | None] + capability_fn: Callable[[Capabilities], CapabilityEntityT | None] class EcovacsLegacyEntity(Entity): diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index 1fbf65aec65..513a0d350f6 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -4,10 +4,10 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Generic from deebot_client.capabilities import CapabilitySet from deebot_client.events import CleanCountEvent, CutDirectionEvent, VolumeEvent +from deebot_client.events.base import Event from homeassistant.components.number import ( NumberEntity, @@ -23,16 +23,14 @@ from .entity import ( EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EcovacsEntity, - EventT, ) from .util import get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsNumberEntityDescription( +class EcovacsNumberEntityDescription[EventT: Event]( NumberEntityDescription, EcovacsCapabilityEntityDescription, - Generic[EventT], ): """Ecovacs number entity description.""" @@ -94,7 +92,7 @@ async def async_setup_entry( async_add_entities(entities) -class EcovacsNumberEntity( +class EcovacsNumberEntity[EventT: Event]( EcovacsDescriptionEntity[CapabilitySet[EventT, [int]]], NumberEntity, ): diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index deddb7e252a..84f86fdd2cd 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -2,11 +2,12 @@ from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic +from typing import Any from deebot_client.capabilities import CapabilitySetTypes from deebot_client.device import Device from deebot_client.events import WorkModeEvent +from deebot_client.events.base import Event from deebot_client.events.water_info import WaterAmountEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -15,15 +16,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import EcovacsConfigEntry -from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT +from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity from .util import get_name_key, get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsSelectEntityDescription( +class EcovacsSelectEntityDescription[EventT: Event]( SelectEntityDescription, EcovacsCapabilityEntityDescription, - Generic[EventT], ): """Ecovacs select entity description.""" @@ -66,7 +66,7 @@ async def async_setup_entry( async_add_entities(entities) -class EcovacsSelectEntity( +class EcovacsSelectEntity[EventT: Event]( EcovacsDescriptionEntity[CapabilitySetTypes[EventT, [str], str]], SelectEntity, ): diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 98f3783b231..e84485228e4 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any, Generic +from typing import Any from deebot_client.capabilities import CapabilityEvent, CapabilityLifeSpan, DeviceType from deebot_client.device import Device @@ -46,16 +46,14 @@ from .entity import ( EcovacsDescriptionEntity, EcovacsEntity, EcovacsLegacyEntity, - EventT, ) from .util import get_name_key, get_options, get_supported_entities @dataclass(kw_only=True, frozen=True) -class EcovacsSensorEntityDescription( +class EcovacsSensorEntityDescription[EventT: Event]( EcovacsCapabilityEntityDescription, SensorEntityDescription, - Generic[EventT], ): """Ecovacs sensor entity description.""" From cd51070219b35d7ca641f04304964969457e8a3e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 11:39:13 +0200 Subject: [PATCH 0430/1664] Migrate kmtronic to use runtime_data (#147193) --- homeassistant/components/kmtronic/__init__.py | 24 +++++++------------ homeassistant/components/kmtronic/const.py | 3 --- .../components/kmtronic/coordinator.py | 6 ++++- homeassistant/components/kmtronic/switch.py | 10 ++++---- 4 files changed, 18 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/kmtronic/__init__.py b/homeassistant/components/kmtronic/__init__.py index 1c2cfb7cc31..84959217a5d 100644 --- a/homeassistant/components/kmtronic/__init__.py +++ b/homeassistant/components/kmtronic/__init__.py @@ -3,18 +3,16 @@ from pykmtronic.auth import Auth from pykmtronic.hub import KMTronicHubAPI -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client -from .const import DATA_COORDINATOR, DATA_HUB, DOMAIN -from .coordinator import KMtronicCoordinator +from .coordinator import KMTronicConfigEntry, KMtronicCoordinator PLATFORMS = [Platform.SWITCH] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: KMTronicConfigEntry) -> bool: """Set up kmtronic from a config entry.""" session = aiohttp_client.async_get_clientsession(hass) auth = Auth( @@ -27,11 +25,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = KMtronicCoordinator(hass, entry, hub) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_HUB: hub, - DATA_COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -40,15 +34,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, config_entry: KMTronicConfigEntry +) -> None: """Update options.""" await hass.config_entries.async_reload(config_entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: KMTronicConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/kmtronic/const.py b/homeassistant/components/kmtronic/const.py index 2381ad57998..6604b559bc2 100644 --- a/homeassistant/components/kmtronic/const.py +++ b/homeassistant/components/kmtronic/const.py @@ -4,7 +4,4 @@ DOMAIN = "kmtronic" CONF_REVERSE = "reverse" -DATA_HUB = "hub" -DATA_COORDINATOR = "coordinator" - MANUFACTURER = "KMtronic" diff --git a/homeassistant/components/kmtronic/coordinator.py b/homeassistant/components/kmtronic/coordinator.py index 8a94949dea6..a5bebff466b 100644 --- a/homeassistant/components/kmtronic/coordinator.py +++ b/homeassistant/components/kmtronic/coordinator.py @@ -18,12 +18,16 @@ PLATFORMS = [Platform.SWITCH] _LOGGER = logging.getLogger(__name__) +type KMTronicConfigEntry = ConfigEntry[KMtronicCoordinator] + class KMtronicCoordinator(DataUpdateCoordinator[None]): """Coordinator for KMTronic.""" + entry: KMTronicConfigEntry + def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, hub: KMTronicHubAPI + self, hass: HomeAssistant, entry: KMTronicConfigEntry, hub: KMTronicHubAPI ) -> None: """Initialize the KMTronic coordinator.""" super().__init__( diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py index b32f78b0e98..f8d068cec87 100644 --- a/homeassistant/components/kmtronic/switch.py +++ b/homeassistant/components/kmtronic/switch.py @@ -4,23 +4,23 @@ from typing import Any import urllib.parse from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_REVERSE, DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER +from .const import CONF_REVERSE, DOMAIN, MANUFACTURER +from .coordinator import KMTronicConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KMTronicConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config entry example.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - hub = hass.data[DOMAIN][entry.entry_id][DATA_HUB] + coordinator = entry.runtime_data + hub = coordinator.hub reverse = entry.options.get(CONF_REVERSE, False) await hub.async_get_relays() From 544fd2a4a66b3f949c5c84cd14b002f9a84e0c25 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:23:29 +0200 Subject: [PATCH 0431/1664] Migrate lacrosse_view to use runtime_data (#147202) --- .../components/lacrosse_view/__init__.py | 17 +++++------------ .../components/lacrosse_view/coordinator.py | 6 ++++-- .../components/lacrosse_view/diagnostics.py | 11 +++-------- .../components/lacrosse_view/sensor.py | 11 ++++------- .../lacrosse_view/test_diagnostics.py | 2 +- tests/components/lacrosse_view/test_init.py | 2 -- tests/components/lacrosse_view/test_sensor.py | 8 +------- 7 files changed, 18 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index e98d1d421be..6cb5e93acfe 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -6,20 +6,18 @@ import logging from lacrosse_view import LaCrosse, LoginError -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import LaCrosseUpdateCoordinator +from .coordinator import LaCrosseConfigEntry, LaCrosseUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) -> bool: """Set up LaCrosse View from a config entry.""" api = LaCrosse(async_get_clientsession(hass)) @@ -35,9 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("First refresh") await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "coordinator": coordinator, - } + entry.runtime_data = coordinator _LOGGER.debug("Setting up platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -45,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LaCrosseConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 16d7e8b2bb8..1499dd02900 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -17,6 +17,8 @@ from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) +type LaCrosseConfigEntry = ConfigEntry[LaCrosseUpdateCoordinator] + class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): """DataUpdateCoordinator for LaCrosse View.""" @@ -27,12 +29,12 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): id: str hass: HomeAssistant devices: list[Sensor] | None = None - config_entry: ConfigEntry + config_entry: LaCrosseConfigEntry def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, + entry: LaCrosseConfigEntry, api: LaCrosse, ) -> None: """Initialize DataUpdateCoordinator for LaCrosse View.""" diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py index eaf3ded6a4a..479533007c8 100644 --- a/homeassistant/components/lacrosse_view/diagnostics.py +++ b/homeassistant/components/lacrosse_view/diagnostics.py @@ -5,25 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LaCrosseUpdateCoordinator +from .coordinator import LaCrosseConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LaCrosseConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - "coordinator" - ] return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), - "coordinator_data": coordinator.data, + "coordinator_data": entry.runtime_data.data, } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index dde8dfd54a2..d0221e22667 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -32,6 +31,7 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import DOMAIN +from .coordinator import LaCrosseConfigEntry _LOGGER = logging.getLogger(__name__) @@ -159,17 +159,14 @@ UNIT_OF_MEASUREMENT_MAP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaCrosseConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaCrosse View from a config entry.""" - coordinator: DataUpdateCoordinator[list[Sensor]] = hass.data[DOMAIN][ - entry.entry_id - ]["coordinator"] - sensors: list[Sensor] = coordinator.data + coordinator = entry.runtime_data sensor_list = [] - for i, sensor in enumerate(sensors): + for i, sensor in enumerate(coordinator.data): for field in sensor.sensor_field_names: description = SENSOR_DESCRIPTIONS.get(field) if description is None: diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py index 4306173c6b3..0796d3f27f5 100644 --- a/tests/components/lacrosse_view/test_diagnostics.py +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -5,7 +5,7 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.lacrosse_view import DOMAIN +from homeassistant.components.lacrosse_view.const import DOMAIN from homeassistant.core import HomeAssistant from . import MOCK_ENTRY_DATA, TEST_SENSOR diff --git a/tests/components/lacrosse_view/test_init.py b/tests/components/lacrosse_view/test_init.py index 0533dd2abee..3691ee1c7ac 100644 --- a/tests/components/lacrosse_view/test_init.py +++ b/tests/components/lacrosse_view/test_init.py @@ -35,8 +35,6 @@ async def test_unload_entry(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] - entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 diff --git a/tests/components/lacrosse_view/test_sensor.py b/tests/components/lacrosse_view/test_sensor.py index e0dc1e5f35f..f0860f47b01 100644 --- a/tests/components/lacrosse_view/test_sensor.py +++ b/tests/components/lacrosse_view/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch from lacrosse_view import Sensor import pytest -from homeassistant.components.lacrosse_view import DOMAIN +from homeassistant.components.lacrosse_view.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -46,7 +46,6 @@ async def test_entities_added(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -103,7 +102,6 @@ async def test_field_not_supported( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -144,7 +142,6 @@ async def test_field_types( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -172,7 +169,6 @@ async def test_no_field(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -200,7 +196,6 @@ async def test_field_data_missing(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 @@ -228,7 +223,6 @@ async def test_no_readings(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.data[DOMAIN] entries = hass.config_entries.async_entries(DOMAIN) assert entries assert len(entries) == 1 From 7dfd68f8c05ec1e410eaaa84f6cd4ca9dad02c89 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:23:59 +0200 Subject: [PATCH 0432/1664] Migrate keenetic_ndms2 to use runtime_data (#147194) * Migrate keenetic_ndms2 to use runtime_data * Adjust tests --- .../components/keenetic_ndms2/__init__.py | 23 ++++++++----------- .../keenetic_ndms2/binary_sensor.py | 10 +++----- .../components/keenetic_ndms2/config_flow.py | 18 +++++---------- .../components/keenetic_ndms2/const.py | 1 - .../keenetic_ndms2/device_tracker.py | 8 +++---- .../components/keenetic_ndms2/router.py | 4 +++- .../keenetic_ndms2/test_config_flow.py | 19 +++++++-------- 7 files changed, 32 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index a4447dcd904..7986158ab50 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -19,15 +18,14 @@ from .const import ( DEFAULT_INTERFACE, DEFAULT_SCAN_INTERVAL, DOMAIN, - ROUTER, ) -from .router import KeeneticRouter +from .router import KeeneticConfigEntry, KeeneticRouter PLATFORMS = [Platform.BINARY_SENSOR, Platform.DEVICE_TRACKER] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> bool: """Set up the component.""" hass.data.setdefault(DOMAIN, {}) async_add_defaults(hass, entry) @@ -37,27 +35,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) - hass.data[DOMAIN][entry.entry_id] = { - ROUTER: router, - } + entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: KeeneticConfigEntry +) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] - + router = config_entry.runtime_data await router.async_teardown() - hass.data[DOMAIN].pop(config_entry.entry_id) - new_tracked_interfaces: set[str] = set(config_entry.options[CONF_INTERFACES]) if router.tracked_interfaces - new_tracked_interfaces: @@ -92,12 +87,12 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -def async_add_defaults(hass: HomeAssistant, entry: ConfigEntry): +def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] imported_options: dict = hass.data[DOMAIN].get(f"imported_options_{host}", {}) diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index 4d1b5da3552..6eea55c33e7 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -4,24 +4,20 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import KeeneticRouter -from .const import DOMAIN, ROUTER +from .router import KeeneticConfigEntry, KeeneticRouter async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KeeneticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Keenetic NDMS2 component.""" - router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] - - async_add_entities([RouterOnlineBinarySensor(router)]) + async_add_entities([RouterOnlineBinarySensor(config_entry.runtime_data)]) class RouterOnlineBinarySensor(BinarySensorEntity): diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 3dc4c8b1b77..7219819b911 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,12 +8,7 @@ from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -41,9 +36,8 @@ from .const import ( DEFAULT_SCAN_INTERVAL, DEFAULT_TELNET_PORT, DOMAIN, - ROUTER, ) -from .router import KeeneticRouter +from .router import KeeneticConfigEntry class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): @@ -56,7 +50,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: KeeneticConfigEntry, ) -> KeeneticOptionsFlowHandler: """Get the options flow for this handler.""" return KeeneticOptionsFlowHandler() @@ -142,6 +136,8 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): class KeeneticOptionsFlowHandler(OptionsFlow): """Handle options.""" + config_entry: KeeneticConfigEntry + def __init__(self) -> None: """Initialize options flow.""" self._interface_options: dict[str, str] = {} @@ -150,9 +146,7 @@ class KeeneticOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" - router: KeeneticRouter = self.hass.data[DOMAIN][self.config_entry.entry_id][ - ROUTER - ] + router = self.config_entry.runtime_data interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job( router.client.get_interfaces diff --git a/homeassistant/components/keenetic_ndms2/const.py b/homeassistant/components/keenetic_ndms2/const.py index d7db0673690..4a856647387 100644 --- a/homeassistant/components/keenetic_ndms2/const.py +++ b/homeassistant/components/keenetic_ndms2/const.py @@ -5,7 +5,6 @@ from homeassistant.components.device_tracker import ( ) DOMAIN = "keenetic_ndms2" -ROUTER = "router" DEFAULT_TELNET_PORT = 23 DEFAULT_SCAN_INTERVAL = 120 DEFAULT_CONSIDER_HOME = _DEFAULT_CONSIDER_HOME.total_seconds() diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py index 4143611d6af..7de7c497ef3 100644 --- a/homeassistant/components/keenetic_ndms2/device_tracker.py +++ b/homeassistant/components/keenetic_ndms2/device_tracker.py @@ -10,26 +10,24 @@ from homeassistant.components.device_tracker import ( DOMAIN as DEVICE_TRACKER_DOMAIN, ScannerEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import DOMAIN, ROUTER -from .router import KeeneticRouter +from .router import KeeneticConfigEntry, KeeneticRouter _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: KeeneticConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up device tracker for Keenetic NDMS2 component.""" - router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER] + router = config_entry.runtime_data tracked: set[str] = set() diff --git a/homeassistant/components/keenetic_ndms2/router.py b/homeassistant/components/keenetic_ndms2/router.py index 8c3079b910d..364e921cd40 100644 --- a/homeassistant/components/keenetic_ndms2/router.py +++ b/homeassistant/components/keenetic_ndms2/router.py @@ -35,11 +35,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +type KeeneticConfigEntry = ConfigEntry[KeeneticRouter] + class KeeneticRouter: """Keenetic client Object.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: KeeneticConfigEntry) -> None: """Initialize the Client.""" self.hass = hass self.config_entry = config_entry diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 7ddcdf38ed6..c8e23786e68 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -87,19 +87,16 @@ async def test_options(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 # fake router - hass.data.setdefault(keenetic.DOMAIN, {}) - hass.data[keenetic.DOMAIN][entry.entry_id] = { - keenetic.ROUTER: Mock( - client=Mock( - get_interfaces=Mock( - return_value=[ - InterfaceInfo.from_dict({"id": name, "type": "bridge"}) - for name in MOCK_OPTIONS[const.CONF_INTERFACES] - ] - ) + entry.runtime_data = Mock( + client=Mock( + get_interfaces=Mock( + return_value=[ + InterfaceInfo.from_dict({"id": name, "type": "bridge"}) + for name in MOCK_OPTIONS[const.CONF_INTERFACES] + ] ) ) - } + ) result = await hass.config_entries.options.async_init(entry.entry_id) From 313eaff14e927193520d9ae4616fefa85e411723 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:25:57 +0200 Subject: [PATCH 0433/1664] Migrate kaleidescape to use runtime_data (#147171) * Migrate kaleidescape to use runtime_data * Adjust tests --- .../components/kaleidescape/__init__.py | 30 ++++++++----------- .../components/kaleidescape/media_player.py | 18 ++++------- .../components/kaleidescape/remote.py | 19 +++++------- .../components/kaleidescape/sensor.py | 23 ++++++-------- tests/components/kaleidescape/test_init.py | 2 -- 5 files changed, 35 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/kaleidescape/__init__.py b/homeassistant/components/kaleidescape/__init__.py index f074ac640d8..c6639e096d7 100644 --- a/homeassistant/components/kaleidescape/__init__.py +++ b/homeassistant/components/kaleidescape/__init__.py @@ -3,26 +3,22 @@ from __future__ import annotations from dataclasses import dataclass -import logging -from typing import TYPE_CHECKING from kaleidescape import Device as KaleidescapeDevice, KaleidescapeError +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from .const import DOMAIN - -if TYPE_CHECKING: - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import Event, HomeAssistant - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE, Platform.SENSOR] +type KaleidescapeConfigEntry = ConfigEntry[KaleidescapeDevice] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: KaleidescapeConfigEntry +) -> bool: """Set up Kaleidescape from a config entry.""" device = KaleidescapeDevice( entry.data[CONF_HOST], timeout=5, reconnect=True, reconnect_delay=5 @@ -36,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Unable to connect to {entry.data[CONF_HOST]}: {err}" ) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device + entry.runtime_data = device async def disconnect(event: Event) -> None: await device.disconnect() @@ -44,18 +40,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) ) + entry.async_on_unload(device.disconnect) 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, entry: KaleidescapeConfigEntry +) -> bool: """Unload config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await hass.data[DOMAIN][entry.entry_id].disconnect() - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) @dataclass diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py index cd8aa9d4a8e..564b0c41c30 100644 --- a/homeassistant/components/kaleidescape/media_player.py +++ b/homeassistant/components/kaleidescape/media_player.py @@ -2,8 +2,8 @@ from __future__ import annotations +from datetime import datetime import logging -from typing import TYPE_CHECKING from kaleidescape import const as kaleidescape_const @@ -12,19 +12,13 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import utcnow -from .const import DOMAIN +from . import KaleidescapeConfigEntry from .entity import KaleidescapeEntity -if TYPE_CHECKING: - from datetime import datetime - - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - - KALEIDESCAPE_PLAYING_STATES = [ kaleidescape_const.PLAY_STATUS_PLAYING, kaleidescape_const.PLAY_STATUS_FORWARD, @@ -39,11 +33,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KaleidescapeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeMediaPlayer(hass.data[DOMAIN][entry.entry_id])] + entities = [KaleidescapeMediaPlayer(entry.runtime_data)] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py index 2b341e0c429..a71fb7f917a 100644 --- a/homeassistant/components/kaleidescape/remote.py +++ b/homeassistant/components/kaleidescape/remote.py @@ -2,32 +2,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from collections.abc import Iterable +from typing import Any from kaleidescape import const as kaleidescape_const from homeassistant.components.remote import RemoteEntity +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN +from . import KaleidescapeConfigEntry from .entity import KaleidescapeEntity -if TYPE_CHECKING: - from collections.abc import Iterable - from typing import Any - - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KaleidescapeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - entities = [KaleidescapeRemote(hass.data[DOMAIN][entry.entry_id])] + entities = [KaleidescapeRemote(entry.runtime_data)] async_add_entities(entities) diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py index ac0f6504daa..8d7365aa20b 100644 --- a/homeassistant/components/kaleidescape/sensor.py +++ b/homeassistant/components/kaleidescape/sensor.py @@ -2,25 +2,20 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING + +from kaleidescape import Device as KaleidescapeDevice from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import KaleidescapeConfigEntry from .entity import KaleidescapeEntity -if TYPE_CHECKING: - from collections.abc import Callable - - from kaleidescape import Device as KaleidescapeDevice - - from homeassistant.config_entries import ConfigEntry - from homeassistant.core import HomeAssistant - from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - from homeassistant.helpers.typing import StateType - @dataclass(frozen=True, kw_only=True) class KaleidescapeSensorEntityDescription(SensorEntityDescription): @@ -132,11 +127,11 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: KaleidescapeConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform from a config entry.""" - device: KaleidescapeDevice = hass.data[DOMAIN][entry.entry_id] + device = entry.runtime_data async_add_entities( KaleidescapeSensor(device, description) for description in SENSOR_TYPES ) diff --git a/tests/components/kaleidescape/test_init.py b/tests/components/kaleidescape/test_init.py index 01769b9fc57..ed1a9981906 100644 --- a/tests/components/kaleidescape/test_init.py +++ b/tests/components/kaleidescape/test_init.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock import pytest -from homeassistant.components.kaleidescape.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -29,7 +28,6 @@ async def test_unload_config_entry( await hass.async_block_till_done() assert mock_device.disconnect.call_count == 1 - assert mock_config_entry.entry_id not in hass.data[DOMAIN] async def test_config_entry_not_ready( From 1b60ea8951753fd2fcbadaf63ea5e166d047942f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 20 Jun 2025 12:26:07 +0200 Subject: [PATCH 0434/1664] Migrate lutron to use runtime_data (#147198) --- homeassistant/components/lutron/__init__.py | 10 +++++++--- homeassistant/components/lutron/binary_sensor.py | 10 +++------- homeassistant/components/lutron/config_flow.py | 10 +++------- homeassistant/components/lutron/cover.py | 7 +++---- homeassistant/components/lutron/event.py | 7 +++---- homeassistant/components/lutron/fan.py | 10 +++------- homeassistant/components/lutron/light.py | 6 +++--- homeassistant/components/lutron/scene.py | 7 +++---- homeassistant/components/lutron/switch.py | 7 +++---- 9 files changed, 31 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index 6ea3754ddde..97823d404fc 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -29,6 +29,8 @@ ATTR_ACTION = "action" ATTR_FULL_ID = "full_id" ATTR_UUID = "uuid" +type LutronConfigEntry = ConfigEntry[LutronData] + @dataclass(slots=True, kw_only=True) class LutronData: @@ -44,7 +46,9 @@ class LutronData: switches: list[tuple[str, Output]] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: LutronConfigEntry +) -> bool: """Set up the Lutron integration.""" host = config_entry.data[CONF_HOST] @@ -169,7 +173,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b name="Main repeater", ) - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = entry_data + config_entry.runtime_data = entry_data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -222,6 +226,6 @@ def _async_check_device_identifiers( ) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LutronConfigEntry) -> bool: """Clean up resources and entities associated with the integration.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py index 5bed760e1ac..fddfdac7c8d 100644 --- a/homeassistant/components/lutron/binary_sensor.py +++ b/homeassistant/components/lutron/binary_sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any from pylutron import OccupancyGroup @@ -12,19 +11,16 @@ 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 . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron binary_sensor platform. @@ -32,7 +28,7 @@ async def async_setup_entry( Adds occupancy groups from the Main Repeater associated with the config_entry as binary_sensor entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( [ LutronOccupancySensor(area_name, device, entry_data.client) diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py index 3f55a2b131b..bd1cd107e8c 100644 --- a/homeassistant/components/lutron/config_flow.py +++ b/homeassistant/components/lutron/config_flow.py @@ -9,12 +9,7 @@ from urllib.error import HTTPError from pylutron import Lutron import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -23,6 +18,7 @@ from homeassistant.helpers.selector import ( NumberSelectorMode, ) +from . import LutronConfigEntry from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -83,7 +79,7 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index e8f3ad09879..8909e49f7aa 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -13,11 +13,10 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice _LOGGER = logging.getLogger(__name__) @@ -25,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron cover platform. @@ -33,7 +32,7 @@ async def async_setup_entry( Adds shades from the Main Repeater associated with the config_entry as cover entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( [ LutronCover(area_name, device, entry_data.client) diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py index 942e165b97f..d7ec85835b7 100644 --- a/homeassistant/components/lutron/event.py +++ b/homeassistant/components/lutron/event.py @@ -5,13 +5,12 @@ from enum import StrEnum from pylutron import Button, Keypad, Lutron, LutronEvent from homeassistant.components.event import EventEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import slugify -from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, DOMAIN, LutronData +from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, LutronConfigEntry from .entity import LutronKeypad @@ -32,11 +31,11 @@ LEGACY_EVENT_TYPES: dict[LutronEventType, str] = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron event platform.""" - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( LutronEventEntity(area_name, keypad, button, entry_data.client) diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py index 5928c3c2da3..cc63994cdbe 100644 --- a/homeassistant/components/lutron/fan.py +++ b/homeassistant/components/lutron/fan.py @@ -2,25 +2,21 @@ from __future__ import annotations -import logging from typing import Any from pylutron import Output from homeassistant.components.fan import FanEntity, FanEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron fan platform. @@ -28,7 +24,7 @@ async def async_setup_entry( Adds fan controls from the Main Repeater associated with the config_entry as fan entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( [ LutronFan(area_name, device, entry_data.client) diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py index a7489e13b7b..955c4a2af90 100644 --- a/homeassistant/components/lutron/light.py +++ b/homeassistant/components/lutron/light.py @@ -19,14 +19,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL from .entity import LutronDevice async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron light platform. @@ -34,7 +34,7 @@ async def async_setup_entry( Adds dimmers from the Main Repeater associated with the config_entry as light entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 4889f9056ac..5f3736f0882 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -7,17 +7,16 @@ from typing import Any from pylutron import Button, Keypad, Lutron from homeassistant.components.scene import Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronKeypad async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron scene platform. @@ -25,7 +24,7 @@ async def async_setup_entry( Adds scenes from the Main Repeater associated with the config_entry as scene entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data async_add_entities( LutronScene(area_name, keypad, device, entry_data.client) diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py index e1e97d1774a..addde6f95aa 100644 --- a/homeassistant/components/lutron/switch.py +++ b/homeassistant/components/lutron/switch.py @@ -8,17 +8,16 @@ from typing import Any from pylutron import Button, Keypad, Led, Lutron, Output from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, LutronData +from . import LutronConfigEntry from .entity import LutronDevice, LutronKeypad async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LutronConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Lutron switch platform. @@ -26,7 +25,7 @@ async def async_setup_entry( Adds switches from the Main Repeater associated with the config_entry as switch entities. """ - entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id] + entry_data = config_entry.runtime_data entities: list[SwitchEntity] = [] # Add Lutron Switches From 9ae9ad1e43b0228b8d291a1bfba32cb1a2347921 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 20 Jun 2025 12:28:49 +0200 Subject: [PATCH 0435/1664] Improve test-coverage for homee locks (#147160) test for unknown user --- tests/components/homee/test_lock.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/components/homee/test_lock.py b/tests/components/homee/test_lock.py index 3e6ff3f8ec6..6f41185c4ed 100644 --- a/tests/components/homee/test_lock.py +++ b/tests/components/homee/test_lock.py @@ -111,6 +111,23 @@ async def test_lock_changed_by( assert hass.states.get("lock.test_lock").attributes["changed_by"] == expected +async def test_lock_changed_by_unknown_user( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: MagicMock, +) -> None: + """Test lock changed by entries.""" + mock_homee.nodes = [build_mock_node("lock.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + mock_homee.get_user_by_id.return_value = None # Simulate unknown user + attribute = mock_homee.nodes[0].attributes[0] + attribute.changed_by = 2 + attribute.changed_by_id = 1 + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("lock.test_lock").attributes["changed_by"] == "user-Unknown" + + async def test_lock_snapshot( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From a493bdc20817deaaed48b71509961c617cf755a0 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 20 Jun 2025 13:19:45 +0200 Subject: [PATCH 0436/1664] Implement battery group mode in HomeWizard (#146770) * Implement battery group mode for HomeWizard P1 * Clean up test * Disable 'entity_registry_enabled_default' * Fix failing tests because of 'entity_registry_enabled_default' * Proof entities are disabled by default * Undo dev change * Update homeassistant/components/homewizard/select.py * Update homeassistant/components/homewizard/select.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/homewizard/strings.json * Apply suggestions from code review * Update tests due to updated translations --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/homewizard/const.py | 8 +- .../components/homewizard/helpers.py | 7 +- homeassistant/components/homewizard/select.py | 89 ++++++ .../components/homewizard/strings.json | 15 +- tests/components/homewizard/conftest.py | 15 + .../homewizard/fixtures/HWE-P1/batteries.json | 7 + .../snapshots/test_diagnostics.ambr | 8 +- .../homewizard/snapshots/test_select.ambr | 97 ++++++ tests/components/homewizard/test_button.py | 2 +- tests/components/homewizard/test_number.py | 2 +- tests/components/homewizard/test_select.py | 294 ++++++++++++++++++ tests/components/homewizard/test_switch.py | 4 +- 12 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/homewizard/select.py create mode 100644 tests/components/homewizard/fixtures/HWE-P1/batteries.json create mode 100644 tests/components/homewizard/snapshots/test_select.ambr create mode 100644 tests/components/homewizard/test_select.py diff --git a/homeassistant/components/homewizard/const.py b/homeassistant/components/homewizard/const.py index e0448edaf86..ed1c140a23b 100644 --- a/homeassistant/components/homewizard/const.py +++ b/homeassistant/components/homewizard/const.py @@ -8,7 +8,13 @@ import logging from homeassistant.const import Platform DOMAIN = "homewizard" -PLATFORMS = [Platform.BUTTON, Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, +] LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/homewizard/helpers.py b/homeassistant/components/homewizard/helpers.py index c4160b0bbb0..0aee8f80078 100644 --- a/homeassistant/components/homewizard/helpers.py +++ b/homeassistant/components/homewizard/helpers.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from typing import Any, Concatenate -from homewizard_energy.errors import DisabledError, RequestError +from homewizard_energy.errors import DisabledError, RequestError, UnauthorizedError from homeassistant.exceptions import HomeAssistantError @@ -41,5 +41,10 @@ def homewizard_exception_handler[_HomeWizardEntityT: HomeWizardEntity, **_P]( translation_domain=DOMAIN, translation_key="api_disabled", ) from ex + except UnauthorizedError as ex: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_unauthorized", + ) from ex return handler diff --git a/homeassistant/components/homewizard/select.py b/homeassistant/components/homewizard/select.py new file mode 100644 index 00000000000..2ae37883107 --- /dev/null +++ b/homeassistant/components/homewizard/select.py @@ -0,0 +1,89 @@ +"""Support for HomeWizard select platform.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from homewizard_energy import HomeWizardEnergy +from homewizard_energy.models import Batteries, CombinedModels as DeviceResponseEntry + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator +from .entity import HomeWizardEntity +from .helpers import homewizard_exception_handler + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class HomeWizardSelectEntityDescription(SelectEntityDescription): + """Class describing HomeWizard select entities.""" + + available_fn: Callable[[DeviceResponseEntry], bool] + create_fn: Callable[[DeviceResponseEntry], bool] + current_fn: Callable[[DeviceResponseEntry], str | None] + set_fn: Callable[[HomeWizardEnergy, str], Awaitable[Any]] + + +DESCRIPTIONS = [ + HomeWizardSelectEntityDescription( + key="battery_group_mode", + translation_key="battery_group_mode", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[Batteries.Mode.ZERO, Batteries.Mode.STANDBY, Batteries.Mode.TO_FULL], + available_fn=lambda x: x.batteries is not None, + create_fn=lambda x: x.batteries is not None, + current_fn=lambda x: x.batteries.mode if x.batteries else None, + set_fn=lambda api, mode: api.batteries(mode=Batteries.Mode(mode)), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: HomeWizardConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up HomeWizard select based on a config entry.""" + async_add_entities( + HomeWizardSelectEntity( + coordinator=entry.runtime_data, + description=description, + ) + for description in DESCRIPTIONS + if description.create_fn(entry.runtime_data.data) + ) + + +class HomeWizardSelectEntity(HomeWizardEntity, SelectEntity): + """Defines a HomeWizard select entity.""" + + entity_description: HomeWizardSelectEntityDescription + + def __init__( + self, + coordinator: HWEnergyDeviceUpdateCoordinator, + description: HomeWizardSelectEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + + @property + def current_option(self) -> str | None: + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.data) + + @homewizard_exception_handler + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + await self.entity_description.set_fn(self.coordinator.api, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 076e9375d24..4216ece64cb 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -152,14 +152,27 @@ "cloud_connection": { "name": "Cloud connection" } + }, + "select": { + "battery_group_mode": { + "name": "Battery group mode", + "state": { + "zero": "Zero mode", + "to_full": "Manual charge mode", + "standby": "Standby" + } + } } }, "exceptions": { "api_disabled": { "message": "The local API is disabled." }, + "api_unauthorized": { + "message": "The local API is unauthorized. Restore API access by following the instructions in the repair issue." + }, "communication_error": { - "message": "An error occurred while communicating with HomeWizard device" + "message": "An error occurred while communicating with your HomeWizard Energy device" } }, "issues": { diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index b8367f87e57..c6098342d25 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -4,6 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch from homewizard_energy.models import ( + Batteries, CombinedModels, Device, Measurement, @@ -64,6 +65,13 @@ def mock_homewizardenergy( if get_fixture_path(f"{device_fixture}/system.json", DOMAIN).exists() else None ), + batteries=( + Batteries.from_dict( + load_json_object_fixture(f"{device_fixture}/batteries.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/batteries.json", DOMAIN).exists() + else None + ), ) # device() call is used during configuration flow @@ -112,6 +120,13 @@ def mock_homewizardenergy_v2( if get_fixture_path(f"v2/{device_fixture}/system.json", DOMAIN).exists() else None ), + batteries=( + Batteries.from_dict( + load_json_object_fixture(f"{device_fixture}/batteries.json", DOMAIN) + ) + if get_fixture_path(f"{device_fixture}/batteries.json", DOMAIN).exists() + else None + ), ) # device() call is used during configuration flow diff --git a/tests/components/homewizard/fixtures/HWE-P1/batteries.json b/tests/components/homewizard/fixtures/HWE-P1/batteries.json new file mode 100644 index 00000000000..279e49606b3 --- /dev/null +++ b/tests/components/homewizard/fixtures/HWE-P1/batteries.json @@ -0,0 +1,7 @@ +{ + "mode": "zero", + "power_w": -404, + "target_power_w": -400, + "max_consumption_w": 1600, + "max_production_w": 800 +} diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr index c8addf72368..449dfd0c02f 100644 --- a/tests/components/homewizard/snapshots/test_diagnostics.ambr +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -278,7 +278,13 @@ # name: test_diagnostics[HWE-P1] dict({ 'data': dict({ - 'batteries': None, + 'batteries': dict({ + 'max_consumption_w': 1600.0, + 'max_production_w': 800.0, + 'mode': 'zero', + 'power_w': -404.0, + 'target_power_w': -400.0, + }), 'device': dict({ 'api_version': '1.0.0', 'firmware_version': '4.19', diff --git a/tests/components/homewizard/snapshots/test_select.ambr b/tests/components/homewizard/snapshots/test_select.ambr new file mode 100644 index 00000000000..ecfd80e04da --- /dev/null +++ b/tests/components/homewizard/snapshots/test_select.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Device Battery group mode', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'select.device_battery_group_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'zero', + }) +# --- +# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.device_battery_group_mode', + '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': 'Battery group mode', + 'platform': 'homewizard', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_group_mode', + 'unique_id': 'HWE-P1_5c2fafabcdef_battery_group_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entity_snapshots[HWE-P1-select.device_battery_group_mode].2 + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '5c:2f:af:ab:cd:ef', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homewizard', + '5c2fafabcdef', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'HomeWizard', + 'model': 'Wi-Fi P1 Meter', + 'model_id': 'HWE-P1', + 'name': 'Device', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.19', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/homewizard/test_button.py b/tests/components/homewizard/test_button.py index d0a6d92b36f..f5c28735da4 100644 --- a/tests/components/homewizard/test_button.py +++ b/tests/components/homewizard/test_button.py @@ -61,7 +61,7 @@ async def test_identify_button( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( button.DOMAIN, diff --git a/tests/components/homewizard/test_number.py b/tests/components/homewizard/test_number.py index 67e51cbafe2..ffc31cb3859 100644 --- a/tests/components/homewizard/test_number.py +++ b/tests/components/homewizard/test_number.py @@ -73,7 +73,7 @@ async def test_number_entities( mock_homewizardenergy.system.side_effect = RequestError with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( number.DOMAIN, diff --git a/tests/components/homewizard/test_select.py b/tests/components/homewizard/test_select.py new file mode 100644 index 00000000000..d61f8d167c4 --- /dev/null +++ b/tests/components/homewizard/test_select.py @@ -0,0 +1,294 @@ +"""Test the Select entity for HomeWizard.""" + +from unittest.mock import MagicMock + +from homewizard_energy import UnsupportedError +from homewizard_energy.errors import RequestError, UnauthorizedError +from homewizard_energy.models import Batteries +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homewizard.const import UPDATE_INTERVAL +from homeassistant.components.select import ( + ATTR_OPTION, + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed + +pytestmark = [ + pytest.mark.usefixtures("init_integration"), +] + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-WTR", + [ + "select.device_battery_group_mode", + ], + ), + ( + "SDM230", + [ + "select.device_battery_group_mode", + ], + ), + ( + "SDM630", + [ + "select.device_battery_group_mode", + ], + ), + ( + "HWE-KWH1", + [ + "select.device_battery_group_mode", + ], + ), + ( + "HWE-KWH3", + [ + "select.device_battery_group_mode", + ], + ), + ], +) +async def test_entities_not_created_for_device( + hass: HomeAssistant, + entity_ids: list[str], +) -> None: + """Ensures entities for a specific device are not created.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("HWE-P1", "select.device_battery_group_mode"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_entity_snapshots( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + entity_id: str, +) -> None: + """Test that select entity state and registry entries match snapshots.""" + assert (state := hass.states.get(entity_id)) + assert snapshot == state + + assert (entity_entry := entity_registry.async_get(entity_id)) + assert snapshot == entity_entry + + assert entity_entry.device_id + assert (device_entry := device_registry.async_get(entity_entry.device_id)) + assert snapshot == device_entry + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option", "expected_mode"), + [ + ( + "HWE-P1", + "select.device_battery_group_mode", + "standby", + Batteries.Mode.STANDBY, + ), + ( + "HWE-P1", + "select.device_battery_group_mode", + "to_full", + Batteries.Mode.TO_FULL, + ), + ("HWE-P1", "select.device_battery_group_mode", "zero", Batteries.Mode.ZERO), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_set_option( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, + option: str, + expected_mode: Batteries.Mode, +) -> None: + """Test that selecting an option calls the correct API method.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=expected_mode) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option"), + [ + ("HWE-P1", "select.device_battery_group_mode", "zero"), + ("HWE-P1", "select.device_battery_group_mode", "standby"), + ("HWE-P1", "select.device_battery_group_mode", "to_full"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_request_error( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, + option: str, +) -> None: + """Test that RequestError is handled and raises HomeAssistantError.""" + mock_homewizardenergy.batteries.side_effect = RequestError + with pytest.raises( + HomeAssistantError, + match=r"^An error occurred while communicating with your HomeWizard Energy device$", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id", "option"), + [ + ("HWE-P1", "select.device_battery_group_mode", "to_full"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_unauthorized_error( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, + option: str, +) -> None: + """Test that UnauthorizedError is handled and raises HomeAssistantError.""" + mock_homewizardenergy.batteries.side_effect = UnauthorizedError + with pytest.raises( + HomeAssistantError, + match=r"^The local API is unauthorized\. Restore API access by following the instructions in the repair issue$", + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: option, + }, + blocking=True, + ) + + +@pytest.mark.parametrize("device_fixture", ["HWE-P1"]) +@pytest.mark.parametrize("exception", [RequestError, UnsupportedError]) +@pytest.mark.parametrize( + ("entity_id", "method"), + [ + ("select.device_battery_group_mode", "combined"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_unreachable( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + exception: Exception, + entity_id: str, + method: str, +) -> None: + """Test that unreachable devices are marked as unavailable.""" + mocked_method = getattr(mock_homewizardenergy, method) + mocked_method.side_effect = exception + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("device_fixture", "entity_id"), + [ + ("HWE-P1", "select.device_battery_group_mode"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_select_multiple_state_changes( + hass: HomeAssistant, + mock_homewizardenergy: MagicMock, + entity_id: str, +) -> None: + """Test changing select state multiple times in sequence.""" + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "zero", + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=Batteries.Mode.ZERO) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "to_full", + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=Batteries.Mode.TO_FULL) + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: "standby", + }, + blocking=True, + ) + mock_homewizardenergy.batteries.assert_called_with(mode=Batteries.Mode.STANDBY) + + +@pytest.mark.parametrize( + ("device_fixture", "entity_ids"), + [ + ( + "HWE-P1", + [ + "select.device_battery_group_mode", + ], + ), + ], +) +async def test_disabled_by_default_selects( + hass: HomeAssistant, entity_registry: er.EntityRegistry, entity_ids: list[str] +) -> None: + """Test the disabled by default selects.""" + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + assert (entry := entity_registry.async_get(entity_id)) + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION diff --git a/tests/components/homewizard/test_switch.py b/tests/components/homewizard/test_switch.py index ae9b7653b6d..9eba571273d 100644 --- a/tests/components/homewizard/test_switch.py +++ b/tests/components/homewizard/test_switch.py @@ -149,7 +149,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( switch.DOMAIN, @@ -160,7 +160,7 @@ async def test_switch_entities( with pytest.raises( HomeAssistantError, - match=r"^An error occurred while communicating with HomeWizard device$", + match=r"^An error occurred while communicating with your HomeWizard Energy device$", ): await hass.services.async_call( switch.DOMAIN, From f9d4bde0f68d252dff226d4b5695c40c22df3349 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 20 Jun 2025 13:44:14 +0200 Subject: [PATCH 0437/1664] Bump here-routing to 1.2.0 (#147204) * Bump here-routing to 1.2.0 * Fix mypy typing errors * Correct types for call assertion --- homeassistant/components/here_travel_time/coordinator.py | 6 ++++-- homeassistant/components/here_travel_time/manifest.json | 2 +- homeassistant/components/here_travel_time/model.py | 4 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/here_travel_time/test_sensor.py | 4 ++-- 6 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index 2c678316939..d8c698554c9 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -100,9 +100,11 @@ class HERERoutingDataUpdateCoordinator(DataUpdateCoordinator[HERETravelTimeData] try: response = await self._api.route( transport_mode=TransportMode(params.travel_mode), - origin=here_routing.Place(params.origin[0], params.origin[1]), + origin=here_routing.Place( + float(params.origin[0]), float(params.origin[1]) + ), destination=here_routing.Place( - params.destination[0], params.destination[1] + float(params.destination[0]), float(params.destination[1]) ), routing_mode=params.route_mode, arrival_time=params.arrival, diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json index 0365cf51d97..9d3b622a877 100644 --- a/homeassistant/components/here_travel_time/manifest.json +++ b/homeassistant/components/here_travel_time/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/here_travel_time", "iot_class": "cloud_polling", "loggers": ["here_routing", "here_transit", "homeassistant.helpers.location"], - "requirements": ["here-routing==1.0.1", "here-transit==1.2.1"] + "requirements": ["here-routing==1.2.0", "here-transit==1.2.1"] } diff --git a/homeassistant/components/here_travel_time/model.py b/homeassistant/components/here_travel_time/model.py index cbac2b1c353..a0534d2ff01 100644 --- a/homeassistant/components/here_travel_time/model.py +++ b/homeassistant/components/here_travel_time/model.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from datetime import datetime from typing import TypedDict +from here_routing import RoutingMode + class HERETravelTimeData(TypedDict): """Routing information.""" @@ -27,6 +29,6 @@ class HERETravelTimeAPIParams: destination: list[str] origin: list[str] travel_mode: str - route_mode: str + route_mode: RoutingMode arrival: datetime | None departure: datetime | None diff --git a/requirements_all.txt b/requirements_all.txt index 425d09bd2eb..03dea561c44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1140,7 +1140,7 @@ hdate[astral]==1.1.2 heatmiserV3==2.0.3 # homeassistant.components.here_travel_time -here-routing==1.0.1 +here-routing==1.2.0 # homeassistant.components.here_travel_time here-transit==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 924ebc07ef7..3bf2db2ebef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -992,7 +992,7 @@ hassil==2.2.3 hdate[astral]==1.1.2 # homeassistant.components.here_travel_time -here-routing==1.0.1 +here-routing==1.2.0 # homeassistant.components.here_travel_time here-transit==1.2.1 diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 22042f863bc..7c8946b7049 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -319,8 +319,8 @@ async def test_entity_ids(hass: HomeAssistant, valid_response: MagicMock) -> Non valid_response.assert_called_with( transport_mode=TransportMode.TRUCK, - origin=Place(ORIGIN_LATITUDE, ORIGIN_LONGITUDE), - destination=Place(DESTINATION_LATITUDE, DESTINATION_LONGITUDE), + origin=Place(float(ORIGIN_LATITUDE), float(ORIGIN_LONGITUDE)), + destination=Place(float(DESTINATION_LATITUDE), float(DESTINATION_LONGITUDE)), routing_mode=RoutingMode.FAST, arrival_time=None, departure_time=None, From e28965770eb69bc35519a27bb7632ecddb826484 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 20 Jun 2025 14:31:16 +0200 Subject: [PATCH 0438/1664] Add translations for devolo Home Control exceptions (#147099) * Add translations for devolo Home Control exceptions * Adapt invalid_auth message * Adapt connection_failed message --- .../components/devolo_home_control/__init__.py | 18 ++++++++++++++---- .../devolo_home_control/strings.json | 11 +++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 331bb5df94a..20a1edf734d 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry -from .const import GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] @@ -32,10 +32,16 @@ async def async_setup_entry( credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid) if not credentials_valid: - raise ConfigEntryAuthFailed + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) if await hass.async_add_executor_job(mydevolo.maintenance): - raise ConfigEntryNotReady + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="maintenance", + ) gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids) @@ -69,7 +75,11 @@ async def async_setup_entry( ) ) except GatewayOfflineError as err: - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="connection_failed", + translation_placeholders={"gateway_id": gateway_id}, + ) from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index be853e2d89d..a5a8086ba47 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -45,5 +45,16 @@ "name": "Brightness" } } + }, + "exceptions": { + "connection_failed": { + "message": "Failed to connect to devolo Home Control central unit {gateway_id}." + }, + "invalid_auth": { + "message": "Authentication failed. Please re-authenticaticate with your mydevolo account." + }, + "maintenance": { + "message": "devolo Home Control is currently in maintenance mode." + } } } From 1b73acc025856f4ae28636d8e6405c7bd934e653 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 20 Jun 2025 08:52:34 -0400 Subject: [PATCH 0439/1664] Add sub-device support to Russound RIO (#146763) --- .../components/russound_rio/__init__.py | 35 +++++++++++++++++++ .../components/russound_rio/entity.py | 33 +++++++---------- .../components/russound_rio/media_player.py | 4 +-- tests/components/russound_rio/const.py | 3 +- .../russound_rio/test_media_player.py | 2 +- 5 files changed, 52 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 65fbd89e203..f35a476bbb3 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -9,6 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -52,6 +54,39 @@ async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> ) from err entry.runtime_data = client + device_registry = dr.async_get(hass) + + for controller_id, controller in client.controllers.items(): + _device_identifier = ( + controller.mac_address + or f"{client.controllers[1].mac_address}-{controller_id}" + ) + connections = None + via_device = None + configuration_url = None + if controller_id != 1: + assert client.controllers[1].mac_address + via_device = ( + DOMAIN, + client.controllers[1].mac_address, + ) + else: + assert controller.mac_address + connections = {(CONNECTION_NETWORK_MAC, controller.mac_address)} + if isinstance(client.connection_handler, RussoundTcpConnectionHandler): + configuration_url = f"http://{client.connection_handler.host}" + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _device_identifier)}, + manufacturer="Russound", + name=controller.controller_type, + model=controller.controller_type, + sw_version=controller.firmware_version, + connections=connections, + via_device=via_device, + configuration_url=configuration_url, + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index 9790ff43e68..d7b4e412831 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -4,11 +4,11 @@ from collections.abc import Awaitable, Callable, Coroutine from functools import wraps from typing import Any, Concatenate -from aiorussound import Controller, RussoundClient, RussoundTcpConnectionHandler +from aiorussound import Controller, RussoundClient from aiorussound.models import CallbackType from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -46,6 +46,7 @@ class RussoundBaseEntity(Entity): def __init__( self, controller: Controller, + zone_id: int | None = None, ) -> None: """Initialize the entity.""" self._client = controller.client @@ -57,29 +58,21 @@ class RussoundBaseEntity(Entity): self._controller.mac_address or f"{self._primary_mac_address}-{self._controller.controller_id}" ) + if not zone_id: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_identifier)}, + ) + return + zone = controller.zones[zone_id] self._attr_device_info = DeviceInfo( - # Use MAC address of Russound device as identifier - identifiers={(DOMAIN, self._device_identifier)}, + identifiers={(DOMAIN, f"{self._device_identifier}-{zone_id}")}, + name=zone.name, manufacturer="Russound", - name=controller.controller_type, model=controller.controller_type, sw_version=controller.firmware_version, + suggested_area=zone.name, + via_device=(DOMAIN, self._device_identifier), ) - if isinstance(self._client.connection_handler, RussoundTcpConnectionHandler): - self._attr_device_info["configuration_url"] = ( - f"http://{self._client.connection_handler.host}" - ) - if controller.controller_id != 1: - assert self._client.controllers[1].mac_address - self._attr_device_info["via_device"] = ( - DOMAIN, - self._client.controllers[1].mac_address, - ) - else: - assert controller.mac_address - self._attr_device_info["connections"] = { - (CONNECTION_NETWORK_MAC, controller.mac_address) - } async def _state_update_callback( self, _client: RussoundClient, _callback_type: CallbackType diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index b40b82862f9..7dbc3ae34be 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -60,16 +60,16 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK ) + _attr_name = None def __init__( self, controller: Controller, zone_id: int, sources: dict[int, Source] ) -> None: """Initialize the zone device.""" - super().__init__(controller) + super().__init__(controller, zone_id) self._zone_id = zone_id _zone = self._zone self._sources = sources - self._attr_name = _zone.name self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}" @property diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py index 8269e825e33..e801d6786ad 100644 --- a/tests/components/russound_rio/const.py +++ b/tests/components/russound_rio/const.py @@ -17,6 +17,5 @@ MOCK_RECONFIGURATION_CONFIG = { CONF_PORT: 9622, } -DEVICE_NAME = "mca_c5" NAME_ZONE_1 = "backyard" -ENTITY_ID_ZONE_1 = f"{MP_DOMAIN}.{DEVICE_NAME}_{NAME_ZONE_1}" +ENTITY_ID_ZONE_1 = f"{MP_DOMAIN}.{NAME_ZONE_1}" diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index d0c18a9b1e7..04e1057565d 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -207,7 +207,7 @@ async def test_invalid_source_service( with pytest.raises( HomeAssistantError, - match="Error executing async_select_source on entity media_player.mca_c5_backyard", + match="Error executing async_select_source on entity media_player.backyard", ): await hass.services.async_call( MP_DOMAIN, From 33bde48c9c53ae05d64f39dcb0c1c7d7cdf22ad6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 20 Jun 2025 08:56:08 -0400 Subject: [PATCH 0440/1664] AI Task integration (#145128) * Add AI Task integration * Remove GenTextTaskType * Add AI Task prefs * Add action to LLM task * Remove WS command * Rename result to text for GenTextTaskResult * Apply suggestions from code review Co-authored-by: Allen Porter * Add supported feature for generate text * Update const.py Co-authored-by: HarvsG <11440490+HarvsG@users.noreply.github.com> * Update homeassistant/components/ai_task/services.yaml Co-authored-by: HarvsG <11440490+HarvsG@users.noreply.github.com> * Use WS API to set preferences * Simplify pref storage * Simplify pref test * Update homeassistant/components/ai_task/services.yaml Co-authored-by: Allen Porter --------- Co-authored-by: Allen Porter Co-authored-by: HarvsG <11440490+HarvsG@users.noreply.github.com> --- CODEOWNERS | 2 + homeassistant/components/ai_task/__init__.py | 125 +++++++++++++++++ homeassistant/components/ai_task/const.py | 29 ++++ homeassistant/components/ai_task/entity.py | 103 ++++++++++++++ homeassistant/components/ai_task/http.py | 54 ++++++++ homeassistant/components/ai_task/icons.json | 7 + .../components/ai_task/manifest.json | 9 ++ .../components/ai_task/services.yaml | 19 +++ homeassistant/components/ai_task/strings.json | 22 +++ homeassistant/components/ai_task/task.py | 71 ++++++++++ homeassistant/const.py | 1 + tests/components/ai_task/__init__.py | 1 + tests/components/ai_task/conftest.py | 127 ++++++++++++++++++ .../ai_task/snapshots/test_task.ambr | 22 +++ tests/components/ai_task/test_entity.py | 39 ++++++ tests/components/ai_task/test_http.py | 84 ++++++++++++ tests/components/ai_task/test_init.py | 84 ++++++++++++ tests/components/ai_task/test_task.py | 123 +++++++++++++++++ 18 files changed, 922 insertions(+) create mode 100644 homeassistant/components/ai_task/__init__.py create mode 100644 homeassistant/components/ai_task/const.py create mode 100644 homeassistant/components/ai_task/entity.py create mode 100644 homeassistant/components/ai_task/http.py create mode 100644 homeassistant/components/ai_task/icons.json create mode 100644 homeassistant/components/ai_task/manifest.json create mode 100644 homeassistant/components/ai_task/services.yaml create mode 100644 homeassistant/components/ai_task/strings.json create mode 100644 homeassistant/components/ai_task/task.py create mode 100644 tests/components/ai_task/__init__.py create mode 100644 tests/components/ai_task/conftest.py create mode 100644 tests/components/ai_task/snapshots/test_task.ambr create mode 100644 tests/components/ai_task/test_entity.py create mode 100644 tests/components/ai_task/test_http.py create mode 100644 tests/components/ai_task/test_init.py create mode 100644 tests/components/ai_task/test_task.py diff --git a/CODEOWNERS b/CODEOWNERS index 6670b411df4..1ceb6ff0e7d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -57,6 +57,8 @@ build.json @home-assistant/supervisor /tests/components/aemet/ @Noltari /homeassistant/components/agent_dvr/ @ispysoftware /tests/components/agent_dvr/ @ispysoftware +/homeassistant/components/ai_task/ @home-assistant/core +/tests/components/ai_task/ @home-assistant/core /homeassistant/components/air_quality/ @home-assistant/core /tests/components/air_quality/ @home-assistant/core /homeassistant/components/airgradient/ @airgradienthq @joostlek diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py new file mode 100644 index 00000000000..8b3d6e04966 --- /dev/null +++ b/homeassistant/components/ai_task/__init__.py @@ -0,0 +1,125 @@ +"""Integration to offer AI tasks to Home Assistant.""" + +import logging + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import ( + HassJobType, + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, + callback, +) +from homeassistant.helpers import config_validation as cv, storage +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType + +from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature +from .entity import AITaskEntity +from .http import async_setup as async_setup_conversation_http +from .task import GenTextTask, GenTextTaskResult, async_generate_text + +__all__ = [ + "DOMAIN", + "AITaskEntity", + "AITaskEntityFeature", + "GenTextTask", + "GenTextTaskResult", + "async_generate_text", + "async_setup", + "async_setup_entry", + "async_unload_entry", +] + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Register the process service.""" + entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass) + hass.data[DATA_COMPONENT] = entity_component + hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) + await hass.data[DATA_PREFERENCES].async_load() + async_setup_conversation_http(hass) + hass.services.async_register( + DOMAIN, + "generate_text", + async_service_generate_text, + schema=vol.Schema( + { + vol.Required("task_name"): cv.string, + vol.Optional("entity_id"): cv.entity_id, + vol.Required("instructions"): cv.string, + } + ), + supports_response=SupportsResponse.ONLY, + job_type=HassJobType.Coroutinefunction, + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return await hass.data[DATA_COMPONENT].async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.data[DATA_COMPONENT].async_unload_entry(entry) + + +async def async_service_generate_text(call: ServiceCall) -> ServiceResponse: + """Run the run task service.""" + result = await async_generate_text(hass=call.hass, **call.data) + return result.as_dict() # type: ignore[return-value] + + +class AITaskPreferences: + """AI Task preferences.""" + + KEYS = ("gen_text_entity_id",) + + gen_text_entity_id: str | None = None + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the preferences.""" + self._store: storage.Store[dict[str, str | None]] = storage.Store( + hass, 1, DOMAIN + ) + + async def async_load(self) -> None: + """Load the data from the store.""" + data = await self._store.async_load() + if data is None: + return + for key in self.KEYS: + setattr(self, key, data[key]) + + @callback + def async_set_preferences( + self, + *, + gen_text_entity_id: str | None | UndefinedType = UNDEFINED, + ) -> None: + """Set the preferences.""" + changed = False + for key, value in (("gen_text_entity_id", gen_text_entity_id),): + if value is not UNDEFINED: + if getattr(self, key) != value: + setattr(self, key, value) + changed = True + + if not changed: + return + + self._store.async_delay_save(self.as_dict, 10) + + @callback + def as_dict(self) -> dict[str, str | None]: + """Get the current preferences.""" + return {key: getattr(self, key) for key in self.KEYS} diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py new file mode 100644 index 00000000000..69786178583 --- /dev/null +++ b/homeassistant/components/ai_task/const.py @@ -0,0 +1,29 @@ +"""Constants for the AI Task integration.""" + +from __future__ import annotations + +from enum import IntFlag +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.helpers.entity_component import EntityComponent + + from . import AITaskPreferences + from .entity import AITaskEntity + +DOMAIN = "ai_task" +DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) +DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") + +DEFAULT_SYSTEM_PROMPT = ( + "You are a Home Assistant expert and help users with their tasks." +) + + +class AITaskEntityFeature(IntFlag): + """Supported features of the AI task entity.""" + + GENERATE_TEXT = 1 + """Generate text based on instructions.""" diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py new file mode 100644 index 00000000000..88ce8144fb7 --- /dev/null +++ b/homeassistant/components/ai_task/entity.py @@ -0,0 +1,103 @@ +"""Entity for the AI Task integration.""" + +from collections.abc import AsyncGenerator +import contextlib +from typing import final + +from propcache.api import cached_property + +from homeassistant.components.conversation import ( + ChatLog, + UserContent, + async_get_chat_log, +) +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers import llm +from homeassistant.helpers.chat_session import async_get_chat_session +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.util import dt as dt_util + +from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature +from .task import GenTextTask, GenTextTaskResult + + +class AITaskEntity(RestoreEntity): + """Entity that supports conversations.""" + + _attr_should_poll = False + _attr_supported_features = AITaskEntityFeature(0) + __last_activity: str | None = None + + @property + @final + def state(self) -> str | None: + """Return the state of the entity.""" + if self.__last_activity is None: + return None + return self.__last_activity + + @cached_property + def supported_features(self) -> AITaskEntityFeature: + """Flag supported features.""" + return self._attr_supported_features + + async def async_internal_added_to_hass(self) -> None: + """Call when the entity is added to hass.""" + await super().async_internal_added_to_hass() + state = await self.async_get_last_state() + if ( + state is not None + and state.state is not None + and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN) + ): + self.__last_activity = state.state + + @final + @contextlib.asynccontextmanager + async def _async_get_ai_task_chat_log( + self, + task: GenTextTask, + ) -> AsyncGenerator[ChatLog]: + """Context manager used to manage the ChatLog used during an AI Task.""" + # pylint: disable-next=contextmanager-generator-missing-cleanup + with ( + async_get_chat_session(self.hass) as session, + async_get_chat_log( + self.hass, + session, + None, + ) as chat_log, + ): + await chat_log.async_provide_llm_data( + llm.LLMContext( + platform=self.platform.domain, + context=None, + language=None, + assistant=DOMAIN, + device_id=None, + ), + user_llm_prompt=DEFAULT_SYSTEM_PROMPT, + ) + + chat_log.async_add_user_content(UserContent(task.instructions)) + + yield chat_log + + @final + async def internal_async_generate_text( + self, + task: GenTextTask, + ) -> GenTextTaskResult: + """Run a gen text task.""" + self.__last_activity = dt_util.utcnow().isoformat() + self.async_write_ha_state() + async with self._async_get_ai_task_chat_log(task) as chat_log: + return await self._async_generate_text(task, chat_log) + + async def _async_generate_text( + self, + task: GenTextTask, + chat_log: ChatLog, + ) -> GenTextTaskResult: + """Handle a gen text task.""" + raise NotImplementedError diff --git a/homeassistant/components/ai_task/http.py b/homeassistant/components/ai_task/http.py new file mode 100644 index 00000000000..6d44a4e8d3c --- /dev/null +++ b/homeassistant/components/ai_task/http.py @@ -0,0 +1,54 @@ +"""HTTP endpoint for AI Task integration.""" + +from typing import Any + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DATA_PREFERENCES + + +@callback +def async_setup(hass: HomeAssistant) -> None: + """Set up the HTTP API for the conversation integration.""" + websocket_api.async_register_command(hass, websocket_get_preferences) + websocket_api.async_register_command(hass, websocket_set_preferences) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ai_task/preferences/get", + } +) +@callback +def websocket_get_preferences( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Get AI task preferences.""" + preferences = hass.data[DATA_PREFERENCES] + connection.send_result(msg["id"], preferences.as_dict()) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "ai_task/preferences/set", + vol.Optional("gen_text_entity_id"): vol.Any(str, None), + } +) +@websocket_api.require_admin +@callback +def websocket_set_preferences( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Set AI task preferences.""" + preferences = hass.data[DATA_PREFERENCES] + msg.pop("type") + msg_id = msg.pop("id") + preferences.async_set_preferences(**msg) + connection.send_result(msg_id, preferences.as_dict()) diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json new file mode 100644 index 00000000000..cb09e5c8f5d --- /dev/null +++ b/homeassistant/components/ai_task/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "generate_text": { + "service": "mdi:file-star-four-points-outline" + } + } +} diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json new file mode 100644 index 00000000000..c685410530d --- /dev/null +++ b/homeassistant/components/ai_task/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ai_task", + "name": "AI Task", + "codeowners": ["@home-assistant/core"], + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/ai_task", + "integration_type": "system", + "quality_scale": "internal" +} diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml new file mode 100644 index 00000000000..32715bf77d7 --- /dev/null +++ b/homeassistant/components/ai_task/services.yaml @@ -0,0 +1,19 @@ +generate_text: + fields: + task_name: + example: "home summary" + required: true + selector: + text: + instructions: + example: "Generate a funny notification that garage door was left open" + required: true + selector: + text: + entity_id: + required: false + selector: + entity: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_TEXT diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json new file mode 100644 index 00000000000..1cdbf20ba4f --- /dev/null +++ b/homeassistant/components/ai_task/strings.json @@ -0,0 +1,22 @@ +{ + "services": { + "generate_text": { + "name": "Generate text", + "description": "Use AI to run a task that generates text.", + "fields": { + "task_name": { + "name": "Task Name", + "description": "Name of the task." + }, + "instructions": { + "name": "Instructions", + "description": "Instructions on what needs to be done." + }, + "entity_id": { + "name": "Entity ID", + "description": "Entity ID to run the task on. If not provided, the preferred entity will be used." + } + } + } + } +} diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py new file mode 100644 index 00000000000..d0c59fdd09a --- /dev/null +++ b/homeassistant/components/ai_task/task.py @@ -0,0 +1,71 @@ +"""AI tasks to be handled by agents.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.core import HomeAssistant + +from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature + + +async def async_generate_text( + hass: HomeAssistant, + *, + task_name: str, + entity_id: str | None = None, + instructions: str, +) -> GenTextTaskResult: + """Run a task in the AI Task integration.""" + if entity_id is None: + entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id + + if entity_id is None: + raise ValueError("No entity_id provided and no preferred entity set") + + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) + if entity is None: + raise ValueError(f"AI Task entity {entity_id} not found") + + if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features: + raise ValueError(f"AI Task entity {entity_id} does not support generating text") + + return await entity.internal_async_generate_text( + GenTextTask( + name=task_name, + instructions=instructions, + ) + ) + + +@dataclass(slots=True) +class GenTextTask: + """Gen text task to be processed.""" + + name: str + """Name of the task.""" + + instructions: str + """Instructions on what needs to be done.""" + + def __str__(self) -> str: + """Return task as a string.""" + return f"" + + +@dataclass(slots=True) +class GenTextTaskResult: + """Result of gen text task.""" + + conversation_id: str + """Unique identifier for the conversation.""" + + text: str + """Generated text.""" + + def as_dict(self) -> dict[str, str]: + """Return result as a dict.""" + return { + "conversation_id": self.conversation_id, + "text": self.text, + } diff --git a/homeassistant/const.py b/homeassistant/const.py index f692f428920..0abdcd59b77 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -40,6 +40,7 @@ PLATFORM_FORMAT: Final = "{platform}.{domain}" class Platform(StrEnum): """Available entity platforms.""" + AI_TASK = "ai_task" AIR_QUALITY = "air_quality" ALARM_CONTROL_PANEL = "alarm_control_panel" ASSIST_SATELLITE = "assist_satellite" diff --git a/tests/components/ai_task/__init__.py b/tests/components/ai_task/__init__.py new file mode 100644 index 00000000000..b4ca4688eb4 --- /dev/null +++ b/tests/components/ai_task/__init__.py @@ -0,0 +1 @@ +"""Tests for the AI Task integration.""" diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py new file mode 100644 index 00000000000..2060c51bfa4 --- /dev/null +++ b/tests/components/ai_task/conftest.py @@ -0,0 +1,127 @@ +"""Test helpers for AI Task integration.""" + +import pytest + +from homeassistant.components.ai_task import ( + DOMAIN, + AITaskEntity, + AITaskEntityFeature, + GenTextTask, + GenTextTaskResult, +) +from homeassistant.components.conversation import AssistantContent, ChatLog +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) + +TEST_DOMAIN = "test" +TEST_ENTITY_ID = "ai_task.test_task_entity" + + +class MockAITaskEntity(AITaskEntity): + """Mock AI Task entity for testing.""" + + _attr_name = "Test Task Entity" + _attr_supported_features = AITaskEntityFeature.GENERATE_TEXT + + def __init__(self) -> None: + """Initialize the mock entity.""" + super().__init__() + self.mock_generate_text_tasks = [] + + async def _async_generate_text( + self, task: GenTextTask, chat_log: ChatLog + ) -> GenTextTaskResult: + """Mock handling of generate text task.""" + self.mock_generate_text_tasks.append(task) + chat_log.async_add_assistant_content_without_tools( + AssistantContent(self.entity_id, "Mock result") + ) + return GenTextTaskResult( + conversation_id=chat_log.conversation_id, + text="Mock result", + ) + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a configuration entry for AI Task.""" + entry = MockConfigEntry(domain=TEST_DOMAIN, entry_id="mock-test-entry") + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +def mock_ai_task_entity( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockAITaskEntity: + """Mock AI Task entity.""" + return MockAITaskEntity() + + +@pytest.fixture +async def init_components( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ai_task_entity: MockAITaskEntity, +): + """Initialize the AI Task integration with a mock entity.""" + assert await async_setup_component(hass, "homeassistant", {}) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.AI_TASK] + ) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Unload test config entry.""" + await hass.config_entries.async_forward_entry_unload( + config_entry, Platform.AI_TASK + ) + return True + + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + ) -> None: + """Set up test tts platform via config entry.""" + async_add_entities([mock_ai_task_entity]) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, ConfigFlow): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr new file mode 100644 index 00000000000..6d155c82a68 --- /dev/null +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -0,0 +1,22 @@ +# serializer version: 1 +# name: test_run_text_task_updates_chat_log + list([ + dict({ + 'content': ''' + You are a Home Assistant expert and help users with their tasks. + Current time is 15:59:00. Today's date is 2025-06-14. + ''', + 'role': 'system', + }), + dict({ + 'content': 'Test prompt', + 'role': 'user', + }), + dict({ + 'agent_id': 'ai_task.test_task_entity', + 'content': 'Mock result', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/ai_task/test_entity.py b/tests/components/ai_task/test_entity.py new file mode 100644 index 00000000000..aa9afbf6560 --- /dev/null +++ b/tests/components/ai_task/test_entity.py @@ -0,0 +1,39 @@ +"""Tests for the AI Task entity model.""" + +from freezegun import freeze_time + +from homeassistant.components.ai_task import async_generate_text +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY_ID, MockAITaskEntity + +from tests.common import MockConfigEntry + + +@freeze_time("2025-06-08 16:28:13") +async def test_state_generate_text( + hass: HomeAssistant, + init_components: None, + mock_config_entry: MockConfigEntry, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the state of the AI Task entity is updated when generating text.""" + entity = hass.states.get(TEST_ENTITY_ID) + assert entity is not None + assert entity.state == STATE_UNKNOWN + + result = await async_generate_text( + hass, + task_name="Test task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + assert result.text == "Mock result" + + entity = hass.states.get(TEST_ENTITY_ID) + assert entity.state == "2025-06-08T16:28:13+00:00" + + assert mock_ai_task_entity.mock_generate_text_tasks + task = mock_ai_task_entity.mock_generate_text_tasks[0] + assert task.instructions == "Test prompt" diff --git a/tests/components/ai_task/test_http.py b/tests/components/ai_task/test_http.py new file mode 100644 index 00000000000..4436e1d45d5 --- /dev/null +++ b/tests/components/ai_task/test_http.py @@ -0,0 +1,84 @@ +"""Test the HTTP API for AI Task integration.""" + +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + + +async def test_ws_preferences( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components: None, +) -> None: + """Test preferences via the WebSocket API.""" + client = await hass_ws_client(hass) + + # Get initial preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": None, + } + + # Set preferences + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": "ai_task.summary_1", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_1", + } + + # Get updated preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_1", + } + + # Update an existing preference + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": "ai_task.summary_2", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } + + # Get updated preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } + + # No preferences set will preserve existing preferences + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } + + # Get updated preferences + await client.send_json_auto_id({"type": "ai_task/preferences/get"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "gen_text_entity_id": "ai_task.summary_2", + } diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py new file mode 100644 index 00000000000..2f45d812b1f --- /dev/null +++ b/tests/components/ai_task/test_init.py @@ -0,0 +1,84 @@ +"""Test initialization of the AI Task component.""" + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.ai_task import AITaskPreferences +from homeassistant.components.ai_task.const import DATA_PREFERENCES +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY_ID + +from tests.common import flush_store + + +async def test_preferences_storage_load( + hass: HomeAssistant, +) -> None: + """Test that AITaskPreferences are stored and loaded correctly.""" + preferences = AITaskPreferences(hass) + await preferences.async_load() + + # Initial state should be None for entity IDs + for key in AITaskPreferences.KEYS: + assert getattr(preferences, key) is None, f"Initial {key} should be None" + + new_values = {key: f"ai_task.test_{key}" for key in AITaskPreferences.KEYS} + + preferences.async_set_preferences(**new_values) + + # Verify that current preferences object is updated + for key, value in new_values.items(): + assert getattr(preferences, key) == value, ( + f"Current {key} should match set value" + ) + + await flush_store(preferences._store) + + # Create a new preferences instance to test loading from store + new_preferences_instance = AITaskPreferences(hass) + await new_preferences_instance.async_load() + + for key in AITaskPreferences.KEYS: + assert getattr(preferences, key) == getattr(new_preferences_instance, key), ( + f"Loaded {key} should match saved value" + ) + + +@pytest.mark.parametrize( + ("set_preferences", "msg_extra"), + [ + ( + {"gen_text_entity_id": TEST_ENTITY_ID}, + {}, + ), + ( + {}, + {"entity_id": TEST_ENTITY_ID}, + ), + ], +) +async def test_generate_text_service( + hass: HomeAssistant, + init_components: None, + freezer: FrozenDateTimeFactory, + set_preferences: dict[str, str | None], + msg_extra: dict[str, str], +) -> None: + """Test the generate text service.""" + preferences = hass.data[DATA_PREFERENCES] + preferences.async_set_preferences(**set_preferences) + + result = await hass.services.async_call( + "ai_task", + "generate_text", + { + "task_name": "Test Name", + "instructions": "Test prompt", + } + | msg_extra, + blocking=True, + return_response=True, + ) + + assert result["text"] == "Mock result" diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py new file mode 100644 index 00000000000..d4df66d83f9 --- /dev/null +++ b/tests/components/ai_task/test_task.py @@ -0,0 +1,123 @@ +"""Test tasks for the AI Task integration.""" + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_text +from homeassistant.components.conversation import async_get_chat_log +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session + +from .conftest import TEST_ENTITY_ID, MockAITaskEntity + +from tests.typing import WebSocketGenerator + + +async def test_run_task_preferred_entity( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test running a task with an unknown entity.""" + client = await hass_ws_client(hass) + + with pytest.raises( + ValueError, match="No entity_id provided and no preferred entity set" + ): + await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": "ai_task.unknown", + } + ) + msg = await client.receive_json() + assert msg["success"] + + with pytest.raises(ValueError, match="AI Task entity ai_task.unknown not found"): + await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + + await client.send_json_auto_id( + { + "type": "ai_task/preferences/set", + "gen_text_entity_id": TEST_ENTITY_ID, + } + ) + msg = await client.receive_json() + assert msg["success"] + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + result = await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + assert result.text == "Mock result" + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + mock_ai_task_entity.supported_features = AITaskEntityFeature(0) + with pytest.raises( + ValueError, + match="AI Task entity ai_task.test_task_entity does not support generating text", + ): + await async_generate_text( + hass, + task_name="Test Task", + instructions="Test prompt", + ) + + +async def test_run_text_task_unknown_entity( + hass: HomeAssistant, + init_components: None, +) -> None: + """Test running a text task with an unknown entity.""" + + with pytest.raises( + ValueError, match="AI Task entity ai_task.unknown_entity not found" + ): + await async_generate_text( + hass, + task_name="Test Task", + entity_id="ai_task.unknown_entity", + instructions="Test prompt", + ) + + +@freeze_time("2025-06-14 22:59:00") +async def test_run_text_task_updates_chat_log( + hass: HomeAssistant, + init_components: None, + snapshot: SnapshotAssertion, +) -> None: + """Test that running a text task updates the chat log.""" + result = await async_generate_text( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + assert result.text == "Mock result" + + with ( + chat_session.async_get_chat_session(hass, result.conversation_id) as session, + async_get_chat_log(hass, session) as chat_log, + ): + assert chat_log.content == snapshot From 46aea5d9dce0291948bee81a48b1e4e426117510 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 20 Jun 2025 15:59:54 +0300 Subject: [PATCH 0441/1664] Bump zwave-js-server-python to 0.64.0 (#147176) --- homeassistant/components/zwave_js/api.py | 45 +++++++++---------- .../components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 22 ++++----- 5 files changed, 35 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index c1a24b6ea65..168df5edcaa 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -32,19 +32,19 @@ from zwave_js_server.exceptions import ( NotFoundError, SetValueFailed, ) -from zwave_js_server.firmware import controller_firmware_update_otw, update_firmware +from zwave_js_server.firmware import driver_firmware_update_otw, update_firmware from zwave_js_server.model.controller import ( ControllerStatistics, InclusionGrant, ProvisioningEntry, QRProvisioningInformation, ) -from zwave_js_server.model.controller.firmware import ( - ControllerFirmwareUpdateData, - ControllerFirmwareUpdateProgress, - ControllerFirmwareUpdateResult, -) from zwave_js_server.model.driver import Driver +from zwave_js_server.model.driver.firmware import ( + DriverFirmwareUpdateData, + DriverFirmwareUpdateProgress, + DriverFirmwareUpdateResult, +) from zwave_js_server.model.endpoint import Endpoint from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.log_message import LogMessage @@ -2340,8 +2340,8 @@ def _get_node_firmware_update_progress_dict( } -def _get_controller_firmware_update_progress_dict( - progress: ControllerFirmwareUpdateProgress, +def _get_driver_firmware_update_progress_dict( + progress: DriverFirmwareUpdateProgress, ) -> dict[str, int | float]: """Get a dictionary of a controller's firmware update progress.""" return { @@ -2370,7 +2370,8 @@ async def websocket_subscribe_firmware_update_status( ) -> None: """Subscribe to the status of a firmware update.""" assert node.client.driver - controller = node.client.driver.controller + driver = node.client.driver + controller = driver.controller @callback def async_cleanup() -> None: @@ -2408,21 +2409,21 @@ async def websocket_subscribe_firmware_update_status( ) @callback - def forward_controller_progress(event: dict) -> None: - progress: ControllerFirmwareUpdateProgress = event["firmware_update_progress"] + def forward_driver_progress(event: dict) -> None: + progress: DriverFirmwareUpdateProgress = event["firmware_update_progress"] connection.send_message( websocket_api.event_message( msg[ID], { "event": event["event"], - **_get_controller_firmware_update_progress_dict(progress), + **_get_driver_firmware_update_progress_dict(progress), }, ) ) @callback - def forward_controller_finished(event: dict) -> None: - finished: ControllerFirmwareUpdateResult = event["firmware_update_finished"] + def forward_driver_finished(event: dict) -> None: + finished: DriverFirmwareUpdateResult = event["firmware_update_finished"] connection.send_message( websocket_api.event_message( msg[ID], @@ -2436,8 +2437,8 @@ async def websocket_subscribe_firmware_update_status( if controller.own_node == node: msg[DATA_UNSUBSCRIBE] = unsubs = [ - controller.on("firmware update progress", forward_controller_progress), - controller.on("firmware update finished", forward_controller_finished), + driver.on("firmware update progress", forward_driver_progress), + driver.on("firmware update finished", forward_driver_finished), ] else: msg[DATA_UNSUBSCRIBE] = unsubs = [ @@ -2447,17 +2448,13 @@ async def websocket_subscribe_firmware_update_status( connection.subscriptions[msg["id"]] = async_cleanup connection.send_result(msg[ID]) - if node.is_controller_node and ( - controller_progress := controller.firmware_update_progress - ): + if node.is_controller_node and (driver_progress := driver.firmware_update_progress): connection.send_message( websocket_api.event_message( msg[ID], { "event": "firmware update progress", - **_get_controller_firmware_update_progress_dict( - controller_progress - ), + **_get_driver_firmware_update_progress_dict(driver_progress), }, ) ) @@ -2559,9 +2556,9 @@ class FirmwareUploadView(HomeAssistantView): try: if node.client.driver.controller.own_node == node: - await controller_firmware_update_otw( + await driver_firmware_update_otw( node.client.ws_server_url, - ControllerFirmwareUpdateData( + DriverFirmwareUpdateData( uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), ), diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 8719c333753..082a3dd9f95 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.63.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.64.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 03dea561c44..90e902ed8c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3193,7 +3193,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.63.0 +zwave-js-server-python==0.64.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bf2db2ebef..633d6904fc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2625,7 +2625,7 @@ zeversolar==0.3.2 zha==0.0.60 # homeassistant.components.zwave_js -zwave-js-server-python==0.63.0 +zwave-js-server-python==0.64.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 83a22cbee32..3f1f9b737bd 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -32,7 +32,7 @@ from zwave_js_server.model.controller import ( ProvisioningEntry, QRProvisioningInformation, ) -from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData +from zwave_js_server.model.driver.firmware import DriverFirmwareUpdateData from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateData from zwave_js_server.model.value import ConfigurationValue, get_value_id_str @@ -3501,7 +3501,7 @@ async def test_firmware_upload_view( "homeassistant.components.zwave_js.api.update_firmware", ) as mock_node_cmd, patch( - "homeassistant.components.zwave_js.api.controller_firmware_update_otw", + "homeassistant.components.zwave_js.api.driver_firmware_update_otw", ) as mock_controller_cmd, patch.dict( "homeassistant.components.zwave_js.api.USER_AGENT", @@ -3544,7 +3544,7 @@ async def test_firmware_upload_view_controller( "homeassistant.components.zwave_js.api.update_firmware", ) as mock_node_cmd, patch( - "homeassistant.components.zwave_js.api.controller_firmware_update_otw", + "homeassistant.components.zwave_js.api.driver_firmware_update_otw", ) as mock_controller_cmd, patch.dict( "homeassistant.components.zwave_js.api.USER_AGENT", @@ -3557,7 +3557,7 @@ async def test_firmware_upload_view_controller( ) mock_node_cmd.assert_not_called() assert mock_controller_cmd.call_args[0][1:2] == ( - ControllerFirmwareUpdateData( + DriverFirmwareUpdateData( "file", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" ), ) @@ -4415,7 +4415,7 @@ async def test_subscribe_controller_firmware_update_status( event = Event( type="firmware update progress", data={ - "source": "controller", + "source": "driver", "event": "firmware update progress", "progress": { "sentFragments": 1, @@ -4424,7 +4424,7 @@ async def test_subscribe_controller_firmware_update_status( }, }, ) - client.driver.controller.receive_event(event) + client.driver.receive_event(event) msg = await ws_client.receive_json() assert msg["event"] == { @@ -4439,7 +4439,7 @@ async def test_subscribe_controller_firmware_update_status( event = Event( type="firmware update finished", data={ - "source": "controller", + "source": "driver", "event": "firmware update finished", "result": { "status": 255, @@ -4447,7 +4447,7 @@ async def test_subscribe_controller_firmware_update_status( }, }, ) - client.driver.controller.receive_event(event) + client.driver.receive_event(event) msg = await ws_client.receive_json() assert msg["event"] == { @@ -4464,13 +4464,13 @@ async def test_subscribe_controller_firmware_update_status_initial_value( ws_client = await hass_ws_client(hass) device = get_device(hass, client.driver.controller.nodes[1]) - assert client.driver.controller.firmware_update_progress is None + assert client.driver.firmware_update_progress is None # Send a firmware update progress event before the WS command event = Event( type="firmware update progress", data={ - "source": "controller", + "source": "driver", "event": "firmware update progress", "progress": { "sentFragments": 1, @@ -4479,7 +4479,7 @@ async def test_subscribe_controller_firmware_update_status_initial_value( }, }, ) - client.driver.controller.receive_event(event) + client.driver.receive_event(event) client.async_send_command_no_wait.return_value = {} From f7429f343154cdf04a3fadc65284528372ed861b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 20 Jun 2025 15:19:39 +0200 Subject: [PATCH 0442/1664] Fix Shelly entity names for gen1 sleeping devices (#147019) --- homeassistant/components/shelly/entity.py | 1 - tests/components/shelly/test_sensor.py | 44 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 2c1678d56d9..5a420a4543b 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -653,7 +653,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): ) elif entry is not None: self._attr_unique_id = entry.unique_id - self._attr_name = cast(str, entry.original_name) @callback def _update_callback(self) -> None: diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index e95d4cfaeb2..8f021c2d58a 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -40,6 +41,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( + MOCK_MAC, init_integration, mock_polling_rpc_update, mock_rest_update, @@ -1585,3 +1587,45 @@ async def test_rpc_switch_no_returned_energy_sensor( await init_integration(hass, 3) assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None + + +async def test_block_friendly_name_sleeping_sensor( + hass: HomeAssistant, + mock_block_device: Mock, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test friendly name for restored sleeping sensor.""" + entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) + device = register_device(device_registry, entry) + + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sensor_0-temp", + suggested_object_id="test_name_temperature", + original_name="Test name temperature", + disabled_by=None, + config_entry=entry, + device_id=device.id, + ) + + # Old name, the word "temperature" starts with a lower case letter + assert entity.original_name == "Test name temperature" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + + # New name, the word "temperature" starts with a capital letter + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity.entity_id)) + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" From d9e5bad55e810a69b13c0cdcd9d9b6f744cfe687 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Fri, 20 Jun 2025 16:55:48 +0200 Subject: [PATCH 0443/1664] Use entity name in homee (#147142) * add name to HomeeEntity * review change --- homeassistant/components/homee/entity.py | 2 ++ tests/components/homee/fixtures/events.json | 2 +- tests/components/homee/snapshots/test_event.ambr | 12 ++++++------ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py index d8344c4226a..ddb16315e7d 100644 --- a/homeassistant/components/homee/entity.py +++ b/homeassistant/components/homee/entity.py @@ -42,6 +42,8 @@ class HomeeEntity(Entity): model=get_name_for_enum(NodeProfile, node.profile), via_device=(DOMAIN, entry.runtime_data.settings.uid), ) + if attribute.name: + self._attr_name = attribute.name self._host_connected = entry.runtime_data.connected diff --git a/tests/components/homee/fixtures/events.json b/tests/components/homee/fixtures/events.json index dc541bca597..f1d5c961ce9 100644 --- a/tests/components/homee/fixtures/events.json +++ b/tests/components/homee/fixtures/events.json @@ -61,7 +61,7 @@ "changed_by_id": 0, "based_on": 1, "data": "", - "name": "" + "name": "Kitchen Light" }, { "id": 3, diff --git a/tests/components/homee/snapshots/test_event.ambr b/tests/components/homee/snapshots/test_event.ambr index 40b9a99fcc4..981b6263984 100644 --- a/tests/components/homee/snapshots/test_event.ambr +++ b/tests/components/homee/snapshots/test_event.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_event_snapshot[event.remote_control_switch_1-entry] +# name: test_event_snapshot[event.remote_control_kitchen_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -18,7 +18,7 @@ 'disabled_by': None, 'domain': 'event', 'entity_category': None, - 'entity_id': 'event.remote_control_switch_1', + 'entity_id': 'event.remote_control_kitchen_light', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -30,7 +30,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Switch 1', + 'original_name': 'Kitchen Light', 'platform': 'homee', 'previous_unique_id': None, 'suggested_object_id': None, @@ -40,7 +40,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_event_snapshot[event.remote_control_switch_1-state] +# name: test_event_snapshot[event.remote_control_kitchen_light-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'button', @@ -50,10 +50,10 @@ 'lower', 'released', ]), - 'friendly_name': 'Remote Control Switch 1', + 'friendly_name': 'Remote Control Kitchen Light', }), 'context': , - 'entity_id': 'event.remote_control_switch_1', + 'entity_id': 'event.remote_control_kitchen_light', 'last_changed': , 'last_reported': , 'last_updated': , From 6738085391bb3b84c6705ffe275b284322166a7d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 20 Jun 2025 10:54:11 -0500 Subject: [PATCH 0444/1664] Minor clean up missed in previous PR (#147229) --- homeassistant/components/assist_satellite/__init__.py | 2 +- homeassistant/components/assist_satellite/entity.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index f1f38f343f9..6bfbdfb33a8 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -133,7 +133,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: service_func=handle_ask_question, schema=vol.All( { - vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, vol.Optional("question_media_id"): str, vol.Optional("preannounce"): bool, diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py index d32bad2c824..e7a10ef63f6 100644 --- a/homeassistant/components/assist_satellite/entity.py +++ b/homeassistant/components/assist_satellite/entity.py @@ -138,7 +138,6 @@ class AssistSatelliteEntity(entity.Entity): _is_announcing = False _extra_system_prompt: str | None = None _wake_word_intercept_future: asyncio.Future[str | None] | None = None - _stt_intercept_future: asyncio.Future[str | None] | None = None _attr_tts_options: dict[str, Any] | None = None _pipeline_task: asyncio.Task | None = None _ask_question_future: asyncio.Future[str | None] | None = None From 9346c584c381d826260a55f6a8891d7e5f35da86 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 20 Jun 2025 18:42:47 +0200 Subject: [PATCH 0445/1664] Add reconfigure flow to ntfy integration (#143743) --- homeassistant/components/ntfy/config_flow.py | 115 +++++++++ .../components/ntfy/quality_scale.yaml | 2 +- homeassistant/components/ntfy/strings.json | 31 ++- tests/components/ntfy/test_config_flow.py | 233 ++++++++++++++++++ 4 files changed, 378 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ntfy/config_flow.py b/homeassistant/components/ntfy/config_flow.py index 04a6730aa73..ed8d56820c2 100644 --- a/homeassistant/components/ntfy/config_flow.py +++ b/homeassistant/components/ntfy/config_flow.py @@ -90,6 +90,24 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema( } ) +STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema( + { + vol.Exclusive(CONF_USERNAME, ATTR_CREDENTIALS): TextSelector( + TextSelectorConfig( + type=TextSelectorType.TEXT, + autocomplete="username", + ), + ), + vol.Optional(CONF_PASSWORD, default=""): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, + autocomplete="current-password", + ), + ), + vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str, + } +) + STEP_USER_TOPIC_SCHEMA = vol.Schema( { vol.Required(CONF_TOPIC): str, @@ -244,6 +262,103 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]}, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for ntfy.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass) + if token := user_input.get(CONF_TOKEN): + ntfy = Ntfy( + entry.data[CONF_URL], + session, + token=user_input[CONF_TOKEN], + ) + else: + ntfy = Ntfy( + entry.data[CONF_URL], + session, + username=user_input.get(CONF_USERNAME, entry.data[CONF_USERNAME]), + password=user_input[CONF_PASSWORD], + ) + + try: + account = await ntfy.account() + if not token: + token = (await ntfy.generate_token("Home Assistant")).token + except NtfyUnauthorizedAuthenticationError: + errors["base"] = "invalid_auth" + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + errors["base"] = "cannot_connect" + except NtfyException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if entry.data[CONF_USERNAME]: + if entry.data[CONF_USERNAME] != account.username: + return self.async_abort( + reason="account_mismatch", + description_placeholders={ + CONF_USERNAME: entry.data[CONF_USERNAME], + "wrong_username": account.username, + }, + ) + + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_TOKEN: token}, + ) + self._async_abort_entries_match( + { + CONF_URL: entry.data[CONF_URL], + CONF_USERNAME: account.username, + } + ) + return self.async_update_reload_and_abort( + entry, + data_updates={ + CONF_USERNAME: account.username, + CONF_TOKEN: token, + }, + ) + if entry.data[CONF_USERNAME]: + return self.async_show_form( + step_id="reconfigure_user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, + suggested_values=user_input, + ), + errors=errors, + description_placeholders={ + CONF_NAME: entry.title, + CONF_USERNAME: entry.data[CONF_USERNAME], + }, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_RECONFIGURE_DATA_SCHEMA, + suggested_values=user_input, + ), + errors=errors, + description_placeholders={CONF_NAME: entry.title}, + ) + + async def async_step_reconfigure_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow for authenticated ntfy entry.""" + + return await self.async_step_reconfigure(user_input) + class TopicSubentryFlowHandler(ConfigSubentryFlow): """Handle subentry flow for adding and modifying a topic.""" diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index 0d075f0014b..43a96135baf 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -72,7 +72,7 @@ rules: comment: the notify entity uses the device name as entity name, no translation required exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: the integration has no repairs diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index 13704d960be..cef662d6f2f 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -39,7 +39,33 @@ }, "data_description": { "password": "Enter the password corresponding to the aforementioned username to automatically create an access token", - "token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token" + "token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'" + } + }, + "reconfigure": { + "title": "Configuration for {name}", + "description": "You can either log in with your **ntfy** username and password, and Home Assistant will automatically create an access token to authenticate with **ntfy**, or you can provide an access token directly", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "username": "[%key:component::ntfy::config::step::user::sections::auth::data_description::username%]", + "password": "[%key:component::ntfy::config::step::user::sections::auth::data_description::password%]", + "token": "Enter a new or existing access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'" + } + }, + "reconfigure_user": { + "title": "[%key:component::ntfy::config::step::reconfigure::title%]", + "description": "Enter the password for **{username}** below. Home Assistant will automatically create a new access token to authenticate with **ntfy**. You can also directly provide a valid access token", + "data": { + "password": "[%key:common::config_flow::data::password%]", + "token": "[%key:common::config_flow::data::access_token%]" + }, + "data_description": { + "password": "[%key:component::ntfy::config::step::reauth_confirm::data_description::password%]", + "token": "[%key:component::ntfy::config::step::reconfigure::data_description::token%]" } } }, @@ -51,7 +77,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**" + "account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "config_subentries": { diff --git a/tests/components/ntfy/test_config_flow.py b/tests/components/ntfy/test_config_flow.py index 2d3656536a9..48909552e08 100644 --- a/tests/components/ntfy/test_config_flow.py +++ b/tests/components/ntfy/test_config_flow.py @@ -498,3 +498,236 @@ async def test_flow_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "account_mismatch" + + +@pytest.mark.parametrize( + ("entry_data", "user_input", "step_id"), + [ + ( + {CONF_USERNAME: None, CONF_TOKEN: None}, + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + "reconfigure", + ), + ( + {CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"}, + {CONF_TOKEN: "newtoken"}, + "reconfigure_user", + ), + ], +) +async def test_flow_reconfigure( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + entry_data: dict[str, str | None], + user_input: dict[str, str], + step_id: str, +) -> None: + """Test reconfigure flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + **entry_data, + }, + ) + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == step_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_USERNAME] == "username" + assert config_entry.data[CONF_TOKEN] == "newtoken" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("entry_data", "step_id"), + [ + ({CONF_USERNAME: None, CONF_TOKEN: None}, "reconfigure"), + ({CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"}, "reconfigure_user"), + ], +) +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reconfigure_token( + hass: HomeAssistant, + entry_data: dict[str, Any], + step_id: str, +) -> None: + """Test reconfigure flow with access token.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + **entry_data, + }, + ) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == step_id + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "access_token"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_USERNAME] == "username" + assert config_entry.data[CONF_TOKEN] == "access_token" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + ( + NtfyHTTPError(418001, 418, "I'm a teapot", ""), + "cannot_connect", + ), + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + "invalid_auth", + ), + (NtfyException, "cannot_connect"), + (TypeError, "unknown"), + ], +) +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + mock_aiontfy: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test reconfigure flow errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: None, + CONF_TOKEN: None, + }, + ) + mock_aiontfy.generate_token.return_value = AccountTokenResponse( + token="newtoken", last_access=datetime.now() + ) + mock_aiontfy.account.side_effect = exception + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_aiontfy.account.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_USERNAME] == "username" + assert config_entry.data[CONF_TOKEN] == "newtoken" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reconfigure_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow already configured.""" + other_config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "username", + }, + ) + other_config_entry.add_to_hass(hass) + + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(hass.config_entries.async_entries()) == 2 + + +@pytest.mark.usefixtures("mock_aiontfy") +async def test_flow_reconfigure_account_mismatch( + hass: HomeAssistant, +) -> None: + """Test reconfigure flow account mismatch.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="ntfy.sh", + data={ + CONF_URL: "https://ntfy.sh/", + CONF_USERNAME: "wrong_username", + CONF_TOKEN: "oldtoken", + }, + ) + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure_user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_TOKEN: "newtoken"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "account_mismatch" From 95f292c43d8d5ee485f99c74d6fda717890b0c57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Fri, 20 Jun 2025 19:27:29 +0200 Subject: [PATCH 0446/1664] Bump aiohomeconnect to 0.18.1 (#147236) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 5e296ba18ac..8ced21ecba5 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -22,6 +22,6 @@ "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], "quality_scale": "platinum", - "requirements": ["aiohomeconnect==0.18.0"], + "requirements": ["aiohomeconnect==0.18.1"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 90e902ed8c7..6d1d834a8e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.18.0 +aiohomeconnect==0.18.1 # homeassistant.components.homekit_controller aiohomekit==3.2.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 633d6904fc7..3ad1ef1ed87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.18.0 +aiohomeconnect==0.18.1 # homeassistant.components.homekit_controller aiohomekit==3.2.15 From 435c08685d1fecb6b196bf1f6c4cf7e1042c2412 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 20 Jun 2025 20:22:33 +0200 Subject: [PATCH 0447/1664] Bump deebot-client to 13.4.0 (#147221) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 8a7388da735..97739f698d9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d1d834a8e7..a1fa1694dd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,7 +765,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.3.0 +deebot-client==13.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ad1ef1ed87..492978d9000 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -665,7 +665,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.3.0 +deebot-client==13.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 65f897793d184231fa41031b956d73c1ad42775d Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 20 Jun 2025 14:18:03 -0500 Subject: [PATCH 0448/1664] Use string instead of boolean for voice event (#147244) Use string instead of bool --- homeassistant/components/esphome/assist_satellite.py | 6 +++--- tests/components/esphome/test_assist_satellite.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index fdeadd7feb1..f6367165400 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -285,9 +285,9 @@ class EsphomeAssistSatellite( data_to_send = {"text": event.data["stt_output"]["text"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: data_to_send = { - "tts_start_streaming": bool( - event.data and event.data.get("tts_start_streaming") - ), + "tts_start_streaming": "1" + if (event.data and event.data.get("tts_start_streaming")) + else "0", } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 71977f0285c..3acdc1f2029 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -243,12 +243,12 @@ async def test_pipeline_api_audio( event_callback( PipelineEvent( type=PipelineEventType.INTENT_PROGRESS, - data={"tts_start_streaming": True}, + data={"tts_start_streaming": "1"}, ) ) assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, - {"tts_start_streaming": True}, + {"tts_start_streaming": "1"}, ) event_callback( From ace18e540b797ce1088bddeb01ad69b27085a55e Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Fri, 20 Jun 2025 15:59:59 -0400 Subject: [PATCH 0449/1664] Bump aiorussound to 4.6.1 (#147233) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 30b9205f439..a74a1887836 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.6.0"], + "requirements": ["aiorussound==4.6.1"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index a1fa1694dd6..4eca8e12383 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.6.0 +aiorussound==4.6.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 492978d9000..c63e4f2f551 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -351,7 +351,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.6.0 +aiorussound==4.6.1 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 9bcd74c44946f80d48e214338c634f62db6530ed Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 20 Jun 2025 15:39:22 -0500 Subject: [PATCH 0450/1664] Change async_supports_streaming_input to an instance method (#147245) --- homeassistant/components/tts/entity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py index 2c3fd446d2f..dc6f22570fc 100644 --- a/homeassistant/components/tts/entity.py +++ b/homeassistant/components/tts/entity.py @@ -89,11 +89,11 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH """Return a mapping with the default options.""" return self._attr_default_options - @classmethod - def async_supports_streaming_input(cls) -> bool: + def async_supports_streaming_input(self) -> bool: """Return if the TTS engine supports streaming input.""" return ( - cls.async_stream_tts_audio is not TextToSpeechEntity.async_stream_tts_audio + self.__class__.async_stream_tts_audio + is not TextToSpeechEntity.async_stream_tts_audio ) @callback From 2e5de732a71191db26c31c4fabf97cd18fb4ee71 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 21 Jun 2025 01:32:14 +0200 Subject: [PATCH 0451/1664] Bump pyHomee to version 1.2.10 (#147248) bump pyHomee to version 1.2.10 --- homeassistant/components/homee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json index 16ee6085439..16169676835 100644 --- a/homeassistant/components/homee/manifest.json +++ b/homeassistant/components/homee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["homee"], "quality_scale": "bronze", - "requirements": ["pyHomee==1.2.9"] + "requirements": ["pyHomee==1.2.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4eca8e12383..cd9ff2a1a13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1799,7 +1799,7 @@ pyEmby==1.10 pyHik==0.3.2 # homeassistant.components.homee -pyHomee==1.2.9 +pyHomee==1.2.10 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c63e4f2f551..e01054aafd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1510,7 +1510,7 @@ pyDuotecno==2024.10.1 pyElectra==1.2.4 # homeassistant.components.homee -pyHomee==1.2.9 +pyHomee==1.2.10 # homeassistant.components.rfxtrx pyRFXtrx==0.31.1 From 7442f7af28d28a2f6e5417f4cb45e08476be63ac Mon Sep 17 00:00:00 2001 From: hanwg Date: Sat, 21 Jun 2025 09:21:10 +0800 Subject: [PATCH 0452/1664] Fix Telegram bot parsing of inline keyboard (#146376) * bug fix for inline keyboard * update inline keyboard test * Update tests/components/telegram_bot/test_telegram_bot.py Co-authored-by: Martin Hjelmare * revert last_message_id and updated tests * removed TypeError test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/telegram_bot/bot.py | 4 +- .../telegram_bot/test_telegram_bot.py | 130 +++++++++++++++++- 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 534923b3568..f313972635f 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -321,8 +321,8 @@ class TelegramNotificationService: for key in row_keyboard.split(","): if ":/" in key: # check if command or URL - if key.startswith("https://"): - label = key.split(",")[0] + if "https://" in key: + label = key.split(":")[0] url = key[len(label) + 1 :] buttons.append(InlineKeyboardButton(label, url=url)) else: diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 24b6deb27b5..fd313867561 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -1,12 +1,14 @@ """Tests for the telegram_bot component.""" import base64 +from datetime import datetime import io from typing import Any from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest -from telegram import Update +from telegram import Chat, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update +from telegram.constants import ChatType, ParseMode from telegram.error import ( InvalidToken, NetworkError, @@ -16,28 +18,37 @@ from telegram.error import ( ) from homeassistant.components.telegram_bot import ( + ATTR_LATITUDE, + ATTR_LONGITUDE, + async_setup_entry, +) +from homeassistant.components.telegram_bot.const import ( ATTR_AUTHENTICATION, ATTR_CALLBACK_QUERY_ID, ATTR_CAPTION, ATTR_CHAT_ID, + ATTR_DISABLE_NOTIF, + ATTR_DISABLE_WEB_PREV, ATTR_FILE, + ATTR_KEYBOARD, ATTR_KEYBOARD_INLINE, - ATTR_LATITUDE, - ATTR_LONGITUDE, ATTR_MESSAGE, + ATTR_MESSAGE_TAG, ATTR_MESSAGE_THREAD_ID, ATTR_MESSAGEID, ATTR_OPTIONS, + ATTR_PARSER, ATTR_PASSWORD, ATTR_QUESTION, + ATTR_REPLY_TO_MSGID, ATTR_SHOW_ALERT, ATTR_STICKER_ID, ATTR_TARGET, + ATTR_TIMEOUT, ATTR_URL, ATTR_USERNAME, ATTR_VERIFY_SSL, CONF_CONFIG_ENTRY_ID, - CONF_PLATFORM, DOMAIN, PLATFORM_BROADCAST, SERVICE_ANSWER_CALLBACK_QUERY, @@ -55,12 +66,12 @@ from homeassistant.components.telegram_bot import ( SERVICE_SEND_STICKER, SERVICE_SEND_VIDEO, SERVICE_SEND_VOICE, - async_setup_entry, ) from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, + CONF_PLATFORM, HTTP_BASIC_AUTHENTICATION, HTTP_BEARER_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, @@ -96,6 +107,26 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N SERVICE_SEND_MESSAGE, {ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"}, ), + ( + SERVICE_SEND_MESSAGE, + { + ATTR_KEYBOARD: ["/command1, /command2", "/command3"], + ATTR_MESSAGE: "test_message", + ATTR_PARSER: ParseMode.HTML, + ATTR_TIMEOUT: 15, + ATTR_DISABLE_NOTIF: True, + ATTR_DISABLE_WEB_PREV: True, + ATTR_MESSAGE_TAG: "mock_tag", + ATTR_REPLY_TO_MSGID: 12345, + }, + ), + ( + SERVICE_SEND_MESSAGE, + { + ATTR_KEYBOARD: [], + ATTR_MESSAGE: "test_message", + }, + ), ( SERVICE_SEND_STICKER, { @@ -145,6 +176,95 @@ async def test_send_message( assert (response["chats"][0]["message_id"]) == 12345 +@pytest.mark.parametrize( + ("input", "expected"), + [ + ( + { + ATTR_MESSAGE: "test_message", + ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link", + }, + InlineKeyboardMarkup( + # 1 row with 3 buttons + [ + [ + InlineKeyboardButton(callback_data="/cmd1", text="command1"), + InlineKeyboardButton(callback_data="/cmd2", text="CMD2"), + InlineKeyboardButton(url="https://mock_link", text="mock_link"), + ] + ] + ), + ), + ( + { + ATTR_MESSAGE: "test_message", + ATTR_KEYBOARD_INLINE: [ + [["command1", "/cmd1"]], + [["mock_link", "https://mock_link"]], + ], + }, + InlineKeyboardMarkup( + # 2 rows each with 1 button + [ + [InlineKeyboardButton(callback_data="/cmd1", text="command1")], + [InlineKeyboardButton(url="https://mock_link", text="mock_link")], + ] + ), + ), + ], +) +async def test_send_message_with_inline_keyboard( + hass: HomeAssistant, + webhook_platform, + input: dict[str, Any], + expected: InlineKeyboardMarkup, +) -> None: + """Test the send_message service. + + Tests any service that does not require files to be sent. + """ + context = Context() + events = async_capture_events(hass, "telegram_sent") + + with patch( + "homeassistant.components.telegram_bot.bot.Bot.send_message", + AsyncMock( + return_value=Message( + message_id=12345, + date=datetime.now(), + chat=Chat(id=123456, type=ChatType.PRIVATE), + ) + ), + ) as mock_send_message: + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + input, + blocking=True, + context=context, + return_response=True, + ) + await hass.async_block_till_done() + + mock_send_message.assert_called_once_with( + 12345678, + "test_message", + parse_mode=ParseMode.MARKDOWN, + disable_web_page_preview=None, + disable_notification=False, + reply_to_message_id=None, + reply_markup=expected, + read_timeout=None, + message_thread_id=None, + ) + + assert len(events) == 1 + assert events[0].context == context + + assert len(response["chats"]) == 1 + assert (response["chats"][0]["message_id"]) == 12345 + + @patch( "builtins.open", mock_open( From 79a9f34150e3ae92e4ef651b127f9908093ae1fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 21 Jun 2025 10:53:17 +0100 Subject: [PATCH 0453/1664] Handle the new JSON payload from traccar clients (#147254) --- homeassistant/components/traccar/__init__.py | 42 ++++++++++++++--- homeassistant/components/traccar/const.py | 1 - tests/components/traccar/test_init.py | 47 +++++++++++++++++++- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 5b9bc2551b7..e8c151179ce 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,9 +1,12 @@ """Support for Traccar Client.""" from http import HTTPStatus +from json import JSONDecodeError +import logging from aiohttp import web import voluptuous as vol +from voluptuous.humanize import humanize_error from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry @@ -20,7 +23,6 @@ from .const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_SPEED, - ATTR_TIMESTAMP, DOMAIN, ) @@ -29,6 +31,7 @@ PLATFORMS = [Platform.DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" +LOGGER = logging.getLogger(__name__) DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 @@ -49,21 +52,50 @@ WEBHOOK_SCHEMA = vol.Schema( vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float), vol.Optional(ATTR_BEARING): vol.Coerce(float), vol.Optional(ATTR_SPEED): vol.Coerce(float), - vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int), }, extra=vol.REMOVE_EXTRA, ) +def _parse_json_body(json_body: dict) -> dict: + """Parse JSON body from request.""" + location = json_body.get("location", {}) + coords = location.get("coords", {}) + battery_level = location.get("battery", {}).get("level") + return { + "id": json_body.get("device_id"), + "lat": coords.get("latitude"), + "lon": coords.get("longitude"), + "accuracy": coords.get("accuracy"), + "altitude": coords.get("altitude"), + "batt": battery_level * 100 if battery_level is not None else DEFAULT_BATTERY, + "bearing": coords.get("heading"), + "speed": coords.get("speed"), + } + + async def handle_webhook( - hass: HomeAssistant, webhook_id: str, request: web.Request + hass: HomeAssistant, + webhook_id: str, + request: web.Request, ) -> web.Response: """Handle incoming webhook with Traccar Client request.""" + if not (requestdata := dict(request.query)): + try: + requestdata = _parse_json_body(await request.json()) + except JSONDecodeError as error: + LOGGER.error("Error parsing JSON body: %s", error) + return web.Response( + text="Invalid JSON", + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) try: - data = WEBHOOK_SCHEMA(dict(request.query)) + data = WEBHOOK_SCHEMA(requestdata) except vol.MultipleInvalid as error: + LOGGER.warning(humanize_error(requestdata, error)) return web.Response( - text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + text=error.error_message, + status=HTTPStatus.UNPROCESSABLE_ENTITY, ) attrs = { diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index df4bfa8ec99..f6928cc9ee9 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -17,7 +17,6 @@ ATTR_LONGITUDE = "lon" ATTR_MOTION = "motion" ATTR_SPEED = "speed" ATTR_STATUS = "status" -ATTR_TIMESTAMP = "timestamp" ATTR_TRACKER = "tracker" ATTR_TRACCAR_ID = "traccar_id" diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index fb90262a084..eb864cadd87 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -146,8 +146,12 @@ async def test_enter_and_exit( assert len(entity_registry.entities) == 1 -async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None: - """Test when additional attributes are present.""" +async def test_enter_with_attrs_as_query( + hass: HomeAssistant, + client, + webhook_id, +) -> None: + """Test when additional attributes are present URL query.""" url = f"/api/webhook/{webhook_id}" data = { "timestamp": 123456789, @@ -197,6 +201,45 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None assert state.attributes["altitude"] == 123 +async def test_enter_with_attrs_as_payload( + hass: HomeAssistant, client, webhook_id +) -> None: + """Test when additional attributes are present in JSON payload.""" + url = f"/api/webhook/{webhook_id}" + data = { + "location": { + "coords": { + "heading": "105.32", + "latitude": "1.0", + "longitude": "1.1", + "accuracy": 10.5, + "altitude": 102.0, + "speed": 100.0, + }, + "extras": {}, + "manual": True, + "is_moving": False, + "_": "&id=123&lat=1.0&lon=1.1×tamp=2013-09-17T07:32:51Z&", + "odometer": 0, + "activity": {"type": "still"}, + "timestamp": "2013-09-17T07:32:51Z", + "battery": {"level": 0.1, "is_charging": False}, + }, + "device_id": "123", + } + + req = await client.post(url, json=data) + await hass.async_block_till_done() + assert req.status == HTTPStatus.OK + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device_id']}") + assert state.state == STATE_NOT_HOME + assert state.attributes["gps_accuracy"] == 10.5 + assert state.attributes["battery_level"] == 10.0 + assert state.attributes["speed"] == 100.0 + assert state.attributes["bearing"] == 105.32 + assert state.attributes["altitude"] == 102.0 + + async def test_two_devices(hass: HomeAssistant, client, webhook_id) -> None: """Test updating two different devices.""" url = f"/api/webhook/{webhook_id}" From c453eed32df9c13bd3689741cf2be1c71b4d944f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 21 Jun 2025 16:44:22 +0300 Subject: [PATCH 0454/1664] Bump aioamazondevices to 3.1.14 (#147257) --- 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 aeecb5bc96c..a2bb423860b 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": "bronze", - "requirements": ["aioamazondevices==3.1.12"] + "requirements": ["aioamazondevices==3.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index cd9ff2a1a13..c1663ab70e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.12 +aioamazondevices==3.1.14 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e01054aafd2..066a64a2713 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.12 +aioamazondevices==3.1.14 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From f3533dff44005dc800f05998c8c510e2b34017af Mon Sep 17 00:00:00 2001 From: Shai Ungar Date: Sun, 22 Jun 2025 00:50:53 +0300 Subject: [PATCH 0455/1664] Bump pyseventeentrack to 1.1.1 (#147253) Update pyseventeentrack requirement to version 1.1.1 --- homeassistant/components/seventeentrack/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json index 34019208a14..19daedb1b5e 100644 --- a/homeassistant/components/seventeentrack/manifest.json +++ b/homeassistant/components/seventeentrack/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyseventeentrack"], - "requirements": ["pyseventeentrack==1.0.2"] + "requirements": ["pyseventeentrack==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c1663ab70e2..a04fd6f44b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2321,7 +2321,7 @@ pyserial==3.5 pysesame2==1.0.1 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.2 +pyseventeentrack==1.1.1 # homeassistant.components.sia pysiaalarm==3.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 066a64a2713..d67ae457a5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1924,7 +1924,7 @@ pysensibo==1.2.1 pyserial==3.5 # homeassistant.components.seventeentrack -pyseventeentrack==1.0.2 +pyseventeentrack==1.1.1 # homeassistant.components.sia pysiaalarm==3.1.1 From a102eaf0cddfd72d654606d3497bb408ab599984 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 22 Jun 2025 02:14:26 +0200 Subject: [PATCH 0456/1664] Bump uiprotect to version 7.14.1 (#147280) --- 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 f99d910adf9..47e2a01e798 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.14.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index a04fd6f44b8..e008b8ec249 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2988,7 +2988,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.0 +uiprotect==7.14.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d67ae457a5d..77aee9cf055 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2459,7 +2459,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.0 +uiprotect==7.14.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 66e2fd997bb410ef6ed983e4135981b775680ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 22 Jun 2025 09:27:44 +0200 Subject: [PATCH 0457/1664] Battery voltage translation key (#147238) * Add translation_key * Update strings.json * Update snapshots * Switch icon to DC * Update snapshots --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 1 + homeassistant/components/matter/strings.json | 3 + .../matter/snapshots/test_sensor.ambr | 264 +++++++++--------- tests/components/matter/test_sensor.py | 2 +- 5 files changed, 140 insertions(+), 133 deletions(-) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index ac3e70dcfc8..c71a5d07e24 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -57,6 +57,9 @@ "bat_replacement_description": { "default": "mdi:battery-sync" }, + "battery_voltage": { + "default": "mdi:current-dc" + }, "flow": { "default": "mdi:pipe" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 70e4cb238f5..9cab1a2c02f 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -345,6 +345,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( key="PowerSourceBatVoltage", + translation_key="battery_voltage", native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, device_class=SensorDeviceClass.VOLTAGE, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 7cae16c5e9b..72e4d8c50b7 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -324,6 +324,9 @@ "battery_replacement_description": { "name": "Battery type" }, + "battery_voltage": { + "name": "Battery voltage" + }, "current_phase": { "name": "Current phase" }, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 4e63735a2d7..14169c84e15 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1360,7 +1360,7 @@ 'state': '100', }) # --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-entry] +# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -1375,7 +1375,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.eve_door_voltage', + 'entity_id': 'sensor.eve_door_battery_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1393,26 +1393,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Battery voltage', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery_voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', 'unit_of_measurement': , }) # --- -# name: test_sensors[eve_contact_sensor][sensor.eve_door_voltage-state] +# name: test_sensors[eve_contact_sensor][sensor.eve_door_battery_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Eve Door Voltage', + 'friendly_name': 'Eve Door Battery voltage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.eve_door_voltage', + 'entity_id': 'sensor.eve_door_battery_voltage', 'last_changed': , 'last_reported': , 'last_updated': , @@ -1932,6 +1932,65 @@ 'state': '100', }) # --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-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.eve_thermo_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve_thermo][sensor.eve_thermo_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Thermo Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_thermo_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.05', + }) +# --- # name: test_sensors[eve_thermo][sensor.eve_thermo_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2037,65 +2096,6 @@ 'state': '10', }) # --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_voltage-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.eve_thermo_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[eve_thermo][sensor.eve_thermo_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Eve Thermo Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.eve_thermo_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '3.05', - }) -# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2149,6 +2149,65 @@ 'state': '100', }) # --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_voltage-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.eve_weather_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[eve_weather_sensor][sensor.eve_weather_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Eve Weather Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.eve_weather_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.956', + }) +# --- # name: test_sensors[eve_weather_sensor][sensor.eve_weather_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2314,65 +2373,6 @@ 'state': '16.03', }) # --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_voltage-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.eve_weather_voltage', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - 'sensor': dict({ - 'suggested_display_precision': 0, - }), - 'sensor.private': dict({ - 'suggested_unit_of_measurement': , - }), - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Voltage', - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-0-PowerSourceBatVoltage-47-11', - 'unit_of_measurement': , - }) -# --- -# name: test_sensors[eve_weather_sensor][sensor.eve_weather_voltage-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'voltage', - 'friendly_name': 'Eve Weather Voltage', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.eve_weather_voltage', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.956', - }) -# --- # name: test_sensors[extractor_hood][sensor.mock_extractor_hood_activated_carbon_filter_condition-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5304,7 +5304,7 @@ 'state': 'CR123A', }) # --- -# name: test_sensors[smoke_detector][sensor.smoke_sensor_voltage-entry] +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -5319,7 +5319,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.smoke_sensor_voltage', + 'entity_id': 'sensor.smoke_sensor_battery_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -5337,26 +5337,26 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Voltage', + 'original_name': 'Battery voltage', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'battery_voltage', 'unique_id': '00000000000004D2-0000000000000001-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', 'unit_of_measurement': , }) # --- -# name: test_sensors[smoke_detector][sensor.smoke_sensor_voltage-state] +# name: test_sensors[smoke_detector][sensor.smoke_sensor_battery_voltage-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'voltage', - 'friendly_name': 'Smoke sensor Voltage', + 'friendly_name': 'Smoke sensor Battery voltage', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.smoke_sensor_voltage', + 'entity_id': 'sensor.smoke_sensor_battery_voltage', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index e15e3f9f53e..e70101bf804 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -156,7 +156,7 @@ async def test_battery_sensor_voltage( matter_node: MatterNode, ) -> None: """Test battery voltage sensor.""" - entity_id = "sensor.eve_door_voltage" + entity_id = "sensor.eve_door_battery_voltage" state = hass.states.get(entity_id) assert state assert state.state == "3.558" From db3090078b8ea8c9a2b53b3812af283cce67c1ec Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 22 Jun 2025 09:31:16 +0200 Subject: [PATCH 0458/1664] Remove deprecated support feature values in camera (#146988) --- homeassistant/components/camera/__init__.py | 24 +++--------------- homeassistant/helpers/entity.py | 27 +-------------------- tests/components/camera/test_init.py | 25 ------------------- tests/helpers/test_entity.py | 26 -------------------- 4 files changed, 5 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 8348c53cd1c..4286e7462cc 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -498,19 +498,6 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """Flag supported features.""" return self._attr_supported_features - @property - def supported_features_compat(self) -> CameraEntityFeature: - """Return the supported features as CameraEntityFeature. - - Remove this compatibility shim in 2025.1 or later. - """ - features = self.supported_features - if type(features) is int: - new_features = CameraEntityFeature(features) - self._report_deprecated_supported_features_values(new_features) - return new_features - return features - @cached_property def is_recording(self) -> bool: """Return true if the device is recording.""" @@ -704,9 +691,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - self.__supports_stream = ( - self.supported_features_compat & CameraEntityFeature.STREAM - ) + self.__supports_stream = self.supported_features & CameraEntityFeature.STREAM await self.async_refresh_providers(write_state=False) async def async_refresh_providers(self, *, write_state: bool = True) -> None: @@ -735,7 +720,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): self, fn: Callable[[HomeAssistant, Camera], Coroutine[None, None, _T | None]] ) -> _T | None: """Get first provider that supports this camera.""" - if CameraEntityFeature.STREAM not in self.supported_features_compat: + if CameraEntityFeature.STREAM not in self.supported_features: return None return await fn(self.hass, self) @@ -785,7 +770,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): def camera_capabilities(self) -> CameraCapabilities: """Return the camera capabilities.""" frontend_stream_types = set() - if CameraEntityFeature.STREAM in self.supported_features_compat: + if CameraEntityFeature.STREAM in self.supported_features: if self._supports_native_async_webrtc: # The camera has a native WebRTC implementation frontend_stream_types.add(StreamType.WEB_RTC) @@ -805,8 +790,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): """ super().async_write_ha_state() if self.__supports_stream != ( - supports_stream := self.supported_features_compat - & CameraEntityFeature.STREAM + supports_stream := self.supported_features & CameraEntityFeature.STREAM ): self.__supports_stream = supports_stream self._invalidate_camera_capabilities_cache() diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index ad029633f8e..832bbf219f8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -7,7 +7,7 @@ import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable, Mapping import dataclasses -from enum import Enum, IntFlag, auto +from enum import Enum, auto import functools as ft import logging import math @@ -1622,31 +1622,6 @@ class Entity( self.hass, integration_domain=platform_name, module=type(self).__module__ ) - @callback - def _report_deprecated_supported_features_values( - self, replacement: IntFlag - ) -> None: - """Report deprecated supported features values.""" - if self._deprecated_supported_features_reported is True: - return - self._deprecated_supported_features_reported = True - report_issue = self._suggest_report_issue() - report_issue += ( - " and reference " - "https://developers.home-assistant.io/blog/2023/12/28/support-feature-magic-numbers-deprecation" - ) - _LOGGER.warning( - ( - "Entity %s (%s) is using deprecated supported features" - " values which will be removed in HA Core 2025.1. Instead it should use" - " %s, please %s" - ), - self.entity_id, - type(self), - repr(replacement), - report_issue, - ) - class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True): """A class that describes toggle entities.""" diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 839394edbef..09aae385a89 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -41,7 +41,6 @@ from homeassistant.util import dt as dt_util from .common import EMPTY_8_6_JPEG, STREAM_SOURCE, mock_turbo_jpeg from tests.common import ( - MockEntityPlatform, async_fire_time_changed, help_test_all, import_and_test_deprecated_constant_enum, @@ -834,30 +833,6 @@ def test_deprecated_state_constants( import_and_test_deprecated_constant_enum(caplog, module, enum, "STATE_", "2025.10") -def test_deprecated_supported_features_ints( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test deprecated supported features ints.""" - - class MockCamera(camera.Camera): - @property - def supported_features(self) -> int: - """Return supported features.""" - return 1 - - entity = MockCamera() - entity.hass = hass - entity.platform = MockEntityPlatform(hass) - assert entity.supported_features_compat is camera.CameraEntityFeature(1) - assert "MockCamera" in caplog.text - assert "is using deprecated supported features values" in caplog.text - assert "Instead it should use" in caplog.text - assert "CameraEntityFeature.ON_OFF" in caplog.text - caplog.clear() - assert entity.supported_features_compat is camera.CameraEntityFeature(1) - assert "is using deprecated supported features values" not in caplog.text - - @pytest.mark.usefixtures("mock_camera") async def test_entity_picture_url_changes_on_token_update(hass: HomeAssistant) -> None: """Test the token is rotated and entity entity picture cache is cleared.""" diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 92f73132292..706f1a1a806 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -4,7 +4,6 @@ import asyncio from collections.abc import Iterable import dataclasses from datetime import timedelta -from enum import IntFlag import logging import threading from typing import Any @@ -2488,31 +2487,6 @@ async def test_cached_entity_property_override(hass: HomeAssistant) -> None: return "🤡" -async def test_entity_report_deprecated_supported_features_values( - caplog: pytest.LogCaptureFixture, -) -> None: - """Test reporting deprecated supported feature values only happens once.""" - ent = entity.Entity() - - class MockEntityFeatures(IntFlag): - VALUE1 = 1 - VALUE2 = 2 - - ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) - assert ( - "is using deprecated supported features values which will be removed" - in caplog.text - ) - assert "MockEntityFeatures.VALUE2" in caplog.text - - caplog.clear() - ent._report_deprecated_supported_features_values(MockEntityFeatures(2)) - assert ( - "is using deprecated supported features values which will be removed" - not in caplog.text - ) - - async def test_remove_entity_registry( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From 8cead00bc718bd156ea09d58b9a60fe6d871b5d0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 22 Jun 2025 12:19:03 +0200 Subject: [PATCH 0459/1664] Bump aioimmich to 0.10.1 (#147293) bump aioimmich to 0.10.1 --- homeassistant/components/immich/__init__.py | 1 + homeassistant/components/immich/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index 18782ec6fd3..ced4aa44449 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -33,6 +33,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bo entry.data[CONF_HOST], entry.data[CONF_PORT], entry.data[CONF_SSL], + "home-assistant", ) try: diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 36c993e9c8f..80dcd87cd88 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.9.1"] + "requirements": ["aioimmich==0.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e008b8ec249..0a4627f2e59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -280,7 +280,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.9.1 +aioimmich==0.10.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 77aee9cf055..fceefd04355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -265,7 +265,7 @@ aiohue==4.7.4 aioimaplib==2.0.1 # homeassistant.components.immich -aioimmich==0.9.1 +aioimmich==0.10.1 # homeassistant.components.apache_kafka aiokafka==0.10.0 From daa4ddabfe9ca16f46cd6ab45021f5c819ff75c9 Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Sun, 22 Jun 2025 21:12:09 +0900 Subject: [PATCH 0460/1664] Switchbot Cloud: Fix device type filtering in sensor (#146945) * Add Smart Lock Ultra support and fix device type filtering in sensor integration * Adding fix in binary sensor * Fix --------- Co-authored-by: Joostlek --- .../switchbot_cloud/binary_sensor.py | 1 + .../components/switchbot_cloud/sensor.py | 1 + .../switchbot_cloud/test_binary_sensor.py | 39 +++++++++++++++++++ .../components/switchbot_cloud/test_sensor.py | 26 +++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 tests/components/switchbot_cloud/test_binary_sensor.py diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 14278072c83..752c428fa6c 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -69,6 +69,7 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type ] + if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 9975bd49186..9920717a8d7 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -151,6 +151,7 @@ async def async_setup_entry( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] + if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/tests/components/switchbot_cloud/test_binary_sensor.py b/tests/components/switchbot_cloud/test_binary_sensor.py new file mode 100644 index 00000000000..753653af9a8 --- /dev/null +++ b/tests/components/switchbot_cloud/test_binary_sensor.py @@ -0,0 +1,39 @@ +"""Test for the switchbot_cloud binary sensors.""" + +from unittest.mock import patch + +from switchbot_api import Device + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import configure_integration + + +async def test_unsupported_device_type( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_list_devices, + mock_get_status, +) -> None: + """Test that unsupported device types do not create sensors.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="unsupported-id-1", + deviceName="unsupported-device", + deviceType="UnsupportedDevice", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = {} + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.BINARY_SENSOR] + ): + entry = await configure_integration(hass) + + # Assert no binary sensor entities were created for unsupported device type + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len([e for e in entities if e.domain == "binary_sensor"]) == 0 diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 440e71f3124..99b6acc7401 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -67,3 +67,29 @@ async def test_meter_no_coordinator_data( entry = await configure_integration(hass) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_unsupported_device_type( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_list_devices, + mock_get_status, +) -> None: + """Test that unsupported device types do not create sensors.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="unsupported-id-1", + deviceName="unsupported-device", + deviceType="UnsupportedDevice", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = {} + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + # Assert no sensor entities were created for unsupported device type + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len([e for e in entities if e.domain == "sensor"]) == 0 From d4e7667ea01894926715e6929d113cea4c0c43fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 16:24:16 +0200 Subject: [PATCH 0461/1664] Bump aioesphomeapi to 33.0.0 (#147296) fixes compat warning with protobuf 6.x changelog: https://github.com/esphome/aioesphomeapi/compare/v32.2.4...v33.0.0 Not a breaking change for HA since we are already on protobuf 6 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6142b9ce5ec..0577ed10c19 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==32.2.4", + "aioesphomeapi==33.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 0a4627f2e59..0c485b79a81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.4 +aioesphomeapi==33.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fceefd04355..51b55d206af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.4 +aioesphomeapi==33.0.0 # homeassistant.components.flo aioflo==2021.11.0 From 41e53297c22d57cbf0c344e21f074ee83f29bd35 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 22 Jun 2025 16:54:48 +0200 Subject: [PATCH 0462/1664] Add update entity to immich integration (#147273) * add update entity * remove unneccessary entity description * rename update entity to version * simplify test * define static attribute outside of the constructor * move min version check into coordinator --- homeassistant/components/immich/__init__.py | 2 +- .../components/immich/coordinator.py | 12 +++- homeassistant/components/immich/strings.json | 5 ++ homeassistant/components/immich/update.py | 57 +++++++++++++++++ tests/components/immich/conftest.py | 25 +++++--- .../immich/snapshots/test_diagnostics.ambr | 22 ++++--- .../immich/snapshots/test_update.ambr | 61 +++++++++++++++++++ tests/components/immich/test_update.py | 45 ++++++++++++++ 8 files changed, 209 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/immich/update.py create mode 100644 tests/components/immich/snapshots/test_update.ambr create mode 100644 tests/components/immich/test_update.py diff --git a/homeassistant/components/immich/__init__.py b/homeassistant/components/immich/__init__.py index ced4aa44449..d40615dbe88 100644 --- a/homeassistant/components/immich/__init__.py +++ b/homeassistant/components/immich/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.UPDATE] async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool: diff --git a/homeassistant/components/immich/coordinator.py b/homeassistant/components/immich/coordinator.py index 2e89b0dae29..eaa24ec94c1 100644 --- a/homeassistant/components/immich/coordinator.py +++ b/homeassistant/components/immich/coordinator.py @@ -13,7 +13,9 @@ from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, ImmichServerStorage, + ImmichServerVersionCheck, ) +from awesomeversion import AwesomeVersion from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL @@ -33,6 +35,7 @@ class ImmichData: server_about: ImmichServerAbout server_storage: ImmichServerStorage server_usage: ImmichServerStatistics | None + server_version_check: ImmichServerVersionCheck | None type ImmichConfigEntry = ConfigEntry[ImmichDataUpdateCoordinator] @@ -71,9 +74,16 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]): if self.is_admin else None ) + server_version_check = ( + await self.api.server.async_get_version_check() + if AwesomeVersion(server_about.version) >= AwesomeVersion("v1.134.0") + else None + ) except ImmichUnauthorizedError as err: raise ConfigEntryAuthFailed from err except CONNECT_ERRORS as err: raise UpdateFailed from err - return ImmichData(server_about, server_storage, server_usage) + return ImmichData( + server_about, server_storage, server_usage, server_version_check + ) diff --git a/homeassistant/components/immich/strings.json b/homeassistant/components/immich/strings.json index 875eb79f50b..83ee7574630 100644 --- a/homeassistant/components/immich/strings.json +++ b/homeassistant/components/immich/strings.json @@ -68,6 +68,11 @@ "usage_by_videos": { "name": "Disk used by videos" } + }, + "update": { + "update": { + "name": "Version" + } } } } diff --git a/homeassistant/components/immich/update.py b/homeassistant/components/immich/update.py new file mode 100644 index 00000000000..9955e355c96 --- /dev/null +++ b/homeassistant/components/immich/update.py @@ -0,0 +1,57 @@ +"""Update platform for the Immich integration.""" + +from __future__ import annotations + +from homeassistant.components.update import UpdateEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import ImmichConfigEntry, ImmichDataUpdateCoordinator +from .entity import ImmichEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImmichConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add immich server update entity.""" + coordinator = entry.runtime_data + + if coordinator.data.server_version_check is not None: + async_add_entities([ImmichUpdateEntity(coordinator)]) + + +class ImmichUpdateEntity(ImmichEntity, UpdateEntity): + """Define Immich update entity.""" + + _attr_translation_key = "update" + + def __init__( + self, + coordinator: ImmichDataUpdateCoordinator, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_update" + + @property + def installed_version(self) -> str: + """Current installed immich server version.""" + return self.coordinator.data.server_about.version + + @property + def latest_version(self) -> str: + """Available new immich server version.""" + assert self.coordinator.data.server_version_check + return self.coordinator.data.server_version_check.release_version + + @property + def release_url(self) -> str | None: + """URL to the full release notes of the new immich server version.""" + return ( + f"https://github.com/immich-app/immich/releases/tag/{self.latest_version}" + ) diff --git a/tests/components/immich/conftest.py b/tests/components/immich/conftest.py index f8f959e0b0a..6c7813cbd85 100644 --- a/tests/components/immich/conftest.py +++ b/tests/components/immich/conftest.py @@ -8,6 +8,7 @@ from aioimmich.server.models import ( ImmichServerAbout, ImmichServerStatistics, ImmichServerStorage, + ImmichServerVersionCheck, ) from aioimmich.users.models import ImmichUserObject import pytest @@ -79,21 +80,21 @@ def mock_immich_server() -> AsyncMock: mock = AsyncMock(spec=ImmichServer) mock.async_get_about_info.return_value = ImmichServerAbout.from_dict( { - "version": "v1.132.3", - "versionUrl": "https://github.com/immich-app/immich/releases/tag/v1.132.3", + "version": "v1.134.0", + "versionUrl": "https://github.com/immich-app/immich/releases/tag/v1.134.0", "licensed": False, - "build": "14709928600", - "buildUrl": "https://github.com/immich-app/immich/actions/runs/14709928600", - "buildImage": "v1.132.3", + "build": "15281783550", + "buildUrl": "https://github.com/immich-app/immich/actions/runs/15281783550", + "buildImage": "v1.134.0", "buildImageUrl": "https://github.com/immich-app/immich/pkgs/container/immich-server", "repository": "immich-app/immich", "repositoryUrl": "https://github.com/immich-app/immich", - "sourceRef": "v1.132.3", - "sourceCommit": "02994883fe3f3972323bb6759d0170a4062f5236", - "sourceUrl": "https://github.com/immich-app/immich/commit/02994883fe3f3972323bb6759d0170a4062f5236", + "sourceRef": "v1.134.0", + "sourceCommit": "58ae77ec9204a2e43a8cb2f1fd27482af40d0891", + "sourceUrl": "https://github.com/immich-app/immich/commit/58ae77ec9204a2e43a8cb2f1fd27482af40d0891", "nodejs": "v22.14.0", "exiftool": "13.00", - "ffmpeg": "7.0.2-7", + "ffmpeg": "7.0.2-9", "libvips": "8.16.1", "imagemagick": "7.1.1-47", } @@ -130,6 +131,12 @@ def mock_immich_server() -> AsyncMock: ], } ) + mock.async_get_version_check.return_value = ImmichServerVersionCheck.from_dict( + { + "checkedAt": "2025-06-21T16:35:10.352Z", + "releaseVersion": "v1.135.3", + } + ) return mock diff --git a/tests/components/immich/snapshots/test_diagnostics.ambr b/tests/components/immich/snapshots/test_diagnostics.ambr index b3dd3c47db6..4f09e5fbe86 100644 --- a/tests/components/immich/snapshots/test_diagnostics.ambr +++ b/tests/components/immich/snapshots/test_diagnostics.ambr @@ -3,23 +3,23 @@ dict({ 'data': dict({ 'server_about': dict({ - 'build': '14709928600', - 'build_image': 'v1.132.3', + 'build': '15281783550', + 'build_image': 'v1.134.0', 'build_image_url': 'https://github.com/immich-app/immich/pkgs/container/immich-server', - 'build_url': 'https://github.com/immich-app/immich/actions/runs/14709928600', + 'build_url': 'https://github.com/immich-app/immich/actions/runs/15281783550', 'exiftool': '13.00', - 'ffmpeg': '7.0.2-7', + 'ffmpeg': '7.0.2-9', 'imagemagick': '7.1.1-47', 'libvips': '8.16.1', 'licensed': False, 'nodejs': 'v22.14.0', 'repository': 'immich-app/immich', 'repository_url': 'https://github.com/immich-app/immich', - 'source_commit': '02994883fe3f3972323bb6759d0170a4062f5236', - 'source_ref': 'v1.132.3', - 'source_url': 'https://github.com/immich-app/immich/commit/02994883fe3f3972323bb6759d0170a4062f5236', - 'version': 'v1.132.3', - 'version_url': 'https://github.com/immich-app/immich/releases/tag/v1.132.3', + 'source_commit': '58ae77ec9204a2e43a8cb2f1fd27482af40d0891', + 'source_ref': 'v1.134.0', + 'source_url': 'https://github.com/immich-app/immich/commit/58ae77ec9204a2e43a8cb2f1fd27482af40d0891', + 'version': 'v1.134.0', + 'version_url': 'https://github.com/immich-app/immich/releases/tag/v1.134.0', }), 'server_storage': dict({ 'disk_available': '136.3 GiB', @@ -49,6 +49,10 @@ 'usage_videos': 65234281361, 'videos': 1836, }), + 'server_version_check': dict({ + 'checked_at': '2025-06-21T16:35:10.352000+00:00', + 'release_version': 'v1.135.3', + }), }), 'entry': dict({ 'data': dict({ diff --git a/tests/components/immich/snapshots/test_update.ambr b/tests/components/immich/snapshots/test_update.ambr new file mode 100644 index 00000000000..f3864511d13 --- /dev/null +++ b/tests/components/immich/snapshots/test_update.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_update[update.someone_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.someone_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': 'Version', + 'platform': 'immich', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'update', + 'unique_id': 'e7ef5713-9dab-4bd4-b899-715b0ca4379e_update', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[update.someone_version-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/immich/icon.png', + 'friendly_name': 'Someone Version', + 'in_progress': False, + 'installed_version': 'v1.134.0', + 'latest_version': 'v1.135.3', + 'release_summary': None, + 'release_url': 'https://github.com/immich-app/immich/releases/tag/v1.135.3', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.someone_version', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/immich/test_update.py b/tests/components/immich/test_update.py new file mode 100644 index 00000000000..95b4044850d --- /dev/null +++ b/tests/components/immich/test_update.py @@ -0,0 +1,45 @@ +"""Test the Immich update platform.""" + +from unittest.mock import Mock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +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_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Immich update platform.""" + + with patch("homeassistant.components.immich.PLATFORMS", [Platform.UPDATE]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_update_min_version( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_immich: Mock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Immich update platform with min version not installed.""" + + mock_immich.server.async_get_about_info.return_value.version = "v1.132.3" + + with patch("homeassistant.components.immich.PLATFORMS", [Platform.UPDATE]): + await setup_integration(hass, mock_config_entry) + + assert not hass.states.async_all() From 7d421bf22322904e661f399c776d7982ed9b51d6 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 22 Jun 2025 19:02:43 +0200 Subject: [PATCH 0463/1664] Fix regex patterns in foobot sensor tests (#147306) --- tests/components/foobot/test_sensor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py index d9d80191075..f0095effeb4 100644 --- a/tests/components/foobot/test_sensor.py +++ b/tests/components/foobot/test_sensor.py @@ -34,11 +34,11 @@ async def test_default_setup( ) -> None: """Test the default setup.""" aioclient_mock.get( - re.compile("api.foobot.io/v2/owner/.*"), + re.compile(r"api\.foobot\.io/v2/owner/.*"), text=await async_load_fixture(hass, "devices.json", "foobot"), ) aioclient_mock.get( - re.compile("api.foobot.io/v2/device/.*"), + re.compile(r"api\.foobot\.io/v2/device/.*"), text=await async_load_fixture(hass, "data.json", "foobot"), ) assert await async_setup_component(hass, sensor.DOMAIN, {"sensor": VALID_CONFIG}) @@ -65,7 +65,7 @@ async def test_setup_timeout_error( """Expected failures caused by a timeout in API response.""" fake_async_add_entities = MagicMock() - aioclient_mock.get(re.compile("api.foobot.io/v2/owner/.*"), exc=TimeoutError()) + aioclient_mock.get(re.compile(r"api\.foobot\.io/v2/owner/.*"), exc=TimeoutError()) with pytest.raises(PlatformNotReady): await foobot.async_setup_platform(hass, VALID_CONFIG, fake_async_add_entities) @@ -78,7 +78,7 @@ async def test_setup_permanent_error( errors = [HTTPStatus.BAD_REQUEST, HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN] for error in errors: - aioclient_mock.get(re.compile("api.foobot.io/v2/owner/.*"), status=error) + aioclient_mock.get(re.compile(r"api\.foobot\.io/v2/owner/.*"), status=error) result = await foobot.async_setup_platform( hass, VALID_CONFIG, fake_async_add_entities ) @@ -93,7 +93,7 @@ async def test_setup_temporary_error( errors = [HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.INTERNAL_SERVER_ERROR] for error in errors: - aioclient_mock.get(re.compile("api.foobot.io/v2/owner/.*"), status=error) + aioclient_mock.get(re.compile(r"api\.foobot\.io/v2/owner/.*"), status=error) with pytest.raises(PlatformNotReady): await foobot.async_setup_platform( hass, VALID_CONFIG, fake_async_add_entities From 3734c4e91dd417b25e25066a076078b777e3b356 Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 22 Jun 2025 19:05:56 +0200 Subject: [PATCH 0464/1664] fix reconfig in case of no connection. (#147275) --- homeassistant/components/homee/config_flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index 773ca0dff1d..fcf03322d0d 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -129,8 +129,6 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): ): str } ), - description_placeholders={ - "name": reconfigure_entry.runtime_data.settings.uid - }, + description_placeholders={"name": str(reconfigure_entry.unique_id)}, errors=errors, ) From 75946065f2660cb28f7efa693f2ced4759049b8a Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sun, 22 Jun 2025 23:55:51 +0200 Subject: [PATCH 0465/1664] Combine executor calls in devolo Home Control (#147216) --- .../devolo_home_control/__init__.py | 34 +++++++++++-------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 20a1edf734d..80320e2a849 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -29,21 +29,9 @@ async def async_setup_entry( """Set up the devolo account from a config entry.""" mydevolo = configure_mydevolo(entry.data) - credentials_valid = await hass.async_add_executor_job(mydevolo.credentials_valid) - - if not credentials_valid: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="invalid_auth", - ) - - if await hass.async_add_executor_job(mydevolo.maintenance): - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="maintenance", - ) - - gateway_ids = await hass.async_add_executor_job(mydevolo.get_gateway_ids) + gateway_ids = await hass.async_add_executor_job( + check_mydevolo_and_get_gateway_ids, mydevolo + ) if entry.unique_id and GATEWAY_SERIAL_PATTERN.match(entry.unique_id): uuid = await hass.async_add_executor_job(mydevolo.uuid) @@ -115,3 +103,19 @@ def configure_mydevolo(conf: Mapping[str, Any]) -> Mydevolo: mydevolo.user = conf[CONF_USERNAME] mydevolo.password = conf[CONF_PASSWORD] return mydevolo + + +def check_mydevolo_and_get_gateway_ids(mydevolo: Mydevolo) -> list[str]: + """Check if the credentials are valid and return user's gateway IDs as long as mydevolo is not in maintenance mode.""" + if not mydevolo.credentials_valid(): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) + if mydevolo.maintenance(): + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="maintenance", + ) + + return mydevolo.get_gateway_ids() From fcba1183f86133be1f5bffce0278390a48d534fa Mon Sep 17 00:00:00 2001 From: msw Date: Sun, 22 Jun 2025 14:57:02 -0700 Subject: [PATCH 0466/1664] Add water filter replacement and usage sensors to SmartThings (#147279) * Add "Filter status" binary sensor for Samsung refrigerators * Add "Water filter usage" sensor for Samsung refrigerators --- .../components/smartthings/binary_sensor.py | 8 + .../components/smartthings/sensor.py | 10 ++ .../components/smartthings/strings.json | 3 + .../snapshots/test_binary_sensor.ambr | 147 +++++++++++++++++ .../smartthings/snapshots/test_sensor.ambr | 156 ++++++++++++++++++ 5 files changed, 324 insertions(+) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index ea8db71c481..aafb05576bf 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -80,6 +80,14 @@ CAPABILITY_TO_SENSORS: dict[ entity_category=EntityCategory.DIAGNOSTIC, ) }, + Capability.CUSTOM_WATER_FILTER: { + Attribute.WATER_FILTER_STATUS: SmartThingsBinarySensorEntityDescription( + key=Attribute.WATER_FILTER_STATUS, + translation_key="filter_status", + device_class=BinarySensorDeviceClass.PROBLEM, + is_on_key="replace", + ) + }, Capability.SAMSUNG_CE_STEAM_CLOSET_KEEP_FRESH_MODE: { Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription( key=Attribute.OPERATING_STATE, diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index ef066c02130..a38331d6aed 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -325,6 +325,16 @@ CAPABILITY_TO_SENSORS: dict[ ) ] }, + Capability.CUSTOM_WATER_FILTER: { + Attribute.WATER_FILTER_USAGE: [ + SmartThingsSensorEntityDescription( + key=Attribute.WATER_FILTER_USAGE, + translation_key="water_filter_usage", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ) + ] + }, Capability.DISHWASHER_OPERATING_STATE: { Attribute.MACHINE_STATE: [ SmartThingsSensorEntityDescription( diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 5a1d111b617..53e08546583 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -595,6 +595,9 @@ }, "water_consumption": { "name": "Water consumption" + }, + "water_filter_usage": { + "name": "Water filter usage" } }, "switch": { diff --git a/tests/components/smartthings/snapshots/test_binary_sensor.ambr b/tests/components/smartthings/snapshots/test_binary_sensor.ambr index 40784adcec6..7be4d3af55b 100644 --- a/tests/components/smartthings/snapshots/test_binary_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_binary_sensor.ambr @@ -679,6 +679,55 @@ 'state': 'on', }) # --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_filter_status-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.refrigerator_filter_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': 'Filter status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_status', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_custom.waterFilter_waterFilterStatus_waterFilterStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_filter_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Refrigerator Filter status', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_filter_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_ref_normal_000001][binary_sensor.refrigerator_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -826,6 +875,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_filter_status-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.refrigerator_filter_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': 'Filter status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_status', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_custom.waterFilter_waterFilterStatus_waterFilterStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_filter_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Refrigerator Filter status', + }), + 'context': , + 'entity_id': 'binary_sensor.refrigerator_filter_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_01001][binary_sensor.refrigerator_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -924,6 +1022,55 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_filter_status-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.frigo_filter_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': 'Filter status', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_status', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_custom.waterFilter_waterFilterStatus_waterFilterStatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_filter_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Frigo Filter status', + }), + 'context': , + 'entity_id': 'binary_sensor.frigo_filter_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_all_entities[da_ref_normal_01011][binary_sensor.frigo_freezer_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 40180b88bca..f88524116ee 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -5122,6 +5122,58 @@ 'state': '0.0135559777781698', }) # --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_water_filter_usage-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.refrigerator_water_filter_usage', + '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': 'Water filter usage', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_filter_usage', + 'unique_id': '7db87911-7dce-1cf2-7119-b953432a2f09_main_custom.waterFilter_waterFilterUsage_waterFilterUsage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ref_normal_000001][sensor.refrigerator_water_filter_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Water filter usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.refrigerator_water_filter_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- # name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5516,6 +5568,58 @@ 'state': '0.0270189050030708', }) # --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_water_filter_usage-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.refrigerator_water_filter_usage', + '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': 'Water filter usage', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_filter_usage', + 'unique_id': '7d3feb98-8a36-4351-c362-5e21ad3a78dd_main_custom.waterFilter_waterFilterUsage_waterFilterUsage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ref_normal_01001][sensor.refrigerator_water_filter_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Water filter usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.refrigerator_water_filter_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52', + }) +# --- # name: test_all_entities[da_ref_normal_01011][sensor.frigo_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5910,6 +6014,58 @@ 'state': '0.0143511805540986', }) # --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_water_filter_usage-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.frigo_water_filter_usage', + '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': 'Water filter usage', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_filter_usage', + 'unique_id': '5758b2ec-563e-f39b-ec39-208e54aabf60_main_custom.waterFilter_waterFilterUsage_waterFilterUsage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_ref_normal_01011][sensor.frigo_water_filter_usage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frigo Water filter usage', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frigo_water_filter_usage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '97', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 25968925e7af48a76d630e7d8a7c35e7c69bb74e Mon Sep 17 00:00:00 2001 From: DeerMaximum <43999966+DeerMaximum@users.noreply.github.com> Date: Sun, 22 Jun 2025 23:57:33 +0200 Subject: [PATCH 0467/1664] Use has_entity_name in NINA (#146755) * Comply with has-entity-name rule. * Fix tests --- .../components/nina/binary_sensor.py | 1 + tests/components/nina/test_binary_sensor.py | 60 +++++++++---------- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index be7e5995fbc..be37a802d47 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -56,6 +56,7 @@ class NINAMessage(CoordinatorEntity[NINADataUpdateCoordinator], BinarySensorEnti """Representation of an NINA warning.""" _attr_device_class = BinarySensorDeviceClass.SAFETY + _attr_has_entity_name = True def __init__( self, diff --git a/tests/components/nina/test_binary_sensor.py b/tests/components/nina/test_binary_sensor.py index 6ed1aee7e9d..d18b7562b53 100644 --- a/tests/components/nina/test_binary_sensor.py +++ b/tests/components/nina/test_binary_sensor.py @@ -66,8 +66,8 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert conf_entry.state is ConfigEntryState.LOADED - state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") - entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + state_w1 = hass.states.get("binary_sensor.nina_warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_1") assert state_w1.state == STATE_ON assert state_w1.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112" @@ -91,8 +91,8 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert entry_w1.unique_id == "083350000000-1" assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") - entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + state_w2 = hass.states.get("binary_sensor.nina_warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_2") assert state_w2.state == STATE_OFF assert state_w2.attributes.get(ATTR_HEADLINE) is None @@ -110,8 +110,8 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert entry_w2.unique_id == "083350000000-2" assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") - entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + state_w3 = hass.states.get("binary_sensor.nina_warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_3") assert state_w3.state == STATE_OFF assert state_w3.attributes.get(ATTR_HEADLINE) is None @@ -129,8 +129,8 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert entry_w3.unique_id == "083350000000-3" assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") - entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + state_w4 = hass.states.get("binary_sensor.nina_warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_4") assert state_w4.state == STATE_OFF assert state_w4.attributes.get(ATTR_HEADLINE) is None @@ -148,8 +148,8 @@ async def test_sensors(hass: HomeAssistant, entity_registry: er.EntityRegistry) assert entry_w4.unique_id == "083350000000-4" assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") - entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + state_w5 = hass.states.get("binary_sensor.nina_warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_5") assert state_w5.state == STATE_OFF assert state_w5.attributes.get(ATTR_HEADLINE) is None @@ -187,8 +187,8 @@ async def test_sensors_without_corona_filter( assert conf_entry.state is ConfigEntryState.LOADED - state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") - entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + state_w1 = hass.states.get("binary_sensor.nina_warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_1") assert state_w1.state == STATE_ON assert ( @@ -218,8 +218,8 @@ async def test_sensors_without_corona_filter( assert entry_w1.unique_id == "083350000000-1" assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") - entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + state_w2 = hass.states.get("binary_sensor.nina_warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_2") assert state_w2.state == STATE_ON assert state_w2.attributes.get(ATTR_HEADLINE) == "Ausfall Notruf 112" @@ -243,8 +243,8 @@ async def test_sensors_without_corona_filter( assert entry_w2.unique_id == "083350000000-2" assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") - entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + state_w3 = hass.states.get("binary_sensor.nina_warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_3") assert state_w3.state == STATE_OFF assert state_w3.attributes.get(ATTR_HEADLINE) is None @@ -262,8 +262,8 @@ async def test_sensors_without_corona_filter( assert entry_w3.unique_id == "083350000000-3" assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") - entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + state_w4 = hass.states.get("binary_sensor.nina_warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_4") assert state_w4.state == STATE_OFF assert state_w4.attributes.get(ATTR_HEADLINE) is None @@ -281,8 +281,8 @@ async def test_sensors_without_corona_filter( assert entry_w4.unique_id == "083350000000-4" assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") - entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + state_w5 = hass.states.get("binary_sensor.nina_warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_5") assert state_w5.state == STATE_OFF assert state_w5.attributes.get(ATTR_HEADLINE) is None @@ -320,40 +320,40 @@ async def test_sensors_with_area_filter( assert conf_entry.state is ConfigEntryState.LOADED - state_w1 = hass.states.get("binary_sensor.warning_aach_stadt_1") - entry_w1 = entity_registry.async_get("binary_sensor.warning_aach_stadt_1") + state_w1 = hass.states.get("binary_sensor.nina_warning_aach_stadt_1") + entry_w1 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_1") assert state_w1.state == STATE_ON assert entry_w1.unique_id == "083350000000-1" assert state_w1.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w2 = hass.states.get("binary_sensor.warning_aach_stadt_2") - entry_w2 = entity_registry.async_get("binary_sensor.warning_aach_stadt_2") + state_w2 = hass.states.get("binary_sensor.nina_warning_aach_stadt_2") + entry_w2 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_2") assert state_w2.state == STATE_OFF assert entry_w2.unique_id == "083350000000-2" assert state_w2.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w3 = hass.states.get("binary_sensor.warning_aach_stadt_3") - entry_w3 = entity_registry.async_get("binary_sensor.warning_aach_stadt_3") + state_w3 = hass.states.get("binary_sensor.nina_warning_aach_stadt_3") + entry_w3 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_3") assert state_w3.state == STATE_OFF assert entry_w3.unique_id == "083350000000-3" assert state_w3.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w4 = hass.states.get("binary_sensor.warning_aach_stadt_4") - entry_w4 = entity_registry.async_get("binary_sensor.warning_aach_stadt_4") + state_w4 = hass.states.get("binary_sensor.nina_warning_aach_stadt_4") + entry_w4 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_4") assert state_w4.state == STATE_OFF assert entry_w4.unique_id == "083350000000-4" assert state_w4.attributes.get("device_class") == BinarySensorDeviceClass.SAFETY - state_w5 = hass.states.get("binary_sensor.warning_aach_stadt_5") - entry_w5 = entity_registry.async_get("binary_sensor.warning_aach_stadt_5") + state_w5 = hass.states.get("binary_sensor.nina_warning_aach_stadt_5") + entry_w5 = entity_registry.async_get("binary_sensor.nina_warning_aach_stadt_5") assert state_w5.state == STATE_OFF From b47706f360341655b64f765249df27ec07ca1c68 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 23 Jun 2025 01:01:15 +0300 Subject: [PATCH 0468/1664] Add sensor platform to Alexa Devices (#146469) * Add sensor platform to Amazon Devices * fix merge after rename * fix requirements * cleanup * Revert "cleanup" This reverts commit f34892da8a9cc1836870ceef8f8e48ca946b3ff6. * tests * move logic in sensor entity description * update tests * apply review comment * apply review comments --- .../components/alexa_devices/__init__.py | 1 + .../components/alexa_devices/sensor.py | 88 +++++++++++ tests/components/alexa_devices/conftest.py | 8 +- .../alexa_devices/snapshots/test_sensor.ambr | 54 +++++++ tests/components/alexa_devices/test_sensor.py | 143 ++++++++++++++++++ 5 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/alexa_devices/sensor.py create mode 100644 tests/components/alexa_devices/snapshots/test_sensor.ambr create mode 100644 tests/components/alexa_devices/test_sensor.py diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 7a4139a65da..aff4c1bb391 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -8,6 +8,7 @@ from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, Platform.NOTIFY, + Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/alexa_devices/sensor.py b/homeassistant/components/alexa_devices/sensor.py new file mode 100644 index 00000000000..89c2bdce9b7 --- /dev/null +++ b/homeassistant/components/alexa_devices/sensor.py @@ -0,0 +1,88 @@ +"""Support for sensors.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from aioamazondevices.api import AmazonDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import LIGHT_LUX, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import AmazonConfigEntry +from .entity import AmazonEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class AmazonSensorEntityDescription(SensorEntityDescription): + """Amazon Devices sensor entity description.""" + + native_unit_of_measurement_fn: Callable[[AmazonDevice, str], str] | None = None + + +SENSORS: Final = ( + AmazonSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement_fn=lambda device, _key: ( + UnitOfTemperature.CELSIUS + if device.sensors[_key].scale == "CELSIUS" + else UnitOfTemperature.FAHRENHEIT + ), + ), + AmazonSensorEntityDescription( + key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AmazonConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Amazon Devices sensors based on a config entry.""" + + coordinator = entry.runtime_data + + async_add_entities( + AmazonSensorEntity(coordinator, serial_num, sensor_desc) + for sensor_desc in SENSORS + for serial_num in coordinator.data + if coordinator.data[serial_num].sensors.get(sensor_desc.key) is not None + ) + + +class AmazonSensorEntity(AmazonEntity, SensorEntity): + """Sensor device.""" + + entity_description: AmazonSensorEntityDescription + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor.""" + if self.entity_description.native_unit_of_measurement_fn: + return self.entity_description.native_unit_of_measurement_fn( + self.device, self.entity_description.key + ) + + return super().native_unit_of_measurement + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.device.sensors[self.entity_description.key].value diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index f1f40eebd27..79851550528 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from aioamazondevices.api import AmazonDevice +from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor from aioamazondevices.const import DEVICE_TYPE_TO_MODEL import pytest @@ -58,7 +58,11 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: bluetooth_state=True, entity_id="11111111-2222-3333-4444-555555555555", appliance_id="G1234567890123456789012345678A", - sensors={}, + sensors={ + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", scale="CELSIUS" + ) + }, ) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( diff --git a/tests/components/alexa_devices/snapshots/test_sensor.ambr b/tests/components/alexa_devices/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ae245b5c463 --- /dev/null +++ b/tests/components/alexa_devices/snapshots/test_sensor.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_all_entities[sensor.echo_test_temperature-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.echo_test_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': 'alexa_devices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'echo_test_serial_number-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.echo_test_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Echo Test Temperature', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.echo_test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.5', + }) +# --- diff --git a/tests/components/alexa_devices/test_sensor.py b/tests/components/alexa_devices/test_sensor.py new file mode 100644 index 00000000000..e8875fe08a4 --- /dev/null +++ b/tests/components/alexa_devices/test_sensor.py @@ -0,0 +1,143 @@ +"""Tests for the Alexa Devices sensor platform.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from aioamazondevices.api import AmazonDeviceSensor +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, +) +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.alexa_devices.coordinator import SCAN_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 .const import TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.alexa_devices.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "side_effect", + [ + CannotConnect, + CannotRetrieveData, + CannotAuthenticate, + ], +) +async def test_coordinator_data_update_fails( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, +) -> None: + """Test coordinator data update exceptions.""" + + entity_id = "sensor.echo_test_temperature" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == "22.5" + + mock_amazon_devices_client.get_devices_data.side_effect = side_effect + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + +async def test_offline_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test offline device handling.""" + + entity_id = "sensor.echo_test_temperature" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = False + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == STATE_UNAVAILABLE + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].online = True + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id)) + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("sensor", "api_value", "scale", "state_value", "unit"), + [ + ( + "temperature", + "86", + "FAHRENHEIT", + "30.0", # State machine converts to °C + "°C", # State machine converts to °C + ), + ("temperature", "22.5", "CELSIUS", "22.5", "°C"), + ("illuminance", "800", None, "800", "lx"), + ], +) +async def test_unit_of_measurement( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + sensor: str, + api_value: Any, + scale: str | None, + state_value: Any, + unit: str | None, +) -> None: + """Test sensor unit of measurement handling.""" + + entity_id = f"sensor.echo_test_{sensor}" + + mock_amazon_devices_client.get_devices_data.return_value[ + TEST_SERIAL_NUMBER + ].sensors = {sensor: AmazonDeviceSensor(name=sensor, value=api_value, scale=scale)} + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id)) + assert state.state == state_value + assert state.attributes["unit_of_measurement"] == unit From a7290f92cfd29c43b6d81ae0ccbc782667b81b4b Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sun, 22 Jun 2025 18:02:16 -0400 Subject: [PATCH 0469/1664] Add number entity to Russound RIO (#147228) * Add number entity to Russound RIO * Fixes * Fix tests * Change entity name --- .../components/russound_rio/__init__.py | 2 +- .../components/russound_rio/entity.py | 7 + .../components/russound_rio/media_player.py | 6 - .../components/russound_rio/number.py | 112 +++ .../components/russound_rio/strings.json | 16 + tests/components/russound_rio/conftest.py | 4 + .../russound_rio/snapshots/test_number.ambr | 913 ++++++++++++++++++ tests/components/russound_rio/test_number.py | 70 ++ 8 files changed, 1123 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/russound_rio/number.py create mode 100644 tests/components/russound_rio/snapshots/test_number.ambr create mode 100644 tests/components/russound_rio/test_number.py diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index f35a476bbb3..51ca9a9b1ea 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/russound_rio/entity.py b/homeassistant/components/russound_rio/entity.py index d7b4e412831..1fe6a7876d1 100644 --- a/homeassistant/components/russound_rio/entity.py +++ b/homeassistant/components/russound_rio/entity.py @@ -6,6 +6,7 @@ from typing import Any, Concatenate from aiorussound import Controller, RussoundClient from aiorussound.models import CallbackType +from aiorussound.rio import ZoneControlSurface from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -58,6 +59,7 @@ class RussoundBaseEntity(Entity): self._controller.mac_address or f"{self._primary_mac_address}-{self._controller.controller_id}" ) + self._zone_id = zone_id if not zone_id: self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._device_identifier)}, @@ -74,6 +76,11 @@ class RussoundBaseEntity(Entity): via_device=(DOMAIN, self._device_identifier), ) + @property + def _zone(self) -> ZoneControlSurface: + assert self._zone_id + return self._controller.zones[self._zone_id] + async def _state_update_callback( self, _client: RussoundClient, _callback_type: CallbackType ) -> None: diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 7dbc3ae34be..aaaad05a2bc 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING from aiorussound import Controller from aiorussound.const import FeatureFlag from aiorussound.models import PlayStatus, Source -from aiorussound.rio import ZoneControlSurface from aiorussound.util import is_feature_supported from homeassistant.components.media_player import ( @@ -67,15 +66,10 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): ) -> None: """Initialize the zone device.""" super().__init__(controller, zone_id) - self._zone_id = zone_id _zone = self._zone self._sources = sources self._attr_unique_id = f"{self._primary_mac_address}-{_zone.device_str}" - @property - def _zone(self) -> ZoneControlSurface: - return self._controller.zones[self._zone_id] - @property def _source(self) -> Source: return self._zone.fetch_current_source() diff --git a/homeassistant/components/russound_rio/number.py b/homeassistant/components/russound_rio/number.py new file mode 100644 index 00000000000..ae13815fa0a --- /dev/null +++ b/homeassistant/components/russound_rio/number.py @@ -0,0 +1,112 @@ +"""Support for Russound number entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from aiorussound.rio import Controller, ZoneControlSurface + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RussoundConfigEntry +from .entity import RussoundBaseEntity, command + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RussoundZoneNumberEntityDescription(NumberEntityDescription): + """Describes Russound number entities.""" + + value_fn: Callable[[ZoneControlSurface], float] + set_value_fn: Callable[[ZoneControlSurface, float], Awaitable[None]] + + +CONTROL_ENTITIES: tuple[RussoundZoneNumberEntityDescription, ...] = ( + RussoundZoneNumberEntityDescription( + key="balance", + translation_key="balance", + native_min_value=-10, + native_max_value=10, + native_step=1, + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.balance, + set_value_fn=lambda zone, value: zone.set_balance(int(value)), + ), + RussoundZoneNumberEntityDescription( + key="bass", + translation_key="bass", + native_min_value=-10, + native_max_value=10, + native_step=1, + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.bass, + set_value_fn=lambda zone, value: zone.set_bass(int(value)), + ), + RussoundZoneNumberEntityDescription( + key="treble", + translation_key="treble", + native_min_value=-10, + native_max_value=10, + native_step=1, + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.treble, + set_value_fn=lambda zone, value: zone.set_treble(int(value)), + ), + RussoundZoneNumberEntityDescription( + key="turn_on_volume", + translation_key="turn_on_volume", + native_min_value=0, + native_max_value=100, + native_step=2, + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.turn_on_volume * 2, + set_value_fn=lambda zone, value: zone.set_turn_on_volume(int(value / 2)), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RussoundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Russound number entities based on a config entry.""" + client = entry.runtime_data + async_add_entities( + RussoundNumberEntity(controller, zone_id, description) + for controller in client.controllers.values() + for zone_id in controller.zones + for description in CONTROL_ENTITIES + ) + + +class RussoundNumberEntity(RussoundBaseEntity, NumberEntity): + """Defines a Russound number entity.""" + + entity_description: RussoundZoneNumberEntityDescription + + def __init__( + self, + controller: Controller, + zone_id: int, + description: RussoundZoneNumberEntityDescription, + ) -> None: + """Initialize a Russound number entity.""" + super().__init__(controller, zone_id) + self.entity_description = description + self._attr_unique_id = ( + f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" + ) + + @property + def native_value(self) -> float: + """Return the native value of the entity.""" + return float(self.entity_description.value_fn(self._zone)) + + @command + async def async_set_native_value(self, value: float) -> None: + """Set the value.""" + await self.entity_description.set_value_fn(self._zone, value) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index eba66856302..37f78bfa75d 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -40,6 +40,22 @@ "wrong_device": "This Russound controller does not match the existing device ID. Please make sure you entered the correct IP address." } }, + "entity": { + "number": { + "balance": { + "name": "Balance" + }, + "bass": { + "name": "Bass" + }, + "treble": { + "name": "Treble" + }, + "turn_on_volume": { + "name": "Turn-on volume" + } + } + }, "exceptions": { "entry_cannot_connect": { "message": "Error while connecting to {host}:{port}" diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 2516bd81650..5e57c45193b 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -79,6 +79,10 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.unmute = AsyncMock() zone.toggle_mute = AsyncMock() zone.set_seek_time = AsyncMock() + zone.set_balance = AsyncMock() + zone.set_bass = AsyncMock() + zone.set_treble = AsyncMock() + zone.set_turn_on_volume = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/snapshots/test_number.ambr b/tests/components/russound_rio/snapshots/test_number.ambr new file mode 100644 index 00000000000..f1b806a378a --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_number.ambr @@ -0,0 +1,913 @@ +# serializer version: 1 +# name: test_all_entities[number.backyard_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.backyard_balance', + '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': 'Balance', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '00:11:22:33:44:55-C[1].Z[1]-balance', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.backyard_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Backyard Balance', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.backyard_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.backyard_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.backyard_bass', + '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': 'Bass', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bass', + 'unique_id': '00:11:22:33:44:55-C[1].Z[1]-bass', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.backyard_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Backyard Bass', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.backyard_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.backyard_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.backyard_treble', + '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': 'Treble', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'treble', + 'unique_id': '00:11:22:33:44:55-C[1].Z[1]-treble', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.backyard_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Backyard Treble', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.backyard_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.backyard_turn_on_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.backyard_turn_on_volume', + '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': 'Turn-on volume', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_on_volume', + 'unique_id': '00:11:22:33:44:55-C[1].Z[1]-turn_on_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.backyard_turn_on_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Backyard Turn-on volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'context': , + 'entity_id': 'number.backyard_turn_on_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[number.bedroom_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bedroom_balance', + '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': 'Balance', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '00:11:22:33:44:55-C[1].Z[3]-balance', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.bedroom_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Balance', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.bedroom_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.bedroom_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bedroom_bass', + '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': 'Bass', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bass', + 'unique_id': '00:11:22:33:44:55-C[1].Z[3]-bass', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.bedroom_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Bass', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.bedroom_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.bedroom_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bedroom_treble', + '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': 'Treble', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'treble', + 'unique_id': '00:11:22:33:44:55-C[1].Z[3]-treble', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.bedroom_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Treble', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.bedroom_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.bedroom_turn_on_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.bedroom_turn_on_volume', + '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': 'Turn-on volume', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_on_volume', + 'unique_id': '00:11:22:33:44:55-C[1].Z[3]-turn_on_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.bedroom_turn_on_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Turn-on volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'context': , + 'entity_id': 'number.bedroom_turn_on_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[number.kitchen_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kitchen_balance', + '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': 'Balance', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '00:11:22:33:44:55-C[1].Z[2]-balance', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.kitchen_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Balance', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.kitchen_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.kitchen_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kitchen_bass', + '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': 'Bass', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bass', + 'unique_id': '00:11:22:33:44:55-C[1].Z[2]-bass', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.kitchen_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Bass', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.kitchen_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.kitchen_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kitchen_treble', + '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': 'Treble', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'treble', + 'unique_id': '00:11:22:33:44:55-C[1].Z[2]-treble', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.kitchen_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Treble', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.kitchen_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.kitchen_turn_on_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.kitchen_turn_on_volume', + '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': 'Turn-on volume', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_on_volume', + 'unique_id': '00:11:22:33:44:55-C[1].Z[2]-turn_on_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.kitchen_turn_on_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Turn-on volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'context': , + 'entity_id': 'number.kitchen_turn_on_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities[number.living_room_balance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_balance', + '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': 'Balance', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'balance', + 'unique_id': '00:11:22:33:44:55-C[2].Z[9]-balance', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.living_room_balance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Balance', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.living_room_balance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.living_room_bass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_bass', + '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': 'Bass', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'bass', + 'unique_id': '00:11:22:33:44:55-C[2].Z[9]-bass', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.living_room_bass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Bass', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.living_room_bass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.living_room_treble-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_treble', + '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': 'Treble', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'treble', + 'unique_id': '00:11:22:33:44:55-C[2].Z[9]-treble', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.living_room_treble-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Treble', + 'max': 10, + 'min': -10, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.living_room_treble', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[number.living_room_turn_on_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_room_turn_on_volume', + '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': 'Turn-on volume', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'turn_on_volume', + 'unique_id': '00:11:22:33:44:55-C[2].Z[9]-turn_on_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.living_room_turn_on_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Turn-on volume', + 'max': 100, + 'min': 0, + 'mode': , + 'step': 2, + }), + 'context': , + 'entity_id': 'number.living_room_turn_on_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.0', + }) +# --- diff --git a/tests/components/russound_rio/test_number.py b/tests/components/russound_rio/test_number.py new file mode 100644 index 00000000000..ff2c46fb4e1 --- /dev/null +++ b/tests/components/russound_rio/test_number.py @@ -0,0 +1,70 @@ +"""Tests for the Russound RIO number platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration +from .const import NAME_ZONE_1 + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.russound_rio.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_suffix", "value", "expected_method", "expected_arg"), + [ + ("bass", -5, "set_bass", -5), + ("balance", 3, "set_balance", 3), + ("treble", 7, "set_treble", 7), + ("turn_on_volume", 60, "set_turn_on_volume", 30), + ], +) +async def test_setting_number_value( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_suffix: str, + value: int, + expected_method: str, + expected_arg: int, +) -> None: + """Test setting value on Russound number entity.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"{NUMBER_DOMAIN}.{NAME_ZONE_1}_{entity_suffix}", + ATTR_VALUE: value, + }, + blocking=True, + ) + + zone = mock_russound_client.controllers[1].zones[1] + getattr(zone, expected_method).assert_called_once_with(expected_arg) From 93030ad48d718f365b26d01de3b03677d0a81bc5 Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Mon, 23 Jun 2025 04:31:35 -0400 Subject: [PATCH 0470/1664] Remove address info from Rachio calendar events (#145896) Co-authored-by: J. Nick Koston --- homeassistant/components/rachio/calendar.py | 5 ----- homeassistant/components/rachio/const.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py index 18b1b6a4d8f..a8b593e1138 100644 --- a/homeassistant/components/rachio/calendar.py +++ b/homeassistant/components/rachio/calendar.py @@ -16,10 +16,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( - KEY_ADDRESS, KEY_DURATION_SECONDS, KEY_ID, - KEY_LOCALITY, KEY_PROGRAM_ID, KEY_PROGRAM_NAME, KEY_RUN_SUMMARIES, @@ -65,7 +63,6 @@ class RachioCalendarEntity( super().__init__(coordinator) self.base_station = base_station self._event: CalendarEvent | None = None - self._location = coordinator.base_station[KEY_ADDRESS][KEY_LOCALITY] self._attr_translation_placeholders = { "base": coordinator.base_station[KEY_SERIAL_NUMBER] } @@ -87,7 +84,6 @@ class RachioCalendarEntity( end=dt_util.as_local(start_time) + timedelta(seconds=int(event[KEY_TOTAL_RUN_DURATION])), description=valves, - location=self._location, ) def _handle_upcoming_event(self) -> dict[str, Any] | None: @@ -155,7 +151,6 @@ class RachioCalendarEntity( start=event_start, end=event_end, description=valves, - location=self._location, uid=f"{run[KEY_PROGRAM_ID]}/{run[KEY_START_TIME]}", ) event_list.append(event) diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 08a09f309f6..64b26526f57 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -75,8 +75,6 @@ KEY_PROGRAM_ID = "programId" KEY_PROGRAM_NAME = "programName" KEY_PROGRAM_RUN_SUMMARIES = "valveProgramRunSummaries" KEY_TOTAL_RUN_DURATION = "totalRunDurationSeconds" -KEY_ADDRESS = "address" -KEY_LOCALITY = "locality" KEY_SKIP = "skip" KEY_SKIPPABLE = "skippable" From 10c573bbc32df86fd27c0753530eaab07a58b07c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:34:35 +0200 Subject: [PATCH 0471/1664] Use PEP 695 TypeVar syntax for unifi (#147157) --- homeassistant/components/unifi/button.py | 11 ++++++----- .../components/unifi/device_tracker.py | 17 +++++++---------- homeassistant/components/unifi/entity.py | 13 +++++++------ homeassistant/components/unifi/image.py | 11 ++++++----- homeassistant/components/unifi/sensor.py | 11 ++++++----- homeassistant/components/unifi/switch.py | 15 ++++++++------- homeassistant/components/unifi/update.py | 11 +++++------ 7 files changed, 45 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 3e5ef62f49e..470f0091fff 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -11,11 +11,11 @@ import secrets from typing import TYPE_CHECKING, Any import aiounifi -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.devices import Devices from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.wlans import Wlans -from aiounifi.models.api import ApiItemT +from aiounifi.models.api import ApiItem from aiounifi.models.device import ( Device, DevicePowerCyclePortRequest, @@ -35,7 +35,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .entity import ( - HandlerT, UnifiEntity, UnifiEntityDescription, async_device_available_fn, @@ -81,7 +80,7 @@ async def async_regenerate_password_control_fn( @dataclass(frozen=True, kw_only=True) -class UnifiButtonEntityDescription( +class UnifiButtonEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ButtonEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] ): """Class describing UniFi button entity.""" @@ -143,7 +142,9 @@ async def async_setup_entry( ) -class UnifiButtonEntity(UnifiEntity[HandlerT, ApiItemT], ButtonEntity): +class UnifiButtonEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], ButtonEntity +): """Base representation of a UniFi button.""" entity_description: UnifiButtonEntityDescription[HandlerT, ApiItemT] diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 1084c29e75f..8d82c7334c6 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -9,10 +9,10 @@ import logging from typing import Any import aiounifi -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices -from aiounifi.models.api import ApiItemT +from aiounifi.models.api import ApiItem from aiounifi.models.client import Client from aiounifi.models.device import Device from aiounifi.models.event import Event, EventKey @@ -31,12 +31,7 @@ from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .const import DOMAIN -from .entity import ( - HandlerT, - UnifiEntity, - UnifiEntityDescription, - async_device_available_fn, -) +from .entity import UnifiEntity, UnifiEntityDescription, async_device_available_fn from .hub import UnifiHub LOGGER = logging.getLogger(__name__) @@ -142,7 +137,7 @@ def async_device_heartbeat_timedelta_fn(hub: UnifiHub, obj_id: str) -> timedelta @dataclass(frozen=True, kw_only=True) -class UnifiTrackerEntityDescription( +class UnifiTrackerEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( UnifiEntityDescription[HandlerT, ApiItemT], ScannerEntityDescription ): """Class describing UniFi device tracker entity.""" @@ -229,7 +224,9 @@ async def async_setup_entry( ) -class UnifiScannerEntity(UnifiEntity[HandlerT, ApiItemT], ScannerEntity): +class UnifiScannerEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], ScannerEntity +): """Representation of a UniFi scanner.""" entity_description: UnifiTrackerEntityDescription diff --git a/homeassistant/components/unifi/entity.py b/homeassistant/components/unifi/entity.py index 1f9d5b304bc..4b68287ce10 100644 --- a/homeassistant/components/unifi/entity.py +++ b/homeassistant/components/unifi/entity.py @@ -5,7 +5,7 @@ from __future__ import annotations from abc import abstractmethod from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import TYPE_CHECKING import aiounifi from aiounifi.interfaces.api_handlers import ( @@ -14,7 +14,7 @@ from aiounifi.interfaces.api_handlers import ( ItemEvent, UnsubscribeType, ) -from aiounifi.models.api import ApiItemT +from aiounifi.models.api import ApiItem from aiounifi.models.event import Event, EventKey from homeassistant.core import callback @@ -32,8 +32,7 @@ from .const import ATTR_MANUFACTURER, DOMAIN if TYPE_CHECKING: from .hub import UnifiHub -HandlerT = TypeVar("HandlerT", bound=APIHandler) -SubscriptionT = Callable[[CallbackType, ItemEvent], UnsubscribeType] +type SubscriptionType = Callable[[CallbackType, ItemEvent], UnsubscribeType] @callback @@ -95,7 +94,9 @@ def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo: @dataclass(frozen=True, kw_only=True) -class UnifiEntityDescription(EntityDescription, Generic[HandlerT, ApiItemT]): +class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( + EntityDescription +): """UniFi Entity Description.""" api_handler_fn: Callable[[aiounifi.Controller], HandlerT] @@ -128,7 +129,7 @@ class UnifiEntityDescription(EntityDescription, Generic[HandlerT, ApiItemT]): """If entity needs to do regular checks on state.""" -class UnifiEntity(Entity, Generic[HandlerT, ApiItemT]): +class UnifiEntity[HandlerT: APIHandler, ApiItemT: ApiItem](Entity): """Representation of a UniFi entity.""" entity_description: UnifiEntityDescription[HandlerT, ApiItemT] diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index f3045d5fc1c..842e9732b5e 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -8,9 +8,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.wlans import Wlans -from aiounifi.models.api import ApiItemT +from aiounifi.models.api import ApiItem from aiounifi.models.wlan import Wlan from homeassistant.components.image import ImageEntity, ImageEntityDescription @@ -21,7 +21,6 @@ from homeassistant.util import dt as dt_util from . import UnifiConfigEntry from .entity import ( - HandlerT, UnifiEntity, UnifiEntityDescription, async_wlan_available_fn, @@ -37,7 +36,7 @@ def async_wlan_qr_code_image_fn(hub: UnifiHub, wlan: Wlan) -> bytes: @dataclass(frozen=True, kw_only=True) -class UnifiImageEntityDescription( +class UnifiImageEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( ImageEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] ): """Class describing UniFi image entity.""" @@ -75,7 +74,9 @@ async def async_setup_entry( ) -class UnifiImageEntity(UnifiEntity[HandlerT, ApiItemT], ImageEntity): +class UnifiImageEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], ImageEntity +): """Base representation of a UniFi image.""" entity_description: UnifiImageEntityDescription[HandlerT, ApiItemT] diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 47a2c2ba62e..f91a8797d5e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -13,13 +13,13 @@ from decimal import Decimal from functools import partial from typing import TYPE_CHECKING, Literal -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.devices import Devices from aiounifi.interfaces.outlets import Outlets from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.wlans import Wlans -from aiounifi.models.api import ApiItemT +from aiounifi.models.api import ApiItem from aiounifi.models.client import Client from aiounifi.models.device import ( Device, @@ -53,7 +53,6 @@ from homeassistant.util import dt as dt_util, slugify from . import UnifiConfigEntry from .const import DEVICE_STATES from .entity import ( - HandlerT, UnifiEntity, UnifiEntityDescription, async_client_device_info_fn, @@ -356,7 +355,7 @@ def make_device_temperatur_sensors() -> tuple[UnifiSensorEntityDescription, ...] @dataclass(frozen=True, kw_only=True) -class UnifiSensorEntityDescription( +class UnifiSensorEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( SensorEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] ): """Class describing UniFi sensor entity.""" @@ -652,7 +651,9 @@ async def async_setup_entry( ) -class UnifiSensorEntity(UnifiEntity[HandlerT, ApiItemT], SensorEntity): +class UnifiSensorEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], SensorEntity +): """Base representation of a UniFi sensor.""" entity_description: UnifiSensorEntityDescription[HandlerT, ApiItemT] diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 95c7736e0d7..1ca409bec77 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -15,7 +15,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any import aiounifi -from aiounifi.interfaces.api_handlers import ItemEvent +from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent from aiounifi.interfaces.clients import Clients from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups from aiounifi.interfaces.firewall_policies import FirewallPolicies @@ -25,7 +25,7 @@ from aiounifi.interfaces.ports import Ports from aiounifi.interfaces.traffic_routes import TrafficRoutes from aiounifi.interfaces.traffic_rules import TrafficRules from aiounifi.interfaces.wlans import Wlans -from aiounifi.models.api import ApiItemT +from aiounifi.models.api import ApiItem from aiounifi.models.client import Client, ClientBlockRequest from aiounifi.models.device import DeviceSetOutletRelayRequest from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest @@ -54,8 +54,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import UnifiConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN from .entity import ( - HandlerT, - SubscriptionT, + SubscriptionType, UnifiEntity, UnifiEntityDescription, async_client_device_info_fn, @@ -209,7 +208,7 @@ async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> Non @dataclass(frozen=True, kw_only=True) -class UnifiSwitchEntityDescription( +class UnifiSwitchEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem]( SwitchEntityDescription, UnifiEntityDescription[HandlerT, ApiItemT] ): """Class describing UniFi switch entity.""" @@ -218,7 +217,7 @@ class UnifiSwitchEntityDescription( is_on_fn: Callable[[UnifiHub, ApiItemT], bool] # Optional - custom_subscribe: Callable[[aiounifi.Controller], SubscriptionT] | None = None + custom_subscribe: Callable[[aiounifi.Controller], SubscriptionType] | None = None """Callback for additional subscriptions to any UniFi handler.""" only_event_for_state_change: bool = False """Use only UniFi events to trigger state changes.""" @@ -397,7 +396,9 @@ async def async_setup_entry( ) -class UnifiSwitchEntity(UnifiEntity[HandlerT, ApiItemT], SwitchEntity): +class UnifiSwitchEntity[HandlerT: APIHandler, ApiItemT: ApiItem]( + UnifiEntity[HandlerT, ApiItemT], SwitchEntity +): """Base representation of a UniFi switch.""" entity_description: UnifiSwitchEntityDescription[HandlerT, ApiItemT] diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 589b2ff1215..a53700ef969 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import Any, TypeVar +from typing import Any import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent @@ -31,9 +31,6 @@ from .entity import ( LOGGER = logging.getLogger(__name__) -_DataT = TypeVar("_DataT", bound=Device) -_HandlerT = TypeVar("_HandlerT", bound=Devices) - async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None: """Control upgrade of device.""" @@ -41,7 +38,7 @@ async def async_device_control_fn(api: aiounifi.Controller, obj_id: str) -> None @dataclass(frozen=True, kw_only=True) -class UnifiUpdateEntityDescription( +class UnifiUpdateEntityDescription[_HandlerT: Devices, _DataT: Device]( UpdateEntityDescription, UnifiEntityDescription[_HandlerT, _DataT] ): """Class describing UniFi update entity.""" @@ -78,7 +75,9 @@ async def async_setup_entry( ) -class UnifiDeviceUpdateEntity(UnifiEntity[_HandlerT, _DataT], UpdateEntity): +class UnifiDeviceUpdateEntity[_HandlerT: Devices, _DataT: Device]( + UnifiEntity[_HandlerT, _DataT], UpdateEntity +): """Representation of a UniFi device update entity.""" entity_description: UnifiUpdateEntityDescription[_HandlerT, _DataT] From 69d2cd0ac053f651e6d33a93263d986d6c777679 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:40:48 +0200 Subject: [PATCH 0472/1664] Migrate lastfm to use runtime_data (#147330) --- homeassistant/components/lastfm/__init__.py | 14 ++++++-------- homeassistant/components/lastfm/config_flow.py | 12 +++++------- homeassistant/components/lastfm/coordinator.py | 2 ++ homeassistant/components/lastfm/sensor.py | 7 +++---- 4 files changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index 8611d06eee1..b5a4612429e 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -2,19 +2,18 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS -from .coordinator import LastFMDataUpdateCoordinator +from .const import PLATFORMS +from .coordinator import LastFMConfigEntry, LastFMDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bool: """Set up lastfm from a config entry.""" coordinator = LastFMDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -22,12 +21,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bool: """Unload lastfm config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener(hass: HomeAssistant, entry: LastFMConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index ca40aebd0d4..422c50a5fb9 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -8,12 +8,7 @@ from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError import voluptuous as vol -from homeassistant.config_entries import ( - ConfigEntry, - ConfigFlow, - ConfigFlowResult, - OptionsFlow, -) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_API_KEY from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -23,6 +18,7 @@ from homeassistant.helpers.selector import ( ) from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN +from .coordinator import LastFMConfigEntry PLACEHOLDERS = {"api_account_url": "https://www.last.fm/api/account/create"} @@ -81,7 +77,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: LastFMConfigEntry, ) -> LastFmOptionsFlowHandler: """Get the options flow for this handler.""" return LastFmOptionsFlowHandler() @@ -162,6 +158,8 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): class LastFmOptionsFlowHandler(OptionsFlow): """LastFm Options flow handler.""" + config_entry: LastFMConfigEntry + async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/lastfm/coordinator.py b/homeassistant/components/lastfm/coordinator.py index ae89e103b80..ca3c7eda508 100644 --- a/homeassistant/components/lastfm/coordinator.py +++ b/homeassistant/components/lastfm/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_USERS, DOMAIN, LOGGER +type LastFMConfigEntry = ConfigEntry[LastFMDataUpdateCoordinator] + def format_track(track: Track | None) -> str | None: """Format the track.""" diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 89025583e92..0f4d22ba503 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -6,7 +6,6 @@ import hashlib from typing import Any from homeassistant.components.sensor import SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -21,17 +20,17 @@ from .const import ( DOMAIN, STATE_NOT_SCROBBLING, ) -from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData +from .coordinator import LastFMConfigEntry, LastFMDataUpdateCoordinator, LastFMUserData async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LastFMConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize the entries.""" - coordinator: LastFMDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ( LastFmSensor(coordinator, username, entry.entry_id) From 35f310748e279f860f2be95f4f82edcedefb9a74 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Mon, 23 Jun 2025 04:42:36 -0400 Subject: [PATCH 0473/1664] Add switch entity to Russound RIO (#147323) * Add switch entity to Russound RIO * Add switch snapshot --- .../components/russound_rio/__init__.py | 2 +- .../components/russound_rio/icons.json | 12 ++ .../components/russound_rio/strings.json | 5 + .../components/russound_rio/switch.py | 85 ++++++++ tests/components/russound_rio/conftest.py | 1 + .../russound_rio/snapshots/test_switch.ambr | 193 ++++++++++++++++++ tests/components/russound_rio/test_switch.py | 64 ++++++ 7 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/russound_rio/icons.json create mode 100644 homeassistant/components/russound_rio/switch.py create mode 100644 tests/components/russound_rio/snapshots/test_switch.ambr create mode 100644 tests/components/russound_rio/test_switch.py diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 51ca9a9b1ea..ddaa83632df 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS -PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER] +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.NUMBER, Platform.SWITCH] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/russound_rio/icons.json b/homeassistant/components/russound_rio/icons.json new file mode 100644 index 00000000000..7d4ddc4cf98 --- /dev/null +++ b/homeassistant/components/russound_rio/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "switch": { + "loudness": { + "default": "mdi:volume-high", + "state": { + "off": "mdi:volume-low" + } + } + } + } +} diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index 37f78bfa75d..aa9a1cbc65d 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -54,6 +54,11 @@ "turn_on_volume": { "name": "Turn-on volume" } + }, + "switch": { + "loudness": { + "name": "Loudness" + } } }, "exceptions": { diff --git a/homeassistant/components/russound_rio/switch.py b/homeassistant/components/russound_rio/switch.py new file mode 100644 index 00000000000..20ee82ebb5b --- /dev/null +++ b/homeassistant/components/russound_rio/switch.py @@ -0,0 +1,85 @@ +"""Support for Russound RIO switch entities.""" + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + +from aiorussound.rio import Controller, ZoneControlSurface + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import RussoundConfigEntry +from .entity import RussoundBaseEntity, command + +PARALLEL_UPDATES = 0 + + +@dataclass(frozen=True, kw_only=True) +class RussoundZoneSwitchEntityDescription(SwitchEntityDescription): + """Describes Russound RIO switch entity description.""" + + value_fn: Callable[[ZoneControlSurface], bool] + set_value_fn: Callable[[ZoneControlSurface, bool], Awaitable[None]] + + +CONTROL_ENTITIES: tuple[RussoundZoneSwitchEntityDescription, ...] = ( + RussoundZoneSwitchEntityDescription( + key="loudness", + translation_key="loudness", + entity_category=EntityCategory.CONFIG, + value_fn=lambda zone: zone.loudness, + set_value_fn=lambda zone, value: zone.set_loudness(value), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: RussoundConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Russound RIO switch entities based on a config entry.""" + client = entry.runtime_data + async_add_entities( + RussoundSwitchEntity(controller, zone_id, description) + for controller in client.controllers.values() + for zone_id in controller.zones + for description in CONTROL_ENTITIES + ) + + +class RussoundSwitchEntity(RussoundBaseEntity, SwitchEntity): + """Defines a Russound RIO switch entity.""" + + entity_description: RussoundZoneSwitchEntityDescription + + def __init__( + self, + controller: Controller, + zone_id: int, + description: RussoundZoneSwitchEntityDescription, + ) -> None: + """Initialize Russound RIO switch.""" + super().__init__(controller, zone_id) + self.entity_description = description + self._attr_unique_id = ( + f"{self._primary_mac_address}-{self._zone.device_str}-{description.key}" + ) + + @property + def is_on(self) -> bool: + """Return the state of the switch.""" + return self.entity_description.value_fn(self._zone) + + @command + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.entity_description.set_value_fn(self._zone, True) + + @command + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.entity_description.set_value_fn(self._zone, False) diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 5e57c45193b..81091e1d5a8 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -83,6 +83,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.set_bass = AsyncMock() zone.set_treble = AsyncMock() zone.set_turn_on_volume = AsyncMock() + zone.set_loudness = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/snapshots/test_switch.ambr b/tests/components/russound_rio/snapshots/test_switch.ambr new file mode 100644 index 00000000000..38273b8233b --- /dev/null +++ b/tests/components/russound_rio/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_all_entities[switch.backyard_loudness-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.backyard_loudness', + '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': 'Loudness', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'loudness', + 'unique_id': '00:11:22:33:44:55-C[1].Z[1]-loudness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.backyard_loudness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Backyard Loudness', + }), + 'context': , + 'entity_id': 'switch.backyard_loudness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.bedroom_loudness-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.bedroom_loudness', + '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': 'Loudness', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'loudness', + 'unique_id': '00:11:22:33:44:55-C[1].Z[3]-loudness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.bedroom_loudness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bedroom Loudness', + }), + 'context': , + 'entity_id': 'switch.bedroom_loudness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.kitchen_loudness-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.kitchen_loudness', + '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': 'Loudness', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'loudness', + 'unique_id': '00:11:22:33:44:55-C[1].Z[2]-loudness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.kitchen_loudness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Loudness', + }), + 'context': , + 'entity_id': 'switch.kitchen_loudness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_entities[switch.living_room_loudness-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.living_room_loudness', + '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': 'Loudness', + 'platform': 'russound_rio', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'loudness', + 'unique_id': '00:11:22:33:44:55-C[2].Z[9]-loudness', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[switch.living_room_loudness-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living Room Loudness', + }), + 'context': , + 'entity_id': 'switch.living_room_loudness', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/russound_rio/test_switch.py b/tests/components/russound_rio/test_switch.py new file mode 100644 index 00000000000..dadaae1df33 --- /dev/null +++ b/tests/components/russound_rio/test_switch.py @@ -0,0 +1,64 @@ +"""Tests for the Russound RIO switch platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.russound_rio.PLATFORMS", [Platform.SWITCH]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_setting_value( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting value.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "switch.backyard_loudness", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].set_loudness.assert_called_once_with( + True + ) + mock_russound_client.controllers[1].zones[1].set_loudness.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "switch.backyard_loudness", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].set_loudness.assert_called_once_with( + False + ) From b13dd4e6ca4c5419ce2c9c854bfb6b9990571bdf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:09:51 +0200 Subject: [PATCH 0474/1664] Migrate lg_netcast to use runtime_data (#147338) --- .../components/lg_netcast/__init__.py | 26 ++++++++++++------- .../components/lg_netcast/device_trigger.py | 15 +++++------ .../components/lg_netcast/media_player.py | 15 ++++------- 3 files changed, 28 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py index f6fb834ab11..c2509889760 100644 --- a/homeassistant/components/lg_netcast/__init__.py +++ b/homeassistant/components/lg_netcast/__init__.py @@ -2,8 +2,10 @@ from typing import Final +from pylgnetcast import LgNetCastClient + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv @@ -13,21 +15,25 @@ PLATFORMS: Final[list[Platform]] = [Platform.MEDIA_PLAYER] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +type LgNetCastConfigEntry = ConfigEntry[LgNetCastClient] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: LgNetCastConfigEntry +) -> bool: """Set up a config entry.""" - hass.data.setdefault(DOMAIN, {}) + host = config_entry.data[CONF_HOST] + access_token = config_entry.data[CONF_ACCESS_TOKEN] + + client = LgNetCastClient(host, access_token) + + config_entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LgNetCastConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - del hass.data[DOMAIN][entry.entry_id] - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lg_netcast/device_trigger.py b/homeassistant/components/lg_netcast/device_trigger.py index d1808b3e536..c4f48fee431 100644 --- a/homeassistant/components/lg_netcast/device_trigger.py +++ b/homeassistant/components/lg_netcast/device_trigger.py @@ -47,14 +47,13 @@ async def async_validate_trigger_config( except ValueError as err: raise InvalidDeviceAutomationConfig(err) from err - if DOMAIN in hass.data: - for config_entry_id in device.config_entries: - if hass.data[DOMAIN].get(config_entry_id): - break - else: - raise InvalidDeviceAutomationConfig( - f"Device {device.id} is not from an existing {DOMAIN} config entry" - ) + if not any( + entry.entry_id in device.config_entries + for entry in hass.config_entries.async_loaded_entries(DOMAIN) + ): + raise InvalidDeviceAutomationConfig( + f"Device {device.id} is not from an existing {DOMAIN} config entry" + ) return config diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index de652eeef08..ca533a0e3c3 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING, Any -from pylgnetcast import LG_COMMAND, LgNetCastClient, LgNetCastError +from pylgnetcast import LG_COMMAND, LgNetCastError from requests import RequestException from homeassistant.components.media_player import ( @@ -15,13 +15,13 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_MODEL, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.trigger import PluggableAction +from . import LgNetCastConfigEntry from .const import ATTR_MANUFACTURER, DOMAIN from .triggers.turn_on import async_get_turn_on_trigger @@ -46,20 +46,15 @@ SUPPORT_LGTV = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LgNetCastConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a LG Netcast Media Player from a config_entry.""" - - host = config_entry.data[CONF_HOST] - access_token = config_entry.data[CONF_ACCESS_TOKEN] unique_id = config_entry.unique_id name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) model = config_entry.data[CONF_MODEL] - client = LgNetCastClient(host, access_token) - - hass.data[DOMAIN][config_entry.entry_id] = client + client = config_entry.runtime_data async_add_entities([LgTVDevice(client, name, model, unique_id=unique_id)]) From f64533e9e0aa1287614cce2cc59c469c306147cd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:11:03 +0200 Subject: [PATCH 0475/1664] Migrate led_ble to use runtime_data (#147337) --- homeassistant/components/led_ble/__init__.py | 21 ++++++++------------ homeassistant/components/led_ble/light.py | 9 ++++----- homeassistant/components/led_ble/models.py | 3 +++ 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/led_ble/__init__.py b/homeassistant/components/led_ble/__init__.py index 84d7369d706..7f89ab202ac 100644 --- a/homeassistant/components/led_ble/__init__.py +++ b/homeassistant/components/led_ble/__init__.py @@ -10,21 +10,20 @@ from led_ble import BLEAK_EXCEPTIONS, LEDBLE from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DEVICE_TIMEOUT, DOMAIN, UPDATE_SECONDS -from .models import LEDBLEData +from .const import DEVICE_TIMEOUT, UPDATE_SECONDS +from .models import LEDBLEConfigEntry, LEDBLEData PLATFORMS: list[Platform] = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bool: """Set up LED BLE from a config entry.""" address: str = entry.data[CONF_ADDRESS] ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True) @@ -89,9 +88,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: cancel_first_update() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LEDBLEData( - entry.title, led_ble, coordinator - ) + entry.runtime_data = LEDBLEData(entry.title, led_ble, coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -106,17 +103,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> None: """Handle options update.""" - data: LEDBLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != entry.runtime_data.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LEDBLEConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: LEDBLEData = hass.data[DOMAIN].pop(entry.entry_id) - await data.device.stop() + await entry.runtime_data.device.stop() return unload_ok diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py index 2facda734d5..89263555a1e 100644 --- a/homeassistant/components/led_ble/light.py +++ b/homeassistant/components/led_ble/light.py @@ -15,7 +15,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -25,17 +24,17 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DEFAULT_EFFECT_SPEED, DOMAIN -from .models import LEDBLEData +from .const import DEFAULT_EFFECT_SPEED +from .models import LEDBLEConfigEntry async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LEDBLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform for LEDBLE.""" - data: LEDBLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities([LEDBLEEntity(data.coordinator, data.device, entry.title)]) diff --git a/homeassistant/components/led_ble/models.py b/homeassistant/components/led_ble/models.py index a8dd3443dce..077aa9ee7ea 100644 --- a/homeassistant/components/led_ble/models.py +++ b/homeassistant/components/led_ble/models.py @@ -6,8 +6,11 @@ from dataclasses import dataclass from led_ble import LEDBLE +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +type LEDBLEConfigEntry = ConfigEntry[LEDBLEData] + @dataclass class LEDBLEData: From 741e89383b49f96b958a9e8a80b5b8eba7e00a85 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:12:32 +0200 Subject: [PATCH 0476/1664] Migrate leaone to use runtime_data (#147336) --- homeassistant/components/leaone/__init__.py | 28 +++++++++------------ homeassistant/components/leaone/sensor.py | 10 +++----- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/leaone/__init__.py b/homeassistant/components/leaone/__init__.py index 74119cfaa4c..79ac349c69d 100644 --- a/homeassistant/components/leaone/__init__.py +++ b/homeassistant/components/leaone/__init__.py @@ -14,27 +14,26 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN - PLATFORMS: list[Platform] = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) +type LeaoneConfigEntry = ConfigEntry[PassiveBluetoothProcessorCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: LeaoneConfigEntry) -> bool: """Set up Leaone BLE device from a config entry.""" address = entry.unique_id assert address is not None data = LeaoneBluetoothDeviceData() - coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ( - PassiveBluetoothProcessorCoordinator( - hass, - _LOGGER, - address=address, - mode=BluetoothScanningMode.PASSIVE, - update_method=data.update, - ) + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + address=address, + mode=BluetoothScanningMode.PASSIVE, + update_method=data.update, ) + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload( coordinator.async_start() @@ -42,9 +41,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LeaoneConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py index c815a0964e0..db9264b7b89 100644 --- a/homeassistant/components/leaone/sensor.py +++ b/homeassistant/components/leaone/sensor.py @@ -4,11 +4,9 @@ from __future__ import annotations from leaone_ble import DeviceClass as LeaoneSensorDeviceClass, SensorUpdate, Units -from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -26,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info -from .const import DOMAIN +from . import LeaoneConfigEntry from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -106,13 +104,11 @@ def sensor_update_to_bluetooth_data_update( async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: LeaoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Leaone BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) entry.async_on_unload( processor.async_add_entities_listener( From a2785a86dc488e667b678f13e0589eb99271d270 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:13:10 +0200 Subject: [PATCH 0477/1664] Migrate ld2410_ble to use runtime_data (#147335) --- .../components/ld2410_ble/__init__.py | 22 ++++++++----------- .../components/ld2410_ble/binary_sensor.py | 8 +++---- .../components/ld2410_ble/coordinator.py | 14 +++++++++--- homeassistant/components/ld2410_ble/models.py | 4 ++++ homeassistant/components/ld2410_ble/sensor.py | 12 +++++----- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index db67010823d..1a9f3cc57e6 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -11,21 +11,19 @@ from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import ADDRESS, BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN from .coordinator import LD2410BLECoordinator -from .models import LD2410BLEData +from .models import LD2410BLEConfigEntry, LD2410BLEData PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) -> bool: """Set up LD2410 BLE from a config entry.""" address: str = entry.data[CONF_ADDRESS] @@ -69,9 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = LD2410BLEData( - entry.title, ld2410_ble, coordinator - ) + entry.runtime_data = LD2410BLEData(entry.title, ld2410_ble, coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -86,17 +82,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: LD2410BLEConfigEntry +) -> None: """Handle options update.""" - data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != entry.runtime_data.title: await hass.config_entries.async_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LD2410BLEConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - data: LD2410BLEData = hass.data[DOMAIN].pop(entry.entry_id) - await data.device.stop() + await entry.runtime_data.device.stop() return unload_ok diff --git a/homeassistant/components/ld2410_ble/binary_sensor.py b/homeassistant/components/ld2410_ble/binary_sensor.py index 3ba43e0d6dc..ef10a2007c5 100644 --- a/homeassistant/components/ld2410_ble/binary_sensor.py +++ b/homeassistant/components/ld2410_ble/binary_sensor.py @@ -5,7 +5,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -13,8 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import LD2410BLE, LD2410BLECoordinator -from .const import DOMAIN -from .models import LD2410BLEData +from .models import LD2410BLEConfigEntry ENTITY_DESCRIPTIONS = ( BinarySensorEntityDescription( @@ -30,11 +28,11 @@ ENTITY_DESCRIPTIONS = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LD2410BLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform for LD2410BLE.""" - data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( LD2410BLEBinarySensor(data.coordinator, data.device, entry.title, description) for description in ENTITY_DESCRIPTIONS diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index b318542e798..f3d2f544faf 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -1,18 +1,23 @@ """Data coordinator for receiving LD2410B updates.""" +from __future__ import annotations + from datetime import datetime import logging import time +from typing import TYPE_CHECKING from ld2410_ble import LD2410BLE, LD2410BLEState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN +if TYPE_CHECKING: + from .models import LD2410BLEConfigEntry + _LOGGER = logging.getLogger(__name__) NEVER_TIME = -86400.0 @@ -22,10 +27,13 @@ DEBOUNCE_SECONDS = 1.0 class LD2410BLECoordinator(DataUpdateCoordinator[None]): """Data coordinator for receiving LD2410B updates.""" - config_entry: ConfigEntry + config_entry: LD2410BLEConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, ld2410_ble: LD2410BLE + self, + hass: HomeAssistant, + config_entry: LD2410BLEConfigEntry, + ld2410_ble: LD2410BLE, ) -> None: """Initialise the coordinator.""" super().__init__( diff --git a/homeassistant/components/ld2410_ble/models.py b/homeassistant/components/ld2410_ble/models.py index a7f5f4f2e3e..46dd226e303 100644 --- a/homeassistant/components/ld2410_ble/models.py +++ b/homeassistant/components/ld2410_ble/models.py @@ -6,8 +6,12 @@ from dataclasses import dataclass from ld2410_ble import LD2410BLE +from homeassistant.config_entries import ConfigEntry + from .coordinator import LD2410BLECoordinator +type LD2410BLEConfigEntry = ConfigEntry[LD2410BLEData] + @dataclass class LD2410BLEData: diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py index db4e42580c4..87e173e4d15 100644 --- a/homeassistant/components/ld2410_ble/sensor.py +++ b/homeassistant/components/ld2410_ble/sensor.py @@ -1,12 +1,13 @@ """LD2410 BLE integration sensor platform.""" +from ld2410_ble import LD2410BLE + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr @@ -14,9 +15,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import LD2410BLE, LD2410BLECoordinator -from .const import DOMAIN -from .models import LD2410BLEData +from .coordinator import LD2410BLECoordinator +from .models import LD2410BLEConfigEntry MOVING_TARGET_DISTANCE_DESCRIPTION = SensorEntityDescription( key="moving_target_distance", @@ -121,11 +121,11 @@ SENSOR_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LD2410BLEConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the platform for LD2410BLE.""" - data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( LD2410BLESensor( data.coordinator, From 82c1751f8519b53fe64b2bf32553432f73658083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Jun 2025 12:40:37 +0200 Subject: [PATCH 0478/1664] Matter dishwasher alarm (#146842) * Update binary_sensor.py * Update silabs_dishwasher.json DishwasherAlarm * DishwasherAlarm * Update snapshot * DishwasherAlarm * test_dishwasher_alarm * DishwasherAlarm * Update silabs_dishwasher.json * Update snapshot --- .../components/matter/binary_sensor.py | 30 ++++++ homeassistant/components/matter/strings.json | 6 ++ .../fixtures/nodes/silabs_dishwasher.json | 8 ++ .../matter/snapshots/test_binary_sensor.ambr | 98 +++++++++++++++++++ tests/components/matter/test_binary_sensor.py | 18 ++++ 5 files changed, 160 insertions(+) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 2d04a936ee5..95efe46309c 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -377,4 +377,34 @@ DISCOVERY_SCHEMAS = [ ), allow_multi=True, ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="DishwasherAlarmInflowError", + translation_key="dishwasher_alarm_inflow", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + measurement_to_ha=lambda x: ( + x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.DishwasherAlarm.Attributes.State,), + allow_multi=True, + ), + MatterDiscoverySchema( + platform=Platform.BINARY_SENSOR, + entity_description=MatterBinarySensorEntityDescription( + key="DishwasherAlarmDoorError", + translation_key="dishwasher_alarm_door", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + measurement_to_ha=lambda x: ( + x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError + ), + ), + entity_class=MatterBinarySensor, + required_attributes=(clusters.DishwasherAlarm.Attributes.State,), + allow_multi=True, + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 72e4d8c50b7..01c8d74426e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -88,6 +88,12 @@ }, "boost_state": { "name": "Boost state" + }, + "dishwasher_alarm_inflow": { + "name": "Inflow alarm" + }, + "dishwasher_alarm_door": { + "name": "Door alarm" } }, "button": { diff --git a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json index c5015bc1c34..d0efcc7e004 100644 --- a/tests/components/matter/fixtures/nodes/silabs_dishwasher.json +++ b/tests/components/matter/fixtures/nodes/silabs_dishwasher.json @@ -417,6 +417,14 @@ "1/89/65528": [1], "1/89/65529": [0], "1/89/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/93/0": 63, + "1/93/2": 0, + "1/93/3": 63, + "1/93/65533": 1, + "1/93/65532": 0, + "1/93/65531": [0, 2, 3, 65533, 65532, 65531, 65529, 65528], + "1/93/65529": [], + "1/93/65528": [], "1/96/0": null, "1/96/1": null, "1/96/3": [ diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index f13d86c4557..f6c7780c517 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -489,6 +489,104 @@ 'state': 'on', }) # --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_door_alarm-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': , + 'entity_id': 'binary_sensor.dishwasher_door_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door alarm', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_alarm_door', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-DishwasherAlarmDoorError-93-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_door_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dishwasher Door alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_door_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_inflow_alarm-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': , + 'entity_id': 'binary_sensor.dishwasher_inflow_alarm', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inflow alarm', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dishwasher_alarm_inflow', + 'unique_id': '00000000000004D2-0000000000000036-MatterNodeDevice-1-DishwasherAlarmInflowError-93-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[silabs_dishwasher][binary_sensor.dishwasher_inflow_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dishwasher Inflow alarm', + }), + 'context': , + 'entity_id': 'binary_sensor.dishwasher_inflow_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charging_status-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index e221140b85b..873d6f17528 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -257,3 +257,21 @@ async def test_pump( state = hass.states.get("binary_sensor.mock_pump_problem") assert state assert state.state == "on" + + +@pytest.mark.parametrize("node_fixture", ["silabs_dishwasher"]) +async def test_dishwasher_alarm( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test dishwasher alarm sensors.""" + state = hass.states.get("binary_sensor.dishwasher_door_alarm") + assert state + + set_node_attribute(matter_node, 1, 93, 2, 4) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("binary_sensor.dishwasher_door_alarm") + assert state + assert state.state == "on" From 4d2f0f2de63a7c576529821666f7f6fc7eb14d09 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:14:11 +0200 Subject: [PATCH 0479/1664] Migrate laundrify to use runtime_data (#147331) * Migrate laundrify to use runtime_data * Adjust test --- .../components/laundrify/__init__.py | 20 ++++++------------- .../components/laundrify/binary_sensor.py | 9 +++------ .../components/laundrify/coordinator.py | 2 ++ homeassistant/components/laundrify/sensor.py | 9 +++------ tests/components/laundrify/conftest.py | 3 +-- 5 files changed, 15 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/laundrify/__init__.py b/homeassistant/components/laundrify/__init__.py index 7e3dd848348..b45ca25bd2e 100644 --- a/homeassistant/components/laundrify/__init__.py +++ b/homeassistant/components/laundrify/__init__.py @@ -7,21 +7,19 @@ import logging from laundrify_aio import LaundrifyAPI from laundrify_aio.exceptions import ApiConnectionException, UnauthorizedException -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import LaundrifyUpdateCoordinator +from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LaundrifyConfigEntry) -> bool: """Set up laundrify from a config entry.""" session = async_get_clientsession(hass) @@ -38,26 +36,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - "api": api_client, - "coordinator": coordinator, - } + entry.runtime_data = coordinator 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, entry: LaundrifyConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_migrate_entry(hass: HomeAssistant, entry: LaundrifyConfigEntry) -> bool: """Migrate entry.""" _LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 82f4f7609dc..0cfbaae6c20 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -10,28 +10,25 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, MANUFACTURER, MODELS -from .coordinator import LaundrifyUpdateCoordinator +from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + entry: LaundrifyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up sensors from a config entry created in the integrations UI.""" - coordinator: LaundrifyUpdateCoordinator = hass.data[DOMAIN][config.entry_id][ - "coordinator" - ] + coordinator = entry.runtime_data async_add_entities( LaundrifyPowerPlug(coordinator, device) for device in coordinator.data.values() diff --git a/homeassistant/components/laundrify/coordinator.py b/homeassistant/components/laundrify/coordinator.py index 928e30a9ed5..cca1cb2122c 100644 --- a/homeassistant/components/laundrify/coordinator.py +++ b/homeassistant/components/laundrify/coordinator.py @@ -16,6 +16,8 @@ from .const import DEFAULT_POLL_INTERVAL, DOMAIN, REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) +type LaundrifyConfigEntry = ConfigEntry[LaundrifyUpdateCoordinator] + class LaundrifyUpdateCoordinator(DataUpdateCoordinator[dict[str, LaundrifyDevice]]): """Class to manage fetching laundrify API data.""" diff --git a/homeassistant/components/laundrify/sensor.py b/homeassistant/components/laundrify/sensor.py index 3c343861b0a..7caa6a9b044 100644 --- a/homeassistant/components/laundrify/sensor.py +++ b/homeassistant/components/laundrify/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -18,21 +17,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LaundrifyUpdateCoordinator +from .coordinator import LaundrifyConfigEntry, LaundrifyUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config: ConfigEntry, + entry: LaundrifyConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add power sensor for passed config_entry in HA.""" - coordinator: LaundrifyUpdateCoordinator = hass.data[DOMAIN][config.entry_id][ - "coordinator" - ] + coordinator = entry.runtime_data sensor_entities: list[LaundrifyPowerSensor | LaundrifyEnergySensor] = [] for device in coordinator.data.values(): diff --git a/tests/components/laundrify/conftest.py b/tests/components/laundrify/conftest.py index 4a78a2e9025..c9ad1e528a5 100644 --- a/tests/components/laundrify/conftest.py +++ b/tests/components/laundrify/conftest.py @@ -6,8 +6,7 @@ from unittest.mock import AsyncMock, patch from laundrify_aio import LaundrifyAPI, LaundrifyDevice import pytest -from homeassistant.components.laundrify import DOMAIN -from homeassistant.components.laundrify.const import MANUFACTURER +from homeassistant.components.laundrify.const import DOMAIN, MANUFACTURER from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant From 436fcb7e85806ab56b3d0f78f4e06ecee4f0ac2a Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 23 Jun 2025 19:14:18 +0800 Subject: [PATCH 0480/1664] Fixed YoLink incorrect valve status (#147021) * Fix valve status * Fix as suggested --- homeassistant/components/yolink/manifest.json | 2 +- homeassistant/components/yolink/valve.py | 5 ++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 74e2259f050..779b830637b 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.5.2"] + "requirements": ["yolink-api==0.5.5"] } diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py index 0e8a5e61855..06dee8af540 100644 --- a/homeassistant/components/yolink/valve.py +++ b/homeassistant/components/yolink/valve.py @@ -155,7 +155,10 @@ class YoLinkValveEntity(YoLinkEntity, ValveEntity): @property def available(self) -> bool: """Return true is device is available.""" - if self.coordinator.dev_net_type is not None: + if ( + self.coordinator.device.is_support_mode_switching() + and self.coordinator.dev_net_type is not None + ): # When the device operates in Class A mode, it cannot be controlled. return self.coordinator.dev_net_type != ATTR_DEVICE_MODEL_A return super().available diff --git a/requirements_all.txt b/requirements_all.txt index 0c485b79a81..c6e8b8060c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3154,7 +3154,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.5.2 +yolink-api==0.5.5 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51b55d206af..29addc25eff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2598,7 +2598,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.5.2 +yolink-api==0.5.5 # homeassistant.components.youless youless-api==2.2.0 From 0ab23ccb51a039a6bbbb91b7bf5821a558a3bdd3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:14:38 +0200 Subject: [PATCH 0481/1664] Migrate landisgyr_heat_meter to use runtime_data (#147329) --- .../landisgyr_heat_meter/__init__.py | 18 +++++++--------- .../landisgyr_heat_meter/coordinator.py | 9 ++++++-- .../components/landisgyr_heat_meter/sensor.py | 21 +++++++------------ 3 files changed, 23 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/__init__.py b/homeassistant/components/landisgyr_heat_meter/__init__.py index 7e7ebe61eb7..669de160811 100644 --- a/homeassistant/components/landisgyr_heat_meter/__init__.py +++ b/homeassistant/components/landisgyr_heat_meter/__init__.py @@ -7,20 +7,19 @@ from typing import Any import ultraheat_api -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from .const import DOMAIN -from .coordinator import UltraheatCoordinator +from .coordinator import UltraheatConfigEntry, UltraheatCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: UltraheatConfigEntry) -> bool: """Set up heat meter from a config entry.""" _LOGGER.debug("Initializing %s integration on %s", DOMAIN, entry.data[CONF_DEVICE]) @@ -30,22 +29,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = UltraheatCoordinator(hass, entry, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: UltraheatConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: UltraheatConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug("Migrating from version %s", config_entry.version) diff --git a/homeassistant/components/landisgyr_heat_meter/coordinator.py b/homeassistant/components/landisgyr_heat_meter/coordinator.py index 4214fa1db3e..bda19fd6fc3 100644 --- a/homeassistant/components/landisgyr_heat_meter/coordinator.py +++ b/homeassistant/components/landisgyr_heat_meter/coordinator.py @@ -15,14 +15,19 @@ from .const import POLLING_INTERVAL, ULTRAHEAT_TIMEOUT _LOGGER = logging.getLogger(__name__) +type UltraheatConfigEntry = ConfigEntry[UltraheatCoordinator] + class UltraheatCoordinator(DataUpdateCoordinator[HeatMeterResponse]): """Coordinator for getting data from the ultraheat api.""" - config_entry: ConfigEntry + config_entry: UltraheatConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, api: HeatMeterService + self, + hass: HomeAssistant, + config_entry: UltraheatConfigEntry, + api: HeatMeterService, ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py index 9bb4af572fd..6a7d7c63103 100644 --- a/homeassistant/components/landisgyr_heat_meter/sensor.py +++ b/homeassistant/components/landisgyr_heat_meter/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( EntityCategory, UnitOfEnergy, @@ -29,13 +28,11 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from . import DOMAIN +from .const import DOMAIN +from .coordinator import UltraheatConfigEntry, UltraheatCoordinator _LOGGER = logging.getLogger(__name__) @@ -270,14 +267,12 @@ HEAT_METER_SENSOR_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: UltraheatConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" unique_id = entry.entry_id - coordinator: DataUpdateCoordinator[HeatMeterResponse] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator = entry.runtime_data model = entry.data["model"] @@ -295,7 +290,7 @@ async def async_setup_entry( class HeatMeterSensor( - CoordinatorEntity[DataUpdateCoordinator[HeatMeterResponse]], + CoordinatorEntity[UltraheatCoordinator], SensorEntity, ): """Representation of a Sensor.""" @@ -304,7 +299,7 @@ class HeatMeterSensor( def __init__( self, - coordinator: DataUpdateCoordinator[HeatMeterResponse], + coordinator: UltraheatCoordinator, description: HeatMeterSensorEntityDescription, device: DeviceInfo, ) -> None: @@ -312,7 +307,7 @@ class HeatMeterSensor( super().__init__(coordinator) self.key = description.key self._attr_unique_id = ( - f"{coordinator.config_entry.data['device_number']}_{description.key}" # type: ignore[union-attr] + f"{coordinator.config_entry.data['device_number']}_{description.key}" ) self._attr_name = f"Heat Meter {description.name}" self.entity_description = description From 1119716c32203ce27cee547b00deb7d96625e250 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:15:01 +0200 Subject: [PATCH 0482/1664] Clean superfluous cloud deps from pyproject (#147223) --- homeassistant/package_constraints.txt | 1 - pyproject.toml | 40 --------------------------- requirements.txt | 8 ------ 3 files changed, 49 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3eb77beed93..e47c5f7d66c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,6 @@ ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 mutagen==1.47.0 -numpy==2.3.0 orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 diff --git a/pyproject.toml b/pyproject.toml index 4295c23740f..f7ac7476d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,41 +46,16 @@ dependencies = [ "ciso8601==2.3.2", "cronsim==2.6", "fnv-hash-fast==1.5.0", - # ha-ffmpeg is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "ha-ffmpeg==3.2.2", # hass-nabucasa is imported by helpers which don't depend on the cloud # integration "hass-nabucasa==0.103.0", - # hassil is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "hassil==2.2.3", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.28.1", "home-assistant-bluetooth==1.13.1", - # home_assistant_intents is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "home-assistant-intents==2025.6.10", "ifaddr==0.2.0", "Jinja2==3.1.6", "lru-dict==1.3.0", - # mutagen is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "mutagen==1.47.0", - # numpy is indirectly imported from onboarding via the import chain - # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "numpy==2.3.0", "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==45.0.3", @@ -90,22 +65,7 @@ dependencies = [ "orjson==3.10.18", "packaging>=23.1", "psutil-home-assistant==0.0.1", - # pymicro_vad is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "pymicro-vad==1.0.1", - # pyspeex-noise is indirectly imported from onboarding via the import chain - # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "pyspeex-noise==1.0.2", "python-slugify==8.0.4", - # PyTurboJPEG is indirectly imported from onboarding via the import chain - # onboarding->cloud->camera->pyturbojpeg. Onboarding needs - # to be setup in stage 0, but we don't want to also promote cloud with all its - # dependencies to stage 0. - "PyTurboJPEG==1.8.0", "PyYAML==6.0.2", "requests==2.32.4", "securetar==2025.2.1", diff --git a/requirements.txt b/requirements.txt index b47d33e7a44..39ec6dd87dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,17 +23,12 @@ certifi>=2021.5.30 ciso8601==2.3.2 cronsim==2.6 fnv-hash-fast==1.5.0 -ha-ffmpeg==3.2.2 hass-nabucasa==0.103.0 -hassil==2.2.3 httpx==0.28.1 home-assistant-bluetooth==1.13.1 -home-assistant-intents==2025.6.10 ifaddr==0.2.0 Jinja2==3.1.6 lru-dict==1.3.0 -mutagen==1.47.0 -numpy==2.3.0 PyJWT==2.10.1 cryptography==45.0.3 Pillow==11.2.1 @@ -42,10 +37,7 @@ pyOpenSSL==25.1.0 orjson==3.10.18 packaging>=23.1 psutil-home-assistant==0.0.1 -pymicro-vad==1.0.1 -pyspeex-noise==1.0.2 python-slugify==8.0.4 -PyTurboJPEG==1.8.0 PyYAML==6.0.2 requests==2.32.4 securetar==2025.2.1 From 3b4eb7c7493c60f22d9c0f63a2fadc81cb4ea956 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:15:08 +0200 Subject: [PATCH 0483/1664] Migrate lametric to use runtime_data (#147328) * Migrate lametric to use runtime_data * One more * Drop unused hass_config --- homeassistant/components/lametric/__init__.py | 14 ++++++-------- homeassistant/components/lametric/button.py | 8 +++----- homeassistant/components/lametric/coordinator.py | 6 ++++-- homeassistant/components/lametric/diagnostics.py | 8 +++----- homeassistant/components/lametric/helpers.py | 16 +++++----------- homeassistant/components/lametric/notify.py | 14 ++++++++------ homeassistant/components/lametric/number.py | 8 +++----- .../components/lametric/quality_scale.yaml | 3 ++- homeassistant/components/lametric/select.py | 8 +++----- homeassistant/components/lametric/sensor.py | 8 +++----- homeassistant/components/lametric/switch.py | 8 +++----- 11 files changed, 43 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py index 89659fbd2c0..efc784354e1 100644 --- a/homeassistant/components/lametric/__init__.py +++ b/homeassistant/components/lametric/__init__.py @@ -1,14 +1,13 @@ """Support for LaMetric time.""" from homeassistant.components import notify as hass_notify -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -17,16 +16,16 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LaMetric integration.""" async_setup_services(hass) - hass.data[DOMAIN] = {"hass_config": config} + return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LaMetricConfigEntry) -> bool: """Set up LaMetric from a config entry.""" coordinator = LaMetricDataUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Set up notify platform, no entry support for notify component yet, @@ -37,15 +36,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: Platform.NOTIFY, DOMAIN, {CONF_NAME: coordinator.data.name, "entry_id": entry.entry_id}, - hass.data[DOMAIN]["hass_config"], + {}, ) ) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LaMetricConfigEntry) -> bool: """Unload LaMetric config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] await hass_notify.async_reload(hass, DOMAIN) return unload_ok diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py index 3c7d754fa0b..7b141665a4f 100644 --- a/homeassistant/components/lametric/button.py +++ b/homeassistant/components/lametric/button.py @@ -9,13 +9,11 @@ from typing import Any from demetriek import LaMetricDevice from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -57,11 +55,11 @@ BUTTONS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric button based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricButtonEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/coordinator.py b/homeassistant/components/lametric/coordinator.py index c292b2971b6..54301506366 100644 --- a/homeassistant/components/lametric/coordinator.py +++ b/homeassistant/components/lametric/coordinator.py @@ -13,13 +13,15 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN, LOGGER, SCAN_INTERVAL +type LaMetricConfigEntry = ConfigEntry[LaMetricDataUpdateCoordinator] + class LaMetricDataUpdateCoordinator(DataUpdateCoordinator[Device]): """The LaMetric Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: LaMetricConfigEntry - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: LaMetricConfigEntry) -> None: """Initialize the LaMatric coordinator.""" self.lametric = LaMetricDevice( host=entry.data[CONF_HOST], diff --git a/homeassistant/components/lametric/diagnostics.py b/homeassistant/components/lametric/diagnostics.py index c14ed998ace..9df72ee40fa 100644 --- a/homeassistant/components/lametric/diagnostics.py +++ b/homeassistant/components/lametric/diagnostics.py @@ -6,11 +6,9 @@ import json 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 -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry TO_REDACT = { "device_id", @@ -21,10 +19,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LaMetricConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data # Round-trip via JSON to trigger serialization data = json.loads(coordinator.data.to_json()) return async_redact_data(data, TO_REDACT) diff --git a/homeassistant/components/lametric/helpers.py b/homeassistant/components/lametric/helpers.py index 8620b0c7cd9..55b5ef1bb8b 100644 --- a/homeassistant/components/lametric/helpers.py +++ b/homeassistant/components/lametric/helpers.py @@ -12,7 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity @@ -57,15 +57,9 @@ def async_get_coordinator_by_device_id( if (device_entry := device_registry.async_get(device_id)) is None: raise ValueError(f"Unknown LaMetric device ID: {device_id}") - for entry_id in device_entry.config_entries: - if ( - (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.domain == DOMAIN - and entry.entry_id in hass.data[DOMAIN] - ): - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ] - return coordinator + entry: LaMetricConfigEntry + for entry in hass.config_entries.async_loaded_entries(DOMAIN): + if entry.entry_id in device_entry.config_entries: + return entry.runtime_data raise ValueError(f"No coordinator for device ID: {device_id}") diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index 195924e2da5..db453d2fc20 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from demetriek import ( AlarmSound, @@ -24,8 +24,8 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.enum import try_parse_enum -from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND, DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .const import CONF_CYCLES, CONF_ICON_TYPE, CONF_PRIORITY, CONF_SOUND +from .coordinator import LaMetricConfigEntry async def async_get_service( @@ -36,10 +36,12 @@ async def async_get_service( """Get the LaMetric notification service.""" if discovery_info is None: return None - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][ + entry: LaMetricConfigEntry | None = hass.config_entries.async_get_entry( discovery_info["entry_id"] - ] - return LaMetricNotificationService(coordinator.lametric) + ) + if TYPE_CHECKING: + assert entry is not None + return LaMetricNotificationService(entry.runtime_data.lametric) class LaMetricNotificationService(BaseNotificationService): diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py index 7f356741d76..acd196d4b34 100644 --- a/homeassistant/components/lametric/number.py +++ b/homeassistant/components/lametric/number.py @@ -9,13 +9,11 @@ from typing import Any from demetriek import Device, LaMetricDevice, Range from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -57,11 +55,11 @@ NUMBERS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric number based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricNumberEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/quality_scale.yaml b/homeassistant/components/lametric/quality_scale.yaml index a8982bb938b..a01115bab3e 100644 --- a/homeassistant/components/lametric/quality_scale.yaml +++ b/homeassistant/components/lametric/quality_scale.yaml @@ -17,7 +17,7 @@ rules: Entities of this integration does not explicitly subscribe to events. entity-unique-id: done has-entity-name: done - runtime-data: todo + runtime-data: done test-before-configure: done test-before-setup: done unique-config-entry: done @@ -33,6 +33,7 @@ rules: parallel-updates: todo reauthentication-flow: done test-coverage: done + # Gold devices: done diagnostics: done diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py index eab7cd5997c..993ec7c909a 100644 --- a/homeassistant/components/lametric/select.py +++ b/homeassistant/components/lametric/select.py @@ -9,13 +9,11 @@ from typing import Any from demetriek import BrightnessMode, Device, LaMetricDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -42,11 +40,11 @@ SELECTS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric select based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricSelectEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py index a5d5da3c046..309c8093204 100644 --- a/homeassistant/components/lametric/sensor.py +++ b/homeassistant/components/lametric/sensor.py @@ -12,13 +12,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity @@ -44,11 +42,11 @@ SENSORS = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric sensor based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricSensorEntity( coordinator=coordinator, diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py index 85e61164639..8e4fb611d3e 100644 --- a/homeassistant/components/lametric/switch.py +++ b/homeassistant/components/lametric/switch.py @@ -9,13 +9,11 @@ from typing import Any from demetriek import Device, LaMetricDevice from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LaMetricDataUpdateCoordinator +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator from .entity import LaMetricEntity from .helpers import lametric_exception_handler @@ -47,11 +45,11 @@ SWITCHES = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LaMetricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LaMetric switch based on a config entry.""" - coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LaMetricSwitchEntity( coordinator=coordinator, From bf733fdec5d0a52da53ce9ccb8e1419080220026 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 23 Jun 2025 13:16:57 +0200 Subject: [PATCH 0484/1664] Remove config flow unique_id migration from devolo Home Control (#147327) Remove config flow unique_id conversion from devolo Home Control --- homeassistant/components/devolo_home_control/__init__.py | 6 +----- homeassistant/components/devolo_home_control/const.py | 3 --- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/homeassistant/components/devolo_home_control/__init__.py b/homeassistant/components/devolo_home_control/__init__.py index 80320e2a849..51e4152be98 100644 --- a/homeassistant/components/devolo_home_control/__init__.py +++ b/homeassistant/components/devolo_home_control/__init__.py @@ -18,7 +18,7 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry -from .const import DOMAIN, GATEWAY_SERIAL_PATTERN, PLATFORMS +from .const import DOMAIN, PLATFORMS type DevoloHomeControlConfigEntry = ConfigEntry[list[HomeControl]] @@ -33,10 +33,6 @@ async def async_setup_entry( check_mydevolo_and_get_gateway_ids, mydevolo ) - if entry.unique_id and GATEWAY_SERIAL_PATTERN.match(entry.unique_id): - uuid = await hass.async_add_executor_job(mydevolo.uuid) - hass.config_entries.async_update_entry(entry, unique_id=uuid) - def shutdown(event: Event) -> None: for gateway in entry.runtime_data: gateway.websocket_disconnect( diff --git a/homeassistant/components/devolo_home_control/const.py b/homeassistant/components/devolo_home_control/const.py index bd2282ad99f..e517e269916 100644 --- a/homeassistant/components/devolo_home_control/const.py +++ b/homeassistant/components/devolo_home_control/const.py @@ -1,7 +1,5 @@ """Constants for the devolo_home_control integration.""" -import re - from homeassistant.const import Platform DOMAIN = "devolo_home_control" @@ -14,5 +12,4 @@ PLATFORMS = [ Platform.SIREN, Platform.SWITCH, ] -GATEWAY_SERIAL_PATTERN = re.compile(r"\d{16}") SUPPORTED_MODEL_TYPES = ["2600", "2601"] From 2bfb09cb117ece3f9cc9f71b8134dbee76fc61c3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Jun 2025 13:29:29 +0200 Subject: [PATCH 0485/1664] Improve test of WS command get_services cache handling (#147134) --- .../components/websocket_api/test_commands.py | 44 ++++++++++++++++--- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 2c9cc19c84b..6e4fa34ed26 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -17,6 +17,9 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_OK, TYPE_AUTH_REQUIRED, ) +from homeassistant.components.websocket_api.commands import ( + ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, +) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS @@ -667,14 +670,41 @@ async def test_get_services( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: """Test get_services command.""" - for id_ in (5, 6): - await websocket_client.send_json({"id": id_, "type": "get_services"}) + assert ALL_SERVICE_DESCRIPTIONS_JSON_CACHE not in hass.data + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": {}, "success": True, "type": "result"} - msg = await websocket_client.receive_json() - assert msg["id"] == id_ - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"].keys() == hass.services.async_services().keys() + # Check cache is reused + old_cache = hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": {}, "success": True, "type": "result"} + assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache + + # Load a service and check cache is updated + assert await async_setup_component(hass, "logger", {}) + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == { + "id": 3, + "result": {"logger": {"set_default_level": ANY, "set_level": ANY}}, + "success": True, + "type": "result", + } + assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Check cache is reused + old_cache = hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "get_services"}) + msg = await websocket_client.receive_json() + assert msg == { + "id": 4, + "result": {"logger": {"set_default_level": ANY, "set_level": ANY}}, + "success": True, + "type": "result", + } + assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache async def test_get_config( From d06da8c2daf388671d255b143ad3e329a52f8d85 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 13:41:53 +0200 Subject: [PATCH 0486/1664] Migrate lcn to use runtime_data (#147333) --- homeassistant/components/lcn/__init__.py | 43 ++++++++----------- homeassistant/components/lcn/binary_sensor.py | 23 ++++------ homeassistant/components/lcn/climate.py | 13 +++--- homeassistant/components/lcn/const.py | 4 -- homeassistant/components/lcn/cover.py | 15 +++---- homeassistant/components/lcn/entity.py | 4 +- homeassistant/components/lcn/helpers.py | 38 +++++++++++----- homeassistant/components/lcn/light.py | 15 +++---- homeassistant/components/lcn/scene.py | 12 +++--- homeassistant/components/lcn/sensor.py | 15 +++---- homeassistant/components/lcn/services.py | 21 ++++++--- homeassistant/components/lcn/switch.py | 27 ++++-------- homeassistant/components/lcn/websocket.py | 40 ++++++++--------- 13 files changed, 127 insertions(+), 143 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 11cee726eb0..efc981b754c 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -16,7 +16,6 @@ from pypck.connection import ( ) from pypck.lcn_defs import LcnEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, @@ -38,21 +37,20 @@ from homeassistant.helpers import ( from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_ACKNOWLEDGE, CONF_DIM_MODE, CONF_DOMAIN_DATA, CONF_SK_NUM_TRIES, CONF_TARGET_VALUE_LOCKED, CONF_TRANSITION, - CONNECTION, - DEVICE_CONNECTIONS, DOMAIN, PLATFORMS, ) from .helpers import ( AddressType, InputType, + LcnConfigEntry, + LcnRuntimeData, async_update_config_entry, generate_unique_id, purge_device_registry, @@ -69,18 +67,14 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LCN component.""" - hass.data.setdefault(DOMAIN, {}) - async_setup_services(hass) await register_panel_and_ws_api(hass) return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) -> bool: """Set up a connection to PCHK host from a config entry.""" - if config_entry.entry_id in hass.data[DOMAIN]: - return False settings = { "SK_NUM_TRIES": config_entry.data[CONF_SK_NUM_TRIES], @@ -114,11 +108,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b ) from ex _LOGGER.debug('LCN connected to "%s"', config_entry.title) - hass.data[DOMAIN][config_entry.entry_id] = { - CONNECTION: lcn_connection, - DEVICE_CONNECTIONS: {}, - ADD_ENTITIES_CALLBACKS: {}, - } + config_entry.runtime_data = LcnRuntimeData( + connection=lcn_connection, + device_connections={}, + add_entities_callbacks={}, + ) # Update config_entry with LCN device serials await async_update_config_entry(hass, config_entry) @@ -146,7 +140,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_migrate_entry( + hass: HomeAssistant, config_entry: LcnConfigEntry +) -> bool: """Migrate old entry.""" _LOGGER.debug( "Migrating configuration from version %s.%s", @@ -195,7 +191,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> async def async_migrate_entities( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: """Migrate entity registry.""" @@ -217,25 +213,24 @@ async def async_migrate_entities( await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) -> bool: """Close connection to PCHK host represented by config_entry.""" # forward unloading to platforms unload_ok = await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS ) - if unload_ok and config_entry.entry_id in hass.data[DOMAIN]: - host = hass.data[DOMAIN].pop(config_entry.entry_id) - await host[CONNECTION].async_close() + if unload_ok: + await config_entry.runtime_data.connection.async_close() return unload_ok def async_host_event_received( - hass: HomeAssistant, config_entry: ConfigEntry, event: pypck.lcn_defs.LcnEvent + hass: HomeAssistant, config_entry: LcnConfigEntry, event: pypck.lcn_defs.LcnEvent ) -> None: """Process received event from LCN.""" - lcn_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + lcn_connection = config_entry.runtime_data.connection async def reload_config_entry() -> None: """Close connection and schedule config entry for reload.""" @@ -258,7 +253,7 @@ def async_host_event_received( def async_host_input_received( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, device_registry: dr.DeviceRegistry, inp: pypck.inputs.Input, ) -> None: @@ -266,7 +261,7 @@ def async_host_input_received( if not isinstance(inp, pypck.inputs.ModInput): return - lcn_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + lcn_connection = config_entry.runtime_data.connection logical_address = lcn_connection.physical_to_logical(inp.physical_source_addr) address = ( logical_address.seg_id, diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 65afae56f22..d8418c6d838 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.components.script import scripts_with_entity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -22,19 +21,13 @@ from homeassistant.helpers.issue_registry import ( ) from homeassistant.helpers.typing import ConfigType -from .const import ( - ADD_ENTITIES_CALLBACKS, - BINSENSOR_PORTS, - CONF_DOMAIN_DATA, - DOMAIN, - SETPOINTS, -) +from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -53,7 +46,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -63,7 +56,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_BINARY_SENSOR: add_entities} ) @@ -79,7 +72,7 @@ async def async_setup_entry( class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for regulator locks.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN binary sensor.""" super().__init__(config, config_entry) @@ -138,7 +131,7 @@ class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): class LcnBinarySensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for binary sensor ports.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN binary sensor.""" super().__init__(config, config_entry) @@ -174,7 +167,7 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): """Representation of a LCN sensor for key locks.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN sensor.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py index e91ae723714..5dc1419cecc 100644 --- a/homeassistant/components/lcn/climate.py +++ b/homeassistant/components/lcn/climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_DOMAIN, @@ -26,23 +25,21 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_SETPOINT, CONF_TARGET_VALUE_LOCKED, - DOMAIN, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -56,7 +53,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -66,7 +63,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_CLIMATE: add_entities} ) @@ -82,7 +79,7 @@ async def async_setup_entry( class LcnClimate(LcnEntity, ClimateEntity): """Representation of a LCN climate device.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize of a LCN climate device.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index d67c02ed56a..d8831c66f0b 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -15,12 +15,8 @@ PLATFORMS = [ ] DOMAIN = "lcn" -DATA_LCN = "lcn" DEFAULT_NAME = "pchk" -ADD_ENTITIES_CALLBACKS = "add_entities_callbacks" -CONNECTION = "connection" -DEVICE_CONNECTIONS = "device_connections" CONF_HARDWARE_SERIAL = "hardware_serial" CONF_SOFTWARE_SERIAL = "software_serial" CONF_HARDWARE_TYPE = "hardware_type" diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index 068d8f5ba11..cb292f7cadf 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -12,28 +12,25 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_MOTOR, CONF_POSITIONING_MODE, CONF_REVERSE_TIME, - DOMAIN, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -50,7 +47,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN cover entities from a config entry.""" @@ -60,7 +57,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_COVER: add_entities} ) @@ -81,7 +78,7 @@ class LcnOutputsCover(LcnEntity, CoverEntity): _attr_is_opening = False _attr_assumed_state = True - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN cover.""" super().__init__(config, config_entry) @@ -188,7 +185,7 @@ class LcnRelayCover(LcnEntity, CoverEntity): positioning_mode: pypck.lcn_defs.MotorPositioningMode - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN cover.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py index a1940fc7ac3..f94251983b4 100644 --- a/homeassistant/components/lcn/entity.py +++ b/homeassistant/components/lcn/entity.py @@ -2,7 +2,6 @@ from collections.abc import Callable -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -13,6 +12,7 @@ from .helpers import ( AddressType, DeviceConnectionType, InputType, + LcnConfigEntry, generate_unique_id, get_device_connection, get_resource, @@ -29,7 +29,7 @@ class LcnEntity(Entity): def __init__( self, config: ConfigType, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Initialize the LCN device.""" self.config = config diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 1bc4c6caa41..515f64b6e31 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -3,11 +3,14 @@ from __future__ import annotations import asyncio +from collections.abc import Callable, Iterable from copy import deepcopy +from dataclasses import dataclass import re from typing import cast import pypck +from pypck.connection import PchkConnectionManager from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -33,12 +36,27 @@ from .const import ( CONF_HARDWARE_TYPE, CONF_SCENES, CONF_SOFTWARE_SERIAL, - CONNECTION, - DEVICE_CONNECTIONS, DOMAIN, ) + +@dataclass +class LcnRuntimeData: + """Data for LCN config entry.""" + + connection: PchkConnectionManager + """Connection to PCHK host.""" + + device_connections: dict[str, DeviceConnectionType] + """Logical addresses of devices connected to the host.""" + + add_entities_callbacks: dict[str, Callable[[Iterable[ConfigType]], None]] + """Callbacks to add entities for platforms.""" + + # typing +type LcnConfigEntry = ConfigEntry[LcnRuntimeData] + type AddressType = tuple[int, int, bool] type DeviceConnectionType = pypck.module.ModuleConnection | pypck.module.GroupConnection @@ -62,10 +80,10 @@ DOMAIN_LOOKUP = { def get_device_connection( - hass: HomeAssistant, address: AddressType, config_entry: ConfigEntry + hass: HomeAssistant, address: AddressType, config_entry: LcnConfigEntry ) -> DeviceConnectionType: """Return a lcn device_connection.""" - host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + host_connection = config_entry.runtime_data.connection addr = pypck.lcn_addr.LcnAddr(*address) return host_connection.get_address_conn(addr) @@ -165,7 +183,7 @@ def purge_device_registry( device_registry.async_remove_device(device_id) -def register_lcn_host_device(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +def register_lcn_host_device(hass: HomeAssistant, config_entry: LcnConfigEntry) -> None: """Register LCN host for given config_entry in device registry.""" device_registry = dr.async_get(hass) @@ -179,7 +197,7 @@ def register_lcn_host_device(hass: HomeAssistant, config_entry: ConfigEntry) -> def register_lcn_address_devices( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: """Register LCN modules and groups defined in config_entry as devices in device registry. @@ -217,9 +235,9 @@ def register_lcn_address_devices( model=device_model, ) - hass.data[DOMAIN][config_entry.entry_id][DEVICE_CONNECTIONS][ - device_entry.id - ] = get_device_connection(hass, address, config_entry) + config_entry.runtime_data.device_connections[device_entry.id] = ( + get_device_connection(hass, address, config_entry) + ) async def async_update_device_config( @@ -254,7 +272,7 @@ async def async_update_device_config( async def async_update_config_entry( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LcnConfigEntry ) -> None: """Fill missing values in config_entry with infos from LCN bus.""" device_configs = deepcopy(config_entry.data[CONF_DEVICES]) diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index cba7c0888b7..cd6b5c7057e 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -14,29 +14,26 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DIMMABLE, CONF_DOMAIN_DATA, CONF_OUTPUT, CONF_TRANSITION, - DOMAIN, OUTPUT_PORTS, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -53,7 +50,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN light entities from a config entry.""" @@ -63,7 +60,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_LIGHT: add_entities} ) @@ -83,7 +80,7 @@ class LcnOutputLight(LcnEntity, LightEntity): _attr_is_on = False _attr_brightness = 255 - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN light.""" super().__init__(config, config_entry) @@ -175,7 +172,7 @@ class LcnRelayLight(LcnEntity, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN light.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py index 072d0a20757..1d6839b5d91 100644 --- a/homeassistant/components/lcn/scene.py +++ b/homeassistant/components/lcn/scene.py @@ -7,28 +7,26 @@ from typing import Any import pypck from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_OUTPUTS, CONF_REGISTER, CONF_TRANSITION, - DOMAIN, OUTPUT_PORTS, ) from .entity import LcnEntity +from .helpers import LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -42,7 +40,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -52,7 +50,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_SCENE: add_entities} ) @@ -68,7 +66,7 @@ async def async_setup_entry( class LcnScene(LcnEntity, Scene): """Representation of a LCN scene.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN scene.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 0c78ea6637a..fd90c024383 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, CONF_DOMAIN, @@ -29,9 +28,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, - DOMAIN, LED_PORTS, S0_INPUTS, SETPOINTS, @@ -39,7 +36,7 @@ from .const import ( VARIABLES, ) from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE, @@ -67,7 +64,7 @@ UNIT_OF_MEASUREMENT_MAPPING = { def add_lcn_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -86,7 +83,7 @@ def add_lcn_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -96,7 +93,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_SENSOR: add_entities} ) @@ -112,7 +109,7 @@ async def async_setup_entry( class LcnVariableSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for variables.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN sensor.""" super().__init__(config, config_entry) @@ -157,7 +154,7 @@ class LcnVariableSensor(LcnEntity, SensorEntity): class LcnLedLogicSensor(LcnEntity, SensorEntity): """Representation of a LCN sensor for leds and logicops.""" - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN sensor.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 33550d9785d..15d60639a1c 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -36,7 +36,6 @@ from .const import ( CONF_TRANSITION, CONF_VALUE, CONF_VARIABLE, - DEVICE_CONNECTIONS, DOMAIN, LED_PORTS, LED_STATUS, @@ -49,7 +48,7 @@ from .const import ( VAR_UNITS, VARIABLES, ) -from .helpers import DeviceConnectionType, is_states_string +from .helpers import DeviceConnectionType, LcnConfigEntry, is_states_string class LcnServiceCall: @@ -68,18 +67,28 @@ class LcnServiceCall: def get_device_connection(self, service: ServiceCall) -> DeviceConnectionType: """Get address connection object.""" + entries: list[LcnConfigEntry] = self.hass.config_entries.async_loaded_entries( + DOMAIN + ) device_id = service.data[CONF_DEVICE_ID] device_registry = dr.async_get(self.hass) - if not (device := device_registry.async_get(device_id)): + if not (device := device_registry.async_get(device_id)) or not ( + entry := next( + ( + entry + for entry in entries + if entry.entry_id == device.primary_config_entry + ), + None, + ) + ): raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_device_id", translation_placeholders={"device_id": device_id}, ) - return self.hass.data[DOMAIN][device.primary_config_entry][DEVICE_CONNECTIONS][ - device_id - ] + return entry.runtime_data.device_connections[device_id] async def async_call_service(self, service: ServiceCall) -> ServiceResponse: """Execute service call.""" diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index 6267a081bc9..f0bb432fef9 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -7,29 +7,20 @@ from typing import Any import pypck from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from .const import ( - ADD_ENTITIES_CALLBACKS, - CONF_DOMAIN_DATA, - CONF_OUTPUT, - DOMAIN, - OUTPUT_PORTS, - RELAY_PORTS, - SETPOINTS, -) +from .const import CONF_DOMAIN_DATA, CONF_OUTPUT, OUTPUT_PORTS, RELAY_PORTS, SETPOINTS from .entity import LcnEntity -from .helpers import InputType +from .helpers import InputType, LcnConfigEntry PARALLEL_UPDATES = 0 def add_lcn_switch_entities( - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, entity_configs: Iterable[ConfigType], ) -> None: @@ -52,7 +43,7 @@ def add_lcn_switch_entities( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LCN switch entities from a config entry.""" @@ -62,7 +53,7 @@ async def async_setup_entry( async_add_entities, ) - hass.data[DOMAIN][config_entry.entry_id][ADD_ENTITIES_CALLBACKS].update( + config_entry.runtime_data.add_entities_callbacks.update( {DOMAIN_SWITCH: add_entities} ) @@ -80,7 +71,7 @@ class LcnOutputSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) @@ -129,7 +120,7 @@ class LcnRelaySwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) @@ -179,7 +170,7 @@ class LcnRegulatorLockSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) @@ -235,7 +226,7 @@ class LcnKeyLockSwitch(LcnEntity, SwitchEntity): _attr_is_on = False - def __init__(self, config: ConfigType, config_entry: ConfigEntry) -> None: + def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: """Initialize the LCN switch.""" super().__init__(config, config_entry) diff --git a/homeassistant/components/lcn/websocket.py b/homeassistant/components/lcn/websocket.py index 545ee1e0043..87399afc295 100644 --- a/homeassistant/components/lcn/websocket.py +++ b/homeassistant/components/lcn/websocket.py @@ -4,15 +4,17 @@ from __future__ import annotations from collections.abc import Awaitable, Callable from functools import wraps -from typing import TYPE_CHECKING, Any, Final +from typing import Any, Final import lcn_frontend as lcn_panel import voluptuous as vol from homeassistant.components import panel_custom, websocket_api from homeassistant.components.http import StaticPathConfig -from homeassistant.components.websocket_api import AsyncWebSocketCommandHandler -from homeassistant.config_entries import ConfigEntry +from homeassistant.components.websocket_api import ( + ActiveConnection, + AsyncWebSocketCommandHandler, +) from homeassistant.const import ( CONF_ADDRESS, CONF_DEVICES, @@ -28,16 +30,15 @@ from homeassistant.helpers import ( ) from .const import ( - ADD_ENTITIES_CALLBACKS, CONF_DOMAIN_DATA, CONF_HARDWARE_SERIAL, CONF_HARDWARE_TYPE, CONF_SOFTWARE_SERIAL, - CONNECTION, DOMAIN, ) from .helpers import ( DeviceConnectionType, + LcnConfigEntry, async_update_device_config, generate_unique_id, get_device_config, @@ -58,11 +59,8 @@ from .schemas import ( DOMAIN_DATA_SWITCH, ) -if TYPE_CHECKING: - from homeassistant.components.websocket_api import ActiveConnection - type AsyncLcnWebSocketCommandHandler = Callable[ - [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry], Awaitable[None] + [HomeAssistant, ActiveConnection, dict[str, Any], LcnConfigEntry], Awaitable[None] ] URL_BASE: Final = "/lcn_static" @@ -127,7 +125,7 @@ async def websocket_get_device_configs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Get device configs.""" connection.send_result(msg["id"], config_entry.data[CONF_DEVICES]) @@ -147,7 +145,7 @@ async def websocket_get_entity_configs( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Get entities configs.""" if CONF_ADDRESS in msg: @@ -178,10 +176,10 @@ async def websocket_scan_devices( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Scan for new devices.""" - host_connection = hass.data[DOMAIN][config_entry.entry_id][CONNECTION] + host_connection = config_entry.runtime_data.connection await host_connection.scan_modules() for device_connection in host_connection.address_conns.values(): @@ -210,7 +208,7 @@ async def websocket_add_device( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Add a device.""" if get_device_config(msg[CONF_ADDRESS], config_entry): @@ -256,7 +254,7 @@ async def websocket_delete_device( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Delete a device.""" device_config = get_device_config(msg[CONF_ADDRESS], config_entry) @@ -318,7 +316,7 @@ async def websocket_add_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Add an entity.""" if not (device_config := get_device_config(msg[CONF_ADDRESS], config_entry)): @@ -347,9 +345,7 @@ async def websocket_add_entity( } # Create new entity and add to corresponding component - add_entities = hass.data[DOMAIN][msg["entry_id"]][ADD_ENTITIES_CALLBACKS][ - msg[CONF_DOMAIN] - ] + add_entities = config_entry.runtime_data.add_entities_callbacks[msg[CONF_DOMAIN]] add_entities([entity_config]) # Add entity config to config_entry @@ -386,7 +382,7 @@ async def websocket_delete_entity( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Delete an entity.""" entity_config = next( @@ -426,7 +422,7 @@ async def websocket_delete_entity( async def async_create_or_update_device_in_config_entry( hass: HomeAssistant, device_connection: DeviceConnectionType, - config_entry: ConfigEntry, + config_entry: LcnConfigEntry, ) -> None: """Create or update device in config_entry according to given device_connection.""" address = ( @@ -455,7 +451,7 @@ async def async_create_or_update_device_in_config_entry( def get_entity_entry( - hass: HomeAssistant, entity_config: dict, config_entry: ConfigEntry + hass: HomeAssistant, entity_config: dict, config_entry: LcnConfigEntry ) -> er.RegistryEntry | None: """Get entity RegistryEntry from entity_config.""" entity_registry = er.async_get(hass) From 2a97b128c3f9695a9cbc8f3dfdc2c8d60a30be05 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 23 Jun 2025 13:42:33 +0200 Subject: [PATCH 0487/1664] Bump IMGW-PIB backend library to version 1.1.0 (#147341) --- homeassistant/components/imgw_pib/config_flow.py | 4 +++- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py index 805bfa2ccb3..78d77737a39 100644 --- a/homeassistant/components/imgw_pib/config_flow.py +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -45,7 +45,9 @@ class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN): try: imgwpib = await ImgwPib.create( - client_session, hydrological_station_id=station_id + client_session, + hydrological_station_id=station_id, + hydrological_details=False, ) hydrological_data = await imgwpib.get_hydrological_data() except (ClientError, TimeoutError, ApiError): diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index e2d6e2bf584..42d536da8f5 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.0.10"] + "requirements": ["imgw_pib==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c6e8b8060c9..b2e4d536b0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1231,7 +1231,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.0.10 +imgw_pib==1.1.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29addc25eff..35596bb12f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1062,7 +1062,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.0.10 +imgw_pib==1.1.0 # homeassistant.components.incomfort incomfort-client==0.6.9 From b2520394f483b922474fae06cc8325fb70ac606a Mon Sep 17 00:00:00 2001 From: rrooggiieerr Date: Mon, 23 Jun 2025 14:47:52 +0300 Subject: [PATCH 0488/1664] Lametric add configuration url (#147118) * Set cofiguration URL to LaMetric device web interface * Update LaMetric unit tests to accomodate fro configuration url --- homeassistant/components/lametric/entity.py | 1 + tests/components/lametric/test_button.py | 8 ++++---- tests/components/lametric/test_number.py | 4 ++-- tests/components/lametric/test_select.py | 2 +- tests/components/lametric/test_sensor.py | 2 +- tests/components/lametric/test_switch.py | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index eb331650870..4764974b5e0 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -31,4 +31,5 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]): name=coordinator.data.name, sw_version=coordinator.data.os_version, serial_number=coordinator.data.serial_number, + configuration_url=f"https://{coordinator.data.wifi.ip}/", ) diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index cc8c1379fe0..cf8d76ca5f3 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -42,7 +42,7 @@ async def test_button_app_next( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } @@ -89,7 +89,7 @@ async def test_button_app_previous( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } @@ -137,7 +137,7 @@ async def test_button_dismiss_current_notification( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } @@ -185,7 +185,7 @@ async def test_button_dismiss_all_notifications( assert entry.device_id device_entry = device_registry.async_get(entry.device_id) assert device_entry - assert device_entry.configuration_url is None + assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") } diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index 6e052603c24..f34cf04aed9 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -55,7 +55,7 @@ async def test_brightness( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None @@ -104,7 +104,7 @@ async def test_volume( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index e4b9870f52b..177092f061e 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -48,7 +48,7 @@ async def test_brightness_mode( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index 08b289e2425..a0719edfc9d 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -41,7 +41,7 @@ async def test_wifi_signal( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 3e73b710942..155a315881f 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -50,7 +50,7 @@ async def test_bluetooth( device = device_registry.async_get(entry.device_id) assert device - assert device.configuration_url is None + assert device.configuration_url == "https://127.0.0.1/" assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} assert device.entry_type is None assert device.hw_version is None From 756b85884072351f7dca09c478532879fa39b417 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 23 Jun 2025 14:10:50 +0200 Subject: [PATCH 0489/1664] Wallbox fix too many requests by API (#147197) --- homeassistant/components/wallbox/const.py | 2 +- .../components/wallbox/coordinator.py | 177 ++++++--- homeassistant/components/wallbox/lock.py | 4 +- homeassistant/components/wallbox/number.py | 4 +- homeassistant/components/wallbox/sensor.py | 6 - homeassistant/components/wallbox/strings.json | 3 + tests/components/wallbox/__init__.py | 21 + tests/components/wallbox/test_config_flow.py | 142 +++---- tests/components/wallbox/test_init.py | 125 ++++-- tests/components/wallbox/test_lock.py | 124 ++++-- tests/components/wallbox/test_number.py | 370 +++++++++++------- tests/components/wallbox/test_select.py | 37 +- tests/components/wallbox/test_switch.py | 131 +++---- 13 files changed, 692 insertions(+), 454 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 5aa659a0527..34d17e52275 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -3,7 +3,7 @@ from enum import StrEnum DOMAIN = "wallbox" -UPDATE_INTERVAL = 30 +UPDATE_INTERVAL = 60 BIDIRECTIONAL_MODEL_PREFIXES = ["QS"] diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 8276ee14eaf..598bfa7429a 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -90,7 +90,9 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error return require_authentication @@ -137,56 +139,65 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - # Only show max_icp_current if power_boost is available in the wallbox unit: - if ( - data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 - and CHARGER_POWER_BOOST_KEY - in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] - ): - data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_ICP_CURRENT_KEY + try: + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY ] + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + # Only show max_icp_current if power_boost is available in the wallbox unit: + if ( + data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 + and CHARGER_POWER_BOOST_KEY + in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] + ): + data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_ICP_CURRENT_KEY + ] - data[CHARGER_CURRENCY_KEY] = ( - f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - ) + data[CHARGER_CURRENCY_KEY] = ( + f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + ) - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) - # Set current solar charging mode - eco_smart_enabled = ( - data[CHARGER_DATA_KEY] - .get(CHARGER_ECO_SMART_KEY, {}) - .get(CHARGER_ECO_SMART_STATUS_KEY) - ) + # Set current solar charging mode + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) - eco_smart_mode = ( - data[CHARGER_DATA_KEY] - .get(CHARGER_ECO_SMART_KEY, {}) - .get(CHARGER_ECO_SMART_MODE_KEY) - ) - if eco_smart_mode is None: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED - elif eco_smart_enabled is False: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF - elif eco_smart_mode == 0: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE - elif eco_smart_mode == 1: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF + elif eco_smart_mode == 0: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE + elif eco_smart_mode == 1: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR - return data + return data # noqa: TRY300 + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" @@ -200,7 +211,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" @@ -217,7 +234,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" @@ -227,8 +250,16 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - - self._wallbox.setEnergyCost(self._station, energy_cost) + try: + self._wallbox.setEnergyCost(self._station, energy_cost) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" @@ -246,7 +277,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" @@ -256,11 +293,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" - - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) + try: + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" @@ -270,13 +315,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" - - if option == EcoSmartMode.ECO_MODE: - self._wallbox.enableEcoSmart(self._station, 0) - elif option == EcoSmartMode.FULL_SOLAR: - self._wallbox.enableEcoSmart(self._station, 1) - else: - self._wallbox.disableEcoSmart(self._station) + try: + if option == EcoSmartMode.ECO_MODE: + self._wallbox.enableEcoSmart(self._station, 0) + elif option == EcoSmartMode.FULL_SOLAR: + self._wallbox.enableEcoSmart(self._station, 1) + else: + self._wallbox.disableEcoSmart(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index ef35734ed7e..7acc56f67f2 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -41,7 +41,7 @@ async def async_setup_entry( ) except InvalidAuth: return - except ConnectionError as exc: + except HomeAssistantError as exc: raise PlatformNotReady from exc async_add_entities( diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index a5880f6e0f7..80773478582 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,7 @@ from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -93,7 +93,7 @@ async def async_setup_entry( ) except InvalidAuth: return - except ConnectionError as exc: + except HomeAssistantError as exc: raise PlatformNotReady from exc async_add_entities( diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 4b0ec8175e3..e19fc2b936a 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging from typing import cast from homeassistant.components.sensor import ( @@ -49,11 +48,6 @@ from .const import ( from .coordinator import WallboxCoordinator from .entity import WallboxEntity -CHARGER_STATION = "station" -UPDATE_INTERVAL = 30 - -_LOGGER = logging.getLogger(__name__) - @dataclass(frozen=True) class WallboxSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 68602a960c2..ee98a4855e3 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -112,6 +112,9 @@ "exceptions": { "api_failed": { "message": "Error communicating with Wallbox API" + }, + "too_many_requests": { + "message": "Error communicating with Wallbox API, too many requests" } } } diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 83e39d2f602..37e7d5059f0 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -162,6 +162,9 @@ test_response_no_power_boost = { http_404_error = requests.exceptions.HTTPError() http_404_error.response = requests.Response() http_404_error.response.status_code = HTTPStatus.NOT_FOUND +http_429_error = requests.exceptions.HTTPError() +http_429_error.response = requests.Response() +http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS authorisation_response = { "data": { @@ -192,6 +195,24 @@ authorisation_response_unauthorised = { } } +invalid_reauth_response = { + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, +} + +http_403_error = requests.exceptions.HTTPError() +http_403_error.response = requests.Response() +http_403_error.response.status_code = HTTPStatus.FORBIDDEN + +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND + async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox sensor class setup.""" diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index 467e20c51c1..bdfb4cad18d 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -1,9 +1,6 @@ """Test the Wallbox config flow.""" -from http import HTTPStatus -import json - -import requests_mock +from unittest.mock import Mock, patch from homeassistant import config_entries from homeassistant.components.wallbox import config_flow @@ -24,23 +21,21 @@ from homeassistant.data_entry_flow import FlowResultType from . import ( authorisation_response, authorisation_response_unauthorised, + http_403_error, + http_404_error, setup_integration, ) from tests.common import MockConfigEntry -test_response = json.loads( - json.dumps( - { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_MAX_AVAILABLE_POWER_KEY: "xx", - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: "xx", - CHARGER_ADDED_ENERGY_KEY: "44.697", - CHARGER_DATA_KEY: {CHARGER_MAX_CHARGING_CURRENT_KEY: 24}, - } - ) -) +test_response = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_MAX_AVAILABLE_POWER_KEY: "xx", + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: "xx", + CHARGER_ADDED_ENERGY_KEY: "44.697", + CHARGER_DATA_KEY: {CHARGER_MAX_CHARGING_CURRENT_KEY: 24}, +} async def test_show_set_form(hass: HomeAssistant) -> None: @@ -59,17 +54,16 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -89,17 +83,16 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response_unauthorised, - status_code=HTTPStatus.NOT_FOUND, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.NOT_FOUND, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_404_error), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -119,17 +112,16 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -148,18 +140,16 @@ async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response_unauthorised), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result = await entry.start_reauth_flow(hass) result2 = await hass.config_entries.flow.async_configure( @@ -183,26 +173,16 @@ async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json={ - "jwt": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - "user_id": 12345, - "ttl": 145656758, - "refresh_token_ttl": 145756758, - "error": False, - "status": 200, - }, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response_unauthorised), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result = await entry.start_reauth_flow(hass) result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 6d6a5cd1417..5048385aaf6 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,16 +1,15 @@ """Test Wallbox Init Component.""" -import requests_mock +from unittest.mock import Mock, patch -from homeassistant.components.wallbox.const import ( - CHARGER_MAX_CHARGING_CURRENT_KEY, - DOMAIN, -) +from homeassistant.components.wallbox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import ( authorisation_response, + http_403_error, + http_429_error, setup_integration, setup_integration_connection_error, setup_integration_no_eco_mode, @@ -53,18 +52,16 @@ async def test_wallbox_refresh_failed_connection_error_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=404, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(return_value=test_response), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -81,18 +78,68 @@ async def test_wallbox_refresh_failed_invalid_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=403, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=403, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): + wallbox = hass.data[DOMAIN][entry.entry_id] + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_refresh_failed_http_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with authentication error.""" + + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(side_effect=http_403_error), + ), + ): + wallbox = hass.data[DOMAIN][entry.entry_id] + + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_refresh_failed_too_many_requests( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with authentication error.""" + + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(side_effect=http_429_error), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -109,18 +156,16 @@ async def test_wallbox_refresh_failed_connection_error( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=403, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 1d48e53b515..5842d708f11 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -1,15 +1,18 @@ """Test Wallbox Lock component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_429_error, setup_integration, setup_integration_platform_not_ready, setup_integration_read_only, @@ -28,18 +31,20 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - assert state assert state.state == "unlocked" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_LOCKED_UNLOCKED_KEY: False}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + ), + ): await hass.services.async_call( "lock", SERVICE_LOCK, @@ -66,36 +71,73 @@ async def test_wallbox_lock_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_LOCKED_UNLOCKED_KEY: False}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=ConnectionError), + ), + pytest.raises(ConnectionError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "lock", - SERVICE_LOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "lock", - SERVICE_UNLOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=ConnectionError), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=ConnectionError), + ), + pytest.raises(ConnectionError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) async def test_wallbox_lock_class_authentication_error( diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c319668c161..c603ae24106 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -1,22 +1,26 @@ """Test Wallbox Switch component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox import InvalidAuth from homeassistant.components.wallbox.const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_MAX_ICP_CURRENT_KEY, ) +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_403_error, + http_404_error, + http_429_error, setup_integration, setup_integration_bidir, setup_integration_platform_not_ready, @@ -29,6 +33,14 @@ from .const import ( from tests.common import MockConfigEntry +mock_wallbox = Mock() +mock_wallbox.authenticate = Mock(return_value=authorisation_response) +mock_wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}) +mock_wallbox.setMaxChargingCurrent = Mock( + return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20} +) +mock_wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}) + async def test_wallbox_number_class( hass: HomeAssistant, entry: MockConfigEntry @@ -37,17 +49,16 @@ async def test_wallbox_number_class( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=200, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + ), + ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) assert state.attributes["min"] == 6 assert state.attributes["max"] == 25 @@ -82,19 +93,16 @@ async def test_wallbox_number_energy_class( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}), + ), + ): await hass.services.async_call( "number", SERVICE_SET_VALUE, @@ -113,59 +121,113 @@ async def test_wallbox_number_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, - ATTR_VALUE: 20, - }, - blocking=True, - ) - -async def test_wallbox_number_class_energy_price_connection_error( +async def test_wallbox_number_class_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) + +async def test_wallbox_number_class_energy_price_update_failed( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + +async def test_wallbox_number_class_energy_price_update_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) async def test_wallbox_number_class_energy_price_auth_error( @@ -175,28 +237,26 @@ async def test_wallbox_number_class_energy_price_auth_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=403, - ) - - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) async def test_wallbox_number_class_platform_not_ready( @@ -218,19 +278,16 @@ async def test_wallbox_number_class_icp_energy( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + ), + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -249,28 +306,26 @@ async def test_wallbox_number_class_icp_energy_auth_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=403, - ) - - with pytest.raises(InvalidAuth): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) async def test_wallbox_number_class_icp_energy_connection_error( @@ -280,25 +335,52 @@ async def test_wallbox_number_class_icp_energy_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) + +async def test_wallbox_number_class_icp_energy_too_many_request( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index 516b1e87c27..f59a8367b41 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant, HomeAssistantError from . import ( authorisation_response, http_404_error, + http_429_error, setup_integration_select, test_response, test_response_eco_mode, @@ -109,7 +110,41 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), - pytest.raises(HomeAssistantError, match="Error communicating with Wallbox API"), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_too_many_requests_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + mock_authenticate, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration_select(hass, entry, response) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( SELECT_DOMAIN, diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index b7c3a81dc73..eb983ca44ce 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -1,15 +1,16 @@ """Test Wallbox Lock component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, setup_integration +from . import authorisation_response, http_404_error, http_429_error, setup_integration from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -26,18 +27,20 @@ async def test_wallbox_switch_class( assert state assert state.state == "on" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + ): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -64,72 +67,52 @@ async def test_wallbox_switch_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + # Test behavior when a connection error occurs + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - -async def test_wallbox_switch_class_authentication_error( +async def test_wallbox_switch_class_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox switch class connection error.""" await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + # Test behavior when a connection error occurs + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=403, - ) - - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) From 2e155831e65e35d6c1b75483ce329ee38d9032db Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 23 Jun 2025 15:38:16 +0300 Subject: [PATCH 0490/1664] Fix reload for Shelly devices with no script support (#147344) --- homeassistant/components/shelly/coordinator.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index cba559a9773..fa434588b34 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -835,6 +835,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): except InvalidAuthError: self.config_entry.async_start_reauth(self.hass) return + except RpcCallError as err: + # Ignore 404 (No handler for) error + if err.code != 404: + LOGGER.debug( + "Error during shutdown for device %s: %s", + self.name, + err.message, + ) + return except DeviceConnectionError as err: # If the device is restarting or has gone offline before # the ping/pong timeout happens, the shutdown command From 2ba9cb1510379ea1771f1f0599e23dfdd9b1cc01 Mon Sep 17 00:00:00 2001 From: Brian Rogers Date: Mon, 23 Jun 2025 04:31:35 -0400 Subject: [PATCH 0491/1664] Remove address info from Rachio calendar events (#145896) Co-authored-by: J. Nick Koston --- homeassistant/components/rachio/calendar.py | 5 ----- homeassistant/components/rachio/const.py | 2 -- 2 files changed, 7 deletions(-) diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py index 18b1b6a4d8f..a8b593e1138 100644 --- a/homeassistant/components/rachio/calendar.py +++ b/homeassistant/components/rachio/calendar.py @@ -16,10 +16,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( - KEY_ADDRESS, KEY_DURATION_SECONDS, KEY_ID, - KEY_LOCALITY, KEY_PROGRAM_ID, KEY_PROGRAM_NAME, KEY_RUN_SUMMARIES, @@ -65,7 +63,6 @@ class RachioCalendarEntity( super().__init__(coordinator) self.base_station = base_station self._event: CalendarEvent | None = None - self._location = coordinator.base_station[KEY_ADDRESS][KEY_LOCALITY] self._attr_translation_placeholders = { "base": coordinator.base_station[KEY_SERIAL_NUMBER] } @@ -87,7 +84,6 @@ class RachioCalendarEntity( end=dt_util.as_local(start_time) + timedelta(seconds=int(event[KEY_TOTAL_RUN_DURATION])), description=valves, - location=self._location, ) def _handle_upcoming_event(self) -> dict[str, Any] | None: @@ -155,7 +151,6 @@ class RachioCalendarEntity( start=event_start, end=event_end, description=valves, - location=self._location, uid=f"{run[KEY_PROGRAM_ID]}/{run[KEY_START_TIME]}", ) event_list.append(event) diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 08a09f309f6..64b26526f57 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -75,8 +75,6 @@ KEY_PROGRAM_ID = "programId" KEY_PROGRAM_NAME = "programName" KEY_PROGRAM_RUN_SUMMARIES = "valveProgramRunSummaries" KEY_TOTAL_RUN_DURATION = "totalRunDurationSeconds" -KEY_ADDRESS = "address" -KEY_LOCALITY = "locality" KEY_SKIP = "skip" KEY_SKIPPABLE = "skippable" From 9ed6f226c64cad30feb0ede0fa229f4d188d58ea Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 8 Jun 2025 17:57:50 +0200 Subject: [PATCH 0492/1664] Bump uiprotect to 7.12.0 (#146337) --- 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 64bb278a8e2..713fe2f5248 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.11.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.12.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index d0e8b10ce42..fd50d40f5bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.11.0 +uiprotect==7.12.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 861c8097470..f34958f8775 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.11.0 +uiprotect==7.12.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 83f26f7393a7f3a798d4f98a10a3f478ae361046 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Jun 2025 02:26:54 +0200 Subject: [PATCH 0493/1664] Bump uiprotect to 7.13.0 (#146410) --- 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 713fe2f5248..3c32935a995 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.12.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.13.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index fd50d40f5bf..2e24bc14133 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.12.0 +uiprotect==7.13.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f34958f8775..8f2236b00a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.12.0 +uiprotect==7.13.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 0a8d1171293cdddd9c9bae54dccee1a86b97de4d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 11 Jun 2025 23:52:31 +0200 Subject: [PATCH 0494/1664] Bump reolink-aio to 0.14.0 (#146566) --- homeassistant/components/reolink/entity.py | 2 +- homeassistant/components/reolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/reolink/conftest.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index d7e8817b1b7..0d91670fc84 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -82,7 +82,7 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] connections={(CONNECTION_NETWORK_MAC, self._host.api.mac_address)}, name=self._host.api.nvr_name, model=self._host.api.model, - model_id=self._host.api.item_number, + model_id=self._host.api.item_number(), manufacturer=self._host.api.manufacturer, hw_version=self._host.api.hardware_version, sw_version=self._host.api.sw_version, diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 5ae8b0305e4..917ef9e73f7 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.13.5"] + "requirements": ["reolink-aio==0.14.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2e24bc14133..5c84100f3e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2652,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.5 +reolink-aio==0.14.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f2236b00a5..08182911a07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.13.5 +reolink-aio==0.14.0 # homeassistant.components.rflink rflink==0.0.66 diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index a2155ba00eb..1d8244a890a 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -99,7 +99,7 @@ def reolink_connect_class() -> Generator[MagicMock]: host_mock.sw_upload_progress.return_value = 100 host_mock.manufacturer = "Reolink" host_mock.model = TEST_HOST_MODEL - host_mock.item_number = TEST_ITEM_NUMBER + host_mock.item_number.return_value = TEST_ITEM_NUMBER host_mock.camera_model.return_value = TEST_CAM_MODEL host_mock.camera_name.return_value = TEST_NVR_NAME host_mock.camera_hardware_version.return_value = "IPC_00001" From 04b3227b9b880fc8cee4e7c16a3b2194708113e9 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Thu, 12 Jun 2025 18:36:50 +0200 Subject: [PATCH 0495/1664] Bump pypck to 0.8.7 (#146657) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index be5d6299f09..9575c01515b 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.6", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.7", "lcn-frontend==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c84100f3e0..5220f415fa6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2236,7 +2236,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.6 +pypck==0.8.7 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08182911a07..f1cda7f651d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1857,7 +1857,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.6 +pypck==0.8.7 # homeassistant.components.pglab pypglab==0.0.5 From b249ae408fcf313369bdcb12f59b4335554b119b Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Jun 2025 11:49:16 -0500 Subject: [PATCH 0496/1664] Update rokuecp to 0.19.5 (#146788) --- homeassistant/components/roku/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 7fe2fb3b686..d5e2e2e5224 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -10,7 +10,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["rokuecp"], - "requirements": ["rokuecp==0.19.3"], + "requirements": ["rokuecp==0.19.5"], "ssdp": [ { "st": "roku:ecp", diff --git a/requirements_all.txt b/requirements_all.txt index 5220f415fa6..15204957762 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2673,7 +2673,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1cda7f651d..6415238dcbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2204,7 +2204,7 @@ rflink==0.0.66 ring-doorbell==0.9.13 # homeassistant.components.roku -rokuecp==0.19.3 +rokuecp==0.19.5 # homeassistant.components.romy romy==0.0.10 From 01a133a2b821bcb91cd75318df66000f31387dca Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sat, 14 Jun 2025 19:53:51 +0200 Subject: [PATCH 0497/1664] Use Shelly main device area as suggested area for sub-devices (#146810) --- homeassistant/components/shelly/button.py | 8 +++++-- homeassistant/components/shelly/climate.py | 5 +++- .../components/shelly/coordinator.py | 12 +++++++++- homeassistant/components/shelly/entity.py | 24 +++++++++++++++---- homeassistant/components/shelly/event.py | 5 +++- homeassistant/components/shelly/sensor.py | 6 ++++- homeassistant/components/shelly/utils.py | 9 ++++++- 7 files changed, 57 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py index eab7514514d..ad03a373dba 100644 --- a/homeassistant/components/shelly/button.py +++ b/homeassistant/components/shelly/button.py @@ -235,11 +235,15 @@ 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.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) else: self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index 26fabe7e8b5..abc387f3efd 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -211,7 +211,10 @@ 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, sensor_block + coordinator.device, + coordinator.mac, + sensor_block, + suggested_area=coordinator.suggested_area, ) self._attr_name = get_block_entity_name( self.coordinator.device, sensor_block, None diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index f980ba8f914..cba559a9773 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -31,7 +31,11 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + issue_registry as ir, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -114,6 +118,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( self.device = device self.device_id: str | None = None self._pending_platforms: list[Platform] | None = None + self.suggested_area: str | None = None device_name = device.name if device.initialized else entry.title interval_td = timedelta(seconds=update_interval) # The device has come online at least once. In the case of a sleeping RPC @@ -176,6 +181,11 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( 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)}", ) + # 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: + area_registry = ar.async_get(self.hass) + if (area := area_registry.async_get_area(area_id)) is not None: + self.suggested_area = area.name self.device_id = device_entry.id async def shutdown(self) -> None: diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1b0078890af..2c1678d56d9 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -362,7 +362,10 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): 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, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" @@ -405,7 +408,10 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): super().__init__(coordinator) self.key = key self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) @@ -521,7 +527,9 @@ class ShellyRestAttributeEntity(CoordinatorEntity[ShellyBlockCoordinator]): ) self._attr_unique_id = f"{coordinator.mac}-{attribute}" self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac + coordinator.device, + coordinator.mac, + suggested_area=coordinator.suggested_area, ) self._last_value = None @@ -630,7 +638,10 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.entity_description = description self._attr_device_info = get_block_device_info( - coordinator.device, coordinator.mac, block + coordinator.device, + coordinator.mac, + block, + suggested_area=coordinator.suggested_area, ) if block is not None: @@ -698,7 +709,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.entity_description = description self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = self._attr_unique_id = ( f"{coordinator.mac}-{key}-{attribute}" diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 677ea1f6138..2eb9ff00964 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -207,7 +207,10 @@ class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): super().__init__(coordinator) self.event_id = int(key.split(":")[-1]) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key + coordinator.device, + coordinator.mac, + key, + suggested_area=coordinator.suggested_area, ) self._attr_unique_id = f"{coordinator.mac}-{key}" self._attr_name = get_rpc_entity_name(coordinator.device, key) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 0ea246c7734..3a6f5f221c5 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -139,7 +139,11 @@ class RpcEmeterPhaseSensor(RpcSensor): super().__init__(coordinator, key, attribute, description) self._attr_device_info = get_rpc_device_info( - coordinator.device, coordinator.mac, key, description.emeter_phase + coordinator.device, + coordinator.mac, + 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 cc0f2cf75d5..953fcbace06 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -751,6 +751,7 @@ def get_rpc_device_info( mac: str, key: str | None = None, emeter_phase: str | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for RPC device.""" if key is None: @@ -770,6 +771,7 @@ 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", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -784,6 +786,7 @@ def get_rpc_device_info( identifiers={(DOMAIN, f"{mac}-{key}")}, name=get_rpc_sub_device_name(device, key), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) @@ -805,7 +808,10 @@ def get_blu_trv_device_info( def get_block_device_info( - device: BlockDevice, mac: str, block: Block | None = None + device: BlockDevice, + mac: str, + block: Block | None = None, + suggested_area: str | None = None, ) -> DeviceInfo: """Return device info for Block device.""" if ( @@ -820,6 +826,7 @@ def get_block_device_info( identifiers={(DOMAIN, f"{mac}-{block.description}")}, name=get_block_sub_device_name(device, block), manufacturer="Shelly", + suggested_area=suggested_area, via_device=(DOMAIN, mac), ) From d684360ebd1fc148823d5985f2b9485508055746 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:46:11 +0200 Subject: [PATCH 0498/1664] Fix blocking open in Minecraft Server (#146820) Fix blocking open by dnspython --- .../components/minecraft_server/__init__.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index d8f60380a6c..e74b78446e5 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import logging from typing import Any +import dns.asyncresolver import dns.rdata import dns.rdataclass import dns.rdatatype @@ -22,20 +23,23 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def load_dnspython_rdata_classes() -> None: - """Load dnspython rdata classes used by mcstatus.""" +def prevent_dnspython_blocking_operations() -> None: + """Prevent dnspython blocking operations by pre-loading required data.""" + + # Blocking import: https://github.com/rthalley/dnspython/issues/1083 for rdtype in dns.rdatatype.RdataType: if not dns.rdatatype.is_metatype(rdtype) or rdtype == dns.rdatatype.OPT: dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call] + # Blocking open: https://github.com/rthalley/dnspython/issues/1200 + dns.asyncresolver.get_default_resolver() + async def async_setup_entry( hass: HomeAssistant, entry: MinecraftServerConfigEntry ) -> bool: """Set up Minecraft Server from a config entry.""" - - # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) - await hass.async_add_executor_job(load_dnspython_rdata_classes) + await hass.async_add_executor_job(prevent_dnspython_blocking_operations) # Create coordinator instance and store it. coordinator = MinecraftServerCoordinator(hass, entry) From a6e6b6db5adbcdd1ab009bc5e1907f1c651341c4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 15 Jun 2025 01:45:28 +0300 Subject: [PATCH 0499/1664] Bump aioamazondevices to 3.1.3 (#146828) --- 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 96f17d541fc..0ea4c32a75e 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": "bronze", - "requirements": ["aioamazondevices==3.1.2"] + "requirements": ["aioamazondevices==3.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 15204957762..253b7959c46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.2 +aioamazondevices==3.1.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6415238dcbd..190f257486b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.2 +aioamazondevices==3.1.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 87ecf552dce70094abc61bd1997bf40d826dfbc8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Jun 2025 14:44:58 +0200 Subject: [PATCH 0500/1664] Add unique ID support to Trend integration YAML configuration (#147346) --- .../components/trend/binary_sensor.py | 3 ++ tests/components/trend/test_binary_sensor.py | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 2bc5949b970..30058bb056c 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_SENSORS, + CONF_UNIQUE_ID, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -89,6 +90,7 @@ SENSOR_SCHEMA = vol.All( vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float), vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int, vol.Optional(CONF_MIN_SAMPLES, default=2): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), _validate_min_max, @@ -121,6 +123,7 @@ async def async_setup_platform( min_samples=sensor_config[CONF_MIN_SAMPLES], max_samples=sensor_config[CONF_MAX_SAMPLES], device_class=sensor_config.get(CONF_DEVICE_CLASS), + unique_id=sensor_config.get(CONF_UNIQUE_ID), sensor_entity_id=generate_entity_id( ENTITY_ID_FORMAT, sensor_name, hass=hass ), diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index 4f19c7e3427..289d7510fbe 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -48,6 +48,7 @@ async def _setup_legacy_component(hass: HomeAssistant, params: dict[str, Any]) - ) async def test_basic_trend_setup_from_yaml( hass: HomeAssistant, + entity_registry: er.EntityRegistry, states: list[str], inverted: bool, expected_state: str, @@ -72,6 +73,43 @@ async def test_basic_trend_setup_from_yaml( assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) assert sensor_state.state == expected_state + # Verify that entity without unique_id in YAML is not in the registry + entity_entry = entity_registry.async_get("binary_sensor.test_trend_sensor") + assert entity_entry is None + + +async def test_trend_setup_from_yaml_with_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + """Test trend setup from YAML with unique_id.""" + await _setup_legacy_component( + hass, + { + "friendly_name": "Test state with ID", + "entity_id": "sensor.cpu_temp", + "unique_id": "my_unique_trend_sensor", + "max_samples": 2.0, + "min_gradient": 0.0, + "sample_duration": 0.0, + }, + ) + + # Set some states to ensure the sensor works + hass.states.async_set("sensor.cpu_temp", "1") + await hass.async_block_till_done() + hass.states.async_set("sensor.cpu_temp", "2") + await hass.async_block_till_done() + + # Check that the sensor exists and has the correct state + assert (sensor_state := hass.states.get("binary_sensor.test_trend_sensor")) + assert sensor_state.state == STATE_ON + + # Check that the entity is registered with the correct unique_id + entity_entry = entity_registry.async_get("binary_sensor.test_trend_sensor") + assert entity_entry is not None + assert entity_entry.unique_id == "my_unique_trend_sensor" + @pytest.mark.parametrize( ("states", "inverted", "expected_state"), From 8e685b16264f325e9bbe11e4d1fd00bc1c457e50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Jun 2025 11:13:20 -0500 Subject: [PATCH 0501/1664] Bump aiohttp to 3.12.13 (#146830) changelog: https://github.com/aio-libs/aiohttp/compare/v3.12.12...v3.12.13 Likely does not affect us at all but just in case, tagging --- 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 ac33b8fe9f1..706218022b3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,7 +7,7 @@ aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.7.0 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 067b310f371..226420c7605 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.12", + "aiohttp==3.12.13", "aiohttp_cors==0.7.0", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index 333198a5346..70f214bbfc5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiofiles==24.1.0 aiohasupervisor==0.3.1 -aiohttp==3.12.12 +aiohttp==3.12.13 aiohttp_cors==0.7.0 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From 05831493e217362ef8edc8f80fa37f2f1e3549bf Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 14 Jun 2025 19:01:41 +0200 Subject: [PATCH 0502/1664] Bump motion blinds to 0.6.28 (#146831) --- homeassistant/components/motion_blinds/cover.py | 1 + homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 165c4c19675..9cff2956a5f 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -62,6 +62,7 @@ TILT_DEVICE_MAP = { BlindType.VerticalBlind: CoverDeviceClass.BLIND, BlindType.VerticalBlindLeft: CoverDeviceClass.BLIND, BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, + BlindType.RollerTiltMotor: CoverDeviceClass.BLIND, } TILT_ONLY_DEVICE_MAP = { diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1a6c9c5f82f..a82da20396f 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.27"] + "requirements": ["motionblinds==0.6.28"] } diff --git a/requirements_all.txt b/requirements_all.txt index 253b7959c46..b8fc088a725 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1448,7 +1448,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 190f257486b..17f45b98e10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1237,7 +1237,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.27 +motionblinds==0.6.28 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From a7b2f800f857ab8f276d191e187f928d2edc2c05 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 15 Jun 2025 19:32:13 +0200 Subject: [PATCH 0503/1664] Bump pypck to 0.8.8 (#146841) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 9575c01515b..30584bc33f6 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.8.7", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.8", "lcn-frontend==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index b8fc088a725..943708399a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2236,7 +2236,7 @@ pypaperless==4.1.0 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.7 +pypck==0.8.8 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17f45b98e10..34dc54576bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1857,7 +1857,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.0 # homeassistant.components.lcn -pypck==0.8.7 +pypck==0.8.8 # homeassistant.components.pglab pypglab==0.0.5 From da9775615705eed2b5a12bc9f9e25b35fbc7e965 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 16 Jun 2025 15:15:17 +0200 Subject: [PATCH 0504/1664] Fix missing key for ecosmart in older Wallbox models (#146847) * fix 146839, missing key * added tests for this issue * added tests for this issue * added tests for this issue, formatting * Prevent loading select on missing key * Prevent loading select on missing key - formatting fixed * Update homeassistant/components/wallbox/coordinator.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/wallbox/const.py | 1 + .../components/wallbox/coordinator.py | 21 ++++++++++------ homeassistant/components/wallbox/select.py | 16 ++++++------ tests/components/wallbox/__init__.py | 25 +++++++++++++++++++ tests/components/wallbox/test_init.py | 13 ++++++++++ 5 files changed, 61 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index d978e1ec7c9..5aa659a0527 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -74,3 +74,4 @@ class EcoSmartMode(StrEnum): OFF = "off" ECO_MODE = "eco_mode" FULL_SOLAR = "full_solar" + DISABLED = "disabled" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 60f062e57cc..8276ee14eaf 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -166,13 +166,20 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) # Set current solar charging mode - eco_smart_enabled = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_STATUS_KEY - ] - eco_smart_mode = data[CHARGER_DATA_KEY][CHARGER_ECO_SMART_KEY][ - CHARGER_ECO_SMART_MODE_KEY - ] - if eco_smart_enabled is False: + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) + + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF elif eco_smart_mode == 0: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 7ad7a135bc8..0048aa35c7c 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -63,15 +63,15 @@ async def async_setup_entry( ) -> None: """Create wallbox select entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities( - WallboxSelect(coordinator, description) - for ent in coordinator.data - if ( - (description := SELECT_TYPES.get(ent)) - and description.supported_fn(coordinator) + if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED: + async_add_entities( + WallboxSelect(coordinator, description) + for ent in coordinator.data + if ( + (description := SELECT_TYPES.get(ent)) + and description.supported_fn(coordinator) + ) ) - ) class WallboxSelect(WallboxEntity, SelectEntity): diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index d347777f7e8..83e39d2f602 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -216,6 +216,31 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response_no_power_boost, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_select( hass: HomeAssistant, entry: MockConfigEntry, response ) -> None: diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index b4b5a199243..6d6a5cd1417 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -13,6 +13,7 @@ from . import ( authorisation_response, setup_integration, setup_integration_connection_error, + setup_integration_no_eco_mode, setup_integration_read_only, test_response, ) @@ -138,3 +139,15 @@ async def test_wallbox_refresh_failed_read_only( assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_setup_load_entry_no_eco_mode( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox Unload.""" + + await setup_integration_no_eco_mode(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED From d66dee54112c84e8f9d4918bef5ce2f6e940a4b0 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Mon, 16 Jun 2025 10:29:00 +0200 Subject: [PATCH 0505/1664] Bump bthome-ble to 3.13.1 (#146871) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 4130606ff5c..0bbdfae50e4 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.12.4"] + "requirements": ["bthome-ble==3.13.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 943708399a5..e5a1e59a501 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,7 +683,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34dc54576bb..431fedfe1ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -607,7 +607,7 @@ brottsplatskartan==1.0.5 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.12.4 +bthome-ble==3.13.1 # homeassistant.components.buienradar buienradar==1.0.6 From 9b744e2fef13f511abc24f186603a66b427157cb Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 15 Jun 2025 20:53:32 +0200 Subject: [PATCH 0506/1664] Bump reolink-aio to 0.14.1 (#146903) --- 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 917ef9e73f7..04996689bf7 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.0"] + "requirements": ["reolink-aio==0.14.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5a1e59a501..7bf44aa7d91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2652,7 +2652,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.0 +reolink-aio==0.14.1 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 431fedfe1ea..3107b7a898a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2195,7 +2195,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.0 +reolink-aio==0.14.1 # homeassistant.components.rflink rflink==0.0.66 From d0060a2b213f1622cd4a7defbbb549c8ebf024a4 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 16 Jun 2025 14:17:36 +0200 Subject: [PATCH 0507/1664] Add debug log for update in onedrive (#146907) --- homeassistant/components/onedrive/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index 3eb7d762712..ff05b19f84d 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -66,6 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): translation_domain=DOMAIN, translation_key="authentication_failed" ) from err except OneDriveException as err: + _LOGGER.debug("Failed to fetch drive data: %s") raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err From 94a2642ce9579017102b81e5d6876d40446a574c Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Sun, 22 Jun 2025 21:12:09 +0900 Subject: [PATCH 0508/1664] Switchbot Cloud: Fix device type filtering in sensor (#146945) * Add Smart Lock Ultra support and fix device type filtering in sensor integration * Adding fix in binary sensor * Fix --------- Co-authored-by: Joostlek --- .../switchbot_cloud/binary_sensor.py | 1 + .../components/switchbot_cloud/sensor.py | 1 + .../switchbot_cloud/test_binary_sensor.py | 39 +++++++++++++++++++ .../components/switchbot_cloud/test_sensor.py | 26 +++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 tests/components/switchbot_cloud/test_binary_sensor.py diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 14278072c83..752c428fa6c 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -69,6 +69,7 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type ] + if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 9975bd49186..9920717a8d7 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -151,6 +151,7 @@ async def async_setup_entry( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] + if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/tests/components/switchbot_cloud/test_binary_sensor.py b/tests/components/switchbot_cloud/test_binary_sensor.py new file mode 100644 index 00000000000..753653af9a8 --- /dev/null +++ b/tests/components/switchbot_cloud/test_binary_sensor.py @@ -0,0 +1,39 @@ +"""Test for the switchbot_cloud binary sensors.""" + +from unittest.mock import patch + +from switchbot_api import Device + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import configure_integration + + +async def test_unsupported_device_type( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_list_devices, + mock_get_status, +) -> None: + """Test that unsupported device types do not create sensors.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="unsupported-id-1", + deviceName="unsupported-device", + deviceType="UnsupportedDevice", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = {} + + with patch( + "homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.BINARY_SENSOR] + ): + entry = await configure_integration(hass) + + # Assert no binary sensor entities were created for unsupported device type + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len([e for e in entities if e.domain == "binary_sensor"]) == 0 diff --git a/tests/components/switchbot_cloud/test_sensor.py b/tests/components/switchbot_cloud/test_sensor.py index 0927e3cf1ea..ccad2f1ad6c 100644 --- a/tests/components/switchbot_cloud/test_sensor.py +++ b/tests/components/switchbot_cloud/test_sensor.py @@ -65,3 +65,29 @@ async def test_meter_no_coordinator_data( entry = await configure_integration(hass) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +async def test_unsupported_device_type( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_list_devices, + mock_get_status, +) -> None: + """Test that unsupported device types do not create sensors.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="unsupported-id-1", + deviceName="unsupported-device", + deviceType="UnsupportedDevice", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.return_value = {} + + with patch("homeassistant.components.switchbot_cloud.PLATFORMS", [Platform.SENSOR]): + entry = await configure_integration(hass) + + # Assert no sensor entities were created for unsupported device type + entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert len([e for e in entities if e.domain == "sensor"]) == 0 From d2d5b29e2bbfd1203d50e9646b1384844c65ceab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 16 Jun 2025 23:44:14 +0200 Subject: [PATCH 0509/1664] Bump pySmartThings to 3.2.5 (#146983) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 481048c3bdb..7c3fc47e512 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.4"] + "requirements": ["pysmartthings==3.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7bf44aa7d91..fbc8c403f9c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2341,7 +2341,7 @@ pysmappee==0.2.29 pysmarlaapi==0.8.2 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3107b7a898a..5659daa20ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1941,7 +1941,7 @@ pysmappee==0.2.29 pysmarlaapi==0.8.2 # homeassistant.components.smartthings -pysmartthings==3.2.4 +pysmartthings==3.2.5 # homeassistant.components.smarty pysmarty2==0.10.2 From 8f13520a1ce9c734533a3e77fe1432ee3172a9f5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 17 Jun 2025 07:06:51 -0700 Subject: [PATCH 0510/1664] Bump ical to 10.0.4 (#147005) * Bump ical to 10.0.4 * Bump ical to 10.0.4 in google --- homeassistant/components/google/manifest.json | 2 +- homeassistant/components/local_calendar/manifest.json | 2 +- homeassistant/components/local_todo/manifest.json | 2 +- homeassistant/components/remote_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index fecd245869a..1acfa3a2ad1 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.0"] + "requirements": ["gcal-sync==7.1.0", "oauth2client==4.1.3", "ical==10.0.4"] } diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index e0b08313d63..3bf00f30624 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.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json index c8e80e4f91b..134cea5293b 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.0"] + "requirements": ["ical==10.0.4"] } diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json index 7bdc5362ae7..6da9ce4bb07 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.0"] + "requirements": ["ical==10.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index fbc8c403f9c..b98f3a65b8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1203,7 +1203,7 @@ ibmiotf==0.3.4 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5659daa20ee..c0029e20841 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,7 +1040,7 @@ ibeacon-ble==1.2.0 # homeassistant.components.local_calendar # homeassistant.components.local_todo # homeassistant.components.remote_calendar -ical==10.0.0 +ical==10.0.4 # homeassistant.components.caldav icalendar==6.1.0 From 912c4804cbc30208d3fa3251690683d6fc98d73a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Jun 2025 14:24:31 +0200 Subject: [PATCH 0511/1664] Fix incorrect use of zip in service.async_get_all_descriptions (#147013) * Fix incorrect use of zip in service.async_get_all_descriptions * Fix lint errors in test --- homeassistant/helpers/service.py | 10 +++--- tests/helpers/test_service.py | 54 ++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f157e82bc53..6e1988fe4cd 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -682,9 +682,12 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T def _load_services_files( hass: HomeAssistant, integrations: Iterable[Integration] -) -> list[JSON_TYPE]: +) -> dict[str, JSON_TYPE]: """Load service files for multiple integrations.""" - return [_load_services_file(hass, integration) for integration in integrations] + return { + integration.domain: _load_services_file(hass, integration) + for integration in integrations + } @callback @@ -744,10 +747,9 @@ async def async_get_all_descriptions( _LOGGER.error("Failed to load integration: %s", domain, exc_info=int_or_exc) if integrations: - contents = await hass.async_add_executor_job( + loaded = await hass.async_add_executor_job( _load_services_files, hass, integrations ) - loaded = dict(zip(domains_with_missing_services, contents, strict=False)) # Load translations for all service domains translations = await translation.async_get_translations( diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 38e7e1ae452..6b464faa110 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -16,6 +16,7 @@ from homeassistant import exceptions from homeassistant.auth.permissions import PolicyPermissions import homeassistant.components # noqa: F401 from homeassistant.components.group import DOMAIN as DOMAIN_GROUP, Group +from homeassistant.components.input_button import DOMAIN as DOMAIN_INPUT_BUTTON from homeassistant.components.logger import DOMAIN as DOMAIN_LOGGER from homeassistant.components.shell_command import DOMAIN as DOMAIN_SHELL_COMMAND from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH @@ -42,7 +43,12 @@ from homeassistant.helpers import ( entity_registry as er, service, ) -from homeassistant.loader import async_get_integration +from homeassistant.helpers.translation import async_get_translations +from homeassistant.loader import ( + Integration, + async_get_integration, + async_get_integrations, +) from homeassistant.setup import async_setup_component from homeassistant.util.yaml.loader import parse_yaml @@ -1092,38 +1098,66 @@ async def test_async_get_all_descriptions_failing_integration( """Test async_get_all_descriptions when async_get_integrations returns an exception.""" group_config = {DOMAIN_GROUP: {}} await async_setup_component(hass, DOMAIN_GROUP, group_config) - descriptions = await service.async_get_all_descriptions(hass) - - assert len(descriptions) == 1 - - assert "description" in descriptions["group"]["reload"] - assert "fields" in descriptions["group"]["reload"] logger_config = {DOMAIN_LOGGER: {}} await async_setup_component(hass, DOMAIN_LOGGER, logger_config) + + input_button_config = {DOMAIN_INPUT_BUTTON: {}} + await async_setup_component(hass, DOMAIN_INPUT_BUTTON, input_button_config) + + async def wrap_get_integrations( + hass: HomeAssistant, domains: Iterable[str] + ) -> dict[str, Integration | Exception]: + integrations = await async_get_integrations(hass, domains) + integrations[DOMAIN_LOGGER] = ImportError("Failed to load services.yaml") + return integrations + + async def wrap_get_translations( + hass: HomeAssistant, + language: str, + category: str, + integrations: Iterable[str] | None = None, + config_flow: bool | None = None, + ) -> dict[str, str]: + translations = await async_get_translations( + hass, language, category, integrations, config_flow + ) + return { + key: value + for key, value in translations.items() + if not key.startswith("component.logger.services.") + } + with ( patch( "homeassistant.helpers.service.async_get_integrations", - return_value={"logger": ImportError}, + wraps=wrap_get_integrations, ), patch( "homeassistant.helpers.service.translation.async_get_translations", - return_value={}, + wrap_get_translations, ), ): descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 + assert len(descriptions) == 3 assert "Failed to load integration: logger" in caplog.text # Services are empty defaults if the load fails but should # not raise + assert descriptions[DOMAIN_GROUP]["remove"]["description"] + assert descriptions[DOMAIN_GROUP]["remove"]["fields"] + assert descriptions[DOMAIN_LOGGER]["set_level"] == { "description": "", "fields": {}, "name": "", } + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["description"] + assert descriptions[DOMAIN_INPUT_BUTTON]["press"]["fields"] == {} + assert "target" in descriptions[DOMAIN_INPUT_BUTTON]["press"] + hass.services.async_register(DOMAIN_LOGGER, "new_service", lambda x: None, None) service.async_set_service_schema( hass, DOMAIN_LOGGER, "new_service", {"description": "new service"} From 5ea6cb384625a38f9531346f8e05b5ec66c21b01 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 17 Jun 2025 14:39:22 +0200 Subject: [PATCH 0512/1664] Disable Z-Wave indidator CC entities by default (#147018) * Update discovery tests * Disable Z-Wave indidator CC entities by default --- .../components/zwave_js/discovery.py | 4 + tests/components/zwave_js/test_discovery.py | 115 ++++++++++-------- 2 files changed, 68 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index b46735e4040..924778a9e5b 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -896,6 +896,7 @@ DISCOVERY_SCHEMAS = [ writeable=False, ), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # generic text sensors ZWaveDiscoverySchema( @@ -932,6 +933,7 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -957,6 +959,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # button for Indicator CC ZWaveDiscoverySchema( @@ -980,6 +983,7 @@ DISCOVERY_SCHEMAS = [ writeable=True, ), entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, ), # binary switch # barrier operator signaling states diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 7ef5f0e480f..02296262d1f 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,10 +1,12 @@ """Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.number import ( @@ -12,7 +14,6 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -26,12 +27,13 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) -from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_aeon_smart_switch_6_state( @@ -222,17 +224,24 @@ async def test_merten_507801_disabled_enitites( async def test_zooz_zen72( hass: HomeAssistant, entity_registry: er.EntityRegistry, - client, - switch_zooz_zen72, - integration, + client: MagicMock, + switch_zooz_zen72: Node, + integration: MockConfigEntry, ) -> None: """Test that Zooz ZEN72 Indicators are discovered as number entities.""" - assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 - assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + client.async_send_command.reset_mock() state = hass.states.get(entity_id) assert state assert state.state == STATE_UNKNOWN @@ -246,7 +255,7 @@ async def test_zooz_zen72( }, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -260,16 +269,18 @@ async def test_zooz_zen72( client.async_send_command.reset_mock() entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is None + assert hass.states.get(entity_id) is not None await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == switch_zooz_zen72.node_id @@ -285,53 +296,55 @@ async def test_indicator_test( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - client, - indicator_test, - integration, + client: MagicMock, + indicator_test: Node, + integration: MockConfigEntry, ) -> None: """Test that Indicators are discovered properly. This test covers indicators that we don't already have device fixtures for. """ - device = device_registry.async_get_device( - identifiers={get_device_id(client.driver, indicator_test)} + binary_sensor_entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + sensor_entity_id = "sensor.this_is_a_fake_device_sensor" + switch_entity_id = "switch.this_is_a_fake_device_switch" + + for entity_id in ( + binary_sensor_entity_id, + sensor_entity_id, + ): + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + entity_id = switch_entity_id + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category == EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) - assert device - entities = er.async_entries_for_device(entity_registry, device.id) + await hass.async_block_till_done() + client.async_send_command.reset_mock() - def len_domain(domain): - return len([entity for entity in entities if entity.domain == domain]) - - assert len_domain(NUMBER_DOMAIN) == 0 - assert len_domain(BUTTON_DOMAIN) == 1 # only ping - assert len_domain(BINARY_SENSOR_DOMAIN) == 1 - assert len_domain(SENSOR_DOMAIN) == 3 # include node status + last seen - assert len_domain(SWITCH_DOMAIN) == 1 - - entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = binary_sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF - client.async_send_command.reset_mock() - - entity_id = "sensor.this_is_a_fake_device_sensor" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.DIAGNOSTIC + entity_id = sensor_entity_id state = hass.states.get(entity_id) assert state assert state.state == "0.0" - client.async_send_command.reset_mock() - - entity_id = "switch.this_is_a_fake_device_switch" - entry = entity_registry.async_get(entity_id) - assert entry - assert entry.entity_category == EntityCategory.CONFIG + entity_id = switch_entity_id state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF @@ -342,7 +355,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id @@ -362,7 +375,7 @@ async def test_indicator_test( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_count == 1 args = client.async_send_command.call_args[0][0] assert args["command"] == "node.set_value" assert args["nodeId"] == indicator_test.node_id From 766ddfaacc1fbc245fc2cf8f84531fa559c6e024 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 20 Jun 2025 15:19:39 +0200 Subject: [PATCH 0513/1664] Fix Shelly entity names for gen1 sleeping devices (#147019) --- homeassistant/components/shelly/entity.py | 1 - tests/components/shelly/test_sensor.py | 44 +++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 2c1678d56d9..5a420a4543b 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -653,7 +653,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): ) elif entry is not None: self._attr_unique_id = entry.unique_id - self._attr_name = cast(str, entry.original_name) @callback def _update_callback(self) -> None: diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index e95d4cfaeb2..8f021c2d58a 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -40,6 +41,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from . import ( + MOCK_MAC, init_integration, mock_polling_rpc_update, mock_rest_update, @@ -1585,3 +1587,45 @@ async def test_rpc_switch_no_returned_energy_sensor( await init_integration(hass, 3) assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None + + +async def test_block_friendly_name_sleeping_sensor( + hass: HomeAssistant, + mock_block_device: Mock, + device_registry: DeviceRegistry, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test friendly name for restored sleeping sensor.""" + entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) + device = register_device(device_registry, entry) + + entity = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + f"{MOCK_MAC}-sensor_0-temp", + suggested_object_id="test_name_temperature", + original_name="Test name temperature", + disabled_by=None, + config_entry=entry, + device_id=device.id, + ) + + # Old name, the word "temperature" starts with a lower case letter + assert entity.original_name == "Test name temperature" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity.entity_id)) + + # New name, the word "temperature" starts with a capital letter + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" + + # Make device online + monkeypatch.setattr(mock_block_device, "initialized", True) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert (state := hass.states.get(entity.entity_id)) + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature" From 96d6cacae411458ea08c87b90e8fdf064751973c Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 18 Jun 2025 07:29:04 +0200 Subject: [PATCH 0514/1664] Disable Z-Wave idle notification button (#147026) * Update test * Disable Z-Wave idle notification button * Update tests --- .../components/zwave_js/discovery.py | 1 + .../zwave_js/snapshots/test_diagnostics.ambr | 8 ++-- tests/components/zwave_js/test_button.py | 41 ++++++++++++++++--- tests/components/zwave_js/test_init.py | 12 +----- 4 files changed, 42 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 924778a9e5b..4e9a3321beb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -1188,6 +1188,7 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), allow_multi=True, + entity_registry_enabled_default=False, ), # event # stateful = False diff --git a/tests/components/zwave_js/snapshots/test_diagnostics.ambr b/tests/components/zwave_js/snapshots/test_diagnostics.ambr index dc0dbba59b5..40ed3bbf836 100644 --- a/tests/components/zwave_js/snapshots/test_diagnostics.ambr +++ b/tests/components/zwave_js/snapshots/test_diagnostics.ambr @@ -97,8 +97,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_cover_status', @@ -120,8 +120,8 @@ 'value_id': '52-113-0-Home Security-Cover status', }), dict({ - 'disabled': False, - 'disabled_by': None, + 'disabled': True, + 'disabled_by': 'integration', 'domain': 'button', 'entity_category': 'config', 'entity_id': 'button.multisensor_6_idle_home_security_motion_sensor_status', diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 0282a268b54..422888cab23 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -1,13 +1,21 @@ """Test the Z-Wave JS button entities.""" +from datetime import timedelta +from unittest.mock import MagicMock + import pytest +from zwave_js_server.model.node import Node from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import get_valueless_base_unique_id -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY +from homeassistant.const import ATTR_ENTITY_ID, EntityCategory, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -71,11 +79,32 @@ async def test_ping_entity( async def test_notification_idle_button( - hass: HomeAssistant, client, multisensor_6, integration + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + client: MagicMock, + multisensor_6: Node, + integration: MockConfigEntry, ) -> None: """Test Notification idle button.""" node = multisensor_6 - state = hass.states.get("button.multisensor_6_idle_home_security_cover_status") + entity_id = "button.multisensor_6_idle_home_security_cover_status" + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.CONFIG + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity( + entity_id, + disabled_by=None, + ) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) assert state assert state.state == "unknown" assert ( @@ -88,13 +117,13 @@ async def test_notification_idle_button( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status", + ATTR_ENTITY_ID: entity_id, }, blocking=True, ) - assert len(client.async_send_command_no_wait.call_args_list) == 1 - args = client.async_send_command_no_wait.call_args_list[0][0][0] + assert client.async_send_command_no_wait.call_count == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.manually_idle_notification_value" assert args["nodeId"] == node.node_id assert args["valueId"] == { diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index ef74373ad9e..fa82b051e59 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1812,7 +1812,8 @@ async def test_disabled_node_status_entity_on_node_replaced( assert state.state == STATE_UNAVAILABLE -async def test_disabled_entity_on_value_removed( +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_remove_entity_on_value_removed( hass: HomeAssistant, zp3111: Node, client: MagicMock, @@ -1823,15 +1824,6 @@ async def test_disabled_entity_on_value_removed( "button.4_in_1_sensor_idle_home_security_cover_status" ) - # must reload the integration when enabling an entity - await hass.config_entries.async_unload(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.NOT_LOADED - integration.add_to_hass(hass) - await hass.config_entries.async_setup(integration.entry_id) - await hass.async_block_till_done() - assert integration.state is ConfigEntryState.LOADED - state = hass.states.get(idle_cover_status_button_entity) assert state assert state.state != STATE_UNAVAILABLE From a07531d0e78a47c73118fa15fddaff0b959ad9a9 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Jun 2025 19:57:52 +0200 Subject: [PATCH 0515/1664] Fix log in onedrive (#147029) --- homeassistant/components/onedrive/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py index ff05b19f84d..07a8dbd203b 100644 --- a/homeassistant/components/onedrive/coordinator.py +++ b/homeassistant/components/onedrive/coordinator.py @@ -66,7 +66,7 @@ class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]): translation_domain=DOMAIN, translation_key="authentication_failed" ) from err except OneDriveException as err: - _LOGGER.debug("Failed to fetch drive data: %s") + _LOGGER.debug("Failed to fetch drive data: %s", err, exc_info=True) raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed" ) from err From a15d722f0eda23fea5b87387b92de616014b4896 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 18 Jun 2025 09:11:01 +0200 Subject: [PATCH 0516/1664] Bump holidays lib to 0.75 (#147043) --- 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 5a5f1daf967..c76d6638730 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.74", "babel==2.15.0"] + "requirements": ["holidays==0.75", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 9091dd131dd..f9fae38f1f5 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.74"] + "requirements": ["holidays==0.75"] } diff --git a/requirements_all.txt b/requirements_all.txt index b98f3a65b8b..4f16704fa74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend home-assistant-frontend==20250531.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0029e20841..6f50bd8f64c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.74 +holidays==0.75 # homeassistant.components.frontend home-assistant-frontend==20250531.3 From f75ba9172c54792ea3ee9cd46b295f6a4bce6101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Tue, 17 Jun 2025 20:48:09 +0200 Subject: [PATCH 0517/1664] Bump aiohomeconnect to 0.18.0 (#147044) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index d4b37552fb7..34a3b756119 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -21,6 +21,6 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "iot_class": "cloud_push", "loggers": ["aiohomeconnect"], - "requirements": ["aiohomeconnect==0.17.1"], + "requirements": ["aiohomeconnect==0.18.0"], "zeroconf": ["_homeconnect._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 4f16704fa74..361e6932dff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,7 +265,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.0 # homeassistant.components.homekit_controller aiohomekit==3.2.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f50bd8f64c..cfe2f88c026 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -250,7 +250,7 @@ aioharmony==0.5.2 aiohasupervisor==0.3.1 # homeassistant.components.home_connect -aiohomeconnect==0.17.1 +aiohomeconnect==0.18.0 # homeassistant.components.homekit_controller aiohomekit==3.2.14 From 57eceeea38f16efd356eda8bd4909a151836c12c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:06:53 -0400 Subject: [PATCH 0518/1664] Bump ZHA to 0.0.60 (#147045) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4a5ec7be1dc..4908298847b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.59"], + "requirements": ["zha==0.0.60"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 361e6932dff..20602c5be75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3180,7 +3180,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfe2f88c026..80810a178ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2621,7 +2621,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.59 +zha==0.0.60 # homeassistant.components.zwave_js zwave-js-server-python==0.63.0 From c395c77cd3f18b892b288e1f2306c901fb69b3c0 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Jun 2025 20:59:41 +0200 Subject: [PATCH 0519/1664] Bump pylamarzocco to 2.0.9 (#147046) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 46a29427264..7fdafc4dda1 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.8"] + "requirements": ["pylamarzocco==2.0.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 20602c5be75..b1a4c497e55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2096,7 +2096,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80810a178ce..766f9365188 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1738,7 +1738,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.8 +pylamarzocco==2.0.9 # homeassistant.components.lastfm pylast==5.1.0 From 2c357265b0343dcf859b0ea30a8023c8a9567506 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 17 Jun 2025 21:25:27 +0200 Subject: [PATCH 0520/1664] Handle missing widget in lamarzocco (#147047) --- homeassistant/components/lamarzocco/number.py | 6 +++++- homeassistant/components/lamarzocco/sensor.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index 980a08c09ae..f8cb8b1d6fe 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,6 +58,10 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", @@ -221,7 +225,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity): entity_description: LaMarzoccoNumberEntityDescription @property - def native_value(self) -> float: + def native_value(self) -> float | int: """Return the current value.""" return self.entity_description.native_value_fn(self.coordinator.device) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index 29f1c6209ec..c76f51c3488 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,6 +57,10 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, + available_fn=( + lambda coordinator: WidgetType.CM_COFFEE_BOILER + in coordinator.device.dashboard.config + ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", From 1a3384e8b47662ddea6061ec713733087ac3013b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 15 Jun 2025 18:30:19 +0300 Subject: [PATCH 0521/1664] Bump aioamazondevices to 3.1.4 (#146883) --- 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 0ea4c32a75e..7a7713f861b 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": "bronze", - "requirements": ["aioamazondevices==3.1.3"] + "requirements": ["aioamazondevices==3.1.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index b1a4c497e55..275595cb0d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.3 +aioamazondevices==3.1.4 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 766f9365188..2a1287da822 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.3 +aioamazondevices==3.1.4 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 0b383b7493ea38425a7f6ec8a8c960da330ca440 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 18 Jun 2025 11:20:51 +0300 Subject: [PATCH 0522/1664] Bump aioamazondevices to 3.1.12 (#147055) * Bump aioamazondevices to 3.1.10 * bump to 3.1.12 --- 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 7a7713f861b..aeecb5bc96c 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": "bronze", - "requirements": ["aioamazondevices==3.1.4"] + "requirements": ["aioamazondevices==3.1.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 275595cb0d3..d567bf86353 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.4 +aioamazondevices==3.1.12 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2a1287da822..074194d5da3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.4 +aioamazondevices==3.1.12 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From c66d411826f340d305fcb553560fed726adea14e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Thu, 19 Jun 2025 11:20:29 +0200 Subject: [PATCH 0523/1664] Bump uiprotect to version 7.14.0 (#147102) --- 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 3c32935a995..f99d910adf9 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.13.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index d567bf86353..6d09a5f9037 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.13.0 +uiprotect==7.14.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 074194d5da3..6c65be62ef2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.13.0 +uiprotect==7.14.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 39b64b0af3de3c64a32f28ec70ea30ec3f405810 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 19 Jun 2025 17:56:47 +0200 Subject: [PATCH 0524/1664] Improve advanced Z-Wave battery discovery (#147127) --- .../components/zwave_js/binary_sensor.py | 34 +- homeassistant/components/zwave_js/const.py | 5 +- .../components/zwave_js/discovery.py | 31 +- .../zwave_js/discovery_data_template.py | 32 +- homeassistant/components/zwave_js/sensor.py | 47 +- tests/components/zwave_js/common.py | 1 - tests/components/zwave_js/conftest.py | 14 + .../zwave_js/fixtures/ring_keypad_state.json | 7543 +++++++++++++++++ .../components/zwave_js/test_binary_sensor.py | 52 +- tests/components/zwave_js/test_sensor.py | 93 +- 10 files changed, 7825 insertions(+), 27 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/ring_keypad_state.json diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 1439aa0ca0f..d70690ace31 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -318,12 +318,37 @@ PROPERTY_SENSOR_MAPPINGS: dict[str, PropertyZWaveJSEntityDescription] = { # Mappings for boolean sensors -BOOLEAN_SENSOR_MAPPINGS: dict[int, BinarySensorEntityDescription] = { - CommandClass.BATTERY: BinarySensorEntityDescription( - key=str(CommandClass.BATTERY), +BOOLEAN_SENSOR_MAPPINGS: dict[tuple[int, int | str], BinarySensorEntityDescription] = { + (CommandClass.BATTERY, "backup"): BinarySensorEntityDescription( + key="battery_backup", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "disconnected"): BinarySensorEntityDescription( + key="battery_disconnected", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "isLow"): BinarySensorEntityDescription( + key="battery_is_low", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, ), + (CommandClass.BATTERY, "lowFluid"): BinarySensorEntityDescription( + key="battery_low_fluid", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "overheating"): BinarySensorEntityDescription( + key="battery_overheating", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + (CommandClass.BATTERY, "rechargeable"): BinarySensorEntityDescription( + key="battery_rechargeable", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), } @@ -432,8 +457,9 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): # Entity class attributes self._attr_name = self.generate_name(include_value_name=True) + primary_value = self.info.primary_value if description := BOOLEAN_SENSOR_MAPPINGS.get( - self.info.primary_value.command_class + (primary_value.command_class, primary_value.property_) ): self.entity_description = description diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 3d626710d52..a99e9fd0113 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -139,7 +139,10 @@ ATTR_TWIST_ASSIST = "twist_assist" ADDON_SLUG = "core_zwave_js" # Sensor entity description constants -ENTITY_DESC_KEY_BATTERY = "battery" +ENTITY_DESC_KEY_BATTERY_LEVEL = "battery_level" +ENTITY_DESC_KEY_BATTERY_LIST_STATE = "battery_list_state" +ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY = "battery_maximum_capacity" +ENTITY_DESC_KEY_BATTERY_TEMPERATURE = "battery_temperature" ENTITY_DESC_KEY_CURRENT = "current" ENTITY_DESC_KEY_VOLTAGE = "voltage" ENTITY_DESC_KEY_ENERGY_MEASUREMENT = "energy_measurement" diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 4e9a3321beb..92233dd2e77 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -913,7 +913,6 @@ DISCOVERY_SCHEMAS = [ hint="numeric_sensor", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.BATTERY, CommandClass.ENERGY_PRODUCTION, CommandClass.SENSOR_ALARM, CommandClass.SENSOR_MULTILEVEL, @@ -922,6 +921,36 @@ DISCOVERY_SCHEMAS = [ ), data_template=NumericSensorDataTemplate(), ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"level", "maximumCapacity"}, + ), + data_template=NumericSensorDataTemplate(), + ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="numeric_sensor", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"temperature"}, + ), + data_template=NumericSensorDataTemplate(), + ), + ZWaveDiscoverySchema( + platform=Platform.SENSOR, + hint="list", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BATTERY}, + type={ValueType.NUMBER}, + property={"chargingStatus", "rechargeOrReplace"}, + ), + data_template=NumericSensorDataTemplate(), + ), ZWaveDiscoverySchema( platform=Platform.SENSOR, hint="numeric_sensor", diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index e619c6afc7c..731a786d226 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -133,7 +133,10 @@ from homeassistant.const import ( ) from .const import ( - ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_BATTERY_LEVEL, + ENTITY_DESC_KEY_BATTERY_LIST_STATE, + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, ENTITY_DESC_KEY_CO, ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, @@ -380,8 +383,31 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): def resolve_data(self, value: ZwaveValue) -> NumericSensorDataTemplateData: """Resolve helper class data for a discovered value.""" - if value.command_class == CommandClass.BATTERY: - return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE) + if value.command_class == CommandClass.BATTERY and value.property_ == "level": + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE + ) + if value.command_class == CommandClass.BATTERY and value.property_ in ( + "chargingStatus", + "rechargeOrReplace", + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_LIST_STATE, None + ) + if ( + value.command_class == CommandClass.BATTERY + and value.property_ == "maximumCapacity" + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE + ) + if ( + value.command_class == CommandClass.BATTERY + and value.property_ == "temperature" + ): + return NumericSensorDataTemplateData( + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, UnitOfTemperature.CELSIUS + ) if value.command_class == CommandClass.METER: try: diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 4db14d003b1..05fa785760b 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -58,7 +58,10 @@ from .const import ( ATTR_VALUE, DATA_CLIENT, DOMAIN, - ENTITY_DESC_KEY_BATTERY, + ENTITY_DESC_KEY_BATTERY_LEVEL, + ENTITY_DESC_KEY_BATTERY_LIST_STATE, + ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, ENTITY_DESC_KEY_CO, ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, @@ -95,17 +98,33 @@ from .migrate import async_migrate_statistics_sensors PARALLEL_UPDATES = 0 -# These descriptions should include device class. -ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ - tuple[str, str], SensorEntityDescription -] = { - (ENTITY_DESC_KEY_BATTERY, PERCENTAGE): SensorEntityDescription( - key=ENTITY_DESC_KEY_BATTERY, +# These descriptions should have a non None unit of measurement. +ENTITY_DESCRIPTION_KEY_UNIT_MAP: dict[tuple[str, str], SensorEntityDescription] = { + (ENTITY_DESC_KEY_BATTERY_LEVEL, PERCENTAGE): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_LEVEL, device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, ), + (ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, PERCENTAGE): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_MAXIMUM_CAPACITY, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + ( + ENTITY_DESC_KEY_BATTERY_TEMPERATURE, + UnitOfTemperature.CELSIUS, + ): SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + entity_registry_enabled_default=False, + ), (ENTITY_DESC_KEY_CURRENT, UnitOfElectricCurrent.AMPERE): SensorEntityDescription( key=ENTITY_DESC_KEY_CURRENT, device_class=SensorDeviceClass.CURRENT, @@ -285,8 +304,14 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ ), } -# These descriptions are without device class. +# These descriptions are without unit of measurement. ENTITY_DESCRIPTION_KEY_MAP = { + ENTITY_DESC_KEY_BATTERY_LIST_STATE: SensorEntityDescription( + key=ENTITY_DESC_KEY_BATTERY_LIST_STATE, + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), ENTITY_DESC_KEY_CO: SensorEntityDescription( key=ENTITY_DESC_KEY_CO, state_class=SensorStateClass.MEASUREMENT, @@ -538,7 +563,7 @@ def get_entity_description( """Return the entity description for the given data.""" data_description_key = data.entity_description_key or "" data_unit = data.unit_of_measurement or "" - return ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP.get( + return ENTITY_DESCRIPTION_KEY_UNIT_MAP.get( (data_description_key, data_unit), ENTITY_DESCRIPTION_KEY_MAP.get( data_description_key, @@ -588,6 +613,10 @@ async def async_setup_entry( entities.append( ZWaveListSensor(config_entry, driver, info, entity_description) ) + elif info.platform_hint == "list": + entities.append( + ZWaveListSensor(config_entry, driver, info, entity_description) + ) elif info.platform_hint == "config_parameter": entities.append( ZWaveConfigParameterSensor( diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 64bc981de11..578eeab5ec7 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -21,7 +21,6 @@ ENERGY_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_2" VOLTAGE_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_3" CURRENT_SENSOR = "sensor.smart_plug_with_two_usb_ports_value_electric_consumed_4" SWITCH_ENTITY = "switch.smart_plug_with_two_usb_ports" -LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e0485ced091..25f40e4418d 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -199,6 +199,12 @@ def climate_heatit_z_trm3_no_value_state_fixture() -> dict[str, Any]: return load_json_object_fixture("climate_heatit_z_trm3_no_value_state.json", DOMAIN) +@pytest.fixture(name="ring_keypad_state", scope="package") +def ring_keypad_state_fixture() -> dict[str, Any]: + """Load the Ring keypad state fixture data.""" + return load_json_object_fixture("ring_keypad_state.json", DOMAIN) + + @pytest.fixture(name="nortek_thermostat_state", scope="package") def nortek_thermostat_state_fixture() -> dict[str, Any]: """Load the nortek thermostat node state fixture data.""" @@ -876,6 +882,14 @@ def nortek_thermostat_removed_event_fixture(client) -> Node: return Event("node removed", event_data) +@pytest.fixture(name="ring_keypad") +def ring_keypad_fixture(client: MagicMock, ring_keypad_state: NodeDataType) -> Node: + """Mock a Ring keypad node.""" + node = Node(client, copy.deepcopy(ring_keypad_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="integration") async def integration_fixture( hass: HomeAssistant, diff --git a/tests/components/zwave_js/fixtures/ring_keypad_state.json b/tests/components/zwave_js/fixtures/ring_keypad_state.json new file mode 100644 index 00000000000..3d003518b6e --- /dev/null +++ b/tests/components/zwave_js/fixtures/ring_keypad_state.json @@ -0,0 +1,7543 @@ +{ + "nodeId": 4, + "index": 0, + "installerIcon": 8193, + "userIcon": 8193, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 838, + "productId": 1025, + "productType": 257, + "firmwareVersion": "1.18.0", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/home/dominic/Repositories/zwavejs2mqtt/store/.config-db/devices/0x0346/keypad_v2.json", + "isEmbedded": true, + "manufacturer": "Ring", + "manufacturerId": 838, + "label": "4AK1SZ", + "description": "Keypad v2", + "devices": [ + { + "productType": 257, + "productId": 769 + }, + { + "productType": 257, + "productId": 1025 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "compat": { + "disableStrictEntryControlDataValidation": true + }, + "metadata": { + "inclusion": "Classic Inclusion should be used if the controller does not support SmartStart.\n1. Initiate add flow for Security Devices in the Ring mobile application \u2013 Follow the guided add flow instructions provided in the Ring mobile application.\n2. Select add manually and enter the 5-digit DSK PIN found on the package of the Ring Alarm Keypad or the 5-digit DSK PIN found under the QR code on the device.\n3. After powering on the device, press and hold the #1 button for ~3 seconds. Release the button and the device will enter Classic inclusion mode which implements both classic inclusion with a Node Information Frame, and Network Wide Inclusion. During Classic Inclusion mode, the green Connection LED will blink three times followed by a brief pause, repeatedly. When Classic inclusion times-out, the device will blink alternating red and green a few times", + "exclusion": "1. Initiate remove 'Ring Alarm Keypad' flow in the Ring Alarm mobile application \u2013 Select the settings icon from device details page and choose 'Remove Device' to remove the device. This will place the controller into Remove or 'Z-Wave Exclusion' mode.\n2. Locate the pinhole reset button on the back of the device.\n3. With the controller in Remove (Z-Wave Exclusion) mode, use a paper clip or similar object and tap the pinhole button. The device's Connection LED turns on solid red to indicate the device was removed from the network.", + "reset": "Factory Default Instructions\n1. To restore Ring Alarm Keypad to factory default settings, locate the pinhole reset button on the device. This is found on the back of the device after removing the back bracket.\n2. Using a paperclip or similar object, insert it into the pinhole, press and hold the button down for 10 seconds.\n3. The device's Connection icon LED will rapidly blink green continuously for 10 seconds. After about 10 seconds, when the green blinking stops, release the button. The red LED will turn on solid to indicate the device was removed from the network.\nNote\nUse this procedure only in the event that the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4150/Ring%20Alarm%20Keypad%20Zwave.pdf" + } + }, + "label": "4AK1SZ", + "interviewAttempts": 0, + "isFrequentListening": "250ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 11, + "label": "Secure Keypad" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0346:0x0101:0x0401:1.18.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 27.5, + "lastSeen": "2025-06-18T11:17:39.315Z", + "rssi": -54, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -54, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 2, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-06-18T11:17:39.315Z", + "protocol": 0, + "values": [ + { + "endpoint": 0, + "commandClass": 111, + "commandClassName": "Entry Control", + "property": "keyCacheTimeout", + "propertyName": "keyCacheTimeout", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "How long the key cache must wait for additional characters", + "label": "Key cache timeout", + "min": 1, + "max": 30, + "unit": "seconds", + "stateful": true, + "secret": false + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 111, + "commandClassName": "Entry Control", + "property": "keyCacheSize", + "propertyName": "keyCacheSize", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Number of character that must be stored before sending", + "label": "Key cache size", + "min": 4, + "max": 10, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Heartbeat Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Heartbeat Interval", + "default": 70, + "min": 1, + "max": 70, + "unit": "minutes", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 70 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Message Retry Attempt Limit", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Message Retry Attempt Limit", + "default": 1, + "min": 0, + "max": 5, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Delay Between Retry Attempts", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Delay Between Retry Attempts", + "default": 5, + "min": 1, + "max": 60, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Announcement Audio Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Announcement Audio Volume", + "default": 7, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 7 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Key Tone Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Key Tone Volume", + "default": 6, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Siren Volume", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Siren Volume", + "default": 10, + "min": 0, + "max": 10, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Long Press Duration: Emergency Buttons", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Hold time required to capture a long press", + "label": "Long Press Duration: Emergency Buttons", + "default": 3, + "min": 2, + "max": 5, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Long Press Duration: Number Pad", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Hold time required to capture a long press", + "label": "Long Press Duration: Number Pad", + "default": 3, + "min": 2, + "max": 5, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Timeout: Proximity Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Proximity Display", + "default": 5, + "min": 0, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Timeout: Display on Button Press", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Display on Button Press", + "default": 5, + "min": 0, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Timeout: Display on Status Change", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Timeout: Display on Status Change", + "default": 5, + "min": 1, + "max": 30, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Brightness: Security Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Brightness: Security Mode", + "default": 100, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Brightness: Key Backlight", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Brightness: Key Backlight", + "default": 100, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Key Backlight Ambient Light Sensor Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Key Backlight Ambient Light Sensor Level", + "default": 20, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Proximity Detection", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Proximity Detection", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "LED Ramp Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Ramp Time", + "default": 50, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Battery Low Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Low Threshold", + "default": 15, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 30 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Battery Warning Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Battery Warning Threshold", + "default": 5, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Keypad Language", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Keypad Language", + "default": 30, + "min": 0, + "max": 31, + "states": { + "0": "English", + "2": "French", + "5": "Spanish" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "System Security Mode Blink Duration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "System Security Mode Blink Duration", + "default": 2, + "min": 1, + "max": 60, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Supervision Report Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Supervision Report Timeout", + "default": 10000, + "min": 500, + "max": 30000, + "unit": "ms", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "System Security Mode Display", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Allowable range: 1-600", + "label": "System Security Mode Display", + "default": 0, + "min": 0, + "max": 601, + "states": { + "0": "Always off", + "601": "Always on" + }, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1, + "propertyName": "param023_1", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2, + "propertyName": "param023_2", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4, + "propertyName": "param023_4", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8, + "propertyName": "param023_8", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16, + "propertyName": "param023_16", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 32, + "propertyName": "param023_32", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 1, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 64, + "propertyName": "param023_64", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 128, + "propertyName": "param023_128", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 256, + "propertyName": "param023_256", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 512, + "propertyName": "param023_512", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1024, + "propertyName": "param023_1024", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2048, + "propertyName": "param023_2048", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4096, + "propertyName": "param023_4096", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8192, + "propertyName": "param023_8192", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16384, + "propertyName": "param023_16384", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 32768, + "propertyName": "param023_32768", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 65536, + "propertyName": "param023_65536", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 131072, + "propertyName": "param023_131072", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 262144, + "propertyName": "param023_262144", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 524288, + "propertyName": "param023_524288", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1048576, + "propertyName": "param023_1048576", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2097152, + "propertyName": "param023_2097152", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4194304, + "propertyName": "param023_4194304", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 8388608, + "propertyName": "param023_8388608", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16777216, + "propertyName": "param023_16777216", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 33554432, + "propertyName": "param023_33554432", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 67108864, + "propertyName": "param023_67108864", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 134217728, + "propertyName": "param023_134217728", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 268435456, + "propertyName": "param023_268435456", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 536870912, + "propertyName": "param023_536870912", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 1073741824, + "propertyName": "param023_1073741824", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 2147483648, + "propertyName": "param023_2147483648", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "default": 0, + "min": 0, + "max": 1, + "valueSize": 4, + "format": 3, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Calibrate Speaker", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Calibrate Speaker", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Motion Sensor Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Motion Sensor Timeout", + "default": 3, + "min": 0, + "max": 60, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Z-Wave Sleep Timeout", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "", + "label": "Z-Wave Sleep Timeout", + "default": 10, + "min": 0, + "max": 15, + "valueSize": 1, + "format": 1, + "isAdvanced": true, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Languages Supported Report", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "This parameter reports a bitmask of supported languages", + "label": "Languages Supported Report", + "default": 37, + "min": 0, + "max": 4294967295, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Home Security", + "propertyKey": "Motion sensor status", + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Motion sensor status", + "ccSpecific": { + "notificationType": 7 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "8": "Motion detection" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Power status", + "propertyName": "Power Management", + "propertyKeyName": "Power status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Power status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "Power has been applied" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Software status", + "propertyName": "System", + "propertyKeyName": "Software status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Software status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "4": "System software failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Mains status", + "propertyName": "Power Management", + "propertyKeyName": "Mains status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Mains status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "2": "AC mains disconnected", + "3": "AC mains re-connected" + }, + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1025 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 257 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 838 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "disconnected", + "propertyName": "disconnected", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Battery is disconnected", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeOrReplace", + "propertyName": "rechargeOrReplace", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Recharge or replace", + "min": 0, + "max": 255, + "states": { + "0": "No", + "1": "Soon", + "2": "Now" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "lowFluid", + "propertyName": "lowFluid", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Fluid is low", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "overheating", + "propertyName": "overheating", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Overheating", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "backup", + "propertyName": "backup", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Used as backup", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "rechargeable", + "propertyName": "rechargeable", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Rechargeable", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "chargingStatus", + "propertyName": "chargingStatus", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Charging status", + "min": 0, + "max": 255, + "states": { + "0": "Discharging", + "1": "Charging", + "2": "Maintaining" + }, + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 100 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "temperature", + "propertyName": "temperature", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Temperature", + "min": -128, + "max": 127, + "unit": "\u00b0C", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "maximumCapacity", + "propertyName": "maximumCapacity", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Maximum capacity", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.18", "1.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 28 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.18.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12.4" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 48 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "1.18.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.12.4" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 1, + "propertyName": "0", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0 (default) - Multilevel", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 3, + "propertyName": "0", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0 (default) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 4, + "propertyName": "0", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0 (default) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 5, + "propertyName": "0", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0 (default) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 7, + "propertyName": "0", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 9, + "propertyName": "0", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0 (default) - Sound level", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 8, + "propertyName": "0", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 0, + "propertyKey": 6, + "propertyName": "0", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0 (default) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 0, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + }, + "value": "" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 1, + "propertyName": "Ready", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x03 (Ready) - Multilevel", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 3, + "propertyName": "Ready", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x03 (Ready) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 4, + "propertyName": "Ready", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x03 (Ready) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 5, + "propertyName": "Ready", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x03 (Ready) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 7, + "propertyName": "Ready", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 9, + "propertyName": "Ready", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x03 (Ready) - Sound level", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 8, + "propertyName": "Ready", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 3, + "propertyKey": 6, + "propertyName": "Ready", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x03 (Ready) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 3, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 1, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x02 (Not armed / disarmed) - Multilevel", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 3, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x02 (Not armed / disarmed) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 4, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x02 (Not armed / disarmed) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 5, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x02 (Not armed / disarmed) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 7, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 9, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x02 (Not armed / disarmed) - Sound level", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 8, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 2, + "propertyKey": 6, + "propertyName": "Not armed / disarmed", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x02 (Not armed / disarmed) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 2, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 1, + "propertyName": "Code not accepted", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x09 (Code not accepted) - Multilevel", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 3, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x09 (Code not accepted) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 4, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x09 (Code not accepted) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 5, + "propertyName": "Code not accepted", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x09 (Code not accepted) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 7, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 9, + "propertyName": "Code not accepted", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x09 (Code not accepted) - Sound level", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 8, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 9, + "propertyKey": 6, + "propertyName": "Code not accepted", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x09 (Code not accepted) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 9, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 1, + "propertyName": "Armed Stay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0a (Armed Stay) - Multilevel", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 3, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0a (Armed Stay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 4, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0a (Armed Stay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 5, + "propertyName": "Armed Stay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0a (Armed Stay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 7, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 9, + "propertyName": "Armed Stay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0a (Armed Stay) - Sound level", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 8, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 10, + "propertyKey": 6, + "propertyName": "Armed Stay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0a (Armed Stay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 10, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 1, + "propertyName": "Armed Away", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0b (Armed Away) - Multilevel", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 3, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0b (Armed Away) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 4, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0b (Armed Away) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 5, + "propertyName": "Armed Away", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0b (Armed Away) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 7, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 9, + "propertyName": "Armed Away", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0b (Armed Away) - Sound level", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 8, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 11, + "propertyKey": 6, + "propertyName": "Armed Away", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0b (Armed Away) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 11, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 1, + "propertyName": "Alarming", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0c (Alarming) - Multilevel", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 3, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0c (Alarming) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 4, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0c (Alarming) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 5, + "propertyName": "Alarming", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0c (Alarming) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 7, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 9, + "propertyName": "Alarming", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0c (Alarming) - Sound level", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 8, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 12, + "propertyKey": 6, + "propertyName": "Alarming", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0c (Alarming) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 12, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 1, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0d (Alarming: Burglar) - Multilevel", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 3, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0d (Alarming: Burglar) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 4, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0d (Alarming: Burglar) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 5, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0d (Alarming: Burglar) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 7, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 9, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0d (Alarming: Burglar) - Sound level", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 8, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 13, + "propertyKey": 6, + "propertyName": "Alarming: Burglar", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0d (Alarming: Burglar) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 13, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 1, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0e (Alarming: Smoke / Fire) - Multilevel", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 3, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 4, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 5, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0e (Alarming: Smoke / Fire) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 7, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 9, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0e (Alarming: Smoke / Fire) - Sound level", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 8, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 14, + "propertyKey": 6, + "propertyName": "Alarming: Smoke / Fire", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0e (Alarming: Smoke / Fire) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 14, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 1, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x0f (Alarming: Carbon Monoxide) - Multilevel", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 3, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 4, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 5, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x0f (Alarming: Carbon Monoxide) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 7, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 9, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x0f (Alarming: Carbon Monoxide) - Sound level", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 8, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 15, + "propertyKey": 6, + "propertyName": "Alarming: Carbon Monoxide", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x0f (Alarming: Carbon Monoxide) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 15, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 1, + "propertyName": "Bypass challenge", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x10 (Bypass challenge) - Multilevel", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 3, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x10 (Bypass challenge) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 4, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x10 (Bypass challenge) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 5, + "propertyName": "Bypass challenge", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x10 (Bypass challenge) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 7, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 9, + "propertyName": "Bypass challenge", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x10 (Bypass challenge) - Sound level", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 8, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 16, + "propertyKey": 6, + "propertyName": "Bypass challenge", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x10 (Bypass challenge) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 16, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 1, + "propertyName": "Entry Delay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x11 (Entry Delay) - Multilevel", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 3, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x11 (Entry Delay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 4, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x11 (Entry Delay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 5, + "propertyName": "Entry Delay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x11 (Entry Delay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 7, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 9, + "propertyName": "Entry Delay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x11 (Entry Delay) - Sound level", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 8, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 17, + "propertyKey": 6, + "propertyName": "Entry Delay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x11 (Entry Delay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 17, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 1, + "propertyName": "Exit Delay", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x12 (Exit Delay) - Multilevel", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 3, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x12 (Exit Delay) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 4, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x12 (Exit Delay) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 5, + "propertyName": "Exit Delay", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x12 (Exit Delay) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 7, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 9, + "propertyName": "Exit Delay", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x12 (Exit Delay) - Sound level", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 8, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 18, + "propertyKey": 6, + "propertyName": "Exit Delay", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x12 (Exit Delay) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 18, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 1, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x13 (Alarming: Medical) - Multilevel", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 3, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x13 (Alarming: Medical) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 4, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x13 (Alarming: Medical) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 5, + "propertyName": "Alarming: Medical", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x13 (Alarming: Medical) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 7, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 9, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x13 (Alarming: Medical) - Sound level", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 8, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 19, + "propertyKey": 6, + "propertyName": "Alarming: Medical", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x13 (Alarming: Medical) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 19, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 1, + "propertyName": "Node Identify", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x50 (Node Identify) - Multilevel", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 7, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 9, + "propertyName": "Node Identify", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x50 (Node Identify) - Sound level", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 8, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 6, + "propertyName": "Node Identify", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x50 (Node Identify) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 1, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x60 (Generic event sound notification 1) - Multilevel", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 3, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x60 (Generic event sound notification 1) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 4, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x60 (Generic event sound notification 1) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 5, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x60 (Generic event sound notification 1) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 7, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 9, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x60 (Generic event sound notification 1) - Sound level", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 8, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 96, + "propertyKey": 6, + "propertyName": "Generic event sound notification 1", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x60 (Generic event sound notification 1) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 96, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 1, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x61 (Generic event sound notification 2) - Multilevel", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 3, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x61 (Generic event sound notification 2) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 4, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x61 (Generic event sound notification 2) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 5, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x61 (Generic event sound notification 2) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 7, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 9, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x61 (Generic event sound notification 2) - Sound level", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 8, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 97, + "propertyKey": 6, + "propertyName": "Generic event sound notification 2", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x61 (Generic event sound notification 2) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 97, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 1, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x62 (Generic event sound notification 3) - Multilevel", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 3, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x62 (Generic event sound notification 3) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 4, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x62 (Generic event sound notification 3) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 5, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x62 (Generic event sound notification 3) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 7, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 9, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x62 (Generic event sound notification 3) - Sound level", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 8, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 98, + "propertyKey": 6, + "propertyName": "Generic event sound notification 3", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x62 (Generic event sound notification 3) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 98, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 1, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x63 (Generic event sound notification 4) - Multilevel", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 3, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x63 (Generic event sound notification 4) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 4, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x63 (Generic event sound notification 4) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 5, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x63 (Generic event sound notification 4) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 7, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 9, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x63 (Generic event sound notification 4) - Sound level", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 8, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 99, + "propertyKey": 6, + "propertyName": "Generic event sound notification 4", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x63 (Generic event sound notification 4) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 99, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 1, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x64 (Generic event sound notification 5) - Multilevel", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 3, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x64 (Generic event sound notification 5) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 4, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x64 (Generic event sound notification 5) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 5, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x64 (Generic event sound notification 5) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 7, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 9, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x64 (Generic event sound notification 5) - Sound level", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 8, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 100, + "propertyKey": 6, + "propertyName": "Generic event sound notification 5", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x64 (Generic event sound notification 5) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 100, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 1, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x14 (Alarming: Freeze warning) - Multilevel", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 3, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x14 (Alarming: Freeze warning) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 4, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x14 (Alarming: Freeze warning) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 5, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x14 (Alarming: Freeze warning) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 7, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 9, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x14 (Alarming: Freeze warning) - Sound level", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 8, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 20, + "propertyKey": 6, + "propertyName": "Alarming: Freeze warning", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x14 (Alarming: Freeze warning) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 20, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 1, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x15 (Alarming: Water leak) - Multilevel", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 3, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x15 (Alarming: Water leak) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 4, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x15 (Alarming: Water leak) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 5, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x15 (Alarming: Water leak) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 7, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 9, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x15 (Alarming: Water leak) - Sound level", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 8, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 21, + "propertyKey": 6, + "propertyName": "Alarming: Water leak", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x15 (Alarming: Water leak) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 21, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 1, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x81 (Manufacturer defined 2) - Multilevel", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 3, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x81 (Manufacturer defined 2) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 4, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x81 (Manufacturer defined 2) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 5, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x81 (Manufacturer defined 2) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 7, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 9, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x81 (Manufacturer defined 2) - Sound level", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 8, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 129, + "propertyKey": 6, + "propertyName": "Manufacturer defined 2", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x81 (Manufacturer defined 2) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 129, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 1, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x82 (Manufacturer defined 3) - Multilevel", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 3, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x82 (Manufacturer defined 3) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 4, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x82 (Manufacturer defined 3) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 5, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x82 (Manufacturer defined 3) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 7, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 9, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x82 (Manufacturer defined 3) - Sound level", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 8, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 130, + "propertyKey": 6, + "propertyName": "Manufacturer defined 3", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x82 (Manufacturer defined 3) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 130, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 1, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x83 (Manufacturer defined 4) - Multilevel", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 3, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x83 (Manufacturer defined 4) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 4, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x83 (Manufacturer defined 4) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 5, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x83 (Manufacturer defined 4) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 7, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 9, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x83 (Manufacturer defined 4) - Sound level", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 8, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 131, + "propertyKey": 6, + "propertyName": "Manufacturer defined 4", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x83 (Manufacturer defined 4) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 131, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 1, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x84 (Manufacturer defined 5) - Multilevel", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 3, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x84 (Manufacturer defined 5) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 4, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x84 (Manufacturer defined 5) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 5, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x84 (Manufacturer defined 5) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 7, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 9, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x84 (Manufacturer defined 5) - Sound level", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 8, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 132, + "propertyKey": 6, + "propertyName": "Manufacturer defined 5", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x84 (Manufacturer defined 5) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 132, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 1, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Multilevel", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "0x85 (Manufacturer defined 6) - Multilevel", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 1 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 3, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x85 (Manufacturer defined 6) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 4, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x85 (Manufacturer defined 6) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 5, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x85 (Manufacturer defined 6) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 7, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: Seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of seconds. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: Seconds", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 7 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 9, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Sound level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the volume of a indicator. 0 means off/mute.", + "label": "0x85 (Manufacturer defined 6) - Sound level", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 9 + }, + "max": 100, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 8, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: 1/100th seconds", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of 1/100th seconds. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: 1/100th seconds", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 8 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 133, + "propertyKey": 6, + "propertyName": "Manufacturer defined 6", + "propertyKeyName": "Timeout: Minutes", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Turns the indicator of after this amount of minutes. Can be used together with other timeout properties", + "label": "0x85 (Manufacturer defined 6) - Timeout: Minutes", + "ccSpecific": { + "indicatorId": 133, + "propertyId": 6 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 4, + "index": 0, + "installerIcon": 8193, + "userIcon": 8193, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 64, + "label": "Entry Control" + }, + "specific": { + "key": 11, + "label": "Secure Keypad" + } + }, + "commandClasses": [ + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 2, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 111, + "name": "Entry Control", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index 93ac52f9041..5dfbb0f5bd8 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -1,10 +1,13 @@ """Test the Z-Wave JS binary sensor platform.""" +from datetime import timedelta + import pytest from zwave_js_server.event import Event from zwave_js_server.model.node import Node from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_OFF, @@ -15,17 +18,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import ( DISABLED_LEGACY_BINARY_SENSOR, ENABLED_LEGACY_BINARY_SENSOR, - LOW_BATTERY_BINARY_SENSOR, NOTIFICATION_MOTION_BINARY_SENSOR, PROPERTY_DOOR_STATUS_BINARY_SENSOR, TAMPER_SENSOR, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -34,21 +37,56 @@ def platforms() -> list[str]: return [Platform.BINARY_SENSOR] -async def test_low_battery_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, multisensor_6, integration +async def test_battery_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ring_keypad: Node, + integration: MockConfigEntry, ) -> None: - """Test boolean binary sensor of type low battery.""" - state = hass.states.get(LOW_BATTERY_BINARY_SENSOR) + """Test boolean battery binary sensors.""" + entity_id = "binary_sensor.keypad_v2_low_battery_level" + state = hass.states.get(entity_id) assert state assert state.state == STATE_OFF assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY - entity_entry = entity_registry.async_get(LOW_BATTERY_BINARY_SENSOR) + entity_entry = entity_registry.async_get(entity_id) assert entity_entry assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + disabled_binary_sensor_battery_entities = ( + "binary_sensor.keypad_v2_battery_is_disconnected", + "binary_sensor.keypad_v2_fluid_is_low", + "binary_sensor.keypad_v2_overheating", + "binary_sensor.keypad_v2_rechargeable", + "binary_sensor.keypad_v2_used_as_backup", + ) + + for entity_id in disabled_binary_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state is None # disabled by default + + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + for entity_id in disabled_binary_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + async def test_enabled_legacy_sensor( hass: HomeAssistant, ecolink_door_sensor, integration diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index c93b722334b..c3580df1f27 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -1,6 +1,7 @@ """Test the Z-Wave JS sensor platform.""" import copy +from datetime import timedelta import pytest from zwave_js_server.const.command_class.meter import MeterType @@ -26,6 +27,7 @@ from homeassistant.components.zwave_js.sensor import ( CONTROLLER_STATISTICS_KEY_MAP, NODE_STATISTICS_KEY_MAP, ) +from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, @@ -35,6 +37,7 @@ from homeassistant.const import ( STATE_UNKNOWN, UV_INDEX, EntityCategory, + Platform, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, @@ -45,6 +48,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .common import ( AIR_TEMPERATURE_SENSOR, @@ -57,7 +61,94 @@ from .common import ( VOLTAGE_SENSOR, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + +async def test_battery_sensors( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + ring_keypad: Node, + integration: MockConfigEntry, +) -> None: + """Test numeric battery sensors.""" + entity_id = "sensor.keypad_v2_battery_level" + state = hass.states.get(entity_id) + assert state + assert state.state == "100.0" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + + disabled_sensor_battery_entities = ( + "sensor.keypad_v2_chargingstatus", + "sensor.keypad_v2_maximum_capacity", + "sensor.keypad_v2_rechargeorreplace", + "sensor.keypad_v2_temperature", + ) + + for entity_id in disabled_sensor_battery_entities: + state = hass.states.get(entity_id) + assert state is None # disabled by default + + entity_entry = entity_registry.async_get(entity_id) + + assert entity_entry + assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC + assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + entity_registry.async_update_entity(entity_id, disabled_by=None) + + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() + + entity_id = "sensor.keypad_v2_chargingstatus" + state = hass.states.get(entity_id) + assert state + assert state.state == "Maintaining" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert ATTR_STATE_CLASS not in state.attributes + + entity_id = "sensor.keypad_v2_maximum_capacity" + state = hass.states.get(entity_id) + assert state + assert ( + state.state == "0" + ) # This should be None/unknown but will be fixed in a future PR. + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE + assert ATTR_DEVICE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + entity_id = "sensor.keypad_v2_rechargeorreplace" + state = hass.states.get(entity_id) + assert state + assert state.state == "No" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert ATTR_STATE_CLASS not in state.attributes + + entity_id = "sensor.keypad_v2_temperature" + state = hass.states.get(entity_id) + assert state + assert ( + state.state == "0" + ) # This should be None/unknown but will be fixed in a future PR. + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT async def test_numeric_sensor( From 458aa3cc2272868852e03b723e57fe9bdda4d37b Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 20 Jun 2025 18:39:43 +1000 Subject: [PATCH 0525/1664] Fix Charge Cable binary sensor in Teslemetry (#147136) --- homeassistant/components/teslemetry/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index a32c5fea40e..439df76c838 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -126,7 +126,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( polling=True, polling_value_fn=lambda x: x != "", streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value != "Unknown") + lambda value: callback(value is not None and value != "Unknown") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, From 3534396028d606ba20c8cd72ca828e48c6221993 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 19 Jun 2025 17:42:30 +0200 Subject: [PATCH 0526/1664] [ci] Bump cache key version (#147148) --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af0bdc5c2df..bde1635f692 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 2 + CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.6" From 802fcab1c6cfeede2d67ff10e396f38c0bddfe75 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:55:14 +0200 Subject: [PATCH 0527/1664] Bump homematicip to 2.0.6 (#147151) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 163f3c402dc..d5af2859873 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.5"] + "requirements": ["homematicip==2.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6d09a5f9037..11bb82d6557 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1170,7 +1170,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.5 +homematicip==2.0.6 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c65be62ef2..daf7193ee3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1016,7 +1016,7 @@ home-assistant-frontend==20250531.3 home-assistant-intents==2025.6.10 # homeassistant.components.homematicip_cloud -homematicip==2.0.5 +homematicip==2.0.6 # homeassistant.components.remember_the_milk httplib2==0.20.4 From 7cc6e28916862562999621d34926d3eead8bccd9 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 23 Jun 2025 14:10:50 +0200 Subject: [PATCH 0528/1664] Wallbox fix too many requests by API (#147197) --- homeassistant/components/wallbox/const.py | 2 +- .../components/wallbox/coordinator.py | 177 ++++++--- homeassistant/components/wallbox/lock.py | 4 +- homeassistant/components/wallbox/number.py | 4 +- homeassistant/components/wallbox/sensor.py | 6 - homeassistant/components/wallbox/strings.json | 3 + tests/components/wallbox/__init__.py | 21 + tests/components/wallbox/test_config_flow.py | 142 +++---- tests/components/wallbox/test_init.py | 125 ++++-- tests/components/wallbox/test_lock.py | 124 ++++-- tests/components/wallbox/test_number.py | 370 +++++++++++------- tests/components/wallbox/test_select.py | 37 +- tests/components/wallbox/test_switch.py | 131 +++---- 13 files changed, 692 insertions(+), 454 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 5aa659a0527..34d17e52275 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -3,7 +3,7 @@ from enum import StrEnum DOMAIN = "wallbox" -UPDATE_INTERVAL = 30 +UPDATE_INTERVAL = 60 BIDIRECTIONAL_MODEL_PREFIXES = ["QS"] diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 8276ee14eaf..598bfa7429a 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -90,7 +90,9 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error return require_authentication @@ -137,56 +139,65 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _get_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - # Only show max_icp_current if power_boost is available in the wallbox unit: - if ( - data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 - and CHARGER_POWER_BOOST_KEY - in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] - ): - data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_ICP_CURRENT_KEY + try: + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY ] + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + # Only show max_icp_current if power_boost is available in the wallbox unit: + if ( + data[CHARGER_DATA_KEY].get(CHARGER_MAX_ICP_CURRENT_KEY, 0) > 0 + and CHARGER_POWER_BOOST_KEY + in data[CHARGER_DATA_KEY][CHARGER_PLAN_KEY][CHARGER_FEATURES_KEY] + ): + data[CHARGER_MAX_ICP_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_ICP_CURRENT_KEY + ] - data[CHARGER_CURRENCY_KEY] = ( - f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - ) + data[CHARGER_CURRENCY_KEY] = ( + f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + ) - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) - # Set current solar charging mode - eco_smart_enabled = ( - data[CHARGER_DATA_KEY] - .get(CHARGER_ECO_SMART_KEY, {}) - .get(CHARGER_ECO_SMART_STATUS_KEY) - ) + # Set current solar charging mode + eco_smart_enabled = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_STATUS_KEY) + ) - eco_smart_mode = ( - data[CHARGER_DATA_KEY] - .get(CHARGER_ECO_SMART_KEY, {}) - .get(CHARGER_ECO_SMART_MODE_KEY) - ) - if eco_smart_mode is None: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED - elif eco_smart_enabled is False: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF - elif eco_smart_mode == 0: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE - elif eco_smart_mode == 1: - data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR + eco_smart_mode = ( + data[CHARGER_DATA_KEY] + .get(CHARGER_ECO_SMART_KEY, {}) + .get(CHARGER_ECO_SMART_MODE_KEY) + ) + if eco_smart_mode is None: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.DISABLED + elif eco_smart_enabled is False: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.OFF + elif eco_smart_mode == 0: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE + elif eco_smart_mode == 1: + data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR - return data + return data # noqa: TRY300 + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def _async_update_data(self) -> dict[str, Any]: """Get new sensor data for Wallbox component.""" @@ -200,7 +211,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" @@ -217,7 +234,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" @@ -227,8 +250,16 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - - self._wallbox.setEnergyCost(self._station, energy_cost) + try: + self._wallbox.setEnergyCost(self._station, energy_cost) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" @@ -246,7 +277,13 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error - raise + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" @@ -256,11 +293,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" - - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) + try: + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_pause_charger(self, pause: bool) -> None: """Set wallbox to pause or resume.""" @@ -270,13 +315,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): @_require_authentication def _set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" - - if option == EcoSmartMode.ECO_MODE: - self._wallbox.enableEcoSmart(self._station, 0) - elif option == EcoSmartMode.FULL_SOLAR: - self._wallbox.enableEcoSmart(self._station, 1) - else: - self._wallbox.disableEcoSmart(self._station) + try: + if option == EcoSmartMode.ECO_MODE: + self._wallbox.enableEcoSmart(self._station, 0) + elif option == EcoSmartMode.FULL_SOLAR: + self._wallbox.enableEcoSmart(self._station, 1) + else: + self._wallbox.disableEcoSmart(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 429: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="too_many_requests" + ) from wallbox_connection_error + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="api_failed" + ) from wallbox_connection_error async def async_set_eco_smart(self, option: str) -> None: """Set wallbox solar charging mode.""" diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index ef35734ed7e..7acc56f67f2 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -41,7 +41,7 @@ async def async_setup_entry( ) except InvalidAuth: return - except ConnectionError as exc: + except HomeAssistantError as exc: raise PlatformNotReady from exc async_add_entities( diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index a5880f6e0f7..80773478582 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,7 @@ from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -93,7 +93,7 @@ async def async_setup_entry( ) except InvalidAuth: return - except ConnectionError as exc: + except HomeAssistantError as exc: raise PlatformNotReady from exc async_add_entities( diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 4b0ec8175e3..e19fc2b936a 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging from typing import cast from homeassistant.components.sensor import ( @@ -49,11 +48,6 @@ from .const import ( from .coordinator import WallboxCoordinator from .entity import WallboxEntity -CHARGER_STATION = "station" -UPDATE_INTERVAL = 30 - -_LOGGER = logging.getLogger(__name__) - @dataclass(frozen=True) class WallboxSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 68602a960c2..ee98a4855e3 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -112,6 +112,9 @@ "exceptions": { "api_failed": { "message": "Error communicating with Wallbox API" + }, + "too_many_requests": { + "message": "Error communicating with Wallbox API, too many requests" } } } diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 83e39d2f602..37e7d5059f0 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -162,6 +162,9 @@ test_response_no_power_boost = { http_404_error = requests.exceptions.HTTPError() http_404_error.response = requests.Response() http_404_error.response.status_code = HTTPStatus.NOT_FOUND +http_429_error = requests.exceptions.HTTPError() +http_429_error.response = requests.Response() +http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS authorisation_response = { "data": { @@ -192,6 +195,24 @@ authorisation_response_unauthorised = { } } +invalid_reauth_response = { + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, +} + +http_403_error = requests.exceptions.HTTPError() +http_403_error.response = requests.Response() +http_403_error.response.status_code = HTTPStatus.FORBIDDEN + +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND + async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test wallbox sensor class setup.""" diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index 467e20c51c1..bdfb4cad18d 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -1,9 +1,6 @@ """Test the Wallbox config flow.""" -from http import HTTPStatus -import json - -import requests_mock +from unittest.mock import Mock, patch from homeassistant import config_entries from homeassistant.components.wallbox import config_flow @@ -24,23 +21,21 @@ from homeassistant.data_entry_flow import FlowResultType from . import ( authorisation_response, authorisation_response_unauthorised, + http_403_error, + http_404_error, setup_integration, ) from tests.common import MockConfigEntry -test_response = json.loads( - json.dumps( - { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_MAX_AVAILABLE_POWER_KEY: "xx", - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: "xx", - CHARGER_ADDED_ENERGY_KEY: "44.697", - CHARGER_DATA_KEY: {CHARGER_MAX_CHARGING_CURRENT_KEY: 24}, - } - ) -) +test_response = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_MAX_AVAILABLE_POWER_KEY: "xx", + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: "xx", + CHARGER_ADDED_ENERGY_KEY: "44.697", + CHARGER_DATA_KEY: {CHARGER_MAX_CHARGING_CURRENT_KEY: 24}, +} async def test_show_set_form(hass: HomeAssistant) -> None: @@ -59,17 +54,16 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -89,17 +83,16 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response_unauthorised, - status_code=HTTPStatus.NOT_FOUND, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.NOT_FOUND, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_404_error), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -119,17 +112,16 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -148,18 +140,16 @@ async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response_unauthorised), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result = await entry.start_reauth_flow(hass) result2 = await hass.config_entries.flow.async_configure( @@ -183,26 +173,16 @@ async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json={ - "jwt": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - "user_id": 12345, - "ttl": 145656758, - "refresh_token_ttl": 145756758, - "error": False, - "status": 200, - }, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response_unauthorised), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), + ): result = await entry.start_reauth_flow(hass) result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 6d6a5cd1417..5048385aaf6 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,16 +1,15 @@ """Test Wallbox Init Component.""" -import requests_mock +from unittest.mock import Mock, patch -from homeassistant.components.wallbox.const import ( - CHARGER_MAX_CHARGING_CURRENT_KEY, - DOMAIN, -) +from homeassistant.components.wallbox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from . import ( authorisation_response, + http_403_error, + http_429_error, setup_integration, setup_integration_connection_error, setup_integration_no_eco_mode, @@ -53,18 +52,16 @@ async def test_wallbox_refresh_failed_connection_error_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=404, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(return_value=test_response), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -81,18 +78,68 @@ async def test_wallbox_refresh_failed_invalid_auth( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=403, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=403, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): + wallbox = hass.data[DOMAIN][entry.entry_id] + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_refresh_failed_http_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with authentication error.""" + + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(side_effect=http_403_error), + ), + ): + wallbox = hass.data[DOMAIN][entry.entry_id] + + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED + + +async def test_wallbox_refresh_failed_too_many_requests( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with authentication error.""" + + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(side_effect=http_429_error), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -109,18 +156,16 @@ async def test_wallbox_refresh_failed_connection_error( await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=403, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(side_effect=http_403_error), + ), + ): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 1d48e53b515..5842d708f11 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -1,15 +1,18 @@ """Test Wallbox Lock component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_429_error, setup_integration, setup_integration_platform_not_ready, setup_integration_read_only, @@ -28,18 +31,20 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - assert state assert state.state == "unlocked" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_LOCKED_UNLOCKED_KEY: False}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + ), + ): await hass.services.async_call( "lock", SERVICE_LOCK, @@ -66,36 +71,73 @@ async def test_wallbox_lock_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_LOCKED_UNLOCKED_KEY: False}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=ConnectionError), + ), + pytest.raises(ConnectionError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "lock", - SERVICE_LOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "lock", - SERVICE_UNLOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=ConnectionError), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=ConnectionError), + ), + pytest.raises(ConnectionError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) async def test_wallbox_lock_class_authentication_error( diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c319668c161..c603ae24106 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -1,22 +1,26 @@ """Test Wallbox Switch component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox import InvalidAuth from homeassistant.components.wallbox.const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_MAX_ICP_CURRENT_KEY, ) +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_403_error, + http_404_error, + http_429_error, setup_integration, setup_integration_bidir, setup_integration_platform_not_ready, @@ -29,6 +33,14 @@ from .const import ( from tests.common import MockConfigEntry +mock_wallbox = Mock() +mock_wallbox.authenticate = Mock(return_value=authorisation_response) +mock_wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}) +mock_wallbox.setMaxChargingCurrent = Mock( + return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20} +) +mock_wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}) + async def test_wallbox_number_class( hass: HomeAssistant, entry: MockConfigEntry @@ -37,17 +49,16 @@ async def test_wallbox_number_class( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=200, - ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + ), + ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) assert state.attributes["min"] == 6 assert state.attributes["max"] == 25 @@ -82,19 +93,16 @@ async def test_wallbox_number_energy_class( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}), + ), + ): await hass.services.async_call( "number", SERVICE_SET_VALUE, @@ -113,59 +121,113 @@ async def test_wallbox_number_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, - ATTR_VALUE: 20, - }, - blocking=True, - ) - -async def test_wallbox_number_class_energy_price_connection_error( +async def test_wallbox_number_class_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) + +async def test_wallbox_number_class_energy_price_update_failed( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + +async def test_wallbox_number_class_energy_price_update_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) async def test_wallbox_number_class_energy_price_auth_error( @@ -175,28 +237,26 @@ async def test_wallbox_number_class_energy_price_auth_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setEnergyCost", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_ENERGY_PRICE_KEY: 1.1}, - status_code=403, - ) - - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) async def test_wallbox_number_class_platform_not_ready( @@ -218,19 +278,16 @@ async def test_wallbox_number_class_icp_energy( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + ), + ): await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, @@ -249,28 +306,26 @@ async def test_wallbox_number_class_icp_energy_auth_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=403, - ) - - with pytest.raises(InvalidAuth): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) async def test_wallbox_number_class_icp_energy_connection_error( @@ -280,25 +335,52 @@ async def test_wallbox_number_class_icp_energy_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/chargers/config/12345", - json={CHARGER_MAX_ICP_CURRENT_KEY: 10}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) + +async def test_wallbox_number_class_icp_energy_too_many_request( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index 516b1e87c27..f59a8367b41 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant, HomeAssistantError from . import ( authorisation_response, http_404_error, + http_429_error, setup_integration_select, test_response, test_response_eco_mode, @@ -109,7 +110,41 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), - pytest.raises(HomeAssistantError, match="Error communicating with Wallbox API"), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_too_many_requests_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + mock_authenticate, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration_select(hass, entry, response) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.disableEcoSmart", + new=Mock(side_effect=http_429_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.enableEcoSmart", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( SELECT_DOMAIN, diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index b7c3a81dc73..eb983ca44ce 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -1,15 +1,16 @@ """Test Wallbox Lock component.""" +from unittest.mock import Mock, patch + import pytest -import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, setup_integration +from . import authorisation_response, http_404_error, http_429_error, setup_integration from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -26,18 +27,20 @@ async def test_wallbox_switch_class( assert state assert state.state == "on" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=200, - ) - + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), + ), + ): await hass.services.async_call( "switch", SERVICE_TURN_ON, @@ -64,72 +67,52 @@ async def test_wallbox_switch_class_connection_error( await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, - ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=404, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), + ): + # Test behavior when a connection error occurs + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConnectionError): - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - -async def test_wallbox_switch_class_authentication_error( +async def test_wallbox_switch_class_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox switch class connection error.""" await setup_integration(hass, entry) - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=200, + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.resumeChargingSession", + new=Mock(side_effect=http_429_error), + ), + pytest.raises(HomeAssistantError), + ): + # Test behavior when a connection error occurs + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, ) - mock_request.post( - "https://api.wall-box.com/v3/chargers/12345/remote-action", - json={CHARGER_STATUS_ID_KEY: 193}, - status_code=403, - ) - - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) - with pytest.raises(ConfigEntryAuthFailed): - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) From ddf8e0de4bed1d96c39b0d6bfb01b0d7645eed60 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 20 Jun 2025 20:22:33 +0200 Subject: [PATCH 0529/1664] Bump deebot-client to 13.4.0 (#147221) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 8a7388da735..97739f698d9 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 11bb82d6557..549ff32cd2a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -765,7 +765,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.3.0 +deebot-client==13.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daf7193ee3a..d366dd5c92d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -665,7 +665,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.3.0 +deebot-client==13.4.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 60be2cb1683d9ede1ca5819ef993bda867426bb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sat, 21 Jun 2025 10:53:17 +0100 Subject: [PATCH 0530/1664] Handle the new JSON payload from traccar clients (#147254) --- homeassistant/components/traccar/__init__.py | 42 ++++++++++++++--- homeassistant/components/traccar/const.py | 1 - tests/components/traccar/test_init.py | 47 +++++++++++++++++++- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py index 5b9bc2551b7..e8c151179ce 100644 --- a/homeassistant/components/traccar/__init__.py +++ b/homeassistant/components/traccar/__init__.py @@ -1,9 +1,12 @@ """Support for Traccar Client.""" from http import HTTPStatus +from json import JSONDecodeError +import logging from aiohttp import web import voluptuous as vol +from voluptuous.humanize import humanize_error from homeassistant.components import webhook from homeassistant.config_entries import ConfigEntry @@ -20,7 +23,6 @@ from .const import ( ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_SPEED, - ATTR_TIMESTAMP, DOMAIN, ) @@ -29,6 +31,7 @@ PLATFORMS = [Platform.DEVICE_TRACKER] TRACKER_UPDATE = f"{DOMAIN}_tracker_update" +LOGGER = logging.getLogger(__name__) DEFAULT_ACCURACY = 200 DEFAULT_BATTERY = -1 @@ -49,21 +52,50 @@ WEBHOOK_SCHEMA = vol.Schema( vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float), vol.Optional(ATTR_BEARING): vol.Coerce(float), vol.Optional(ATTR_SPEED): vol.Coerce(float), - vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int), }, extra=vol.REMOVE_EXTRA, ) +def _parse_json_body(json_body: dict) -> dict: + """Parse JSON body from request.""" + location = json_body.get("location", {}) + coords = location.get("coords", {}) + battery_level = location.get("battery", {}).get("level") + return { + "id": json_body.get("device_id"), + "lat": coords.get("latitude"), + "lon": coords.get("longitude"), + "accuracy": coords.get("accuracy"), + "altitude": coords.get("altitude"), + "batt": battery_level * 100 if battery_level is not None else DEFAULT_BATTERY, + "bearing": coords.get("heading"), + "speed": coords.get("speed"), + } + + async def handle_webhook( - hass: HomeAssistant, webhook_id: str, request: web.Request + hass: HomeAssistant, + webhook_id: str, + request: web.Request, ) -> web.Response: """Handle incoming webhook with Traccar Client request.""" + if not (requestdata := dict(request.query)): + try: + requestdata = _parse_json_body(await request.json()) + except JSONDecodeError as error: + LOGGER.error("Error parsing JSON body: %s", error) + return web.Response( + text="Invalid JSON", + status=HTTPStatus.UNPROCESSABLE_ENTITY, + ) try: - data = WEBHOOK_SCHEMA(dict(request.query)) + data = WEBHOOK_SCHEMA(requestdata) except vol.MultipleInvalid as error: + LOGGER.warning(humanize_error(requestdata, error)) return web.Response( - text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY + text=error.error_message, + status=HTTPStatus.UNPROCESSABLE_ENTITY, ) attrs = { diff --git a/homeassistant/components/traccar/const.py b/homeassistant/components/traccar/const.py index df4bfa8ec99..f6928cc9ee9 100644 --- a/homeassistant/components/traccar/const.py +++ b/homeassistant/components/traccar/const.py @@ -17,7 +17,6 @@ ATTR_LONGITUDE = "lon" ATTR_MOTION = "motion" ATTR_SPEED = "speed" ATTR_STATUS = "status" -ATTR_TIMESTAMP = "timestamp" ATTR_TRACKER = "tracker" ATTR_TRACCAR_ID = "traccar_id" diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py index fb90262a084..eb864cadd87 100644 --- a/tests/components/traccar/test_init.py +++ b/tests/components/traccar/test_init.py @@ -146,8 +146,12 @@ async def test_enter_and_exit( assert len(entity_registry.entities) == 1 -async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None: - """Test when additional attributes are present.""" +async def test_enter_with_attrs_as_query( + hass: HomeAssistant, + client, + webhook_id, +) -> None: + """Test when additional attributes are present URL query.""" url = f"/api/webhook/{webhook_id}" data = { "timestamp": 123456789, @@ -197,6 +201,45 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None assert state.attributes["altitude"] == 123 +async def test_enter_with_attrs_as_payload( + hass: HomeAssistant, client, webhook_id +) -> None: + """Test when additional attributes are present in JSON payload.""" + url = f"/api/webhook/{webhook_id}" + data = { + "location": { + "coords": { + "heading": "105.32", + "latitude": "1.0", + "longitude": "1.1", + "accuracy": 10.5, + "altitude": 102.0, + "speed": 100.0, + }, + "extras": {}, + "manual": True, + "is_moving": False, + "_": "&id=123&lat=1.0&lon=1.1×tamp=2013-09-17T07:32:51Z&", + "odometer": 0, + "activity": {"type": "still"}, + "timestamp": "2013-09-17T07:32:51Z", + "battery": {"level": 0.1, "is_charging": False}, + }, + "device_id": "123", + } + + req = await client.post(url, json=data) + await hass.async_block_till_done() + assert req.status == HTTPStatus.OK + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device_id']}") + assert state.state == STATE_NOT_HOME + assert state.attributes["gps_accuracy"] == 10.5 + assert state.attributes["battery_level"] == 10.0 + assert state.attributes["speed"] == 100.0 + assert state.attributes["bearing"] == 105.32 + assert state.attributes["altitude"] == 102.0 + + async def test_two_devices(hass: HomeAssistant, client, webhook_id) -> None: """Test updating two different devices.""" url = f"/api/webhook/{webhook_id}" From f7d933444526e34435e84642ab1e7278d7e12e8c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 21 Jun 2025 16:44:22 +0300 Subject: [PATCH 0531/1664] Bump aioamazondevices to 3.1.14 (#147257) --- 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 aeecb5bc96c..a2bb423860b 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": "bronze", - "requirements": ["aioamazondevices==3.1.12"] + "requirements": ["aioamazondevices==3.1.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 549ff32cd2a..4ccd6a41826 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.12 +aioamazondevices==3.1.14 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d366dd5c92d..9f4ff3f88f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.12 +aioamazondevices==3.1.14 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From f9d5cb957f9300b7a9260587c501d1ab7d555fc9 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 22 Jun 2025 02:14:26 +0200 Subject: [PATCH 0532/1664] Bump uiprotect to version 7.14.1 (#147280) --- 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 f99d910adf9..47e2a01e798 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.14.0", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4ccd6a41826..4d365187ac1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2987,7 +2987,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.0 +uiprotect==7.14.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f4ff3f88f9..b29649985c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2458,7 +2458,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.0 +uiprotect==7.14.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 9d0701198f339a526a1f64b6c67a80dead9fa54f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 18 Jun 2025 15:49:44 -0500 Subject: [PATCH 0533/1664] Bump aioesphomeapi to 32.2.4 (#147100) Bump aioesphomeapi --- 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 9b70aba4de1..6142b9ce5ec 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==32.2.1", + "aioesphomeapi==32.2.4", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 4d365187ac1..7a36e7d7c7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b29649985c3..126585e626a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.1 +aioesphomeapi==32.2.4 # homeassistant.components.flo aioflo==2021.11.0 From 105618734c6631573fbbdeb89d5af71c9d3d21e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Jun 2025 16:24:16 +0200 Subject: [PATCH 0534/1664] Bump aioesphomeapi to 33.0.0 (#147296) fixes compat warning with protobuf 6.x changelog: https://github.com/esphome/aioesphomeapi/compare/v32.2.4...v33.0.0 Not a breaking change for HA since we are already on protobuf 6 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 6142b9ce5ec..0577ed10c19 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==32.2.4", + "aioesphomeapi==33.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 7a36e7d7c7d..2ce35c3fd8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.4 +aioesphomeapi==33.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 126585e626a..8ca017a72c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==32.2.4 +aioesphomeapi==33.0.0 # homeassistant.components.flo aioflo==2021.11.0 From dfd42863bb23464f9f0ffce56317ad4d04dd4c35 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 23 Jun 2025 15:38:16 +0300 Subject: [PATCH 0535/1664] Fix reload for Shelly devices with no script support (#147344) --- homeassistant/components/shelly/coordinator.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index cba559a9773..fa434588b34 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -835,6 +835,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): except InvalidAuthError: self.config_entry.async_start_reauth(self.hass) return + except RpcCallError as err: + # Ignore 404 (No handler for) error + if err.code != 404: + LOGGER.debug( + "Error during shutdown for device %s: %s", + self.name, + err.message, + ) + return except DeviceConnectionError as err: # If the device is restarting or has gone offline before # the ping/pong timeout happens, the shutdown command From 570315687f85a5daf3bf98c6bdc35dfa98749eee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Jun 2025 12:55:05 +0000 Subject: [PATCH 0536/1664] Bump version to 2025.6.2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9d5ed69f8d7..6b51f735e97 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 226420c7605..fc66f3f8d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.1" +version = "2025.6.2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 7ec2e0c52497e2b88ff52caee668453fce436674 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:10:12 +0200 Subject: [PATCH 0537/1664] Move lyric coordinator to separate module (#147357) --- homeassistant/components/lyric/__init__.py | 60 +------------ homeassistant/components/lyric/climate.py | 9 +- homeassistant/components/lyric/coordinator.py | 87 +++++++++++++++++++ homeassistant/components/lyric/entity.py | 14 ++- homeassistant/components/lyric/sensor.py | 9 +- 5 files changed, 105 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/lyric/coordinator.py diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index f99adf26999..8ec9785cef2 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -2,25 +2,16 @@ from __future__ import annotations -import asyncio -from datetime import timedelta -from http import HTTPStatus -import logging - -from aiohttp.client_exceptions import ClientResponseError from aiolyric import Lyric -from aiolyric.exceptions import LyricAuthenticationException, LyricException from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import ( aiohttp_client, config_entry_oauth2_flow, config_validation as cv, ) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .api import ( ConfigEntryLyricClient, @@ -28,11 +19,10 @@ from .api import ( OAuth2SessionLyric, ) from .const import DOMAIN +from .coordinator import LyricDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] @@ -54,53 +44,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client_id = implementation.client_id lyric = Lyric(client, client_id) - async def async_update_data(force_refresh_token: bool = False) -> Lyric: - """Fetch data from Lyric.""" - try: - if not force_refresh_token: - await oauth_session.async_ensure_token_valid() - else: - await oauth_session.force_refresh_token() - except ClientResponseError as exception: - if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - raise ConfigEntryAuthFailed from exception - raise UpdateFailed(exception) from exception - - try: - async with asyncio.timeout(60): - await lyric.get_locations() - await asyncio.gather( - *( - lyric.get_thermostat_rooms( - location.location_id, device.device_id - ) - for location in lyric.locations - for device in location.devices - if device.device_class == "Thermostat" - and device.device_id.startswith("LCC") - ) - ) - - except LyricAuthenticationException as exception: - # Attempt to refresh the token before failing. - # Honeywell appear to have issues keeping tokens saved. - _LOGGER.debug("Authentication failed. Attempting to refresh token") - if not force_refresh_token: - return await async_update_data(force_refresh_token=True) - raise ConfigEntryAuthFailed from exception - except (LyricException, ClientResponseError) as exception: - raise UpdateFailed(exception) from exception - return lyric - - coordinator = DataUpdateCoordinator[Lyric]( + coordinator = LyricDataUpdateCoordinator( hass, - _LOGGER, config_entry=entry, - # Name of the data. For logging purposes. - name="lyric_coordinator", - update_method=async_update_data, - # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=300), + oauth_session=oauth_session, + lyric=lyric, ) # Fetch initial data so we have data when entities subscribe diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index ffcf08b927a..4aeccf991d5 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -8,7 +8,6 @@ import logging from time import localtime, strftime, time from typing import Any -from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation import voluptuous as vol @@ -37,7 +36,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, @@ -48,6 +46,7 @@ from .const import ( PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) +from .coordinator import LyricDataUpdateCoordinator from .entity import LyricDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -126,7 +125,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric climate platform based on a config entry.""" - coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id] + coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( ( @@ -164,7 +163,7 @@ class LyricThermostatType(enum.Enum): class LyricClimate(LyricDeviceEntity, ClimateEntity): """Defines a Honeywell Lyric climate entity.""" - coordinator: DataUpdateCoordinator[Lyric] + coordinator: LyricDataUpdateCoordinator entity_description: ClimateEntityDescription _attr_name = None @@ -178,7 +177,7 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, description: ClimateEntityDescription, location: LyricLocation, device: LyricDevice, diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py new file mode 100644 index 00000000000..c177e233516 --- /dev/null +++ b/homeassistant/components/lyric/coordinator.py @@ -0,0 +1,87 @@ +"""The Honeywell Lyric integration.""" + +from __future__ import annotations + +import asyncio +from datetime import timedelta +from http import HTTPStatus +import logging + +from aiohttp.client_exceptions import ClientResponseError +from aiolyric import Lyric +from aiolyric.exceptions import LyricAuthenticationException, LyricException + +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 .api import OAuth2SessionLyric + +_LOGGER = logging.getLogger(__name__) + + +class LyricDataUpdateCoordinator(DataUpdateCoordinator[Lyric]): + """Data update coordinator for Honeywell Lyric.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + oauth_session: OAuth2SessionLyric, + lyric: Lyric, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name="lyric_coordinator", + update_interval=timedelta(seconds=300), + ) + self.oauth_session = oauth_session + self.lyric = lyric + + async def _async_update_data(self) -> Lyric: + """Fetch data from Lyric.""" + return await self._run_update(False) + + async def _run_update(self, force_refresh_token: bool) -> Lyric: + """Fetch data from Lyric.""" + try: + if not force_refresh_token: + await self.oauth_session.async_ensure_token_valid() + else: + await self.oauth_session.force_refresh_token() + except ClientResponseError as exception: + if exception.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): + raise ConfigEntryAuthFailed from exception + raise UpdateFailed(exception) from exception + + try: + async with asyncio.timeout(60): + await self.lyric.get_locations() + await asyncio.gather( + *( + self.lyric.get_thermostat_rooms( + location.location_id, device.device_id + ) + for location in self.lyric.locations + for device in location.devices + if device.device_class == "Thermostat" + and device.device_id.startswith("LCC") + ) + ) + + except LyricAuthenticationException as exception: + # Attempt to refresh the token before failing. + # Honeywell appear to have issues keeping tokens saved. + _LOGGER.debug("Authentication failed. Attempting to refresh token") + if not force_refresh_token: + return await self._run_update(True) + raise ConfigEntryAuthFailed from exception + except (LyricException, ClientResponseError) as exception: + raise UpdateFailed(exception) from exception + return self.lyric diff --git a/homeassistant/components/lyric/entity.py b/homeassistant/components/lyric/entity.py index 5a5a76f1442..61ba384b861 100644 --- a/homeassistant/components/lyric/entity.py +++ b/homeassistant/components/lyric/entity.py @@ -2,27 +2,25 @@ from __future__ import annotations -from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation from aiolyric.objects.priority import LyricAccessory, LyricRoom from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import LyricDataUpdateCoordinator -class LyricEntity(CoordinatorEntity[DataUpdateCoordinator[Lyric]]): +class LyricEntity(CoordinatorEntity[LyricDataUpdateCoordinator]): """Defines a base Honeywell Lyric entity.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, location: LyricLocation, device: LyricDevice, key: str, @@ -71,7 +69,7 @@ class LyricAccessoryEntity(LyricDeviceEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, location: LyricLocation, device: LyricDevice, room: LyricRoom, diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 065ee0fba9d..ffebb8056cd 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from aiolyric import Lyric from aiolyric.objects.device import LyricDevice from aiolyric.objects.location import LyricLocation from aiolyric.objects.priority import LyricAccessory, LyricRoom @@ -22,7 +21,6 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from .const import ( @@ -33,6 +31,7 @@ from .const import ( PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) +from .coordinator import LyricDataUpdateCoordinator from .entity import LyricAccessoryEntity, LyricDeviceEntity LYRIC_SETPOINT_STATUS_NAMES = { @@ -164,7 +163,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric sensor platform based on a config entry.""" - coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id] + coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( LyricSensor( @@ -199,7 +198,7 @@ class LyricSensor(LyricDeviceEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, description: LyricSensorEntityDescription, location: LyricLocation, device: LyricDevice, @@ -231,7 +230,7 @@ class LyricAccessorySensor(LyricAccessoryEntity, SensorEntity): def __init__( self, - coordinator: DataUpdateCoordinator[Lyric], + coordinator: LyricDataUpdateCoordinator, description: LyricSensorAccessoryEntityDescription, location: LyricLocation, parentDevice: LyricDevice, From d38c880c458d7ff04c06a998a3eaa7f55ad4bb3a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Jun 2025 15:32:57 +0200 Subject: [PATCH 0538/1664] Bump demetriek to 1.3.0 (#147350) * Bump demetriek to 1.3.0 * Fix --- homeassistant/components/lametric/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/lametric/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json index 4c4359d0ddb..d6aceaaebdb 100644 --- a/homeassistant/components/lametric/manifest.json +++ b/homeassistant/components/lametric/manifest.json @@ -13,7 +13,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["demetriek"], - "requirements": ["demetriek==1.2.0"], + "requirements": ["demetriek==1.3.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:LaMetric:1" diff --git a/requirements_all.txt b/requirements_all.txt index b2e4d536b0c..980d443db58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -776,7 +776,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.2.0 +demetriek==1.3.0 # homeassistant.components.denonavr denonavr==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 35596bb12f2..3e83587684e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -676,7 +676,7 @@ defusedxml==0.7.1 deluge-client==1.10.2 # homeassistant.components.lametric -demetriek==1.2.0 +demetriek==1.3.0 # homeassistant.components.denonavr denonavr==1.1.1 diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index d8f21424216..bc16e318a73 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -46,6 +46,7 @@ 'name': '**REDACTED**', 'os_version': '2.2.2', 'serial_number': '**REDACTED**', + 'update': None, 'wifi': dict({ 'active': True, 'available': True, From 9ae3129f161eb661e0d444af85a2be74e0b06f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Jun 2025 16:04:40 +0200 Subject: [PATCH 0539/1664] Matter battery storage (#147235) * BatCapacity * BatCapacity * PowerSourceBatTimeRemaining * BatChargeState * Update strings.json Co-authored-by: Norbert Rittel * Review fixes * Remove uneeded BatCapacity * Update strings.json * Update strings.json Co-authored-by: Joost Lekkerkerker * Update snapshots * Update strings.json * Update snapshot --------- Co-authored-by: Norbert Rittel Co-authored-by: Joost Lekkerkerker --- homeassistant/components/matter/icons.json | 9 + homeassistant/components/matter/sensor.py | 50 ++ homeassistant/components/matter/strings.json | 14 + tests/components/matter/conftest.py | 1 + .../fixtures/nodes/battery_storage.json | 271 ++++++++++ .../matter/snapshots/test_sensor.ambr | 471 ++++++++++++++++++ 6 files changed, 816 insertions(+) create mode 100644 tests/components/matter/fixtures/nodes/battery_storage.json diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index c71a5d07e24..2d7e2888896 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -81,9 +81,18 @@ "valve_position": { "default": "mdi:valve" }, + "battery_charge_state": { + "default": "mdi:battery-charging" + }, "battery_replacement_description": { "default": "mdi:battery-sync-outline" }, + "battery_time_remaining": { + "default": "mdi:battery-clock-outline" + }, + "battery_time_to_full_charge": { + "default": "mdi:battery-clock" + }, "evse_state": { "default": "mdi:ev-station" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 9cab1a2c02f..0eefb536bcf 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -38,6 +38,7 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, UnitOfVolume, UnitOfVolumeFlowRate, ) @@ -84,6 +85,14 @@ BOOST_STATE_MAP = { clusters.WaterHeaterManagement.Enums.BoostStateEnum.kUnknownEnumValue: None, } +CHARGE_STATE_MAP = { + clusters.PowerSource.Enums.BatChargeStateEnum.kUnknown: None, + clusters.PowerSource.Enums.BatChargeStateEnum.kIsNotCharging: "not_charging", + clusters.PowerSource.Enums.BatChargeStateEnum.kIsCharging: "charging", + clusters.PowerSource.Enums.BatChargeStateEnum.kIsAtFullCharge: "full_charge", + clusters.PowerSource.Enums.BatChargeStateEnum.kUnknownEnumValue: None, +} + ESA_STATE_MAP = { clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline", clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online", @@ -355,6 +364,47 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatVoltage,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatTimeRemaining", + translation_key="battery_time_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatTimeRemaining,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatChargeState", + translation_key="battery_charge_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[state for state in CHARGE_STATE_MAP.values() if state is not None], + measurement_to_ha=CHARGE_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatChargeState,), + ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="PowerSourceBatTimeToFullCharge", + translation_key="battery_time_to_full_charge", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.PowerSource.Attributes.BatTimeToFullCharge,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 01c8d74426e..c713dfba615 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -330,6 +330,20 @@ "battery_replacement_description": { "name": "Battery type" }, + "battery_charge_state": { + "name": "Battery charge state", + "state": { + "charging": "[%key:common::state::charging%]", + "full_charge": "Full charge", + "not_charging": "Not charging" + } + }, + "battery_time_remaining": { + "name": "Time remaining" + }, + "battery_time_to_full_charge": { + "name": "Time to full charge" + }, "battery_voltage": { "name": "Battery voltage" }, diff --git a/tests/components/matter/conftest.py b/tests/components/matter/conftest.py index e637d9e40ca..5895c3472d6 100644 --- a/tests/components/matter/conftest.py +++ b/tests/components/matter/conftest.py @@ -76,6 +76,7 @@ async def integration_fixture( params=[ "air_purifier", "air_quality_sensor", + "battery_storage", "color_temperature_light", "cooktop", "dimmable_light", diff --git a/tests/components/matter/fixtures/nodes/battery_storage.json b/tests/components/matter/fixtures/nodes/battery_storage.json new file mode 100644 index 00000000000..8162318b15f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/battery_storage.json @@ -0,0 +1,271 @@ +{ + "node_id": 25, + "date_commissioned": "2025-06-19T17:13:40.727316", + "last_interview": "2025-06-19T17:13:40.727333", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 3 + }, + { + "0": 18, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 42], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 3, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65532, 65533, 65528, 65529, 65531], + "0/31/0": [ + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65533": 2, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 2, 4, 3, 65532, 65533, 65528, 65529, 65531], + "0/40/65532": 0, + "0/40/0": 19, + "0/40/6": "**REDACTED**", + "0/40/1": "TEST_VENDOR", + "0/40/2": 65521, + "0/40/3": "Mock Battery Storage", + "0/40/4": 32768, + "0/40/7": 0, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1.0", + "0/40/18": "6C89C9D11F0BDAAD", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 17104896, + "0/40/22": 1, + "0/40/65533": 5, + "0/40/5": "", + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 18, 19, 21, 22, 65533, 5, 65528, + 65529, 65531 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/4": true, + "0/48/65533": 2, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65532, 2, 3, 1, 4, 65533, 0, 65528, 65529, 65531], + "0/49/0": 1, + "0/49/1": [ + { + "0": "RnJlZWJveC03Mjg2ODE=", + "1": true + } + ], + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "RnJlZWJveC03Mjg2ODE=", + "0/49/7": null, + "0/49/2": 10, + "0/49/3": 30, + "0/49/8": [0], + "0/49/65532": 1, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [ + 0, 1, 4, 5, 6, 7, 2, 3, 8, 65532, 65533, 65528, 65529, 65531 + ], + "0/51/0": [ + { + "0": "WIFI_STA_DEF", + "1": true, + "2": null, + "3": null, + "4": "YFX59wI0", + "5": ["wKgBqA=="], + "6": ["/oAAAAAAAABiVfn//vcCNA==", "KgEOCgKzOZBiVfn//vcCNA=="], + "7": 1 + } + ], + "0/51/1": 1, + "0/51/2": 245, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [0, 1, 2, 8, 65532, 65533, 65528, 65529, 65531], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 2], + "0/60/65531": [65532, 0, 1, 2, 65533, 65528, 65529, 65531], + "0/62/65532": 0, + "0/62/0": [ + { + "1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRGRgkBwEkCAEwCUEEdGR9Cz5LAJceV7SCSogqC7oif2ZaaFbkT0aMcnoFyyfBgkEg7K/IzbpMUEbatodbeOpCPFebunhR9wCXs7B8lTcKNQEoARgkAgE2AwQCBAEYMAQUTYn5+OBsvnwU4qs/Er+byaEnS/AwBRS5+zzv8ZPGnI9mC3wH9vq10JnwlhgwC0D4oAj5zm+W4u/MaHn8Xzqh3zzGdKh2OrSqols1utweoW2ODVMf+AT0WNmG9sOxeaoOPppaFVorZf5T1KtB0T9gGA==", + "2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEE/DujEcdTsX19xbxX+KuKKWiMaA5D9u99P/pVxIOmscd2BA2PadEMNnjvtPOpf+WE2Zxar4rby1IfAClGUUuQrTcKNQEpARgkAmAwBBS5+zzv8ZPGnI9mC3wH9vq10JnwljAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQGkPpvsbkAFEbfPN6H3Kf23R0zzmW/gpAA3kgaL6wKB2Ofm+Tmylw22qM536Kj8mOMwaV0EL1dCCGcuxF98aL6gY", + "254": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "1": "BBmX+KwLR5HGlVNbvlC+dO8Jv9fPthHiTfGpUzi2JJADX5az6GxBAFn02QKHwLcZHyh+lh9faf6rf38/nPYF7/M=", + "2": 4939, + "3": 2, + "4": 25, + "5": "Home", + "254": 3 + } + ], + "0/62/4": [ + "FTABAQAkAgE3AyYUyakYCSYVj6gLsxgmBPIA5y8kBQA3BiYUyakYCSYVj6gLsxgkBwEkCAEwCUEEgYwxrTB+tyiEGfrRwjlXTG34MiQtJXbg5Qqd0ohdRW7MfwYY7vZiX/0h9hI8MqUralFaVPcnghAP0MSJm1YrqTcKNQEpARgkAmAwBBS3BS9aJzt+p6i28Nj+trB2Uu+vdzAFFLcFL1onO36nqLbw2P62sHZS7693GDALQPnIJqOtiZpRoUcwAo5GzvuP5SeVloEfg6jDfAMYWb+Sm6X4b9FLaO9IVlUmABOKG5Ay+6ayHN5KRUFmoo4TrxIY", + "FTABAQAkAgE3AycUQhmZbaIbYjokFQIYJgRWZLcqJAUANwYnFEIZmW2iG2I6JBUCGCQHASQIATAJQQT2AlKGW/kOMjqayzeO0md523/fuhrhGEUU91uQpTiKo0I7wcPpKnmrwfQNPX6g0kEQl+VGaXa3e22lzfu5Tzp0Nwo1ASkBGCQCYDAEFOOMk13ScMKuT2hlaydi1yEJnhTqMAUU44yTXdJwwq5PaGVrJ2LXIQmeFOoYMAtAv2jJd1qd5miXbYesH1XrJ+vgyY0hzGuZ78N6Jw4Cb1oN1sLSpA+PNM0u7+hsEqcSvvn2eSV8EaRR+hg5YQjHDxg=", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEGZf4rAtHkcaVU1u+UL507wm/18+2EeJN8alTOLYkkANflrPobEEAWfTZAofAtxkfKH6WH19p/qt/fz+c9gXv8zcKNQEpARgkAmAwBBT0+qfdyShnG+4Pq01pwOnrxdhHRjAFFPT6p93JKGcb7g+rTWnA6evF2EdGGDALQPVrsFnfFplsQGV5m5EUua+rmo9hAr+OP1bvaifdLqiEIn3uXLTLoKmVUkPImRL2Fb+xcMEAqR2p7RM6ZlFCR20Y" + ], + "0/62/5": 3, + "0/62/65533": 2, + "0/62/65528": [1, 3, 5, 8, 14], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11, 12, 13], + "0/62/65531": [65532, 0, 2, 3, 1, 4, 5, 65533, 65528, 65529, 65531], + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 0, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65532, 65533, 0, 1, 2, 3, 65528, 65529, 65531], + "0/42/65532": 0, + "0/42/0": [], + "0/42/65533": 1, + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [65532, 0, 65533, 1, 2, 3, 65528, 65529, 65531], + "1/29/0": [ + { + "0": 24, + "1": 1 + }, + { + "0": 17, + "1": 1 + }, + { + "0": 1296, + "1": 1 + }, + { + "0": 1293, + "1": 2 + } + ], + "1/29/1": [29, 47, 156, 144, 145, 152, 159], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 3, + "1/29/4": [], + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65532, 65533, 4, 65528, 65529, 65531], + "1/47/65532": 7, + "1/47/65533": 3, + "1/47/0": 0, + "1/47/1": 0, + "1/47/2": "Main", + "1/47/31": [], + "1/47/5": 0, + "1/47/11": 48000, + "1/47/12": 180, + "1/47/13": 7200, + "1/47/18": [], + "1/47/24": 100000, + "1/47/27": 1800, + "1/47/29": null, + "1/47/30": null, + "1/47/65528": [], + "1/47/65529": [], + "1/47/65531": [ + 65532, 65533, 0, 1, 2, 31, 5, 11, 12, 13, 18, 24, 27, 29, 30, 65528, + 65529, 65531 + ], + "1/156/65532": 0, + "1/156/65533": 1, + "1/156/65528": [], + "1/156/65529": [], + "1/156/65531": [65532, 65533, 65528, 65529, 65531], + "1/144/65532": 0, + "1/144/0": 0, + "1/144/1": 0, + "1/144/2": null, + "1/144/8": 0, + "1/144/65533": 1, + "1/144/4": 0, + "1/144/5": 0, + "1/144/65528": [], + "1/144/65529": [], + "1/144/65531": [65532, 0, 1, 2, 8, 65533, 4, 5, 65528, 65529, 65531], + "1/145/65532": 0, + "1/145/0": null, + "1/145/65533": 1, + "1/145/65528": [], + "1/145/65529": [], + "1/145/65531": [65532, 0, 65533, 65528, 65529, 65531], + "1/152/65532": 1, + "1/152/0": 5, + "1/152/1": false, + "1/152/2": 1, + "1/152/3": 0, + "1/152/4": 0, + "1/152/65533": 4, + "1/152/5": null, + "1/152/7": 0, + "1/152/65528": [], + "1/152/65529": [0, 1], + "1/152/65531": [65532, 0, 1, 2, 3, 4, 65533, 5, 7, 65528, 65529, 65531], + "1/159/65532": 0, + "1/159/0": null, + "1/159/65533": 2, + "1/159/1": 0, + "1/159/65528": [1], + "1/159/65529": [0], + "1/159/65531": [65532, 0, 65533, 1, 65528, 65529, 65531] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 14169c84e15..7de836b7092 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1251,6 +1251,477 @@ 'state': '189.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_appliance_energy_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Appliance energy state', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ESAState-152-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_appliance_energy_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Battery Storage Appliance energy state', + 'options': list([ + 'offline', + 'online', + 'fault', + 'power_adjust_active', + 'paused', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_appliance_energy_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'online', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_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.mock_battery_storage_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': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSource-47-12', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Mock Battery Storage Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery_voltage-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.mock_battery_storage_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_voltage', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatVoltage-47-11', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Mock Battery Storage Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '48.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-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.mock_battery_storage_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementActiveCurrent-144-5', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Mock Battery Storage Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_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': , + 'entity_id': 'sensor.mock_battery_storage_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementWatt-144-8', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Mock Battery Storage Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_remaining-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.mock_battery_storage_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_time_remaining', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatTimeRemaining-47-13', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Battery Storage Time remaining', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_to_full_charge-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.mock_battery_storage_time_to_full_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time to full charge', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_time_to_full_charge', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-PowerSourceBatTimeToFullCharge-47-27', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_time_to_full_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Mock Battery Storage Time to full charge', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_time_to_full_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '30.0', + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_voltage-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.mock_battery_storage_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ElectricalPowerMeasurementVoltage-144-4', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Mock Battery Storage Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- # name: test_sensors[cooktop][sensor.mock_cooktop_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0c08b4fc8b6c7159a2daa7722a1149baa0e703a2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Jun 2025 16:06:19 +0200 Subject: [PATCH 0540/1664] Add Matter protocol to Switchbot (#147356) --- homeassistant/brands/switchbot.json | 3 ++- homeassistant/generated/integrations.json | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/brands/switchbot.json b/homeassistant/brands/switchbot.json index 0909b24a146..43963109ee7 100644 --- a/homeassistant/brands/switchbot.json +++ b/homeassistant/brands/switchbot.json @@ -1,5 +1,6 @@ { "domain": "switchbot", "name": "SwitchBot", - "integrations": ["switchbot", "switchbot_cloud"] + "integrations": ["switchbot", "switchbot_cloud"], + "iot_standards": ["matter"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3795bd838ea..02dce5f8a3b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6426,7 +6426,10 @@ "iot_class": "cloud_polling", "name": "SwitchBot Cloud" } - } + }, + "iot_standards": [ + "matter" + ] }, "switcher_kis": { "name": "Switcher", From b48ebeaa8a6f9ccdafb0744ef0d9fe9a6403357a Mon Sep 17 00:00:00 2001 From: Michael Heyman <2618514+michaelheyman@users.noreply.github.com> Date: Mon, 23 Jun 2025 07:09:41 -0700 Subject: [PATCH 0541/1664] Tilt Pi integration (#139726) * Create component via script.scaffold * Create sensor definition * Define coordinator * Define config flow * Refine sensor definition and add tests * Refine coordinator after testing end to end * Redefine sensor in a more idiomatic way * Use entity (common-module) * Follow config-flow conventions more closely * Use custom ConfigEntry to conform to strict-typing * Define API object instead of using aio directly * Test before setup in init * Add diagnostics * Make some more quality changes * Move scan interval to const * Commit generated files * Add quality scale * feedback: Apply consistent language to Tilt Pi refs * feedback: Remove empty manifest fields * feedback: Use translations instead of hardcoded name * feedback: Remove diagnostics * feedback: Idiomatic and general improvements * Use tilt-pi library * feedback: Coordinator data returns dict * feedback: Move client creation to coordinator * feedback: Request only Tilt Pi URL from user * Update homeassistant/components/tilt_pi/entity.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tilt_pi/sensor.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/tilt_pi/entity.py Co-authored-by: Joost Lekkerkerker * feedback: Avoid redundant keyword arguments in function calls * feedback: Remove unused models and variables * feedback: Use icons.json * feedback: Style best practices * Update homeassistant/components/tilt_pi/entity.py Co-authored-by: Joost Lekkerkerker * Update tests/components/tilt_pi/test_config_flow.py Co-authored-by: Joost Lekkerkerker * feedback: Improve config flow unit tests * feedback: Patch TiltPi client mock * feedback: Mark entity-device-class as done * feedback: Align quaity scale with current state * feeback: Create brands file for Tilt brand * feedback: Demonstrate recovery in config flow * feedback: Test coordinator behavior via sensors * Update homeassistant/components/tilt_pi/config_flow.py Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/coordinator.py Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/quality_scale.yaml Co-authored-by: Josef Zweck * Update homeassistant/components/tilt_pi/config_flow.py Co-authored-by: Josef Zweck * feedback: Update tilt_pi quality scale * feedback: Move const to coordinator * feedback: Correct strings.json for incorrect and missing fields * feedback: Use tiltpi package version published via CI * Run ruff format manually * Add missing string for invalid host * Fix * Fix --------- Co-authored-by: Michael Heyman Co-authored-by: Joost Lekkerkerker Co-authored-by: Josef Zweck --- CODEOWNERS | 2 + homeassistant/brands/tilt.json | 5 + homeassistant/components/tilt_pi/__init__.py | 28 +++ .../components/tilt_pi/config_flow.py | 63 +++++ homeassistant/components/tilt_pi/const.py | 8 + .../components/tilt_pi/coordinator.py | 53 +++++ homeassistant/components/tilt_pi/entity.py | 39 ++++ homeassistant/components/tilt_pi/icons.json | 9 + .../components/tilt_pi/manifest.json | 10 + .../components/tilt_pi/quality_scale.yaml | 80 +++++++ homeassistant/components/tilt_pi/sensor.py | 93 ++++++++ homeassistant/components/tilt_pi/strings.json | 31 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 21 +- requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tilt_pi/__init__.py | 12 + tests/components/tilt_pi/conftest.py | 70 ++++++ .../tilt_pi/snapshots/test_sensor.ambr | 217 ++++++++++++++++++ tests/components/tilt_pi/test_config_flow.py | 125 ++++++++++ tests/components/tilt_pi/test_sensor.py | 84 +++++++ 21 files changed, 952 insertions(+), 5 deletions(-) create mode 100644 homeassistant/brands/tilt.json create mode 100644 homeassistant/components/tilt_pi/__init__.py create mode 100644 homeassistant/components/tilt_pi/config_flow.py create mode 100644 homeassistant/components/tilt_pi/const.py create mode 100644 homeassistant/components/tilt_pi/coordinator.py create mode 100644 homeassistant/components/tilt_pi/entity.py create mode 100644 homeassistant/components/tilt_pi/icons.json create mode 100644 homeassistant/components/tilt_pi/manifest.json create mode 100644 homeassistant/components/tilt_pi/quality_scale.yaml create mode 100644 homeassistant/components/tilt_pi/sensor.py create mode 100644 homeassistant/components/tilt_pi/strings.json create mode 100644 tests/components/tilt_pi/__init__.py create mode 100644 tests/components/tilt_pi/conftest.py create mode 100644 tests/components/tilt_pi/snapshots/test_sensor.ambr create mode 100644 tests/components/tilt_pi/test_config_flow.py create mode 100644 tests/components/tilt_pi/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 1ceb6ff0e7d..da247c06cb8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1580,6 +1580,8 @@ build.json @home-assistant/supervisor /tests/components/tile/ @bachya /homeassistant/components/tilt_ble/ @apt-itude /tests/components/tilt_ble/ @apt-itude +/homeassistant/components/tilt_pi/ @michaelheyman +/tests/components/tilt_pi/ @michaelheyman /homeassistant/components/time/ @home-assistant/core /tests/components/time/ @home-assistant/core /homeassistant/components/time_date/ @fabaff diff --git a/homeassistant/brands/tilt.json b/homeassistant/brands/tilt.json new file mode 100644 index 00000000000..0b78925780f --- /dev/null +++ b/homeassistant/brands/tilt.json @@ -0,0 +1,5 @@ +{ + "domain": "tilt", + "name": "Tilt", + "integrations": ["tilt_ble", "tilt_pi"] +} diff --git a/homeassistant/components/tilt_pi/__init__.py b/homeassistant/components/tilt_pi/__init__.py new file mode 100644 index 00000000000..6b292aed302 --- /dev/null +++ b/homeassistant/components/tilt_pi/__init__.py @@ -0,0 +1,28 @@ +"""The Tilt Pi integration.""" + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: TiltPiConfigEntry) -> bool: + """Set up Tilt Pi from a config entry.""" + coordinator = TiltPiDataUpdateCoordinator( + hass, + entry, + ) + + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: TiltPiConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/tilt_pi/config_flow.py b/homeassistant/components/tilt_pi/config_flow.py new file mode 100644 index 00000000000..7770ce372d8 --- /dev/null +++ b/homeassistant/components/tilt_pi/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Tilt Pi integration.""" + +from typing import Any + +import aiohttp +from tiltpi import TiltPiClient, TiltPiError +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + + +class TiltPiConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tilt Pi.""" + + async def _check_connection(self, host: str, port: int) -> str | None: + """Check if we can connect to the TiltPi instance.""" + client = TiltPiClient( + host, + port, + session=async_get_clientsession(self.hass), + ) + try: + await client.get_hydrometers() + except (TiltPiError, TimeoutError, aiohttp.ClientError): + return "cannot_connect" + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a configuration flow initialized by the user.""" + + errors = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + if (host := url.host) is None: + errors[CONF_URL] = "invalid_host" + else: + self._async_abort_entries_match({CONF_HOST: host}) + port = url.port + assert port + error = await self._check_connection(host=host, port=port) + if error: + errors["base"] = error + else: + return self.async_create_entry( + title="Tilt Pi", + data={ + CONF_HOST: host, + CONF_PORT: port, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_URL): str}), + errors=errors, + ) diff --git a/homeassistant/components/tilt_pi/const.py b/homeassistant/components/tilt_pi/const.py new file mode 100644 index 00000000000..a60b737c20f --- /dev/null +++ b/homeassistant/components/tilt_pi/const.py @@ -0,0 +1,8 @@ +"""Constants for the Tilt Pi integration.""" + +import logging +from typing import Final + +LOGGER = logging.getLogger(__package__) + +DOMAIN: Final = "tilt_pi" diff --git a/homeassistant/components/tilt_pi/coordinator.py b/homeassistant/components/tilt_pi/coordinator.py new file mode 100644 index 00000000000..e2b14861a89 --- /dev/null +++ b/homeassistant/components/tilt_pi/coordinator.py @@ -0,0 +1,53 @@ +"""Data update coordinator for Tilt Pi.""" + +from datetime import timedelta +from typing import Final + +from tiltpi import TiltHydrometerData, TiltPiClient, TiltPiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + +SCAN_INTERVAL: Final = timedelta(seconds=60) + +type TiltPiConfigEntry = ConfigEntry[TiltPiDataUpdateCoordinator] + + +class TiltPiDataUpdateCoordinator(DataUpdateCoordinator[dict[str, TiltHydrometerData]]): + """Class to manage fetching Tilt Pi data.""" + + config_entry: TiltPiConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: TiltPiConfigEntry, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + LOGGER, + config_entry=config_entry, + name="Tilt Pi", + update_interval=SCAN_INTERVAL, + ) + self._api = TiltPiClient( + host=config_entry.data[CONF_HOST], + port=config_entry.data[CONF_PORT], + session=async_get_clientsession(hass), + ) + self.identifier = config_entry.entry_id + + async def _async_update_data(self) -> dict[str, TiltHydrometerData]: + """Fetch data from Tilt Pi and return as a dict keyed by mac_id.""" + try: + hydrometers = await self._api.get_hydrometers() + except TiltPiError as err: + raise UpdateFailed(f"Error communicating with Tilt Pi: {err}") from err + + return {h.mac_id: h for h in hydrometers} diff --git a/homeassistant/components/tilt_pi/entity.py b/homeassistant/components/tilt_pi/entity.py new file mode 100644 index 00000000000..c1cf8913843 --- /dev/null +++ b/homeassistant/components/tilt_pi/entity.py @@ -0,0 +1,39 @@ +"""Base entity for Tilt Pi integration.""" + +from tiltpi import TiltHydrometerData + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import TiltPiDataUpdateCoordinator + + +class TiltEntity(CoordinatorEntity[TiltPiDataUpdateCoordinator]): + """Base class for Tilt entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TiltPiDataUpdateCoordinator, + hydrometer: TiltHydrometerData, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._mac_id = hydrometer.mac_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, hydrometer.mac_id)}, + name=f"Tilt {hydrometer.color}", + manufacturer="Tilt Hydrometer", + model=f"{hydrometer.color} Tilt Hydrometer", + ) + + @property + def current_hydrometer(self) -> TiltHydrometerData: + """Return the current hydrometer data for this entity.""" + return self.coordinator.data[self._mac_id] + + @property + def available(self) -> bool: + """Return True if the hydrometer is available (present in coordinator data).""" + return super().available and self._mac_id in self.coordinator.data diff --git a/homeassistant/components/tilt_pi/icons.json b/homeassistant/components/tilt_pi/icons.json new file mode 100644 index 00000000000..1f23c518c38 --- /dev/null +++ b/homeassistant/components/tilt_pi/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "sensor": { + "gravity": { + "default": "mdi:water" + } + } + } +} diff --git a/homeassistant/components/tilt_pi/manifest.json b/homeassistant/components/tilt_pi/manifest.json new file mode 100644 index 00000000000..94c6b7ade86 --- /dev/null +++ b/homeassistant/components/tilt_pi/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tilt_pi", + "name": "Tilt Pi", + "codeowners": ["@michaelheyman"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tilt_pi", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["tilt-pi==0.2.1"] +} diff --git a/homeassistant/components/tilt_pi/quality_scale.yaml b/homeassistant/components/tilt_pi/quality_scale.yaml new file mode 100644 index 00000000000..725ff971067 --- /dev/null +++ b/homeassistant/components/tilt_pi/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: done + 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: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: + status: done + comment: | + The entities are categorized well by using default category. + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: No disabled entities implemented + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: | + No repairs/issues. + stale-devices: todo + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/tilt_pi/sensor.py b/homeassistant/components/tilt_pi/sensor.py new file mode 100644 index 00000000000..4ce40e70bdb --- /dev/null +++ b/homeassistant/components/tilt_pi/sensor.py @@ -0,0 +1,93 @@ +"""Support for Tilt Hydrometer sensors.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Final + +from tiltpi import TiltHydrometerData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import TiltPiConfigEntry, TiltPiDataUpdateCoordinator +from .entity import TiltEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + +ATTR_TEMPERATURE = "temperature" +ATTR_GRAVITY = "gravity" + + +@dataclass(frozen=True, kw_only=True) +class TiltEntityDescription(SensorEntityDescription): + """Describes TiltHydrometerData sensor entity.""" + + value_fn: Callable[[TiltHydrometerData], StateType] + + +SENSOR_TYPES: Final[list[TiltEntityDescription]] = [ + TiltEntityDescription( + key=ATTR_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.temperature, + ), + TiltEntityDescription( + key=ATTR_GRAVITY, + translation_key="gravity", + native_unit_of_measurement="SG", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data.gravity, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: TiltPiConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Tilt Hydrometer sensors.""" + coordinator = config_entry.runtime_data + + async_add_entities( + TiltSensor( + coordinator, + description, + hydrometer, + ) + for description in SENSOR_TYPES + for hydrometer in coordinator.data.values() + ) + + +class TiltSensor(TiltEntity, SensorEntity): + """Defines a Tilt sensor.""" + + entity_description: TiltEntityDescription + + def __init__( + self, + coordinator: TiltPiDataUpdateCoordinator, + description: TiltEntityDescription, + hydrometer: TiltHydrometerData, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, hydrometer) + self.entity_description = description + self._attr_unique_id = f"{hydrometer.mac_id}_{description.key}" + + @property + def native_value(self) -> StateType: + """Return the sensor value.""" + return self.entity_description.value_fn(self.current_hydrometer) diff --git a/homeassistant/components/tilt_pi/strings.json b/homeassistant/components/tilt_pi/strings.json new file mode 100644 index 00000000000..9af85c86641 --- /dev/null +++ b/homeassistant/components/tilt_pi/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + }, + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]" + }, + "data_description": { + "url": "The URL of the Tilt Pi instance." + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" + } + }, + "entity": { + "sensor": { + "gravity": { + "name": "Gravity" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 119830b6111..e164bc09929 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -647,6 +647,7 @@ FLOWS = { "tibber", "tile", "tilt_ble", + "tilt_pi", "time_date", "todoist", "tolo", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 02dce5f8a3b..1202d7d51ec 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6745,11 +6745,22 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "tilt_ble": { - "name": "Tilt Hydrometer BLE", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "tilt": { + "name": "Tilt", + "integrations": { + "tilt_ble": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Tilt Hydrometer BLE" + }, + "tilt_pi": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Tilt Pi" + } + } }, "time_date": { "integration_type": "service", diff --git a/requirements_all.txt b/requirements_all.txt index 980d443db58..ffb47933565 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2936,6 +2936,9 @@ tikteck==0.4 # homeassistant.components.tilt_ble tilt-ble==0.2.3 +# homeassistant.components.tilt_pi +tilt-pi==0.2.1 + # homeassistant.components.tmb tmb==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e83587684e..cb71a90eee1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2413,6 +2413,9 @@ thinqconnect==1.0.5 # homeassistant.components.tilt_ble tilt-ble==0.2.3 +# homeassistant.components.tilt_pi +tilt-pi==0.2.1 + # homeassistant.components.todoist todoist-api-python==2.1.7 diff --git a/tests/components/tilt_pi/__init__.py b/tests/components/tilt_pi/__init__.py new file mode 100644 index 00000000000..a6109c66ca5 --- /dev/null +++ b/tests/components/tilt_pi/__init__.py @@ -0,0 +1,12 @@ +"""Tests for the Tilt Pi 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 integration.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/tilt_pi/conftest.py b/tests/components/tilt_pi/conftest.py new file mode 100644 index 00000000000..dada9596be5 --- /dev/null +++ b/tests/components/tilt_pi/conftest.py @@ -0,0 +1,70 @@ +"""Common fixtures for the Tilt Pi tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from tiltpi import TiltColor, TiltHydrometerData + +from homeassistant.components.tilt_pi.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + +TEST_NAME = "Test Tilt Pi" +TEST_HOST = "192.168.1.123" +TEST_PORT = 1880 +TEST_URL = f"http://{TEST_HOST}:{TEST_PORT}" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.tilt_pi.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: TEST_HOST, + CONF_PORT: TEST_PORT, + }, + ) + + +@pytest.fixture +def mock_tiltpi_client() -> Generator[AsyncMock]: + """Mock a TiltPi client.""" + with ( + patch( + "homeassistant.components.tilt_pi.coordinator.TiltPiClient", + autospec=True, + ) as mock_client, + patch( + "homeassistant.components.tilt_pi.config_flow.TiltPiClient", + new=mock_client, + ), + ): + client = mock_client.return_value + client.get_hydrometers.return_value = [ + TiltHydrometerData( + mac_id="00:1A:2B:3C:4D:5E", + color=TiltColor.BLACK, + temperature=55.0, + gravity=1.010, + ), + TiltHydrometerData( + mac_id="00:1s:99:f1:d2:4f", + color=TiltColor.YELLOW, + temperature=68.0, + gravity=1.015, + ), + ] + yield client diff --git a/tests/components/tilt_pi/snapshots/test_sensor.ambr b/tests/components/tilt_pi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..bcee6881d75 --- /dev/null +++ b/tests/components/tilt_pi/snapshots/test_sensor.ambr @@ -0,0 +1,217 @@ +# serializer version: 1 +# name: test_all_sensors[sensor.tilt_black_gravity-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.tilt_black_gravity', + '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': 'Gravity', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gravity', + 'unique_id': '00:1A:2B:3C:4D:5E_gravity', + 'unit_of_measurement': 'SG', + }) +# --- +# name: test_all_sensors[sensor.tilt_black_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tilt Black Gravity', + 'state_class': , + 'unit_of_measurement': 'SG', + }), + 'context': , + 'entity_id': 'sensor.tilt_black_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.01', + }) +# --- +# name: test_all_sensors[sensor.tilt_black_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.tilt_black_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': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:1A:2B:3C:4D:5E_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.tilt_black_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Tilt Black Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.tilt_black_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.7777777777778', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_gravity-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.tilt_yellow_gravity', + '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': 'Gravity', + 'platform': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gravity', + 'unique_id': '00:1s:99:f1:d2:4f_gravity', + 'unit_of_measurement': 'SG', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_gravity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tilt Yellow Gravity', + 'state_class': , + 'unit_of_measurement': 'SG', + }), + 'context': , + 'entity_id': 'sensor.tilt_yellow_gravity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.015', + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_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.tilt_yellow_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': 'tilt_pi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:1s:99:f1:d2:4f_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.tilt_yellow_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Tilt Yellow Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.tilt_yellow_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- diff --git a/tests/components/tilt_pi/test_config_flow.py b/tests/components/tilt_pi/test_config_flow.py new file mode 100644 index 00000000000..f9b9693b9f8 --- /dev/null +++ b/tests/components/tilt_pi/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the Tilt config flow.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.tilt_pi.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_URL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_async_step_user_gets_form_and_creates_entry( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test that the we can view the form and that the config flow creates an entry.""" + result = await hass.config_entries.flow.async_init( + 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={CONF_URL: "http://192.168.1.123:1880"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } + + +async def test_abort_if_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, +) -> None: + """Test that we abort if we attempt to submit the same entry twice.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_successful_recovery_after_invalid_host( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when user submits invalid host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a invalid host error by providing an invalid URL + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "not-a-valid-url"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"url": "invalid_host"} + + # Demonstrate successful connection on retry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } + + +async def test_successful_recovery_after_connection_error( + hass: HomeAssistant, + mock_tiltpi_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test error shown when connection fails.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + # Simulate a connection error by raising a TimeoutError + mock_tiltpi_client.get_hydrometers.side_effect = TimeoutError() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + # Simulate successful connection on retry + mock_tiltpi_client.get_hydrometers.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_URL: "http://192.168.1.123:1880"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_HOST: "192.168.1.123", + CONF_PORT: 1880, + } diff --git a/tests/components/tilt_pi/test_sensor.py b/tests/components/tilt_pi/test_sensor.py new file mode 100644 index 00000000000..cb4e02818c7 --- /dev/null +++ b/tests/components/tilt_pi/test_sensor.py @@ -0,0 +1,84 @@ +"""Test the Tilt Hydrometer sensors.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion +from tiltpi import TiltColor, TiltPiConnectionError + +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 + + +async def test_all_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_tiltpi_client: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Tilt Pi sensors. + + When making changes to this test, ensure that the snapshot reflects the + new data by generating it via: + + $ pytest tests/components/tilt_pi/test_sensor.py -v --snapshot-update + """ + with patch("homeassistant.components.tilt_pi.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, + mock_config_entry: MockConfigEntry, + mock_tiltpi_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities become unavailable when the coordinator fails.""" + with patch("homeassistant.components.tilt_pi.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + # Simulate a coordinator update failure + mock_tiltpi_client.get_hydrometers.side_effect = TiltPiConnectionError() + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that entities are unavailable + for color in (TiltColor.BLACK, TiltColor.YELLOW): + temperature_entity_id = f"sensor.tilt_{color}_temperature" + gravity_entity_id = f"sensor.tilt_{color}_gravity" + + temperature_state = hass.states.get(temperature_entity_id) + assert temperature_state is not None + assert temperature_state.state == STATE_UNAVAILABLE + + gravity_state = hass.states.get(gravity_entity_id) + assert gravity_state is not None + assert gravity_state.state == STATE_UNAVAILABLE + + # Simulate a coordinator update success + mock_tiltpi_client.get_hydrometers.side_effect = None + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check that entities are now available + for color in (TiltColor.BLACK, TiltColor.YELLOW): + temperature_entity_id = f"sensor.tilt_{color}_temperature" + gravity_entity_id = f"sensor.tilt_{color}_gravity" + + temperature_state = hass.states.get(temperature_entity_id) + assert temperature_state is not None + assert temperature_state.state != STATE_UNAVAILABLE + + gravity_state = hass.states.get(gravity_entity_id) + assert gravity_state is not None + assert gravity_state.state != STATE_UNAVAILABLE From c1e32aa9b775b85e2ba36dc2b0ca9b75d651b5c7 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:10:50 -0400 Subject: [PATCH 0542/1664] Add trigger template alarm control panels (#145461) * Add trigger template alarm control panels * updates * fix jumbled imports * fix comments --- .../template/alarm_control_panel.py | 70 ++++++- homeassistant/components/template/config.py | 1 - .../template/test_alarm_control_panel.py | 174 ++++++++++++++---- 3 files changed, 205 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 29c71973f42..bac3f03afb8 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.alarm_control_panel import ( + DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as ALARM_CONTROL_PANEL_PLATFORM_SCHEMA, AlarmControlPanelEntity, @@ -42,6 +43,7 @@ from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN +from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -49,6 +51,7 @@ from .template_entity import ( make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) _VALID_STATES = [ @@ -253,6 +256,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -276,8 +286,11 @@ class AbstractTemplateAlarmControlPanel( self._attr_code_format = config[CONF_CODE_FORMAT].value self._state: AlarmControlPanelState | None = None + self._attr_supported_features: AlarmControlPanelEntityFeature = ( + AlarmControlPanelEntityFeature(0) + ) - def _register_scripts( + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[ tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int] @@ -423,8 +436,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane if TYPE_CHECKING: assert name is not None - self._attr_supported_features = AlarmControlPanelEntityFeature(0) - for action_id, action_config, supported_feature in self._register_scripts( + for action_id, action_config, supported_feature in self._iterate_scripts( config ): self.add_script(action_id, action_config, name, DOMAIN) @@ -456,3 +468,55 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane "_state", self._template, None, self._update_state ) super()._async_setup_templates() + + +class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel): + """Alarm Control Panel entity based on trigger data.""" + + domain = ALARM_CONTROL_PANEL_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateAlarmControlPanel.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + if isinstance(config.get(CONF_STATE), template.Template): + self._to_render_simple.append(CONF_STATE) + self._parse_result.add(CONF_STATE) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + await self._async_handle_restored_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + if (rendered := self._rendered.get(CONF_STATE)) is not None: + self._handle_state(rendered) + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index e87c9aee989..08a5272f5f2 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -157,7 +157,6 @@ CONFIG_SECTION_SCHEMA = vol.All( }, ), ensure_domains_do_not_have_trigger_or_action( - DOMAIN_ALARM_CONTROL_PANEL, DOMAIN_BUTTON, DOMAIN_FAN, DOMAIN_LOCK, diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index f9820243600..1984b4ea2af 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -30,6 +30,7 @@ from tests.common import MockConfigEntry, assert_setup_component, mock_restore_c TEST_OBJECT_ID = "test_template_panel" TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" TEST_STATE_ENTITY_ID = "alarm_control_panel.test" +TEST_SWITCH = "switch.test_state" @pytest.fixture @@ -110,6 +111,14 @@ TEMPLATE_ALARM_CONFIG = { **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, } +TEST_STATE_TRIGGER = { + "triggers": {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID, TEST_SWITCH]}, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "actions": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + async def async_setup_legacy_format( hass: HomeAssistant, count: int, panel_config: dict[str, Any] @@ -146,6 +155,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, panel_config: dict[str, Any] +) -> None: + """Do setup of alarm control panel integration via trigger format.""" + config = {"template": {"alarm_control_panel": panel_config, **TEST_STATE_TRIGGER}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_panel( hass: HomeAssistant, @@ -158,6 +185,8 @@ async def setup_panel( await async_setup_legacy_format(hass, count, panel_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, panel_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, panel_config) async def async_setup_state_panel( @@ -188,6 +217,16 @@ async def async_setup_state_panel( **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + }, + ) @pytest.fixture @@ -228,6 +267,17 @@ async def setup_base_panel( **panel_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + extra = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra, + **panel_config, + }, + ) @pytest.fixture @@ -264,13 +314,25 @@ async def setup_single_attribute_state_panel( **extra, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **OPTIMISTIC_TEMPLATE_ALARM_CONFIG, + "state": state_template, + **extra, + }, + ) @pytest.mark.parametrize( ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_panel") async def test_template_state_text(hass: HomeAssistant) -> None: @@ -301,56 +363,72 @@ async def test_template_state_text(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("state_template", "expected"), + ("state_template", "expected", "trigger_expected"), [ - ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED), - ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME), - ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY), - ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT), - ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION), - ("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS), - ("{{ 'pending' }}", AlarmControlPanelState.PENDING), - ("{{ 'arming' }}", AlarmControlPanelState.ARMING), - ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING), - ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED), - ("{{ x - 1 }}", STATE_UNKNOWN), + ("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED, None), + ("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME, None), + ("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY, None), + ("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT, None), + ("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION, None), + ( + "{{ 'armed_custom_bypass' }}", + AlarmControlPanelState.ARMED_CUSTOM_BYPASS, + None, + ), + ("{{ 'pending' }}", AlarmControlPanelState.PENDING, None), + ("{{ 'arming' }}", AlarmControlPanelState.ARMING, None), + ("{{ 'disarming' }}", AlarmControlPanelState.DISARMING, None), + ("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED, None), + ("{{ x - 1 }}", STATE_UNKNOWN, STATE_UNAVAILABLE), ], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_panel") -async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: +async def test_state_template_states( + hass: HomeAssistant, expected: str, trigger_expected: str, style: ConfigurationStyle +) -> None: """Test the state template.""" + + # Force a trigger + hass.states.async_set(TEST_STATE_ENTITY_ID, None) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + + if trigger_expected and style == ConfigurationStyle.TRIGGER: + expected = trigger_expected + assert state.state == expected @pytest.mark.parametrize( - ("count", "state_template", "attribute_template"), + ("count", "state_template", "attribute_template", "attribute"), [ ( 1, "{{ 'disarmed' }}", "{% if states.switch.test_state.state %}mdi:check{% endif %}", + "icon", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_panel") -async def test_icon_template( - hass: HomeAssistant, -) -> None: +async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_SWITCH, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -358,30 +436,30 @@ async def test_icon_template( @pytest.mark.parametrize( - ("count", "state_template", "attribute_template"), + ("count", "state_template", "attribute_template", "attribute"), [ ( 1, "{{ 'disarmed' }}", "{% if states.switch.test_state.state %}local/panel.png{% endif %}", + "picture", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_panel") -async def test_picture_template( - hass: HomeAssistant, -) -> None: +async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_SWITCH, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -425,7 +503,8 @@ async def test_setup_config_entry( @pytest.mark.parametrize(("count", "state_template"), [(1, None)]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS] @@ -459,7 +538,8 @@ async def test_optimistic_states(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("panel_config", "state_template", "msg"), @@ -538,11 +618,15 @@ async def test_legacy_template_syntax_error( [ (ConfigurationStyle.LEGACY, TEST_ENTITY_ID), (ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"), + (ConfigurationStyle.TRIGGER, "alarm_control_panel.unnamed_device"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_panel") async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: """Test the accessibility of the name attribute.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, "disarmed") + await hass.async_block_till_done() + state = hass.states.get(test_entity_id) assert state is not None assert state.attributes.get("friendly_name") == "Template Alarm Panel" @@ -552,7 +636,8 @@ async def test_name(hass: HomeAssistant, test_entity_id: str) -> None: ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "service", @@ -615,6 +700,21 @@ async def test_actions( ], ConfigurationStyle.MODERN, ), + ( + [ + { + "name": "test_template_alarm_control_panel_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_alarm_control_panel_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) @pytest.mark.usefixtures("setup_panel") @@ -669,7 +769,8 @@ async def test_nested_unique_id( @pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("panel_config", "code_format", "code_arm_required"), @@ -714,7 +815,8 @@ async def test_code_config(hass: HomeAssistant, code_format, code_arm_required) ("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("restored_state", "initial_state"), From 8e6edf5e34649f9088f4fa3365d218d4596aa01b Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 23 Jun 2025 10:11:15 -0400 Subject: [PATCH 0543/1664] Add trigger based locks to template integration (#145528) * Add trigger based locks to template integration * fix comments --- homeassistant/components/template/config.py | 1 - homeassistant/components/template/lock.py | 73 ++++++- tests/components/template/test_lock.py | 210 +++++++++++++++----- 3 files changed, 232 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 08a5272f5f2..1e1a27e26c6 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -159,7 +159,6 @@ CONFIG_SECTION_SCHEMA = vol.All( ensure_domains_do_not_have_trigger_or_action( DOMAIN_BUTTON, DOMAIN_FAN, - DOMAIN_LOCK, DOMAIN_VACUUM, ), ) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 8ed8a004e92..1ec8b7f7535 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.lock import ( + DOMAIN as LOCK_DOMAIN, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -23,11 +24,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError, TemplateError -from homeassistant.helpers import config_validation as cv +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 .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -36,6 +38,7 @@ from .template_entity import ( make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity CONF_CODE_FORMAT_TEMPLATE = "code_format_template" CONF_CODE_FORMAT = "code_format" @@ -123,6 +126,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerLockEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -147,7 +157,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): self._optimistic = config.get(CONF_OPTIMISTIC) self._attr_assumed_state = bool(self._optimistic) - def _register_scripts( + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], LockEntityFeature | int]]: for action_id, supported_feature in ( @@ -314,7 +324,7 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): if TYPE_CHECKING: assert name is not None - for action_id, action_config, supported_feature in self._register_scripts( + for action_id, action_config, supported_feature in self._iterate_scripts( config ): self.add_script(action_id, action_config, name, DOMAIN) @@ -346,3 +356,60 @@ class TemplateLock(TemplateEntity, AbstractTemplateLock): self._update_code_format, ) super()._async_setup_templates() + + +class TriggerLockEntity(TriggerEntity, AbstractTemplateLock): + """Lock entity based on trigger data.""" + + domain = LOCK_DOMAIN + extra_template_keys = (CONF_STATE,) + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateLock.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + if isinstance(config.get(CONF_CODE_FORMAT), template.Template): + self._to_render_simple.append(CONF_CODE_FORMAT) + self._parse_result.add(CONF_CODE_FORMAT) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, updater in ( + (CONF_STATE, self._handle_state), + (CONF_CODE_FORMAT, self._update_code_format), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + 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 + write_ha_state = True + + if write_ha_state: + self.async_write_ha_state() diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 4435e4a2404..94b0669acd1 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -25,7 +25,19 @@ from tests.common import assert_setup_component TEST_OBJECT_ID = "test_template_lock" TEST_ENTITY_ID = f"lock.{TEST_OBJECT_ID}" -TEST_STATE_ENTITY_ID = "switch.test_state" +TEST_STATE_ENTITY_ID = "sensor.test_state" +TEST_AVAILABILITY_ENTITY_ID = "availability_state.state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} LOCK_ACTION = { "lock": { @@ -113,6 +125,29 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, lock_config: dict[str, Any] +) -> None: + """Do setup of lock integration via trigger format.""" + config = { + "template": { + "lock": {"name": TEST_OBJECT_ID, **lock_config}, + **TEST_STATE_TRIGGER, + } + } + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_lock( hass: HomeAssistant, @@ -125,6 +160,8 @@ async def setup_lock( await async_setup_legacy_format(hass, count, lock_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, lock_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, lock_config) @pytest.fixture @@ -148,6 +185,12 @@ async def setup_base_lock( count, {"state": state_template, **extra_config}, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {"state": state_template, **extra_config}, + ) @pytest.fixture @@ -176,6 +219,15 @@ async def setup_state_lock( "state": state_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **OPTIMISTIC_LOCK, + "state": state_template, + }, + ) @pytest.fixture @@ -199,6 +251,12 @@ async def setup_state_lock_with_extra_config( count, {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra_config}, + ) @pytest.fixture @@ -228,13 +286,20 @@ async def setup_state_lock_with_attribute( count, {**OPTIMISTIC_LOCK, "state": state_template, **extra}, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {**OPTIMISTIC_LOCK, "state": state_template, **extra}, + ) @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_state(hass: HomeAssistant) -> None: @@ -260,10 +325,11 @@ async def test_template_state(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("count", "state_template", "extra_config"), - [(1, "{{ states.switch.test_state.state }}", {"optimistic": True, **OPEN_ACTION})], + [(1, "{{ states.sensor.test_state.state }}", {"optimistic": True, **OPEN_ACTION})], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_lock_optimistic( @@ -293,18 +359,24 @@ async def test_open_lock_optimistic( @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 1 }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_on(hass: HomeAssistant) -> None: """Test the setting of the state with boolean on.""" + # Ensure the trigger executes for trigger configurations + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 == 2 }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_state_boolean_off(hass: HomeAssistant) -> None: @@ -315,7 +387,8 @@ async def test_template_state_boolean_off(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("state_template", "extra_config"), @@ -326,7 +399,7 @@ async def test_template_state_boolean_off(hass: HomeAssistant) -> None: ( "{{ 1==1 }}", { - "not_value_template": "{{ states.switch.test_state.state }}", + "not_value_template": "{{ states.sensor.test_state.state }}", **OPTIMISTIC_LOCK, }, ), @@ -345,6 +418,7 @@ async def test_template_syntax_error(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") @@ -355,7 +429,8 @@ async def test_template_code_template_syntax_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1 + 1 }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_template_static(hass: HomeAssistant) -> None: @@ -371,7 +446,8 @@ async def test_template_static(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("state_template", "expected"), @@ -384,26 +460,33 @@ async def test_template_static(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("setup_state_lock") async def test_state_template(hass: HomeAssistant, expected: str) -> None: """Test state and value_template template.""" + # Ensure the trigger executes for trigger configurations + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) assert state.state == expected -@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) @pytest.mark.parametrize( - "attribute_template", - ["{% if states.switch.test_state.state %}/local/switch.png{% endif %}"], + ("count", "state_template", "attribute"), [(1, "{{ 1==1 }}", "picture")] ) @pytest.mark.parametrize( - ("style", "attribute"), + "attribute_template", + ["{% if states.sensor.test_state.state %}/local/switch.png{% endif %}"], +) +@pytest.mark.parametrize( + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") -async def test_picture_template(hass: HomeAssistant) -> None: +async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None: """Test entity_picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == initial_state hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() @@ -412,22 +495,25 @@ async def test_picture_template(hass: HomeAssistant) -> None: assert state.attributes["entity_picture"] == "/local/switch.png" -@pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 1==1 }}")]) @pytest.mark.parametrize( - "attribute_template", - ["{% if states.switch.test_state.state %}mdi:eye{% endif %}"], + ("count", "state_template", "attribute"), [(1, "{{ 1==1 }}", "icon")] ) @pytest.mark.parametrize( - ("style", "attribute"), + "attribute_template", + ["{% if states.sensor.test_state.state %}mdi:eye{% endif %}"], +) +@pytest.mark.parametrize( + ("style", "initial_state"), [ - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") -async def test_icon_template(hass: HomeAssistant) -> None: +async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None: """Test entity_picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == initial_state hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() @@ -437,10 +523,11 @@ async def test_icon_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -464,10 +551,11 @@ async def test_lock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.switch.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock") async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -492,10 +580,11 @@ async def test_unlock_action(hass: HomeAssistant, calls: list[ServiceCall]) -> N @pytest.mark.parametrize( ("count", "state_template", "extra_config"), - [(1, "{{ states.switch.test_state.state }}", OPEN_ACTION)], + [(1, "{{ states.sensor.test_state.state }}", OPEN_ACTION)], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_lock_with_extra_config") async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -523,7 +612,7 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "{{ '.+' }}", ) ], @@ -533,6 +622,7 @@ async def test_open_action(hass: HomeAssistant, calls: list[ServiceCall]) -> Non [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") @@ -564,7 +654,7 @@ async def test_lock_action_with_code( [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "{{ '.+' }}", ) ], @@ -574,6 +664,7 @@ async def test_lock_action_with_code( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") @@ -616,6 +707,7 @@ async def test_unlock_action_with_code( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.parametrize( @@ -630,6 +722,10 @@ async def test_lock_actions_fail_with_invalid_code( hass: HomeAssistant, calls: list[ServiceCall], test_action ) -> None: """Test invalid lock codes.""" + # Ensure trigger entities updated + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + await hass.services.async_call( lock.DOMAIN, test_action, @@ -656,17 +752,23 @@ async def test_lock_actions_fail_with_invalid_code( ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "attribute", "expected"), [ - (ConfigurationStyle.LEGACY, "code_format_template"), - (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.LEGACY, "code_format_template", 0), + (ConfigurationStyle.MODERN, "code_format", 0), + (ConfigurationStyle.TRIGGER, "code_format", 2), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_lock_actions_dont_execute_with_code_template_rendering_error( - hass: HomeAssistant, calls: list[ServiceCall] + hass: HomeAssistant, calls: list[ServiceCall], expected: int ) -> None: """Test lock code format rendering fails block lock/unlock actions.""" + + # Ensure trigger entities updated + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + await hass.services.async_call( lock.DOMAIN, lock.SERVICE_LOCK, @@ -679,7 +781,10 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( ) await hass.async_block_till_done() - assert len(calls) == 0 + # Trigger expects calls here because trigger based entities don't + # allow template exception resolutions into code_format property so + # the actions will fire using the previous code_format. + assert len(calls) == expected @pytest.mark.parametrize( @@ -687,7 +792,7 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "{{ None }}", ) ], @@ -697,6 +802,7 @@ async def test_lock_actions_dont_execute_with_code_template_rendering_error( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @@ -729,7 +835,7 @@ async def test_actions_with_none_as_codeformat_ignores_code( [ ( 1, - "{{ states.switch.test_state.state }}", + "{{ states.sensor.test_state.state }}", "[12]{1", ) ], @@ -739,6 +845,7 @@ async def test_actions_with_none_as_codeformat_ignores_code( [ (ConfigurationStyle.LEGACY, "code_format_template"), (ConfigurationStyle.MODERN, "code_format"), + (ConfigurationStyle.TRIGGER, "code_format"), ], ) @pytest.mark.parametrize("action", [lock.SERVICE_LOCK, lock.SERVICE_UNLOCK]) @@ -774,10 +881,11 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( @pytest.mark.parametrize( - ("count", "state_template"), [(1, "{{ states.input_select.test_state.state }}")] + ("count", "state_template"), [(1, "{{ states.sensor.test_state.state }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] @@ -785,7 +893,7 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( @pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: """Test value template.""" - hass.states.async_set("input_select.test_state", test_state) + hass.states.async_set(TEST_STATE_ENTITY_ID, test_state) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -797,7 +905,7 @@ async def test_lock_state(hass: HomeAssistant, test_state) -> None: [ ( 1, - "{{ states('switch.test_state') }}", + "{{ states('sensor.test_state') }}", "{{ is_state('availability_state.state', 'on') }}", ) ], @@ -807,20 +915,21 @@ async def test_lock_state(hass: HomeAssistant, test_state) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" # When template returns true.. - hass.states.async_set("availability_state.state", STATE_ON) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON) await hass.async_block_till_done() # Device State should not be unavailable assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false - hass.states.async_set("availability_state.state", STATE_OFF) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() # device state should be unavailable @@ -842,15 +951,20 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_state_lock_with_attribute") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE - assert ("UndefinedError: 'x' is undefined") in caplog_setup_text + err = "'x' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize(("count", "domain"), [(1, lock.DOMAIN)]) From fa71c40ff5098c4668549f6a7cb07e7057ca0cb0 Mon Sep 17 00:00:00 2001 From: LG-ThinQ-Integration Date: Mon, 23 Jun 2025 23:12:28 +0900 Subject: [PATCH 0544/1664] Bump thinqconnect to 1.0.7 (#147073) Co-authored-by: yunseon.park Co-authored-by: Josef Zweck --- homeassistant/components/lg_thinq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json index f9cff23b75c..0abc74d19a4 100644 --- a/homeassistant/components/lg_thinq/manifest.json +++ b/homeassistant/components/lg_thinq/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/lg_thinq", "iot_class": "cloud_push", "loggers": ["thinqconnect"], - "requirements": ["thinqconnect==1.0.5"] + "requirements": ["thinqconnect==1.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffb47933565..0286243cb08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2928,7 +2928,7 @@ thermopro-ble==0.13.0 thingspeak==1.0.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.5 +thinqconnect==1.0.7 # homeassistant.components.tikteck tikteck==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb71a90eee1..26f91948e08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2408,7 +2408,7 @@ thermobeacon-ble==0.10.0 thermopro-ble==0.13.0 # homeassistant.components.lg_thinq -thinqconnect==1.0.5 +thinqconnect==1.0.7 # homeassistant.components.tilt_ble tilt-ble==0.2.3 From 3798e99ac845d7955bbb01020bd07f9c3d6431d4 Mon Sep 17 00:00:00 2001 From: Sanjay Govind Date: Tue, 24 Jun 2025 02:42:14 +1200 Subject: [PATCH 0545/1664] Update bosch_alarm to platinum quality scale (#145027) * update quality scale for bosch_alarm * update quality scale * update quality scale --- .../components/bosch_alarm/manifest.json | 2 +- .../components/bosch_alarm/quality_scale.yaml | 37 ++++++++++--------- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json index 160d6141959..0003d80cc4f 100644 --- a/homeassistant/components/bosch_alarm/manifest.json +++ b/homeassistant/components/bosch_alarm/manifest.json @@ -11,6 +11,6 @@ "documentation": "https://www.home-assistant.io/integrations/bosch_alarm", "integration_type": "device", "iot_class": "local_push", - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["bosch-alarm-mode2==0.4.6"] } diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml index 474dc348fd8..f26050b4883 100644 --- a/homeassistant/components/bosch_alarm/quality_scale.yaml +++ b/homeassistant/components/bosch_alarm/quality_scale.yaml @@ -28,38 +28,41 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo - entity-unavailable: todo + docs-configuration-parameters: + status: exempt + comment: | + No options flow is provided. + docs-installation-parameters: done + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: done test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: done discovery: done - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + 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: status: exempt comment: | Device type integration - entity-category: todo - entity-device-class: todo - entity-disabled-by-default: 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: status: exempt comment: | From f8267b13d7671860c2a2b9144c80cc2aec5fc914 Mon Sep 17 00:00:00 2001 From: Alena Bugrova <54861210+LoSk-p@users.noreply.github.com> Date: Mon, 23 Jun 2025 17:57:51 +0300 Subject: [PATCH 0546/1664] Add Altruist integration to Core (#146158) * add altruist integration and tests * requested fixes + remove some deprecated sensors * add tests for unknown sensor and device attribute in config_flow * use CONF_ in data_schema * suggested fixes * remove test_setup_entry_success * create ZeroconfServiceInfo in tests * use CONF_IP_ADDRESS in tests * add unique id assert * add integration to strict-typing, set unavailable if no sensor key in data, change device name * use add_suggested_values_to_schema, mmHg for pressure * update snapshots and config entry name in tests * remove changes in devcontainer config * fixture for create client error, typing in tests, remove "Altruist" from device name * change native_value_fn return type * change sensor.py docstring * remove device id from entry data, fix docstrings * remove checks for client and device attributes * use less variables in tests * change creating AltruistSensor, remove device from arguments * Update homeassistant/components/altruist/sensor.py * Update homeassistant/components/altruist/quality_scale.yaml * Update homeassistant/components/altruist/quality_scale.yaml * Update quality_scale.yaml * hassfest run * suggested fixes * set suggested_unit_of_measurement for pressure * use mock_config_entry, update snapshots * abort if cant create client on zeroconf step * move sensor names in translatin placeholders --------- Co-authored-by: Josef Zweck --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/altruist/__init__.py | 27 + .../components/altruist/config_flow.py | 107 ++++ homeassistant/components/altruist/const.py | 5 + .../components/altruist/coordinator.py | 64 +++ homeassistant/components/altruist/icons.json | 15 + .../components/altruist/manifest.json | 12 + .../components/altruist/quality_scale.yaml | 83 +++ homeassistant/components/altruist/sensor.py | 249 +++++++++ .../components/altruist/strings.json | 51 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/altruist/__init__.py | 13 + tests/components/altruist/conftest.py | 82 +++ .../altruist/fixtures/real_data.json | 38 ++ .../altruist/fixtures/sensor_names.json | 11 + .../altruist/snapshots/test_sensor.ambr | 507 ++++++++++++++++++ tests/components/altruist/test_config_flow.py | 169 ++++++ tests/components/altruist/test_init.py | 53 ++ tests/components/altruist/test_sensor.py | 55 ++ 25 files changed, 1572 insertions(+) create mode 100644 homeassistant/components/altruist/__init__.py create mode 100644 homeassistant/components/altruist/config_flow.py create mode 100644 homeassistant/components/altruist/const.py create mode 100644 homeassistant/components/altruist/coordinator.py create mode 100644 homeassistant/components/altruist/icons.json create mode 100644 homeassistant/components/altruist/manifest.json create mode 100644 homeassistant/components/altruist/quality_scale.yaml create mode 100644 homeassistant/components/altruist/sensor.py create mode 100644 homeassistant/components/altruist/strings.json create mode 100644 tests/components/altruist/__init__.py create mode 100644 tests/components/altruist/conftest.py create mode 100644 tests/components/altruist/fixtures/real_data.json create mode 100644 tests/components/altruist/fixtures/sensor_names.json create mode 100644 tests/components/altruist/snapshots/test_sensor.ambr create mode 100644 tests/components/altruist/test_config_flow.py create mode 100644 tests/components/altruist/test_init.py create mode 100644 tests/components/altruist/test_sensor.py diff --git a/.strict-typing b/.strict-typing index b34cbfa5fca..68d67ae85b2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -67,6 +67,7 @@ homeassistant.components.alert.* homeassistant.components.alexa.* homeassistant.components.alexa_devices.* homeassistant.components.alpha_vantage.* +homeassistant.components.altruist.* homeassistant.components.amazon_polly.* homeassistant.components.amberelectric.* homeassistant.components.ambient_network.* diff --git a/CODEOWNERS b/CODEOWNERS index da247c06cb8..9f312c77b1e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -93,6 +93,8 @@ build.json @home-assistant/supervisor /tests/components/alexa/ @home-assistant/cloud @ochlocracy @jbouwh /homeassistant/components/alexa_devices/ @chemelli74 /tests/components/alexa_devices/ @chemelli74 +/homeassistant/components/altruist/ @airalab @LoSk-p +/tests/components/altruist/ @airalab @LoSk-p /homeassistant/components/amazon_polly/ @jschlyter /homeassistant/components/amberelectric/ @madpilot /tests/components/amberelectric/ @madpilot diff --git a/homeassistant/components/altruist/__init__.py b/homeassistant/components/altruist/__init__.py new file mode 100644 index 00000000000..6040b347bb5 --- /dev/null +++ b/homeassistant/components/altruist/__init__.py @@ -0,0 +1,27 @@ +"""The Altruist integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import AltruistConfigEntry, AltruistDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool: + """Set up Altruist from a config entry.""" + + coordinator = AltruistDataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: AltruistConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/altruist/config_flow.py b/homeassistant/components/altruist/config_flow.py new file mode 100644 index 00000000000..ec3c8f9d8f9 --- /dev/null +++ b/homeassistant/components/altruist/config_flow.py @@ -0,0 +1,107 @@ +"""Config flow for the Altruist integration.""" + +import logging +from typing import Any + +from altruistclient import AltruistClient, AltruistDeviceModel, AltruistError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import CONF_HOST, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AltruistConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Altruist.""" + + device: AltruistDeviceModel + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + ip_address = "" + if user_input is not None: + ip_address = user_input[CONF_HOST] + try: + client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), ip_address + ) + except AltruistError: + errors["base"] = "no_device_found" + else: + self.device = client.device + await self.async_set_unique_id( + client.device_id, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=self.device.id, + data={ + CONF_HOST: ip_address, + }, + ) + + data_schema = self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_HOST): str}), + {CONF_HOST: ip_address}, + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + description_placeholders={ + "ip_address": ip_address, + }, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + _LOGGER.debug("Zeroconf discovery: %s", discovery_info) + try: + client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), str(discovery_info.ip_address) + ) + except AltruistError: + return self.async_abort(reason="no_device_found") + + self.device = client.device + _LOGGER.debug("Zeroconf device: %s", client.device) + await self.async_set_unique_id(client.device_id) + self._abort_if_unique_id_configured() + self.context.update( + { + "title_placeholders": { + "name": self.device.id, + } + } + ) + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.device.id, + data={ + CONF_HOST: self.device.ip_address, + }, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "model": self.device.id, + }, + ) diff --git a/homeassistant/components/altruist/const.py b/homeassistant/components/altruist/const.py new file mode 100644 index 00000000000..93cbbd2c535 --- /dev/null +++ b/homeassistant/components/altruist/const.py @@ -0,0 +1,5 @@ +"""Constants for the Altruist integration.""" + +DOMAIN = "altruist" + +CONF_HOST = "host" diff --git a/homeassistant/components/altruist/coordinator.py b/homeassistant/components/altruist/coordinator.py new file mode 100644 index 00000000000..0a537e62af6 --- /dev/null +++ b/homeassistant/components/altruist/coordinator.py @@ -0,0 +1,64 @@ +"""Coordinator module for Altruist integration in Home Assistant. + +This module defines the AltruistDataUpdateCoordinator class, which manages +data updates for Altruist sensors using the AltruistClient. +""" + +from datetime import timedelta +import logging + +from altruistclient import AltruistClient, AltruistError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_HOST + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + +type AltruistConfigEntry = ConfigEntry[AltruistDataUpdateCoordinator] + + +class AltruistDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Coordinates data updates for Altruist sensors.""" + + client: AltruistClient + + def __init__( + self, + hass: HomeAssistant, + config_entry: AltruistConfigEntry, + ) -> None: + """Initialize the data update coordinator for Altruist sensors.""" + device_id = config_entry.unique_id + super().__init__( + hass, + logger=_LOGGER, + config_entry=config_entry, + name=f"Altruist {device_id}", + update_interval=UPDATE_INTERVAL, + ) + self._ip_address = config_entry.data[CONF_HOST] + + async def _async_setup(self) -> None: + try: + self.client = await AltruistClient.from_ip_address( + async_get_clientsession(self.hass), self._ip_address + ) + await self.client.fetch_data() + except AltruistError as e: + raise ConfigEntryNotReady("Error in Altruist setup") from e + + async def _async_update_data(self) -> dict[str, str]: + try: + fetched_data = await self.client.fetch_data() + except AltruistError as ex: + raise UpdateFailed( + f"The Altruist {self.client.device_id} is unavailable: {ex}" + ) from ex + return {item["value_type"]: item["value"] for item in fetched_data} diff --git a/homeassistant/components/altruist/icons.json b/homeassistant/components/altruist/icons.json new file mode 100644 index 00000000000..9c012b87b6d --- /dev/null +++ b/homeassistant/components/altruist/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "pm_10": { + "default": "mdi:thought-bubble" + }, + "pm_25": { + "default": "mdi:thought-bubble-outline" + }, + "radiation": { + "default": "mdi:radioactive" + } + } + } +} diff --git a/homeassistant/components/altruist/manifest.json b/homeassistant/components/altruist/manifest.json new file mode 100644 index 00000000000..534830a9b70 --- /dev/null +++ b/homeassistant/components/altruist/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "altruist", + "name": "Altruist", + "codeowners": ["@airalab", "@LoSk-p"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/altruist", + "integration_type": "device", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["altruistclient==0.1.1"], + "zeroconf": ["_altruist._tcp.local."] +} diff --git a/homeassistant/components/altruist/quality_scale.yaml b/homeassistant/components/altruist/quality_scale.yaml new file mode 100644 index 00000000000..4566ac5f6df --- /dev/null +++ b/homeassistant/components/altruist/quality_scale.yaml @@ -0,0 +1,83 @@ +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 provide options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: todo + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device type integration + entity-category: todo + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: No known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: | + Device type integration + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/altruist/sensor.py b/homeassistant/components/altruist/sensor.py new file mode 100644 index 00000000000..f02c442e5cd --- /dev/null +++ b/homeassistant/components/altruist/sensor.py @@ -0,0 +1,249 @@ +"""Defines the Altruist sensor platform.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfPressure, + UnitOfSoundPressure, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +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 . import AltruistConfigEntry +from .coordinator import AltruistDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class AltruistSensorEntityDescription(SensorEntityDescription): + """Class to describe a Sensor entity.""" + + native_value_fn: Callable[[str], float] = float + state_class = SensorStateClass.MEASUREMENT + + +SENSOR_DESCRIPTIONS = [ + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="BME280_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BME280_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BME280_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BME280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BMP_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BMP"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BMP_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BMP"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="BMP280_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "BMP280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PRESSURE, + key="BMP280_pressure", + translation_key="pressure", + native_unit_of_measurement=UnitOfPressure.PA, + suggested_unit_of_measurement=UnitOfPressure.MMHG, + suggested_display_precision=0, + translation_placeholders={"sensor_name": "BMP280"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="HTU21D_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "HTU21D"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="HTU21D_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "HTU21D"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PM10, + translation_key="pm_10", + key="SDS_P1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.PM25, + translation_key="pm_25", + key="SDS_P2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key="SHT3X_humidity", + translation_key="humidity", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SHT3X"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key="SHT3X_temperature", + translation_key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SHT3X"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + key="signal", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SOUND_PRESSURE, + key="PCBA_noiseMax", + translation_key="noise_max", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.SOUND_PRESSURE, + key="PCBA_noiseAvg", + translation_key="noise_avg", + native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, + suggested_display_precision=0, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.CO2, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + translation_key="co2", + key="CCS_CO2", + suggested_display_precision=2, + translation_placeholders={"sensor_name": "CCS"}, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + key="CCS_TVOC", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + key="GC", + native_unit_of_measurement="μR/h", + translation_key="radiation", + suggested_display_precision=2, + ), + AltruistSensorEntityDescription( + device_class=SensorDeviceClass.CO2, + translation_key="co2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + key="SCD4x_co2", + suggested_display_precision=2, + translation_placeholders={"sensor_name": "SCD4x"}, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: AltruistConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add sensors for passed config_entry in HA.""" + coordinator = config_entry.runtime_data + async_add_entities( + AltruistSensor(coordinator, sensor_description) + for sensor_description in SENSOR_DESCRIPTIONS + if sensor_description.key in coordinator.data + ) + + +class AltruistSensor(CoordinatorEntity[AltruistDataUpdateCoordinator], SensorEntity): + """Implementation of a Altruist sensor.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: AltruistDataUpdateCoordinator, + description: AltruistSensorEntityDescription, + ) -> None: + """Initialize the Altruist sensor.""" + super().__init__(coordinator) + self._device = coordinator.client.device + self.entity_description: AltruistSensorEntityDescription = description + self._attr_unique_id = f"{self._device.id}-{description.key}" + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._device.id)}, + manufacturer="Robonomics", + model="Altruist", + sw_version=self._device.fw_version, + configuration_url=f"http://{self._device.ip_address}", + serial_number=self._device.id, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + super().available and self.entity_description.key in self.coordinator.data + ) + + @property + def native_value(self) -> float | int: + """Return the native value of the sensor.""" + string_value = self.coordinator.data[self.entity_description.key] + return self.entity_description.native_value_fn(string_value) diff --git a/homeassistant/components/altruist/strings.json b/homeassistant/components/altruist/strings.json new file mode 100644 index 00000000000..a466e1e3c9d --- /dev/null +++ b/homeassistant/components/altruist/strings.json @@ -0,0 +1,51 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "discovery_confirm": { + "description": "Do you want to start setup {model}?" + }, + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Altruist IP address or hostname in the local network" + }, + "description": "Fill in Altruist IP address or hostname in your local network" + } + }, + "abort": { + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "no_device_found": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "humidity": { + "name": "{sensor_name} humidity" + }, + "pressure": { + "name": "{sensor_name} pressure" + }, + "temperature": { + "name": "{sensor_name} temperature" + }, + "noise_max": { + "name": "Maximum noise" + }, + "noise_avg": { + "name": "Average noise" + }, + "co2": { + "name": "{sensor_name} CO2" + }, + "radiation": { + "name": "Radiation level" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e164bc09929..b9dfefd3327 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -48,6 +48,7 @@ FLOWS = { "airzone_cloud", "alarmdecoder", "alexa_devices", + "altruist", "amberelectric", "ambient_network", "ambient_station", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1202d7d51ec..b3918ac8ded 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -204,6 +204,12 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "altruist": { + "name": "Altruist", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "amazon": { "name": "Amazon", "integrations": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index e675a0bb237..21abaa2a579 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -342,6 +342,11 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_altruist._tcp.local.": [ + { + "domain": "altruist", + }, + ], "_amzn-alexa._tcp.local.": [ { "domain": "roomba", diff --git a/mypy.ini b/mypy.ini index 1fdab75663e..72e52b67959 100644 --- a/mypy.ini +++ b/mypy.ini @@ -425,6 +425,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.altruist.*] +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.amazon_polly.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 0286243cb08..8d8817102cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -461,6 +461,9 @@ airtouch5py==0.3.0 # homeassistant.components.alpha_vantage alpha-vantage==2.3.1 +# homeassistant.components.altruist +altruistclient==0.1.1 + # homeassistant.components.amberelectric amberelectric==2.0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26f91948e08..b53a6779b4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,6 +440,9 @@ airtouch4pyapi==1.0.5 # homeassistant.components.airtouch5 airtouch5py==0.3.0 +# homeassistant.components.altruist +altruistclient==0.1.1 + # homeassistant.components.amberelectric amberelectric==2.0.12 diff --git a/tests/components/altruist/__init__.py b/tests/components/altruist/__init__.py new file mode 100644 index 00000000000..bdbd8c0532a --- /dev/null +++ b/tests/components/altruist/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Altruist 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/altruist/conftest.py b/tests/components/altruist/conftest.py new file mode 100644 index 00000000000..3a7fcd1afe7 --- /dev/null +++ b/tests/components/altruist/conftest.py @@ -0,0 +1,82 @@ +"""Altruist tests configuration.""" + +from collections.abc import Generator +import json +from unittest.mock import AsyncMock, Mock, patch + +from altruistclient import AltruistDeviceModel, AltruistError +import pytest + +from homeassistant.components.altruist.const import CONF_HOST, DOMAIN + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.altruist.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="5366960e8b18", + title="5366960e8b18", + ) + + +@pytest.fixture +def mock_altruist_device() -> Mock: + """Return a mock AltruistDeviceModel.""" + device = Mock(spec=AltruistDeviceModel) + device.id = "5366960e8b18" + device.name = "Altruist Sensor" + device.ip_address = "192.168.1.100" + device.fw_version = "R_2025-03" + return device + + +@pytest.fixture +def mock_altruist_client(mock_altruist_device: Mock) -> Generator[AsyncMock]: + """Return a mock AltruistClient.""" + with ( + patch( + "homeassistant.components.altruist.coordinator.AltruistClient", + autospec=True, + ) as mock_client_class, + patch( + "homeassistant.components.altruist.config_flow.AltruistClient", + new=mock_client_class, + ), + ): + mock_instance = AsyncMock() + mock_instance.device = mock_altruist_device + mock_instance.device_id = mock_altruist_device.id + mock_instance.sensor_names = json.loads( + load_fixture("sensor_names.json", DOMAIN) + ) + mock_instance.fetch_data.return_value = json.loads( + load_fixture("real_data.json", DOMAIN) + ) + + mock_client_class.from_ip_address = AsyncMock(return_value=mock_instance) + + yield mock_instance + + +@pytest.fixture +def mock_altruist_client_fails_once(mock_altruist_client: AsyncMock) -> Generator[None]: + """Patch AltruistClient to fail once and then succeed.""" + with patch( + "homeassistant.components.altruist.config_flow.AltruistClient.from_ip_address", + side_effect=[AltruistError("Connection failed"), mock_altruist_client], + ): + yield diff --git a/tests/components/altruist/fixtures/real_data.json b/tests/components/altruist/fixtures/real_data.json new file mode 100644 index 00000000000..86700f50b4f --- /dev/null +++ b/tests/components/altruist/fixtures/real_data.json @@ -0,0 +1,38 @@ +[ + { + "value_type": "signal", + "value": "-48" + }, + { + "value_type": "SDS_P1", + "value": "0.1" + }, + { + "value_type": "SDS_P2", + "value": "0.23" + }, + { + "value_type": "BME280_humidity", + "value": "54.94141" + }, + { + "value_type": "BME280_temperature", + "value": "22.95313" + }, + { + "value_type": "BME280_pressure", + "value": "99978.16" + }, + { + "value_type": "PCBA_noiseMax", + "value": "60" + }, + { + "value_type": "PCBA_noiseAvg", + "value": "51" + }, + { + "value_type": "GC", + "value": "15.2" + } +] diff --git a/tests/components/altruist/fixtures/sensor_names.json b/tests/components/altruist/fixtures/sensor_names.json new file mode 100644 index 00000000000..41aa997326c --- /dev/null +++ b/tests/components/altruist/fixtures/sensor_names.json @@ -0,0 +1,11 @@ +[ + "signal", + "SDS_P1", + "SDS_P2", + "BME280_humidity", + "BME280_temperature", + "BME280_pressure", + "PCBA_noiseMax", + "PCBA_noiseAvg", + "GC" +] diff --git a/tests/components/altruist/snapshots/test_sensor.ambr b/tests/components/altruist/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..ca74e75542f --- /dev/null +++ b/tests/components/altruist/snapshots/test_sensor.ambr @@ -0,0 +1,507 @@ +# serializer version: 1 +# name: test_all_entities[sensor.5366960e8b18_average_noise-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.5366960e8b18_average_noise', + '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': 'Average noise', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'noise_avg', + 'unique_id': '5366960e8b18-PCBA_noiseAvg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_average_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': '5366960e8b18 Average noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_average_noise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '51.0', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_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.5366960e8b18_bme280_humidity', + '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': 'BME280 humidity', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': '5366960e8b18-BME280_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': '5366960e8b18 BME280 humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '54.94141', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_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.5366960e8b18_bme280_pressure', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'BME280 pressure', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pressure', + 'unique_id': '5366960e8b18-BME280_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_pressure-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pressure', + 'friendly_name': '5366960e8b18 BME280 pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_pressure', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '749.897762397492', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_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.5366960e8b18_bme280_temperature', + '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': 'BME280 temperature', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': '5366960e8b18-BME280_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_bme280_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': '5366960e8b18 BME280 temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_bme280_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.95313', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_maximum_noise-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.5366960e8b18_maximum_noise', + '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': 'Maximum noise', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'noise_max', + 'unique_id': '5366960e8b18-PCBA_noiseMax', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_maximum_noise-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'sound_pressure', + 'friendly_name': '5366960e8b18 Maximum noise', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_maximum_noise', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '60.0', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm10-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.5366960e8b18_pm10', + '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': 'PM10', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm_10', + 'unique_id': '5366960e8b18-SDS_P1', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm10', + 'friendly_name': '5366960e8b18 PM10', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_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.5366960e8b18_pm2_5', + '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': 'PM2.5', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm_25', + 'unique_id': '5366960e8b18-SDS_P2', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'pm25', + 'friendly_name': '5366960e8b18 PM2.5', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.23', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_radiation_level-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.5366960e8b18_radiation_level', + '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': 'Radiation level', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'radiation', + 'unique_id': '5366960e8b18-GC', + 'unit_of_measurement': 'μR/h', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_radiation_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '5366960e8b18 Radiation level', + 'state_class': , + 'unit_of_measurement': 'μR/h', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_radiation_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.2', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_signal_strength-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.5366960e8b18_signal_strength', + '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': 'Signal strength', + 'platform': 'altruist', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '5366960e8b18-signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.5366960e8b18_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': '5366960e8b18 Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.5366960e8b18_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-48.0', + }) +# --- diff --git a/tests/components/altruist/test_config_flow.py b/tests/components/altruist/test_config_flow.py new file mode 100644 index 00000000000..3d04e893d62 --- /dev/null +++ b/tests/components/altruist/test_config_flow.py @@ -0,0 +1,169 @@ +"""Test the Altruist config flow.""" + +from ipaddress import ip_address +from unittest.mock import AsyncMock + +from homeassistant.components.altruist.const import CONF_HOST, DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from tests.common import MockConfigEntry + +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], + hostname="altruist-purple.local.", + name="altruist-purple._altruist._tcp.local.", + port=80, + type="_altruist._tcp.local.", + properties={ + "PATH": "/config", + }, +) + + +async def test_form_user_step_success( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user step shows form and succeeds with valid input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + assert result["result"].unique_id == "5366960e8b18" + + +async def test_form_user_step_cannot_connect_then_recovers( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_altruist_client_fails_once: None, + mock_setup_entry: AsyncMock, +) -> None: + """Test we handle connection error and allow recovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + # First attempt triggers an error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_device_found"} + + # Second attempt recovers with a valid client + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["result"].unique_id == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + + +async def test_form_user_step_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we abort if already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "192.168.1.100"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "5366960e8b18" + assert result["data"] == { + CONF_HOST: "192.168.1.100", + } + assert result["result"].unique_id == "5366960e8b18" + + +async def test_zeroconf_discovery_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_discovery_cant_create_client( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client_fails_once: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf discovery when already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_device_found" diff --git a/tests/components/altruist/test_init.py b/tests/components/altruist/test_init.py new file mode 100644 index 00000000000..67d5b01acb6 --- /dev/null +++ b/tests/components/altruist/test_init.py @@ -0,0 +1,53 @@ +"""Test the Altruist integration.""" + +from unittest.mock import AsyncMock + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry_client_creation_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client_fails_once: None, +) -> None: + """Test setup failure when client creation fails.""" + mock_config_entry.add_to_hass(hass) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_entry_fetch_data_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, +) -> None: + """Test setup failure when initial data fetch fails.""" + mock_config_entry.add_to_hass(hass) + mock_altruist_client.fetch_data.side_effect = Exception("Fetch failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_altruist_client: AsyncMock, +) -> None: + """Test unloading of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + # Now test unloading + 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 diff --git a/tests/components/altruist/test_sensor.py b/tests/components/altruist/test_sensor.py new file mode 100644 index 00000000000..1214adc488f --- /dev/null +++ b/tests/components/altruist/test_sensor.py @@ -0,0 +1,55 @@ +"""Tests for the Altruist integration sensor platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from altruistclient import AltruistError +from freezegun.api import FrozenDateTimeFactory +import pytest +from syrupy.assertion import SnapshotAssertion + +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, async_fire_time_changed, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_altruist_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.altruist.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_connection_error( + hass: HomeAssistant, + mock_altruist_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test coordinator error handling during update.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_altruist_client.fetch_data.side_effect = AltruistError() + + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.5366960e8b18_bme280_temperature").state + == STATE_UNAVAILABLE + ) From a11e2744341b7c7348a70e67cb355389ab12e114 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Jun 2025 10:58:42 -0400 Subject: [PATCH 0547/1664] Address AI Task late comments (#147313) --- homeassistant/components/ai_task/__init__.py | 23 +++++++++++++------ homeassistant/components/ai_task/const.py | 7 +++++- .../components/ai_task/services.yaml | 2 +- homeassistant/components/ai_task/strings.json | 2 +- homeassistant/components/ai_task/task.py | 9 +++++--- tests/components/ai_task/test_task.py | 11 +++++---- 6 files changed, 37 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 8b3d6e04966..7fec89f384e 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -5,6 +5,7 @@ import logging import voluptuous as vol from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import ( HassJobType, HomeAssistant, @@ -17,9 +18,17 @@ from homeassistant.helpers import config_validation as cv, storage from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType -from .const import DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, AITaskEntityFeature +from .const import ( + ATTR_INSTRUCTIONS, + ATTR_TASK_NAME, + DATA_COMPONENT, + DATA_PREFERENCES, + DOMAIN, + SERVICE_GENERATE_TEXT, + AITaskEntityFeature, +) from .entity import AITaskEntity -from .http import async_setup as async_setup_conversation_http +from .http import async_setup as async_setup_http from .task import GenTextTask, GenTextTaskResult, async_generate_text __all__ = [ @@ -45,16 +54,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) await hass.data[DATA_PREFERENCES].async_load() - async_setup_conversation_http(hass) + async_setup_http(hass) hass.services.async_register( DOMAIN, - "generate_text", + SERVICE_GENERATE_TEXT, async_service_generate_text, schema=vol.Schema( { - vol.Required("task_name"): cv.string, - vol.Optional("entity_id"): cv.entity_id, - vol.Required("instructions"): cv.string, + vol.Required(ATTR_TASK_NAME): cv.string, + vol.Optional(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_INSTRUCTIONS): cv.string, } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index 69786178583..b6058c11b45 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import IntFlag -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from homeassistant.util.hass_dict import HassKey @@ -17,6 +17,11 @@ DOMAIN = "ai_task" DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") +SERVICE_GENERATE_TEXT = "generate_text" + +ATTR_INSTRUCTIONS: Final = "instructions" +ATTR_TASK_NAME: Final = "task_name" + DEFAULT_SYSTEM_PROMPT = ( "You are a Home Assistant expert and help users with their tasks." ) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 32715bf77d7..12e3975fca6 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -6,7 +6,7 @@ generate_text: selector: text: instructions: - example: "Generate a funny notification that garage door was left open" + example: "Generate a funny notification that the garage door was left open" required: true selector: text: diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index 1cdbf20ba4f..f994aaebe8e 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -5,7 +5,7 @@ "description": "Use AI to run a task that generates text.", "fields": { "task_name": { - "name": "Task Name", + "name": "Task name", "description": "Name of the task." }, "instructions": { diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index d0c59fdd09a..1ba5838d18b 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature @@ -21,14 +22,16 @@ async def async_generate_text( entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id if entity_id is None: - raise ValueError("No entity_id provided and no preferred entity set") + raise HomeAssistantError("No entity_id provided and no preferred entity set") entity = hass.data[DATA_COMPONENT].get_entity(entity_id) if entity is None: - raise ValueError(f"AI Task entity {entity_id} not found") + raise HomeAssistantError(f"AI Task entity {entity_id} not found") if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features: - raise ValueError(f"AI Task entity {entity_id} does not support generating text") + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support generating text" + ) return await entity.internal_async_generate_text( GenTextTask( diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index d4df66d83f9..d6e266aa02e 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -8,6 +8,7 @@ from homeassistant.components.ai_task import AITaskEntityFeature, async_generate from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -25,7 +26,7 @@ async def test_run_task_preferred_entity( client = await hass_ws_client(hass) with pytest.raises( - ValueError, match="No entity_id provided and no preferred entity set" + HomeAssistantError, match="No entity_id provided and no preferred entity set" ): await async_generate_text( hass, @@ -42,7 +43,9 @@ async def test_run_task_preferred_entity( msg = await client.receive_json() assert msg["success"] - with pytest.raises(ValueError, match="AI Task entity ai_task.unknown not found"): + with pytest.raises( + HomeAssistantError, match="AI Task entity ai_task.unknown not found" + ): await async_generate_text( hass, task_name="Test Task", @@ -74,7 +77,7 @@ async def test_run_task_preferred_entity( mock_ai_task_entity.supported_features = AITaskEntityFeature(0) with pytest.raises( - ValueError, + HomeAssistantError, match="AI Task entity ai_task.test_task_entity does not support generating text", ): await async_generate_text( @@ -91,7 +94,7 @@ async def test_run_text_task_unknown_entity( """Test running a text task with an unknown entity.""" with pytest.raises( - ValueError, match="AI Task entity ai_task.unknown_entity not found" + HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found" ): await async_generate_text( hass, From e98ec38ad828280a45d54df3e5dff111a280f597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 23 Jun 2025 17:26:27 +0200 Subject: [PATCH 0548/1664] Matter energy optimization opt-out attribute (#147096) * ESAStateEnum * Update snapshot * Add test * Update homeassistant/components/matter/strings.json Co-authored-by: Norbert Rittel * Update homeassistant/components/matter/strings.json Co-authored-by: Norbert Rittel * Update homeassistant/components/matter/icons.json Co-authored-by: Martin Hjelmare * Update homeassistant/components/matter/sensor.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/matter/strings.json Co-authored-by: Martin Hjelmare * Update sensor.py * Update snapshot --------- Co-authored-by: Norbert Rittel Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/icons.json | 3 + homeassistant/components/matter/sensor.py | 20 ++ homeassistant/components/matter/strings.json | 9 + .../matter/snapshots/test_sensor.ambr | 186 ++++++++++++++++++ tests/components/matter/test_sensor.py | 12 ++ 5 files changed, 230 insertions(+) diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 2d7e2888896..32f822414aa 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -93,6 +93,9 @@ "battery_time_to_full_charge": { "default": "mdi:battery-clock" }, + "esa_opt_out_state": { + "default": "mdi:home-lightning-bolt" + }, "evse_state": { "default": "mdi:ev-station" }, diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 0eefb536bcf..0b4d3cc3330 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -93,6 +93,13 @@ CHARGE_STATE_MAP = { clusters.PowerSource.Enums.BatChargeStateEnum.kUnknownEnumValue: None, } +DEM_OPT_OUT_STATE_MAP = { + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kNoOptOut: "no_opt_out", + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kLocalOptOut: "local_opt_out", + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kGridOptOut: "grid_opt_out", + clusters.DeviceEnergyManagement.Enums.OptOutStateEnum.kOptOut: "opt_out", +} + ESA_STATE_MAP = { clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOffline: "offline", clusters.DeviceEnergyManagement.Enums.ESAStateEnum.kOnline: "online", @@ -1159,6 +1166,19 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="ESAOptOutState", + translation_key="esa_opt_out_state", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=list(DEM_OPT_OUT_STATE_MAP.values()), + measurement_to_ha=DEM_OPT_OUT_STATE_MAP.get, + ), + entity_class=MatterSensor, + required_attributes=(clusters.DeviceEnergyManagement.Attributes.OptOutState,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index c713dfba615..35a9daa2370 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -363,6 +363,15 @@ "paused": "[%key:common::state::paused%]" } }, + "esa_opt_out_state": { + "name": "Energy optimization opt-out", + "state": { + "no_opt_out": "[%key:common::state::off%]", + "local_opt_out": "Local", + "grid_opt_out": "Grid", + "opt_out": "Local and grid" + } + }, "evse_fault_state": { "name": "Fault state", "state": { diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 7de836b7092..17841121445 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -1486,6 +1486,68 @@ 'state': '0.0', }) # --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_battery_storage_energy_optimization_opt_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy optimization opt-out', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_opt_out_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-1-ESAOptOutState-152-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[battery_storage][sensor.mock_battery_storage_energy_optimization_opt_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Battery Storage Energy optimization opt-out', + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_battery_storage_energy_optimization_opt_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_opt_out', + }) +# --- # name: test_sensors[battery_storage][sensor.mock_battery_storage_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -4645,6 +4707,68 @@ 'state': '32.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_energy_optimization_opt_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.evse_energy_optimization_opt_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy optimization opt-out', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_opt_out_state', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-ESAOptOutState-152-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_energy_optimization_opt_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'evse Energy optimization opt-out', + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'context': , + 'entity_id': 'sensor.evse_energy_optimization_opt_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_opt_out', + }) +# --- # name: test_sensors[silabs_evse_charging][sensor.evse_fault_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -5389,6 +5513,68 @@ 'state': '0.1', }) # --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.water_heater_energy_optimization_opt_out', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy optimization opt-out', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'esa_opt_out_state', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-ESAOptOutState-152-7', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[silabs_water_heater][sensor.water_heater_energy_optimization_opt_out-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Water Heater Energy optimization opt-out', + 'options': list([ + 'no_opt_out', + 'local_opt_out', + 'grid_opt_out', + 'opt_out', + ]), + }), + 'context': , + 'entity_id': 'sensor.water_heater_energy_optimization_opt_out', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'no_opt_out', + }) +# --- # name: test_sensors[silabs_water_heater][sensor.water_heater_hot_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index e70101bf804..3e9af4a6e4b 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -524,6 +524,18 @@ async def test_water_heater( assert state assert state.state == "offline" + # DeviceEnergyManagement -> OptOutState attribute + state = hass.states.get("sensor.water_heater_energy_optimization_opt_out") + assert state + assert state.state == "no_opt_out" + + set_node_attribute(matter_node, 2, 152, 7, 3) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.water_heater_energy_optimization_opt_out") + assert state + assert state.state == "opt_out" + @pytest.mark.parametrize("node_fixture", ["pump"]) async def test_pump( From ccbc5ed65b553a94f8bffb7882a871da9e354954 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Jun 2025 17:50:56 +0200 Subject: [PATCH 0549/1664] Bump aioesphomeapi to 3.1.1 (#147345) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/snapshots/test_diagnostics.ambr | 8 ++++++++ tests/components/esphome/test_diagnostics.py | 3 +++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 0577ed10c19..68bc8fe040e 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==33.0.0", + "aioesphomeapi==33.1.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 8d8817102cd..f76d831c5fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -244,7 +244,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==33.0.0 +aioesphomeapi==33.1.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b53a6779b4e..ec9d8b973b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aioelectricitymaps==0.4.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==33.0.0 +aioesphomeapi==33.1.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index d88f2045e56..dac224c802f 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -82,9 +82,17 @@ 'minor': 99, }), 'device_info': dict({ + 'area': dict({ + 'area_id': 0, + 'name': '', + }), + 'areas': list([ + ]), 'bluetooth_mac_address': '', 'bluetooth_proxy_feature_flags': 0, 'compilation_time': '', + 'devices': list([ + ]), 'esphome_version': '1.0.0', 'friendly_name': 'Test', 'has_deep_sleep': False, diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 8f1843900d7..2653df57adb 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -124,9 +124,12 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "area": {"area_id": 0, "name": ""}, + "areas": [], "bluetooth_mac_address": "**REDACTED**", "bluetooth_proxy_feature_flags": 63, "compilation_time": "", + "devices": [], "esphome_version": "1.0.0", "friendly_name": "Test", "has_deep_sleep": False, From 7bb9936e81c6e804975dcb7aea84bd3e9d3a5f74 Mon Sep 17 00:00:00 2001 From: Foscam-wangzhengyu Date: Tue, 24 Jun 2025 00:10:31 +0800 Subject: [PATCH 0550/1664] Replace foscam dependency (#145766) * Update Public Library * Update conftest.py --- homeassistant/components/foscam/__init__.py | 2 +- homeassistant/components/foscam/config_flow.py | 4 ++-- homeassistant/components/foscam/coordinator.py | 2 +- homeassistant/components/foscam/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/foscam/conftest.py | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py index 9643f333bb5..222a7e44a45 100644 --- a/homeassistant/components/foscam/__init__.py +++ b/homeassistant/components/foscam/__init__.py @@ -1,6 +1,6 @@ """The foscam component.""" -from libpyfoscam import FoscamCamera +from libpyfoscamcgi import FoscamCamera from homeassistant.const import ( CONF_HOST, diff --git a/homeassistant/components/foscam/config_flow.py b/homeassistant/components/foscam/config_flow.py index 19c19a1a5f5..562c3f42f8b 100644 --- a/homeassistant/components/foscam/config_flow.py +++ b/homeassistant/components/foscam/config_flow.py @@ -2,8 +2,8 @@ from typing import Any -from libpyfoscam import FoscamCamera -from libpyfoscam.foscam import ( +from libpyfoscamcgi import FoscamCamera +from libpyfoscamcgi.foscamcgi import ( ERROR_FOSCAM_AUTH, ERROR_FOSCAM_UNAVAILABLE, FOSCAM_SUCCESS, diff --git a/homeassistant/components/foscam/coordinator.py b/homeassistant/components/foscam/coordinator.py index 92eb7615e2a..72bf60cffe0 100644 --- a/homeassistant/components/foscam/coordinator.py +++ b/homeassistant/components/foscam/coordinator.py @@ -4,7 +4,7 @@ import asyncio from datetime import timedelta from typing import Any -from libpyfoscam import FoscamCamera +from libpyfoscamcgi import FoscamCamera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json index 9ddb7c4b4fc..9e6864cf1c6 100644 --- a/homeassistant/components/foscam/manifest.json +++ b/homeassistant/components/foscam/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/foscam", "iot_class": "local_polling", - "loggers": ["libpyfoscam"], - "requirements": ["libpyfoscam==1.2.2"] + "loggers": ["libpyfoscamcgi"], + "requirements": ["libpyfoscamcgi==0.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f76d831c5fe..8e35b3f0f7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1337,7 +1337,7 @@ lektricowifi==0.1 letpot==0.4.0 # homeassistant.components.foscam -libpyfoscam==1.2.2 +libpyfoscamcgi==0.0.6 # homeassistant.components.vivotek libpyvivotek==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec9d8b973b6..409e1bc19e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1153,7 +1153,7 @@ lektricowifi==0.1 letpot==0.4.0 # homeassistant.components.foscam -libpyfoscam==1.2.2 +libpyfoscamcgi==0.0.6 # homeassistant.components.mikrotik librouteros==3.2.0 diff --git a/tests/components/foscam/conftest.py b/tests/components/foscam/conftest.py index 6ff5a0b5af5..f8b4093574f 100644 --- a/tests/components/foscam/conftest.py +++ b/tests/components/foscam/conftest.py @@ -1,6 +1,6 @@ """Common stuff for Foscam tests.""" -from libpyfoscam.foscam import ( +from libpyfoscamcgi.foscamcgi import ( ERROR_FOSCAM_AUTH, ERROR_FOSCAM_CMD, ERROR_FOSCAM_UNAVAILABLE, From 7eaa60b17c3b4aced1b8905a5c5525748f2c9c7a Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 23 Jun 2025 12:10:44 -0400 Subject: [PATCH 0551/1664] Add trigger vacuum entities to template integration (#145534) * Add trigger vacuum entities to template integration * remove comment --------- Co-authored-by: Erik Montnemery --- homeassistant/components/template/config.py | 1 - homeassistant/components/template/vacuum.py | 82 ++++++- tests/components/template/test_vacuum.py | 253 +++++++++++++++++--- 3 files changed, 290 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 1e1a27e26c6..a5aa9a3bd87 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -159,7 +159,6 @@ CONFIG_SECTION_SCHEMA = vol.All( ensure_domains_do_not_have_trigger_or_action( DOMAIN_BUTTON, DOMAIN_FAN, - DOMAIN_VACUUM, ), ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 79e00e7e1c0..1fb5b89ead2 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -39,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN +from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -48,6 +49,7 @@ from .template_entity import ( make_template_entity_common_modern_attributes_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -187,6 +189,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerVacuumEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -213,7 +222,14 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): # List of valid fan speeds self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST] - def _register_scripts( + self._attr_supported_features = ( + VacuumEntityFeature.START | VacuumEntityFeature.STATE + ) + + if self._battery_level_template: + self._attr_supported_features |= VacuumEntityFeature.BATTERY + + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], VacuumEntityFeature | int]]: for action_id, supported_feature in ( @@ -356,18 +372,12 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): if TYPE_CHECKING: assert name is not None - self._attr_supported_features = ( - VacuumEntityFeature.START | VacuumEntityFeature.STATE - ) - for action_id, action_config, supported_feature in self._register_scripts( + for action_id, action_config, supported_feature in self._iterate_scripts( config ): self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - if self._battery_level_template: - self._attr_supported_features |= VacuumEntityFeature.BATTERY - @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -403,3 +413,59 @@ class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): return self._handle_state(result) + + +class TriggerVacuumEntity(TriggerEntity, AbstractTemplateVacuum): + """Vacuum entity based on trigger data.""" + + domain = VACUUM_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateVacuum.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + for key in (CONF_STATE, CONF_FAN_SPEED, CONF_BATTERY_LEVEL): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, updater in ( + (CONF_STATE, self._handle_state), + (CONF_FAN_SPEED, self._update_fan_speed), + (CONF_BATTERY_LEVEL, self._update_battery_level), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py index 90ca0b56afb..ae65823309a 100644 --- a/tests/components/template/test_vacuum.py +++ b/tests/components/template/test_vacuum.py @@ -26,8 +26,26 @@ from tests.components.vacuum import common TEST_OBJECT_ID = "test_vacuum" TEST_ENTITY_ID = f"vacuum.{TEST_OBJECT_ID}" -STATE_INPUT_SELECT = "input_select.state" -BATTERY_LEVEL_INPUT_NUMBER = "input_number.battery_level" +TEST_STATE_SENSOR = "sensor.test_state" +TEST_SPEED_SENSOR = "sensor.test_fan_speed" +TEST_BATTERY_LEVEL_SENSOR = "sensor.test_battery_level" +TEST_AVAILABILITY_ENTITY = "availability_state.state" + +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [ + TEST_STATE_SENSOR, + TEST_SPEED_SENSOR, + TEST_BATTERY_LEVEL_SENSOR, + TEST_AVAILABILITY_ENTITY, + ], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} START_ACTION = { "start": { @@ -140,6 +158,24 @@ async def async_setup_modern_format( await hass.async_block_till_done() +async def async_setup_trigger_format( + hass: HomeAssistant, count: int, vacuum_config: dict[str, Any] +) -> None: + """Do setup of vacuum integration via trigger format.""" + config = {"template": {"vacuum": vacuum_config, **TEST_STATE_TRIGGER}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture async def setup_vacuum( hass: HomeAssistant, @@ -152,6 +188,8 @@ async def setup_vacuum( await async_setup_legacy_format(hass, count, vacuum_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, vacuum_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, vacuum_config) @pytest.fixture @@ -171,6 +209,10 @@ async def setup_test_vacuum_with_extra_config( await async_setup_modern_format( hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **vacuum_config, **extra_config} + ) @pytest.fixture @@ -202,6 +244,16 @@ async def setup_state_vacuum( **TEMPLATE_VACUUM_ACTIONS, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) @pytest.fixture @@ -236,6 +288,17 @@ async def setup_base_vacuum( **extra_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + state_config = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **extra_config, + }, + ) @pytest.fixture @@ -277,6 +340,19 @@ async def setup_single_attribute_state_vacuum( **extra_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + state_config = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + **extra, + **extra_config, + }, + ) @pytest.fixture @@ -313,6 +389,18 @@ async def setup_attributes_state_vacuum( **TEMPLATE_VACUUM_ACTIONS, }, ) + elif style == ConfigurationStyle.TRIGGER: + state_config = {"state": state_template} if state_template else {} + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "attributes": attributes, + **state_config, + **TEMPLATE_VACUUM_ACTIONS, + }, + ) @pytest.mark.parametrize("count", [1]) @@ -333,6 +421,13 @@ async def setup_attributes_state_vacuum( STATE_UNKNOWN, None, ), + ( + ConfigurationStyle.TRIGGER, + None, + {"start": {"service": "script.vacuum_start"}}, + STATE_UNKNOWN, + None, + ), ( ConfigurationStyle.LEGACY, "{{ 'cleaning' }}", @@ -353,6 +448,16 @@ async def setup_attributes_state_vacuum( VacuumActivity.CLEANING, 100, ), + ( + ConfigurationStyle.TRIGGER, + "{{ 'cleaning' }}", + { + "battery_level": "{{ 100 }}", + "start": {"service": "script.vacuum_start"}, + }, + VacuumActivity.CLEANING, + 100, + ), ( ConfigurationStyle.LEGACY, "{{ 'abc' }}", @@ -373,6 +478,16 @@ async def setup_attributes_state_vacuum( STATE_UNKNOWN, None, ), + ( + ConfigurationStyle.TRIGGER, + "{{ 'abc' }}", + { + "battery_level": "{{ 101 }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNKNOWN, + None, + ), ( ConfigurationStyle.LEGACY, "{{ this_function_does_not_exist() }}", @@ -395,18 +510,35 @@ async def setup_attributes_state_vacuum( STATE_UNKNOWN, None, ), + ( + ConfigurationStyle.TRIGGER, + "{{ this_function_does_not_exist() }}", + { + "battery_level": "{{ this_function_does_not_exist() }}", + "fan_speed": "{{ this_function_does_not_exist() }}", + "start": {"service": "script.vacuum_start"}, + }, + STATE_UNAVAILABLE, + None, + ), ], ) @pytest.mark.usefixtures("setup_base_vacuum") async def test_valid_legacy_configs(hass: HomeAssistant, count, parm1, parm2) -> None: """Test: configs.""" + + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + assert len(hass.states.async_all("vacuum")) == count _verify(hass, parm1, parm2) @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("state_template", "extra_config"), @@ -423,13 +555,14 @@ async def test_invalid_configs(hass: HomeAssistant, count) -> None: @pytest.mark.parametrize( ("count", "state_template", "extra_config"), - [(1, "{{ states('input_select.state') }}", {})], + [(1, "{{ states('sensor.test_state') }}", {})], ) @pytest.mark.parametrize( ("style", "attribute"), [ (ConfigurationStyle.LEGACY, "battery_level_template"), (ConfigurationStyle.MODERN, "battery_level"), + (ConfigurationStyle.TRIGGER, "battery_level"), ], ) @pytest.mark.parametrize( @@ -447,6 +580,10 @@ async def test_battery_level_template( hass: HomeAssistant, expected: int | None ) -> None: """Test templates with values from other entities.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + _verify(hass, STATE_UNKNOWN, expected) @@ -455,7 +592,7 @@ async def test_battery_level_template( [ ( 1, - "{{ states('input_select.state') }}", + "{{ states('sensor.test_state') }}", { "fan_speeds": ["low", "medium", "high"], }, @@ -467,6 +604,7 @@ async def test_battery_level_template( [ (ConfigurationStyle.LEGACY, "fan_speed_template"), (ConfigurationStyle.MODERN, "fan_speed"), + (ConfigurationStyle.TRIGGER, "fan_speed"), ], ) @pytest.mark.parametrize( @@ -481,33 +619,39 @@ async def test_battery_level_template( @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_fan_speed_template(hass: HomeAssistant, expected: str | None) -> None: """Test templates with values from other entities.""" + # Ensure trigger entity templates are rendered + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + _verify(hass, STATE_UNKNOWN, None, expected) @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 'on' }}", - "{% if states.switch.test_state.state %}mdi:check{% endif %}", + "{% if states.sensor.test_state.state %}mdi:check{% endif %}", {}, + "icon", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "expected"), [ - (ConfigurationStyle.MODERN, "icon"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_icon_template(hass: HomeAssistant) -> None: +async def test_icon_template(hass: HomeAssistant, expected: int) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == expected - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -515,29 +659,31 @@ async def test_icon_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 'on' }}", - "{% if states.switch.test_state.state %}local/vacuum.png{% endif %}", + "{% if states.sensor.test_state.state %}local/vacuum.png{% endif %}", {}, + "picture", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), + ("style", "expected"), [ - (ConfigurationStyle.MODERN, "picture"), + (ConfigurationStyle.MODERN, ""), + (ConfigurationStyle.TRIGGER, None), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") -async def test_picture_template(hass: HomeAssistant) -> None: +async def test_picture_template(hass: HomeAssistant, expected: int) -> None: """Test picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == expected - hass.states.async_set("switch.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_SENSOR, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -560,6 +706,7 @@ async def test_picture_template(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") @@ -567,14 +714,14 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: """Test availability templates with values from other entities.""" # When template returns true.. - hass.states.async_set("availability_state.state", STATE_ON) + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_ON) await hass.async_block_till_done() # Device State should not be unavailable assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE # When Availability template returns false - hass.states.async_set("availability_state.state", STATE_OFF) + hass.states.async_set(TEST_AVAILABILITY_ENTITY, STATE_OFF) await hass.async_block_till_done() # device state should be unavailable @@ -597,15 +744,22 @@ async def test_available_template_with_entities(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "availability_template"), (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" + + # Ensure state change triggers trigger entity. + hass.states.async_set(TEST_STATE_SENSOR, None) + await hass.async_block_till_done() + assert hass.states.get(TEST_ENTITY_ID) != STATE_UNAVAILABLE - assert "UndefinedError: 'x' is undefined" in caplog_setup_text + err = "'x' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize( @@ -627,7 +781,7 @@ async def test_attribute_templates(hass: HomeAssistant) -> None: state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It ." - hass.states.async_set("sensor.test_state", "Works") + hass.states.async_set(TEST_STATE_SENSOR, "Works") await hass.async_block_till_done() await async_update_entity(hass, TEST_ENTITY_ID) state = hass.states.get(TEST_ENTITY_ID) @@ -635,26 +789,31 @@ async def test_attribute_templates(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("count", "state_template", "attributes"), [ ( 1, - "{{ states('input_select.state') }}", + "{{ states('sensor.test_state') }}", {"test_attribute": "{{ this_function_does_not_exist() }}"}, ) ], ) @pytest.mark.usefixtures("setup_attributes_state_vacuum") async def test_invalid_attribute_template( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that errors are logged if rendering template fails.""" + + hass.states.async_set(TEST_STATE_SENSOR, "Works") + await hass.async_block_till_done() + assert len(hass.states.async_all("vacuum")) == 1 - assert "test_attribute" in caplog_setup_text - assert "TemplateError" in caplog_setup_text + err = "'this_function_does_not_exist' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize("count", [1]) @@ -689,6 +848,21 @@ async def test_invalid_attribute_template( }, ], ), + ( + ConfigurationStyle.TRIGGER, + [ + { + "name": "test_template_vacuum_01", + "state": "{{ true }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_vacuum_02", + "state": "{{ false }}", + **UNIQUE_ID_CONFIG, + }, + ], + ), ], ) @pytest.mark.usefixtures("setup_vacuum") @@ -701,7 +875,8 @@ async def test_unique_id(hass: HomeAssistant) -> None: ("count", "state_template", "extra_config"), [(1, None, START_ACTION)] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_base_vacuum") async def test_unused_services(hass: HomeAssistant) -> None: @@ -741,10 +916,11 @@ async def test_unused_services(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("count", "state_template"), - [(1, "{{ states('input_select.state') }}")], + [(1, "{{ states('sensor.test_state') }}")], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "action", @@ -782,8 +958,8 @@ async def test_state_services( [ ( 1, - "{{ states('input_select.state') }}", - "{{ states('input_select.fan_speed') }}", + "{{ states('sensor.test_state') }}", + "{{ states('sensor.test_fan_speed') }}", { "fan_speeds": ["low", "medium", "high"], }, @@ -795,6 +971,7 @@ async def test_state_services( [ (ConfigurationStyle.LEGACY, "fan_speed_template"), (ConfigurationStyle.MODERN, "fan_speed"), + (ConfigurationStyle.TRIGGER, "fan_speed"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") @@ -835,8 +1012,8 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N [ ( 1, - "{{ states('input_select.state') }}", - "{{ states('input_select.fan_speed') }}", + "{{ states('sensor.test_state') }}", + "{{ states('sensor.test_fan_speed') }}", ) ], ) @@ -845,6 +1022,7 @@ async def test_set_fan_speed(hass: HomeAssistant, calls: list[ServiceCall]) -> N [ (ConfigurationStyle.LEGACY, "fan_speed_template"), (ConfigurationStyle.MODERN, "fan_speed"), + (ConfigurationStyle.TRIGGER, "fan_speed"), ], ) @pytest.mark.usefixtures("setup_single_attribute_state_vacuum") @@ -918,7 +1096,8 @@ async def test_nested_unique_id( @pytest.mark.parametrize(("count", "vacuum_config"), [(1, {"start": []})]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("extra_config", "supported_features"), From 27565df86f97b2201ed1bcb9c04251d2bfd1a048 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Mon, 23 Jun 2025 19:08:18 +0200 Subject: [PATCH 0552/1664] Add PARALLEL_UPDATES constant to binary_sensor and sensor for LCN (#147369) Add PARALLEL_UPDATES to binary_sensor and sensor --- homeassistant/components/lcn/binary_sensor.py | 2 ++ homeassistant/components/lcn/sensor.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index d8418c6d838..b124b3f6188 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -25,6 +25,8 @@ from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry +PARALLEL_UPDATES = 0 + def add_lcn_entities( config_entry: LcnConfigEntry, diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index fd90c024383..da475e50005 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -38,6 +38,8 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry +PARALLEL_UPDATES = 0 + DEVICE_CLASS_MAPPING = { pypck.lcn_defs.VarUnit.CELSIUS: SensorDeviceClass.TEMPERATURE, pypck.lcn_defs.VarUnit.KELVIN: SensorDeviceClass.TEMPERATURE, From e1d5d312b82af2a81d290560300c1b98d2a40e8e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:08:32 +0200 Subject: [PATCH 0553/1664] Migrate linear_garage_door to use runtime_data (#147351) Migrate linear_garage_door to use runtime_data/HassKey --- .../components/linear_garage_door/__init__.py | 16 ++++++---------- .../components/linear_garage_door/coordinator.py | 6 ++++-- .../components/linear_garage_door/cover.py | 8 +++----- .../components/linear_garage_door/diagnostics.py | 8 +++----- .../components/linear_garage_door/light.py | 8 +++----- 5 files changed, 19 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py index c2a6c6a7ed1..a80aa99628b 100644 --- a/homeassistant/components/linear_garage_door/__init__.py +++ b/homeassistant/components/linear_garage_door/__init__.py @@ -2,18 +2,17 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry, LinearUpdateCoordinator PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> bool: """Set up Linear Garage Door from a config entry.""" ir.async_create_issue( @@ -35,21 +34,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: LinearConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: LinearConfigEntry) -> None: """Remove a config entry.""" if not hass.config_entries.async_loaded_entries(DOMAIN): ir.async_delete_issue(hass, DOMAIN, DOMAIN) diff --git a/homeassistant/components/linear_garage_door/coordinator.py b/homeassistant/components/linear_garage_door/coordinator.py index b55affe92e7..3844e1ae7de 100644 --- a/homeassistant/components/linear_garage_door/coordinator.py +++ b/homeassistant/components/linear_garage_door/coordinator.py @@ -19,6 +19,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type LinearConfigEntry = ConfigEntry[LinearUpdateCoordinator] + @dataclass class LinearDevice: @@ -32,9 +34,9 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, LinearDevice]]): """DataUpdateCoordinator for Linear.""" _devices: list[dict[str, Any]] | None = None - config_entry: ConfigEntry + config_entry: LinearConfigEntry - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, config_entry: LinearConfigEntry) -> None: """Initialize DataUpdateCoordinator for Linear.""" super().__init__( hass, diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py index 7b0510f00d1..1f6c0999531 100644 --- a/homeassistant/components/linear_garage_door/cover.py +++ b/homeassistant/components/linear_garage_door/cover.py @@ -8,12 +8,10 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry from .entity import LinearEntity SUPPORTED_SUBDEVICES = ["GDO"] @@ -23,11 +21,11 @@ SCAN_INTERVAL = timedelta(seconds=10) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LinearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Linear Garage Door cover.""" - coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( LinearCoverEntity(coordinator, device_id, device_data.name, sub_device_id) diff --git a/homeassistant/components/linear_garage_door/diagnostics.py b/homeassistant/components/linear_garage_door/diagnostics.py index 21414f02f87..ff5ca5639bf 100644 --- a/homeassistant/components/linear_garage_door/diagnostics.py +++ b/homeassistant/components/linear_garage_door/diagnostics.py @@ -6,21 +6,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry TO_REDACT = {CONF_PASSWORD, CONF_EMAIL} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LinearConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py index ac03894d446..59243817fbb 100644 --- a/homeassistant/components/linear_garage_door/light.py +++ b/homeassistant/components/linear_garage_door/light.py @@ -5,12 +5,10 @@ from typing import Any from linear_garage_door import Linear from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LinearUpdateCoordinator +from .coordinator import LinearConfigEntry from .entity import LinearEntity SUPPORTED_SUBDEVICES = ["Light"] @@ -18,11 +16,11 @@ SUPPORTED_SUBDEVICES = ["Light"] async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LinearConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Linear Garage Door cover.""" - coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data data = coordinator.data async_add_entities( From ce115cbfe15ef14617a0eeb6c623025e4ab2636c Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Mon, 23 Jun 2025 19:08:48 +0200 Subject: [PATCH 0554/1664] Bump aiotedee to 0.2.25 (#147349) * Bump aiotedee to 0.2.24 * bump to 25 * fix snapshot --- homeassistant/components/tedee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tedee/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tedee/manifest.json b/homeassistant/components/tedee/manifest.json index 012e82318ed..6e0f6ee588b 100644 --- a/homeassistant/components/tedee/manifest.json +++ b/homeassistant/components/tedee/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["aiotedee"], "quality_scale": "platinum", - "requirements": ["aiotedee==0.2.23"] + "requirements": ["aiotedee==0.2.25"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e35b3f0f7c..da730bd041c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -405,7 +405,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.23 +aiotedee==0.2.25 # homeassistant.components.tractive aiotractive==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 409e1bc19e2..f03fcba0cdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -387,7 +387,7 @@ aiosyncthing==0.5.1 aiotankerkoenig==0.4.2 # homeassistant.components.tedee -aiotedee==0.2.23 +aiotedee==0.2.25 # homeassistant.components.tractive aiotractive==0.6.0 diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr index 046a8fd210a..63707477df9 100644 --- a/tests/components/tedee/snapshots/test_diagnostics.ambr +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ '0': dict({ 'battery_level': 70, + 'door_state': 0, 'duration_pullspring': 2, 'is_charging': False, 'is_connected': True, @@ -16,6 +17,7 @@ }), '1': dict({ 'battery_level': 70, + 'door_state': 0, 'duration_pullspring': 0, 'is_charging': False, 'is_connected': True, From dfa3fddd355de065b92de56cc41d91ca2066336e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 19:09:38 +0200 Subject: [PATCH 0555/1664] Migrate livisi to use runtime_data (#147352) --- homeassistant/components/livisi/__init__.py | 19 ++++++------------- .../components/livisi/binary_sensor.py | 13 ++++++------- homeassistant/components/livisi/climate.py | 10 ++++------ .../components/livisi/coordinator.py | 6 ++++-- homeassistant/components/livisi/entity.py | 5 ++--- homeassistant/components/livisi/switch.py | 11 +++++------ 6 files changed, 27 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index fc9e381a1c3..befbe6858ef 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -8,19 +8,18 @@ from aiohttp import ClientConnectorError from livisi.aiolivisi import AioLivisi from homeassistant import core -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, device_registry as dr from .const import DOMAIN -from .coordinator import LivisiDataUpdateCoordinator +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator PLATFORMS: Final = [Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.SWITCH] -async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: core.HomeAssistant, entry: LivisiConfigEntry) -> bool: """Set up Livisi Smart Home from a config entry.""" web_session = aiohttp_client.async_get_clientsession(hass) aiolivisi = AioLivisi(web_session) @@ -31,7 +30,7 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo except ClientConnectorError as exception: raise ConfigEntryNotReady from exception - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, @@ -45,16 +44,10 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo entry.async_create_background_task( hass, coordinator.ws_connect(), "livisi-ws_connect" ) + entry.async_on_unload(coordinator.websocket.disconnect) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LivisiConfigEntry) -> bool: """Unload a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - - unload_success = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - await coordinator.websocket.disconnect() - if unload_success: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_success + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py index 50eb4cd28b9..ea61e7741b8 100644 --- a/homeassistant/components/livisi/binary_sensor.py +++ b/homeassistant/components/livisi/binary_sensor.py @@ -8,23 +8,22 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE -from .coordinator import LivisiDataUpdateCoordinator +from .const import LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up binary_sensor device.""" - coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data known_devices = set() @callback @@ -53,7 +52,7 @@ class LivisiBinarySensor(LivisiEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], capability_name: str, @@ -86,7 +85,7 @@ class LivisiWindowDoorSensor(LivisiBinarySensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], ) -> None: diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py index 1f5e3360c7d..05539043d74 100644 --- a/homeassistant/components/livisi/climate.py +++ b/homeassistant/components/livisi/climate.py @@ -11,7 +11,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -19,24 +18,23 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( - DOMAIN, LIVISI_STATE_CHANGE, LOGGER, MAX_TEMPERATURE, MIN_TEMPERATURE, VRCC_DEVICE_TYPE, ) -from .coordinator import LivisiDataUpdateCoordinator +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up climate device.""" - coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data @callback def handle_coordinator_update() -> None: @@ -71,7 +69,7 @@ class LivisiClimate(LivisiEntity, ClimateEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], ) -> None: diff --git a/homeassistant/components/livisi/coordinator.py b/homeassistant/components/livisi/coordinator.py index 6557416ed3a..8d490dca952 100644 --- a/homeassistant/components/livisi/coordinator.py +++ b/homeassistant/components/livisi/coordinator.py @@ -26,14 +26,16 @@ from .const import ( LOGGER, ) +type LivisiConfigEntry = ConfigEntry[LivisiDataUpdateCoordinator] + class LivisiDataUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Class to manage fetching LIVISI data API.""" - config_entry: ConfigEntry + config_entry: LivisiConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, aiolivisi: AioLivisi + self, hass: HomeAssistant, config_entry: LivisiConfigEntry, aiolivisi: AioLivisi ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index af588b0e360..79af35c1f8c 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -7,14 +7,13 @@ from typing import Any from livisi.const import CAPABILITY_MAP -from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, LIVISI_REACHABILITY_CHANGE -from .coordinator import LivisiDataUpdateCoordinator +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): @@ -24,7 +23,7 @@ class LivisiEntity(CoordinatorEntity[LivisiDataUpdateCoordinator]): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], *, diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py index 5599a4af0d4..e053923f551 100644 --- a/homeassistant/components/livisi/switch.py +++ b/homeassistant/components/livisi/switch.py @@ -5,24 +5,23 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES -from .coordinator import LivisiDataUpdateCoordinator +from .const import LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES +from .coordinator import LivisiConfigEntry, LivisiDataUpdateCoordinator from .entity import LivisiEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up switch device.""" - coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data @callback def handle_coordinator_update() -> None: @@ -52,7 +51,7 @@ class LivisiSwitch(LivisiEntity, SwitchEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: LivisiConfigEntry, coordinator: LivisiDataUpdateCoordinator, device: dict[str, Any], ) -> None: From a7de947f00e87cb68b97d2bc79e08beb23abab3e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 23 Jun 2025 19:12:18 +0200 Subject: [PATCH 0556/1664] Add vacuum activity to pylint type hints check (#147162) --- pylint/plugins/hass_enforce_type_hints.py | 4 ++++ tests/pylint/test_enforce_type_hints.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 0760cd33821..32a053527f6 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2795,6 +2795,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="state", return_type=["str", None], ), + TypeHintMatch( + function_name="activity", + return_type=["VacuumActivity", None], + ), TypeHintMatch( function_name="battery_level", return_type=["int", None], diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index ae426b13fcb..41605bf2f2b 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1167,6 +1167,10 @@ def test_vacuum_entity(linter: UnittestLinter, type_hint_checker: BaseChecker) - class MyVacuum( #@ StateVacuumEntity ): + @property + def activity(self) -> VacuumActivity | None: + pass + def send_command( self, command: str, From 06ed452d8f738432d996540a8646e707890a7874 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Jun 2025 16:06:19 +0200 Subject: [PATCH 0557/1664] Add Matter protocol to Switchbot (#147356) --- homeassistant/brands/switchbot.json | 3 ++- homeassistant/generated/integrations.json | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/brands/switchbot.json b/homeassistant/brands/switchbot.json index 0909b24a146..43963109ee7 100644 --- a/homeassistant/brands/switchbot.json +++ b/homeassistant/brands/switchbot.json @@ -1,5 +1,6 @@ { "domain": "switchbot", "name": "SwitchBot", - "integrations": ["switchbot", "switchbot_cloud"] + "integrations": ["switchbot", "switchbot_cloud"], + "iot_standards": ["matter"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 846a5c74ddb..40c1f873382 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6426,7 +6426,10 @@ "iot_class": "cloud_polling", "name": "SwitchBot Cloud" } - } + }, + "iot_standards": [ + "matter" + ] }, "switcher_kis": { "name": "Switcher", From 6b242fd27792f7ffdfd5511e9c1359f24e882ccc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 20:01:21 +0200 Subject: [PATCH 0558/1664] Migrate lifx to use runtime_data and HassKey (#147348) --- homeassistant/components/lifx/__init__.py | 25 ++++++++----------- .../components/lifx/binary_sensor.py | 9 +++---- homeassistant/components/lifx/button.py | 10 +++----- homeassistant/components/lifx/const.py | 10 +++++++- homeassistant/components/lifx/coordinator.py | 6 +++-- homeassistant/components/lifx/diagnostics.py | 9 +++---- homeassistant/components/lifx/light.py | 12 ++++----- homeassistant/components/lifx/manager.py | 16 ++++++------ homeassistant/components/lifx/migration.py | 6 ++--- homeassistant/components/lifx/select.py | 14 +++-------- homeassistant/components/lifx/sensor.py | 9 +++---- homeassistant/components/lifx/util.py | 10 +++++--- 12 files changed, 64 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 7a6d95549ff..99a8adb0182 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -13,7 +13,6 @@ from aiolifx.connection import LIFXConnection import voluptuous as vol from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_PORT, @@ -27,7 +26,7 @@ from homeassistant.helpers.event import async_call_later, async_track_time_inter from homeassistant.helpers.typing import ConfigType from .const import _LOGGER, DATA_LIFX_MANAGER, DOMAIN, TARGET_ANY -from .coordinator import LIFXUpdateCoordinator +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .discovery import async_discover_devices, async_trigger_discovery from .manager import LIFXManager from .migration import async_migrate_entities_devices, async_migrate_legacy_entries @@ -73,7 +72,7 @@ DISCOVERY_COOLDOWN = 5 async def async_legacy_migration( hass: HomeAssistant, - legacy_entry: ConfigEntry, + legacy_entry: LIFXConfigEntry, discovered_devices: Iterable[Light], ) -> bool: """Migrate config entries.""" @@ -157,7 +156,6 @@ class LIFXDiscoveryManager: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" - hass.data[DOMAIN] = {} migrating = bool(async_get_legacy_entry(hass)) discovery_manager = LIFXDiscoveryManager(hass, migrating) @@ -187,7 +185,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bool: """Set up LIFX from a config entry.""" if async_entry_is_legacy(entry): return True @@ -198,10 +196,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_migrate_entities_devices(hass, legacy_entry.entry_id, entry) assert entry.unique_id is not None - domain_data = hass.data[DOMAIN] - if DATA_LIFX_MANAGER not in domain_data: + if DATA_LIFX_MANAGER not in hass.data: manager = LIFXManager(hass) - domain_data[DATA_LIFX_MANAGER] = manager + hass.data[DATA_LIFX_MANAGER] = manager manager.async_setup() host = entry.data[CONF_HOST] @@ -229,21 +226,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Unexpected device found at {host}; expected {entry.unique_id}, found {serial}" ) - domain_data[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: LIFXConfigEntry) -> bool: """Unload a config entry.""" if async_entry_is_legacy(entry): return True - domain_data = hass.data[DOMAIN] if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - coordinator: LIFXUpdateCoordinator = domain_data.pop(entry.entry_id) - coordinator.connection.async_stop() + entry.runtime_data.connection.async_stop() # Only the DATA_LIFX_MANAGER left, remove it. - if len(domain_data) == 1: - manager: LIFXManager = domain_data.pop(DATA_LIFX_MANAGER) + if len(hass.config_entries.async_loaded_entries(DOMAIN)) == 0: + manager = hass.data.pop(DATA_LIFX_MANAGER) manager.async_unload() return unload_ok diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py index f5a974b4626..478a4d306e2 100644 --- a/homeassistant/components/lifx/binary_sensor.py +++ b/homeassistant/components/lifx/binary_sensor.py @@ -7,13 +7,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, HEV_CYCLE_STATE -from .coordinator import LIFXUpdateCoordinator +from .const import HEV_CYCLE_STATE +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .util import lifx_features @@ -27,11 +26,11 @@ HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data if lifx_features(coordinator.device)["hev"]: async_add_entities( diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py index 25ab61aebae..758d7ab6435 100644 --- a/homeassistant/components/lifx/button.py +++ b/homeassistant/components/lifx/button.py @@ -7,13 +7,12 @@ from homeassistant.components.button import ( ButtonEntity, ButtonEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, IDENTIFY, RESTART -from .coordinator import LIFXUpdateCoordinator +from .const import IDENTIFY, RESTART +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity RESTART_BUTTON_DESCRIPTION = ButtonEntityDescription( @@ -31,12 +30,11 @@ IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - domain_data = hass.data[DOMAIN] - coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [LIFXRestartButton(coordinator), LIFXIdentifyButton(coordinator)] ) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index 58c3550b812..ecc572aa006 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -1,8 +1,17 @@ """Const for LIFX.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING + +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .manager import LIFXManager DOMAIN = "lifx" +DATA_LIFX_MANAGER: HassKey[LIFXManager] = HassKey(DOMAIN) TARGET_ANY = "00:00:00:00:00:00" @@ -59,7 +68,6 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { 32767: "50%", 65535: "100%", } -DATA_LIFX_MANAGER = "lifx_manager" LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index b77dbdc015a..79ce843b339 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -65,6 +65,8 @@ ZONES_PER_COLOR_UPDATE_REQUEST = 8 RSSI_DBM_FW = AwesomeVersion("2.77") +type LIFXConfigEntry = ConfigEntry[LIFXUpdateCoordinator] + class FirmwareEffect(IntEnum): """Enumeration of LIFX firmware effects.""" @@ -87,12 +89,12 @@ class SkyType(IntEnum): class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """DataUpdateCoordinator to gather data for a specific lifx device.""" - config_entry: ConfigEntry + config_entry: LIFXConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LIFXConfigEntry, connection: LIFXConnection, ) -> None: """Initialize DataUpdateCoordinator.""" diff --git a/homeassistant/components/lifx/diagnostics.py b/homeassistant/components/lifx/diagnostics.py index b9ef1af4dc6..64e7390b210 100644 --- a/homeassistant/components/lifx/diagnostics.py +++ b/homeassistant/components/lifx/diagnostics.py @@ -5,21 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC from homeassistant.core import HomeAssistant -from .const import CONF_LABEL, DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .const import CONF_LABEL +from .coordinator import LIFXConfigEntry TO_REDACT = [CONF_LABEL, CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LIFXConfigEntry ) -> dict[str, Any]: """Return diagnostics for a LIFX config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": { "title": entry.title, diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 5641786eb61..3d30fcd369e 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -17,7 +17,6 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -37,7 +36,7 @@ from .const import ( INFRARED_BRIGHTNESS, LIFX_CEILING_PRODUCT_IDS, ) -from .coordinator import FirmwareEffect, LIFXUpdateCoordinator +from .coordinator import FirmwareEffect, LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .manager import ( SERVICE_EFFECT_COLORLOOP, @@ -78,13 +77,12 @@ HSBK_KELVIN = 3 async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - domain_data = hass.data[DOMAIN] - coordinator: LIFXUpdateCoordinator = domain_data[entry.entry_id] - manager: LIFXManager = domain_data[DATA_LIFX_MANAGER] + coordinator = entry.runtime_data + manager = hass.data[DATA_LIFX_MANAGER] device = coordinator.device platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -123,7 +121,7 @@ class LIFXLight(LIFXEntity, LightEntity): self, coordinator: LIFXUpdateCoordinator, manager: LIFXManager, - entry: ConfigEntry, + entry: LIFXConfigEntry, ) -> None: """Initialize the light.""" super().__init__(coordinator) diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 9fae2628f1d..33712441157 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -30,8 +30,8 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_extract_referenced_entity_ids -from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DATA_LIFX_MANAGER, DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .util import convert_8_to_16, find_hsbk if TYPE_CHECKING: @@ -494,13 +494,11 @@ class LIFXManager: coordinators: list[LIFXUpdateCoordinator] = [] bulbs: list[Light] = [] - for entry_id, coordinator in self.hass.data[DOMAIN].items(): - if ( - entry_id != DATA_LIFX_MANAGER - and self.entry_id_to_entity_id[entry_id] in entity_ids - ): - coordinators.append(coordinator) - bulbs.append(coordinator.device) + entry: LIFXConfigEntry + for entry in self.hass.config_entries.async_loaded_entries(DOMAIN): + if self.entry_id_to_entity_id[entry.entry_id] in entity_ids: + coordinators.append(entry.runtime_data) + bulbs.append(entry.runtime_data.device) if start_effect_func := self._effect_dispatch.get(service): await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/lifx/migration.py b/homeassistant/components/lifx/migration.py index 9f8365cbceb..1e8855e40db 100644 --- a/homeassistant/components/lifx/migration.py +++ b/homeassistant/components/lifx/migration.py @@ -2,11 +2,11 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import _LOGGER, DOMAIN +from .coordinator import LIFXConfigEntry from .discovery import async_init_discovery_flow @@ -15,7 +15,7 @@ def async_migrate_legacy_entries( hass: HomeAssistant, discovered_hosts_by_serial: dict[str, str], existing_serials: set[str], - legacy_entry: ConfigEntry, + legacy_entry: LIFXConfigEntry, ) -> int: """Migrate the legacy config entries to have an entry per device.""" _LOGGER.debug( @@ -45,7 +45,7 @@ def async_migrate_legacy_entries( @callback def async_migrate_entities_devices( - hass: HomeAssistant, legacy_entry_id: str, new_entry: ConfigEntry + hass: HomeAssistant, legacy_entry_id: str, new_entry: LIFXConfigEntry ) -> None: """Move entities and devices to the new config entry.""" migrated_devices = [] diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py index 13b81e2a784..0913d7a1662 100644 --- a/homeassistant/components/lifx/select.py +++ b/homeassistant/components/lifx/select.py @@ -5,18 +5,12 @@ from __future__ import annotations from aiolifx_themes.themes import ThemeLibrary from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ATTR_THEME, - DOMAIN, - INFRARED_BRIGHTNESS, - INFRARED_BRIGHTNESS_VALUES_MAP, -) -from .coordinator import LIFXUpdateCoordinator +from .const import ATTR_THEME, INFRARED_BRIGHTNESS, INFRARED_BRIGHTNESS_VALUES_MAP +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity from .util import lifx_features @@ -39,11 +33,11 @@ THEME_ENTITY = SelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX from a config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[LIFXEntity] = [] diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py index 96feba633f4..8a9877dc468 100644 --- a/homeassistant/components/lifx/sensor.py +++ b/homeassistant/components/lifx/sensor.py @@ -10,13 +10,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_RSSI, DOMAIN -from .coordinator import LIFXUpdateCoordinator +from .const import ATTR_RSSI +from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator from .entity import LIFXEntity SCAN_INTERVAL = timedelta(seconds=30) @@ -33,11 +32,11 @@ RSSI_SENSOR = SensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LIFXConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up LIFX sensor from config entry.""" - coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities([LIFXRssiSensor(coordinator, RSSI_SENSOR)]) diff --git a/homeassistant/components/lifx/util.py b/homeassistant/components/lifx/util.py index 8286622e6f3..c99880891d2 100644 --- a/homeassistant/components/lifx/util.py +++ b/homeassistant/components/lifx/util.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from functools import partial -from typing import Any +from typing import TYPE_CHECKING, Any from aiolifx import products from aiolifx.aiolifx import Light @@ -21,7 +21,6 @@ from homeassistant.components.light import ( ATTR_RGB_COLOR, ATTR_XY_COLOR, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.util import color as color_util @@ -35,17 +34,20 @@ from .const import ( OVERALL_TIMEOUT, ) +if TYPE_CHECKING: + from .coordinator import LIFXConfigEntry + FIX_MAC_FW = AwesomeVersion("3.70") @callback -def async_entry_is_legacy(entry: ConfigEntry) -> bool: +def async_entry_is_legacy(entry: LIFXConfigEntry) -> bool: """Check if a config entry is the legacy shared one.""" return entry.unique_id is None or entry.unique_id == DOMAIN @callback -def async_get_legacy_entry(hass: HomeAssistant) -> ConfigEntry | None: +def async_get_legacy_entry(hass: HomeAssistant) -> LIFXConfigEntry | None: """Get the legacy config entry.""" for entry in hass.config_entries.async_entries(DOMAIN): if async_entry_is_legacy(entry): From 442fb88011d1f9dfe5bc4067d9ed46836c52cb19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Jun 2025 20:08:13 +0200 Subject: [PATCH 0559/1664] Add update platform to LaMetric (#147354) --- homeassistant/components/lametric/const.py | 1 + homeassistant/components/lametric/update.py | 46 ++++++++++++++ tests/components/lametric/conftest.py | 15 ++++- .../lametric/fixtures/device_sa5.json | 3 + .../lametric/snapshots/test_update.ambr | 62 +++++++++++++++++++ tests/components/lametric/test_update.py | 29 +++++++++ 6 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/lametric/update.py create mode 100644 tests/components/lametric/snapshots/test_update.ambr create mode 100644 tests/components/lametric/test_update.py diff --git a/homeassistant/components/lametric/const.py b/homeassistant/components/lametric/const.py index 4f9472b24f4..8c05b15ad1f 100644 --- a/homeassistant/components/lametric/const.py +++ b/homeassistant/components/lametric/const.py @@ -13,6 +13,7 @@ PLATFORMS = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ] LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lametric/update.py b/homeassistant/components/lametric/update.py new file mode 100644 index 00000000000..d486d9d27ba --- /dev/null +++ b/homeassistant/components/lametric/update.py @@ -0,0 +1,46 @@ +"""LaMetric Update platform.""" + +from awesomeversion import AwesomeVersion + +from homeassistant.components.update import UpdateDeviceClass, UpdateEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import LaMetricConfigEntry, LaMetricDataUpdateCoordinator +from .entity import LaMetricEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LaMetricConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up LaMetric update platform.""" + + coordinator = config_entry.runtime_data + + if coordinator.data.os_version >= AwesomeVersion("2.3.0"): + async_add_entities([LaMetricUpdate(coordinator)]) + + +class LaMetricUpdate(LaMetricEntity, UpdateEntity): + """Representation of LaMetric Update.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + + def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.data.serial_number}-update" + + @property + def installed_version(self) -> str: + """Return the installed version of the entity.""" + return self.coordinator.data.os_version + + @property + def latest_version(self) -> str | None: + """Return the latest version of the entity.""" + if not self.coordinator.data.update: + return None + return self.coordinator.data.update.version diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index da86d1bc4de..f8837054691 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Generator +from contextlib import nullcontext from unittest.mock import AsyncMock, MagicMock, patch from demetriek import CloudDevice, Device @@ -97,12 +98,20 @@ def mock_lametric(device_fixture: str) -> Generator[MagicMock]: @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_lametric: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_lametric: MagicMock, + request: pytest.FixtureRequest, ) -> MockConfigEntry: """Set up the LaMetric integration for testing.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() + context = nullcontext() + if platform := getattr(request, "param", None): + context = patch("homeassistant.components.lametric.PLATFORMS", [platform]) + + with context: + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() return mock_config_entry diff --git a/tests/components/lametric/fixtures/device_sa5.json b/tests/components/lametric/fixtures/device_sa5.json index 47120f672ef..b82a4bda2af 100644 --- a/tests/components/lametric/fixtures/device_sa5.json +++ b/tests/components/lametric/fixtures/device_sa5.json @@ -57,6 +57,9 @@ "name": "spyfly's LaMetric SKY", "os_version": "3.0.13", "serial_number": "SA52100000123TBNC", + "update_available": { + "version": "3.2.1" + }, "wifi": { "active": true, "mac": "AA:BB:CC:DD:EE:FF", diff --git a/tests/components/lametric/snapshots/test_update.ambr b/tests/components/lametric/snapshots/test_update.ambr new file mode 100644 index 00000000000..342cac5b39b --- /dev/null +++ b/tests/components/lametric/snapshots/test_update.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_all_entities[device_sa5-update][update.spyfly_s_lametric_sky_firmware-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.spyfly_s_lametric_sky_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'lametric', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'SA52100000123TBNC-update', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[device_sa5-update][update.spyfly_s_lametric_sky_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/lametric/icon.png', + 'friendly_name': "spyfly's LaMetric SKY Firmware", + 'in_progress': False, + 'installed_version': '3.0.13', + 'latest_version': '3.2.1', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.spyfly_s_lametric_sky_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/lametric/test_update.py b/tests/components/lametric/test_update.py new file mode 100644 index 00000000000..f8e396bd582 --- /dev/null +++ b/tests/components/lametric/test_update.py @@ -0,0 +1,29 @@ +"""Tests for the LaMetric update platform.""" + +from unittest.mock import MagicMock + +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 + +pytestmark = [ + pytest.mark.parametrize("init_integration", [Platform.UPDATE], indirect=True), + pytest.mark.usefixtures("init_integration"), +] + + +@pytest.mark.parametrize("device_fixture", ["device_sa5"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_lametric: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 2833e9762554ffd2a46617073cbca78356398c56 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 23 Jun 2025 11:11:16 -0700 Subject: [PATCH 0560/1664] Default to gemini-2.5-flash (#147334) --- .../google_generative_ai_conversation/const.py | 2 +- .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_init.ambr | 8 ++++---- .../test_config_flow.py | 14 +++++++------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 831e7d8f508..7e699d7c8c0 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -9,7 +9,7 @@ CONF_PROMPT = "prompt" ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "models/gemini-2.0-flash" +RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 60d388d0502..a31827c7acc 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,7 +5,7 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-2.0-flash', + 'chat_model': 'models/gemini-2.5-flash', 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index d8e54b15f61..f89871ff131 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -11,7 +11,7 @@ File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) @@ -28,7 +28,7 @@ b'some file', b'some file', ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) @@ -43,7 +43,7 @@ 'contents': list([ 'Write an opening speech for a Home Assistant release party', ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) @@ -58,7 +58,7 @@ 'contents': list([ 'Write an opening speech for a Home Assistant release party', ]), - 'model': 'models/gemini-2.0-flash', + 'model': 'models/gemini-2.5-flash', }), ), ]) diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 4234355cb5b..0dc0996ad30 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -41,6 +41,12 @@ from tests.common import MockConfigEntry def get_models_pager(): """Return a generator that yields the models.""" + model_25_flash = Mock( + display_name="Gemini 2.5 Flash", + supported_actions=["generateContent"], + ) + model_25_flash.name = "models/gemini-2.5-flash" + model_20_flash = Mock( display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], @@ -59,17 +65,11 @@ def get_models_pager(): ) model_15_pro.name = "models/gemini-1.5-pro-latest" - model_10_pro = Mock( - display_name="Gemini 1.0 Pro", - supported_actions=["generateContent"], - ) - model_10_pro.name = "models/gemini-pro" - async def models_pager(): + yield model_25_flash yield model_20_flash yield model_15_flash yield model_15_pro - yield model_10_pro return models_pager() From e494f66c0275ed01b99dccebd4dfbb46237f7558 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 23 Jun 2025 14:21:29 -0400 Subject: [PATCH 0561/1664] Add label_description to template engine (#147138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/helpers/template.py | 11 ++++++++++ tests/helpers/test_template.py | 34 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index acf78f70380..34b19c07f83 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1734,6 +1734,14 @@ def label_name(hass: HomeAssistant, lookup_value: str) -> str | None: return None +def label_description(hass: HomeAssistant, lookup_value: str) -> str | None: + """Get the label description from a label ID.""" + label_reg = label_registry.async_get(hass) + if label := label_reg.async_get_label(lookup_value): + return label.description + return None + + def _label_id_or_name(hass: HomeAssistant, label_id_or_name: str) -> str | None: """Get the label ID from a label name or ID.""" # If label_name returns a value, we know the input was an ID, otherwise we @@ -3314,6 +3322,9 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["label_name"] = hassfunction(label_name) self.filters["label_name"] = self.globals["label_name"] + self.globals["label_description"] = hassfunction(label_description) + self.filters["label_description"] = self.globals["label_description"] + self.globals["label_areas"] = hassfunction(label_areas) self.filters["label_areas"] = self.globals["label_areas"] diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 8e6e7643df3..15c6a4b7251 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -6295,6 +6295,40 @@ async def test_label_name( assert info.rate_limit is None +async def test_label_description( + hass: HomeAssistant, + label_registry: lr.LabelRegistry, +) -> None: + """Test label_description function.""" + # Test non existing label ID + info = render_to_info(hass, "{{ label_description('1234567890') }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ '1234567890' | label_description }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test wrong value type + info = render_to_info(hass, "{{ label_description(42) }}") + assert_result_info(info, None) + assert info.rate_limit is None + + info = render_to_info(hass, "{{ 42 | label_description }}") + assert_result_info(info, None) + assert info.rate_limit is None + + # Test valid label ID + label = label_registry.async_create("choo choo", description="chugga chugga") + info = render_to_info(hass, f"{{{{ label_description('{label.label_id}') }}}}") + assert_result_info(info, label.description) + assert info.rate_limit is None + + info = render_to_info(hass, f"{{{{ '{label.label_id}' | label_description }}}}") + assert_result_info(info, label.description) + assert info.rate_limit is None + + async def test_label_entities( hass: HomeAssistant, entity_registry: er.EntityRegistry, From 673a2e35adf8b2277f86305c7e0da886133f1121 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 23 Jun 2025 20:39:46 +0200 Subject: [PATCH 0562/1664] Add button entity to Music Assistant to add currently playing item to favorites (#145626) * Add action to Music Assistant to add currently playing item to favorites * add test * Convert to button entity * review comments * Update test_button.ambr * Fix --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Robert Resch --- .../components/music_assistant/__init__.py | 35 ++++- .../components/music_assistant/button.py | 53 +++++++ .../components/music_assistant/helpers.py | 28 ++++ .../components/music_assistant/icons.json | 7 + .../music_assistant/media_player.py | 59 ++----- .../components/music_assistant/strings.json | 7 + .../snapshots/test_button.ambr | 145 ++++++++++++++++++ .../components/music_assistant/test_button.py | 48 ++++++ 8 files changed, 331 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/music_assistant/button.py create mode 100644 homeassistant/components/music_assistant/helpers.py create mode 100644 tests/components/music_assistant/snapshots/test_button.ambr create mode 100644 tests/components/music_assistant/test_button.py diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py index a2d2dae9e3f..32024c5ad13 100644 --- a/homeassistant/components/music_assistant/__init__.py +++ b/homeassistant/components/music_assistant/__init__.py @@ -3,7 +3,8 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from collections.abc import Callable +from dataclasses import dataclass, field from typing import TYPE_CHECKING from music_assistant_client import MusicAssistantClient @@ -31,7 +32,7 @@ if TYPE_CHECKING: from homeassistant.helpers.typing import ConfigType -PLATFORMS = [Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 @@ -39,6 +40,7 @@ LISTEN_READY_TIMEOUT = 30 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type MusicAssistantConfigEntry = ConfigEntry[MusicAssistantEntryData] +type PlayerAddCallback = Callable[[str], None] @dataclass @@ -47,6 +49,8 @@ class MusicAssistantEntryData: mass: MusicAssistantClient listen_task: asyncio.Task + discovered_players: set[str] = field(default_factory=set) + platform_handlers: dict[Platform, PlayerAddCallback] = field(default_factory=dict) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -122,6 +126,33 @@ async def async_setup_entry( # initialize platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # register listener for new players + async def handle_player_added(event: MassEvent) -> None: + """Handle Mass Player Added event.""" + if TYPE_CHECKING: + assert event.object_id is not None + if event.object_id in entry.runtime_data.discovered_players: + return + player = mass.players.get(event.object_id) + if TYPE_CHECKING: + assert player is not None + if not player.expose_to_ha: + return + entry.runtime_data.discovered_players.add(event.object_id) + # run callback for each platform + for callback in entry.runtime_data.platform_handlers.values(): + callback(event.object_id) + + entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) + + # add all current players + for player in mass.players: + if not player.expose_to_ha: + continue + entry.runtime_data.discovered_players.add(player.player_id) + for callback in entry.runtime_data.platform_handlers.values(): + callback(player.player_id) + # register listener for removed players async def handle_player_removed(event: MassEvent) -> None: """Handle Mass Player Removed event.""" diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py new file mode 100644 index 00000000000..7969954e443 --- /dev/null +++ b/homeassistant/components/music_assistant/button.py @@ -0,0 +1,53 @@ +"""Music Assistant Button platform.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MusicAssistantConfigEntry +from .entity import MusicAssistantEntity +from .helpers import catch_musicassistant_error + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MusicAssistantConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Music Assistant MediaPlayer(s) from Config Entry.""" + mass = entry.runtime_data.mass + + def add_player(player_id: str) -> None: + """Handle add player.""" + async_add_entities( + [ + # Add button entity to favorite the currently playing item on the player + MusicAssistantFavoriteButton(mass, player_id) + ] + ) + + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.BUTTON, add_player) + + +class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): + """Representation of a Button entity to favorite the currently playing item on a player.""" + + entity_description = ButtonEntityDescription( + key="favorite_now_playing", + translation_key="favorite_now_playing", + ) + + @property + def available(self) -> bool: + """Return availability of entity.""" + # mark the button as unavailable if the player has no current media item + return super().available and self.player.current_media is not None + + @catch_musicassistant_error + async def async_press(self) -> None: + """Handle the button press command.""" + await self.mass.players.add_currently_playing_to_favorites(self.player_id) diff --git a/homeassistant/components/music_assistant/helpers.py b/homeassistant/components/music_assistant/helpers.py new file mode 100644 index 00000000000..b228e99f76f --- /dev/null +++ b/homeassistant/components/music_assistant/helpers.py @@ -0,0 +1,28 @@ +"""Helpers for the Music Assistant integration.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +import functools +from typing import Any + +from music_assistant_models.errors import MusicAssistantError + +from homeassistant.exceptions import HomeAssistantError + + +def catch_musicassistant_error[**_P, _R]( + func: Callable[_P, Coroutine[Any, Any, _R]], +) -> Callable[_P, Coroutine[Any, Any, _R]]: + """Check and convert commands to players.""" + + @functools.wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + """Catch Music Assistant errors and convert to Home Assistant error.""" + try: + return await func(*args, **kwargs) + except MusicAssistantError as err: + error_msg = str(err) or err.__class__.__name__ + raise HomeAssistantError(error_msg) from err + + return wrapper diff --git a/homeassistant/components/music_assistant/icons.json b/homeassistant/components/music_assistant/icons.json index 0fa64b8d273..24c6eb2a202 100644 --- a/homeassistant/components/music_assistant/icons.json +++ b/homeassistant/components/music_assistant/icons.json @@ -1,4 +1,11 @@ { + "entity": { + "button": { + "favorite_now_playing": { + "default": "mdi:heart-plus" + } + } + }, "services": { "play_media": { "service": "mdi:play" }, "play_announcement": { "service": "mdi:bullhorn" }, diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index a11e334824a..8d4e69bf082 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -3,11 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Coroutine, Mapping +from collections.abc import Mapping from contextlib import suppress -import functools import os -from typing import TYPE_CHECKING, Any, Concatenate +from typing import TYPE_CHECKING, Any from music_assistant_models.constants import PLAYER_CONTROL_NONE from music_assistant_models.enums import ( @@ -18,7 +17,7 @@ from music_assistant_models.enums import ( QueueOption, RepeatMode as MassRepeatMode, ) -from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError +from music_assistant_models.errors import MediaNotFoundError from music_assistant_models.event import MassEvent from music_assistant_models.media_items import ItemMapping, MediaItemType, Track from music_assistant_models.player_queue import PlayerQueue @@ -40,7 +39,7 @@ from homeassistant.components.media_player import ( SearchMediaQuery, async_process_play_media_url, ) -from homeassistant.const import ATTR_NAME, STATE_OFF +from homeassistant.const import ATTR_NAME, STATE_OFF, Platform from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -76,6 +75,7 @@ from .const import ( DOMAIN, ) from .entity import MusicAssistantEntity +from .helpers import catch_musicassistant_error from .media_browser import async_browse_media, async_search_media from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item @@ -120,25 +120,6 @@ SERVICE_TRANSFER_QUEUE = "transfer_queue" SERVICE_GET_QUEUE = "get_queue" -def catch_musicassistant_error[_R, **P]( - func: Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]], -) -> Callable[Concatenate[MusicAssistantPlayer, P], Coroutine[Any, Any, _R]]: - """Check and log commands to players.""" - - @functools.wraps(func) - async def wrapper( - self: MusicAssistantPlayer, *args: P.args, **kwargs: P.kwargs - ) -> _R: - """Catch Music Assistant errors and convert to Home Assistant error.""" - try: - return await func(self, *args, **kwargs) - except MusicAssistantError as err: - error_msg = str(err) or err.__class__.__name__ - raise HomeAssistantError(error_msg) from err - - return wrapper - - async def async_setup_entry( hass: HomeAssistant, entry: MusicAssistantConfigEntry, @@ -146,33 +127,13 @@ async def async_setup_entry( ) -> None: """Set up Music Assistant MediaPlayer(s) from Config Entry.""" mass = entry.runtime_data.mass - added_ids = set() - async def handle_player_added(event: MassEvent) -> None: - """Handle Mass Player Added event.""" - if TYPE_CHECKING: - assert event.object_id is not None - if event.object_id in added_ids: - return - player = mass.players.get(event.object_id) - if TYPE_CHECKING: - assert player is not None - if not player.expose_to_ha: - return - added_ids.add(event.object_id) - async_add_entities([MusicAssistantPlayer(mass, event.object_id)]) + def add_player(player_id: str) -> None: + """Handle add player.""" + async_add_entities([MusicAssistantPlayer(mass, player_id)]) - # register listener for new players - entry.async_on_unload(mass.subscribe(handle_player_added, EventType.PLAYER_ADDED)) - mass_players = [] - # add all current players - for player in mass.players: - if not player.expose_to_ha: - continue - added_ids.add(player.player_id) - mass_players.append(MusicAssistantPlayer(mass, player.player_id)) - - async_add_entities(mass_players) + # register callback to add players when they are discovered + entry.runtime_data.platform_handlers.setdefault(Platform.MEDIA_PLAYER, add_player) # add platform service for play_media with advanced options platform = async_get_current_platform() diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json index c7e7baf88f6..c41bfa70d4c 100644 --- a/homeassistant/components/music_assistant/strings.json +++ b/homeassistant/components/music_assistant/strings.json @@ -31,6 +31,13 @@ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, + "entity": { + "button": { + "favorite_now_playing": { + "name": "Favorite current song" + } + } + }, "issues": { "invalid_server_version": { "title": "The Music Assistant server is not the correct version", diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr new file mode 100644 index 00000000000..ac9e4c660f6 --- /dev/null +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -0,0 +1,145 @@ +# serializer version: 1 +# name: test_button_entities[button.my_super_test_player_2_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.my_super_test_player_2_favorite_current_song', + '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': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': '00:00:00:00:00:02_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.my_super_test_player_2_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'My Super Test Player 2 Favorite current song', + }), + 'context': , + 'entity_id': 'button.my_super_test_player_2_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.test_group_player_1_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_group_player_1_favorite_current_song', + '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': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': 'test_group_player_1_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.test_group_player_1_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Group Player 1 Favorite current song', + }), + 'context': , + 'entity_id': 'button.test_group_player_1_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_entities[button.test_player_1_favorite_current_song-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_player_1_favorite_current_song', + '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': 'Favorite current song', + 'platform': 'music_assistant', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'favorite_now_playing', + 'unique_id': '00:00:00:00:00:01_favorite_now_playing', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_entities[button.test_player_1_favorite_current_song-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Player 1 Favorite current song', + }), + 'context': , + 'entity_id': 'button.test_player_1_favorite_current_song', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py new file mode 100644 index 00000000000..8a1a4b0e241 --- /dev/null +++ b/tests/components/music_assistant/test_button.py @@ -0,0 +1,48 @@ +"""Test Music Assistant button entities.""" + +from unittest.mock import MagicMock, call + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities + + +async def test_button_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + music_assistant_client: MagicMock, +) -> None: + """Test media player.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + snapshot_music_assistant_entities(hass, entity_registry, snapshot, Platform.BUTTON) + + +async def test_button_press_action( + hass: HomeAssistant, + music_assistant_client: MagicMock, +) -> None: + """Test button press action.""" + await setup_integration_from_fixtures(hass, music_assistant_client) + entity_id = "button.my_super_test_player_2_favorite_current_song" + state = hass.states.get(entity_id) + assert state + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + assert music_assistant_client.send_command.call_count == 1 + assert music_assistant_client.send_command.call_args == call( + "music/favorites/add_item", + item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", + ) From 3806e5b65c2dd02e6de27fa8bdfe541dffcdbcbb Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 23 Jun 2025 20:41:00 +0200 Subject: [PATCH 0563/1664] Set KNX to quality scale "silver" (#144879) Update KNX integration quality scale --- homeassistant/components/knx/manifest.json | 1 + homeassistant/components/knx/quality_scale.yaml | 17 ++++++++++------- script/hassfest/quality_scale.py | 1 - 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 36c4bc71273..baa830bfaa4 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -9,6 +9,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["xknx", "xknxproject"], + "quality_scale": "silver", "requirements": [ "xknx==3.8.0", "xknxproject==3.8.2", diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index 63aa4578159..b4b36213c43 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -13,7 +13,7 @@ rules: docs-actions: done docs-high-level-description: done docs-installation-instructions: done - docs-removal-instructions: todo + docs-removal-instructions: done entity-event-setup: done entity-unique-id: done has-entity-name: @@ -41,8 +41,8 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + docs-configuration-parameters: done + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: @@ -64,21 +64,24 @@ rules: comment: | YAML entities don't support devices. UI entities support user-defined devices. diagnostics: done - discovery-update-info: todo + discovery-update-info: + status: exempt + comment: | + KNX doesn't support any provided discovery method. discovery: status: exempt comment: | KNX doesn't support any provided discovery method. - docs-data-update: todo + docs-data-update: done docs-examples: done - docs-known-limitations: todo + docs-known-limitations: done docs-supported-devices: status: exempt comment: | Devices aren't supported directly since communication is on group address level. docs-supported-functions: done docs-troubleshooting: done - docs-use-cases: todo + docs-use-cases: done dynamic-devices: status: exempt comment: | diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 52e5f935117..73505e805bc 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1607,7 +1607,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "konnected", "kostal_plenticore", "kraken", - "knx", "kulersky", "kwb", "lacrosse", From 2862f76fca4476bec3546962e473f9b467775032 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 23 Jun 2025 20:43:01 +0200 Subject: [PATCH 0564/1664] Add support for Reolink Floodlight PoE/WiFi (#146778) * Add support for Floodlight PoE/WiFi * Adjust test * Add test --- homeassistant/components/reolink/__init__.py | 3 + .../components/reolink/config_flow.py | 9 ++- homeassistant/components/reolink/const.py | 1 + homeassistant/components/reolink/host.py | 16 ++++- tests/components/reolink/conftest.py | 2 + tests/components/reolink/test_config_flow.py | 59 +++++++++++++++++++ 6 files changed, 86 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 38445b912bc..3260bff44b5 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -32,6 +32,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import ( BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -107,6 +108,7 @@ async def async_setup_entry( or host.api.supported(None, "privacy_mode") != config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE) or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT) + or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY) ): if host.api.port != config_entry.data[CONF_PORT]: _LOGGER.warning( @@ -130,6 +132,7 @@ async def async_setup_entry( CONF_PORT: host.api.port, CONF_USE_HTTPS: host.api.use_https, CONF_BC_PORT: host.api.baichuan.port, + CONF_BC_ONLY: host.api.baichuan_only, CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"), } hass.config_entries.async_update_entry(config_entry, data=data) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index 659169c3618..eee8b04dfcc 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -38,7 +38,13 @@ from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo -from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN +from .const import ( + CONF_BC_ONLY, + CONF_BC_PORT, + CONF_SUPPORTS_PRIVACY_MODE, + CONF_USE_HTTPS, + DOMAIN, +) from .exceptions import ( PasswordIncompatible, ReolinkException, @@ -296,6 +302,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN): user_input[CONF_PORT] = host.api.port user_input[CONF_USE_HTTPS] = host.api.use_https user_input[CONF_BC_PORT] = host.api.baichuan.port + user_input[CONF_BC_ONLY] = host.api.baichuan_only user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported( None, "privacy_mode" ) diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py index bd9c4bb84a2..db2d105984b 100644 --- a/homeassistant/components/reolink/const.py +++ b/homeassistant/components/reolink/const.py @@ -4,6 +4,7 @@ DOMAIN = "reolink" CONF_USE_HTTPS = "use_https" CONF_BC_PORT = "baichuan_port" +CONF_BC_ONLY = "baichuan_only" CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported" # Conserve battery by not waking the battery cameras each minute during normal update diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 39b58c92ac3..0f64dc05902 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -38,6 +38,7 @@ from .const import ( BATTERY_ALL_WAKE_UPDATE_INTERVAL, BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL, BATTERY_WAKE_UPDATE_INTERVAL, + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -97,6 +98,7 @@ class ReolinkHost: timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=get_aiohttp_session, bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT), + bc_only=config.get(CONF_BC_ONLY, False), ) self.last_wake: defaultdict[int, float] = defaultdict(float) @@ -220,19 +222,27 @@ class ReolinkHost: enable_onvif = None enable_rtmp = None - if not self._api.rtsp_enabled: + if not self._api.rtsp_enabled and not self._api.baichuan_only: _LOGGER.debug( "RTSP is disabled on %s, trying to enable it", self._api.nvr_name ) enable_rtsp = True - if not self._api.onvif_enabled and onvif_supported: + if ( + not self._api.onvif_enabled + and onvif_supported + and not self._api.baichuan_only + ): _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) enable_onvif = True - if not self._api.rtmp_enabled and self._api.protocol == "rtmp": + if ( + not self._api.rtmp_enabled + and self._api.protocol == "rtmp" + and not self._api.baichuan_only + ): _LOGGER.debug( "RTMP is disabled on %s, trying to enable it", self._api.nvr_name ) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 0ca5612f8fd..2f37fca251a 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -10,6 +10,7 @@ from reolink_aio.exceptions import ReolinkError from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -219,6 +220,7 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry: CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index e706af0d067..4b116929ac8 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -19,6 +19,7 @@ from homeassistant import config_entries from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.const import ( + CONF_BC_ONLY, CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, @@ -91,6 +92,7 @@ async def test_config_flow_manual_success( CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -144,6 +146,7 @@ async def test_config_flow_privacy_success( CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -153,6 +156,49 @@ async def test_config_flow_privacy_success( reolink_connect.baichuan.privacy_mode.return_value = False +async def test_config_flow_baichuan_only( + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock +) -> None: + """Successful flow manually initialized by the user for baichuan only device.""" + reolink_connect.baichuan_only = True + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.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"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_HOST: TEST_HOST, + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_NVR_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_PORT: TEST_PORT, + CONF_USE_HTTPS: TEST_USE_HTTPS, + CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, + CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: True, + } + assert result["options"] == { + CONF_PROTOCOL: DEFAULT_PROTOCOL, + } + 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 ) -> None: @@ -308,6 +354,7 @@ async def test_config_flow_errors( CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -329,6 +376,7 @@ async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: "rtsp", @@ -368,6 +416,7 @@ async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -414,6 +463,7 @@ async def test_reauth_abort_unique_id_mismatch( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -484,6 +534,7 @@ async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> No CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, } assert result["options"] == { CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -507,6 +558,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -548,6 +600,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac( timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, bc_port=TEST_BC_PORT, + bc_only=False, ) assert expected_call in reolink_connect_class.call_args_list @@ -606,6 +659,7 @@ async def test_dhcp_ip_update( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -649,6 +703,7 @@ async def test_dhcp_ip_update( timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, bc_port=TEST_BC_PORT, + bc_only=False, ) assert expected_call in reolink_connect_class.call_args_list @@ -686,6 +741,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -718,6 +774,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected( timeout=DEFAULT_TIMEOUT, aiohttp_get_session_callback=ANY, bc_port=TEST_BC_PORT, + bc_only=False, ) assert expected_call in reolink_connect_class.call_args_list @@ -748,6 +805,7 @@ async def test_reconfig(hass: HomeAssistant, mock_setup_entry: MagicMock) -> Non CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, @@ -795,6 +853,7 @@ async def test_reconfig_abort_unique_id_mismatch( CONF_PORT: TEST_PORT, CONF_USE_HTTPS: TEST_USE_HTTPS, CONF_BC_PORT: TEST_BC_PORT, + CONF_BC_ONLY: False, }, options={ CONF_PROTOCOL: DEFAULT_PROTOCOL, From b4af9a31cb13c6f58c624a53230cb2587ac55ada Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 23 Jun 2025 20:44:35 +0200 Subject: [PATCH 0565/1664] Add multiple cmd_id pushes for Reolink floodlight (#146685) Allow for multiple cmd_id pushes --- homeassistant/components/reolink/entity.py | 11 +++++++---- homeassistant/components/reolink/light.py | 2 +- homeassistant/components/reolink/number.py | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py index 467472fef9c..a83dc259e1b 100644 --- a/homeassistant/components/reolink/entity.py +++ b/homeassistant/components/reolink/entity.py @@ -24,7 +24,7 @@ class ReolinkEntityDescription(EntityDescription): """A class that describes entities for Reolink.""" cmd_key: str | None = None - cmd_id: int | None = None + cmd_id: int | list[int] | None = None always_available: bool = False @@ -120,12 +120,15 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None] """Entity created.""" await super().async_added_to_hass() cmd_key = self.entity_description.cmd_key - cmd_id = self.entity_description.cmd_id + cmd_ids = self.entity_description.cmd_id callback_id = f"{self.platform.domain}_{self._attr_unique_id}" if cmd_key is not None: self._host.async_register_update_cmd(cmd_key) - if cmd_id is not None: - self.register_callback(callback_id, cmd_id) + if isinstance(cmd_ids, int): + self.register_callback(callback_id, cmd_ids) + elif isinstance(cmd_ids, list): + for cmd_id in cmd_ids: + self.register_callback(callback_id, cmd_id) # Privacy mode self.register_callback(f"{callback_id}_623", 623) diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py index d48790264d1..1e2c6d49528 100644 --- a/homeassistant/components/reolink/light.py +++ b/homeassistant/components/reolink/light.py @@ -57,7 +57,7 @@ LIGHT_ENTITIES = ( ReolinkLightEntityDescription( key="floodlight", cmd_key="GetWhiteLed", - cmd_id=291, + cmd_id=[291, 289, 438], translation_key="floodlight", supported=lambda api, ch: api.supported(ch, "floodLight"), is_on_fn=lambda api, ch: api.whiteled_state(ch), diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6de702a0395..2de2468ca3d 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -113,6 +113,7 @@ NUMBER_ENTITIES = ( ReolinkNumberEntityDescription( key="floodlight_brightness", cmd_key="GetWhiteLed", + cmd_id=[289, 438], translation_key="floodlight_brightness", entity_category=EntityCategory.CONFIG, native_step=1, From dd3d6f116ec916acc42c9ff2eec53266d003286d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 23 Jun 2025 20:45:24 +0200 Subject: [PATCH 0566/1664] Rename second Reolink lens from "autotrack" to "telephoto" (#146898) * Rename second Reolink lens from "autotrack" to "telephoto" * Adjust tests --- homeassistant/components/reolink/camera.py | 8 ++++---- homeassistant/components/reolink/media_source.py | 8 ++++---- homeassistant/components/reolink/strings.json | 12 ++++++------ tests/components/reolink/test_media_source.py | 4 ++-- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index 119fb625349..b9744f8e002 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -69,21 +69,21 @@ CAMERA_ENTITIES = ( ), ReolinkCameraEntityDescription( key="autotrack_sub", - stream="autotrack_sub", - translation_key="autotrack_sub", + stream="telephoto_sub", + translation_key="telephoto_sub", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), ), ReolinkCameraEntityDescription( key="autotrack_snapshots_sub", stream="autotrack_snapshots_sub", - translation_key="autotrack_snapshots_sub", + translation_key="telephoto_snapshots_sub", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), entity_registry_enabled_default=False, ), ReolinkCameraEntityDescription( key="autotrack_snapshots_main", stream="autotrack_snapshots_main", - translation_key="autotrack_snapshots_main", + translation_key="telephoto_snapshots_main", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), entity_registry_enabled_default=False, ), diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py index 36a2f3c5489..9c8c685d898 100644 --- a/homeassistant/components/reolink/media_source.py +++ b/homeassistant/components/reolink/media_source.py @@ -42,9 +42,9 @@ def res_name(stream: str) -> str: case "main": return "High res." case "autotrack_sub": - return "Autotrack low res." + return "Telephoto low res." case "autotrack_main": - return "Autotrack high res." + return "Telephoto high res." case _: return "Low res." @@ -284,7 +284,7 @@ class ReolinkVODMediaSource(MediaSource): identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub", media_class=MediaClass.CHANNEL, media_content_type=MediaType.PLAYLIST, - title="Autotrack low resolution", + title="Telephoto low resolution", can_play=False, can_expand=True, ), @@ -293,7 +293,7 @@ class ReolinkVODMediaSource(MediaSource): identifier=f"RES|{config_entry_id}|{channel}|autotrack_main", media_class=MediaClass.CHANNEL, media_content_type=MediaType.PLAYLIST, - title="Autotrack high resolution", + title="Telephoto high resolution", can_play=False, can_expand=True, ), diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index e7a970ec1c8..7a77c523b16 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -504,14 +504,14 @@ "ext_lens_1": { "name": "Balanced lens 1" }, - "autotrack_sub": { - "name": "Autotrack fluent" + "telephoto_sub": { + "name": "Telephoto fluent" }, - "autotrack_snapshots_sub": { - "name": "Autotrack snapshots fluent" + "telephoto_snapshots_sub": { + "name": "Telephoto snapshots fluent" }, - "autotrack_snapshots_main": { - "name": "Autotrack snapshots clear" + "telephoto_snapshots_main": { + "name": "Telephoto snapshots clear" } }, "light": { diff --git a/tests/components/reolink/test_media_source.py b/tests/components/reolink/test_media_source.py index 59f0c6c195d..67ae78e5fa4 100644 --- a/tests/components/reolink/test_media_source.py +++ b/tests/components/reolink/test_media_source.py @@ -194,13 +194,13 @@ async def test_browsing( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_sub_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Autotrack low res." + assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto low res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_AT_main_id}" ) assert browse.domain == DOMAIN - assert browse.title == f"{TEST_NVR_NAME} lens 0 Autotrack high res." + assert browse.title == f"{TEST_NVR_NAME} lens 0 Telephoto high res." browse = await async_browse_media( hass, f"{URI_SCHEME}{DOMAIN}/{browse_res_main_id}" From 6af290eb746174b13668d7b04f7d046a0861f36d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 23 Jun 2025 20:53:09 +0200 Subject: [PATCH 0567/1664] Add Reolink Telephoto main stream (#146975) --- homeassistant/components/reolink/camera.py | 7 +++++++ homeassistant/components/reolink/strings.json | 3 +++ 2 files changed, 10 insertions(+) diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index b9744f8e002..44386434cad 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -73,6 +73,13 @@ CAMERA_ENTITIES = ( translation_key="telephoto_sub", supported=lambda api, ch: api.supported(ch, "autotrack_stream"), ), + ReolinkCameraEntityDescription( + key="autotrack_main", + stream="telephoto_main", + translation_key="telephoto_main", + supported=lambda api, ch: api.supported(ch, "autotrack_stream"), + entity_registry_enabled_default=False, + ), ReolinkCameraEntityDescription( key="autotrack_snapshots_sub", stream="autotrack_snapshots_sub", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 7a77c523b16..5473887a8ff 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -507,6 +507,9 @@ "telephoto_sub": { "name": "Telephoto fluent" }, + "telephoto_main": { + "name": "Telephoto clear" + }, "telephoto_snapshots_sub": { "name": "Telephoto snapshots fluent" }, From fc91047d8d01a51da97857adfeaba5f4a22223c0 Mon Sep 17 00:00:00 2001 From: Alex Biddulph Date: Tue, 24 Jun 2025 04:59:18 +1000 Subject: [PATCH 0568/1664] Add sensors for detailed Enphase inverter readings (#146916) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add extra details to Enphase inverters * Bump pyenphase version to 2.1.0 * Add new inverter sensors and translations * Add new endpoint * Start updating tests * Remove duplicate class * Add `max_reported` sensor * Move translation strings to correct location * Update fixtures and snapshots * Update unit tests * Fix linting * Apply suggestions from code review Co-authored-by: Arie Catsman <120491684+catsmanac@users.noreply.github.com> * Fix Telegram bot parsing of inline keyboard (#146376) * bug fix for inline keyboard * update inline keyboard test * Update tests/components/telegram_bot/test_telegram_bot.py Co-authored-by: Martin Hjelmare * revert last_message_id and updated tests * removed TypeError test --------- Co-authored-by: Martin Hjelmare * Handle the new JSON payload from traccar clients (#147254) * Set `entity_id` * Update unit tests * Bump aioamazondevices to 3.1.14 (#147257) * Bump pyseventeentrack to 1.1.1 (#147253) Update pyseventeentrack requirement to version 1.1.1 * Bump uiprotect to version 7.14.1 (#147280) * Fix `state_class`es for energy production * Make `max_reported` `name` more descriptive * Update snapshots * Reuse some translations * Remove unnecessary translation keys * Update unit tests * Update homeassistant/components/enphase_envoy/strings.json * Update homeassistant/components/enphase_envoy/strings.json * Fix --------- Co-authored-by: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Co-authored-by: hanwg Co-authored-by: Martin Hjelmare Co-authored-by: Joakim Sørensen Co-authored-by: Simone Chemelli Co-authored-by: Shai Ungar Co-authored-by: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .../components/enphase_envoy/diagnostics.py | 1 + .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/sensor.py | 109 + .../components/enphase_envoy/strings.json | 27 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../enphase_envoy/fixtures/envoy.json | 16 +- .../fixtures/envoy_1p_metered.json | 12 +- .../fixtures/envoy_acb_batt.json | 12 +- .../enphase_envoy/fixtures/envoy_eu_batt.json | 12 +- .../fixtures/envoy_metered_batt_relay.json | 12 +- .../fixtures/envoy_nobatt_metered_3p.json | 12 +- .../fixtures/envoy_tot_cons_metered.json | 12 +- .../snapshots/test_diagnostics.ambr | 1705 ++++++- .../enphase_envoy/snapshots/test_sensor.ambr | 4337 ++++++++++++++++- tests/components/enphase_envoy/test_init.py | 8 +- tests/components/enphase_envoy/test_sensor.py | 80 +- 17 files changed, 6334 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py index e59a9fa09c5..a1a9d4ed6b4 100644 --- a/homeassistant/components/enphase_envoy/diagnostics.py +++ b/homeassistant/components/enphase_envoy/diagnostics.py @@ -65,6 +65,7 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]: "/ivp/ensemble/generator", "/ivp/meters", "/ivp/meters/readings", + "/ivp/pdm/device_data", "/home", ] diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 6f1e0a943ef..5f74da954a0 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.0.1"], + "requirements": ["pyenphase==2.1.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 594f5f34088..c1088252618 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -45,6 +45,7 @@ from homeassistant.const import ( UnitOfFrequency, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -80,6 +81,114 @@ INVERTER_SENSORS = ( device_class=SensorDeviceClass.POWER, value_fn=attrgetter("last_report_watts"), ), + EnvoyInverterSensorEntityDescription( + key="dc_voltage", + translation_key="dc_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("dc_voltage"), + ), + EnvoyInverterSensorEntityDescription( + key="dc_current", + translation_key="dc_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("dc_current"), + ), + EnvoyInverterSensorEntityDescription( + key="ac_voltage", + translation_key="ac_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("ac_voltage"), + ), + EnvoyInverterSensorEntityDescription( + key="ac_current", + translation_key="ac_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("ac_current"), + ), + EnvoyInverterSensorEntityDescription( + key="ac_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("ac_frequency"), + ), + EnvoyInverterSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + suggested_display_precision=3, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("temperature"), + ), + EnvoyInverterSensorEntityDescription( + key="lifetime_energy", + translation_key="lifetime_energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + value_fn=attrgetter("lifetime_energy"), + ), + EnvoyInverterSensorEntityDescription( + key="energy_today", + translation_key="energy_today", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + value_fn=attrgetter("energy_today"), + ), + EnvoyInverterSensorEntityDescription( + key="last_report_duration", + translation_key="last_report_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("last_report_duration"), + ), + EnvoyInverterSensorEntityDescription( + key="energy_produced", + translation_key="energy_produced", + native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR, + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.ENERGY, + suggested_display_precision=3, + entity_registry_enabled_default=False, + value_fn=attrgetter("energy_produced"), + ), + EnvoyInverterSensorEntityDescription( + key="max_reported", + translation_key="max_reported", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=attrgetter("max_report_watts"), + ), EnvoyInverterSensorEntityDescription( key=LAST_REPORTED_KEY, translation_key=LAST_REPORTED_KEY, diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index e45c746869d..577def459f1 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -380,6 +380,33 @@ }, "aggregated_soc": { "name": "Aggregated battery soc" + }, + "dc_voltage": { + "name": "DC voltage" + }, + "dc_current": { + "name": "DC current" + }, + "ac_voltage": { + "name": "AC voltage" + }, + "ac_current": { + "name": "AC current" + }, + "lifetime_energy": { + "name": "Lifetime energy produced" + }, + "energy_today": { + "name": "Energy produced today" + }, + "energy_produced": { + "name": "Energy produced since previous report" + }, + "max_reported": { + "name": "Lifetime maximum power" + }, + "last_report_duration": { + "name": "Last report duration" } }, "switch": { diff --git a/requirements_all.txt b/requirements_all.txt index da730bd041c..f6de99444c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.0.1 +pyenphase==2.1.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f03fcba0cdc..8cd601e2613 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1634,7 +1634,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.0.1 +pyenphase==2.1.0 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/enphase_envoy/fixtures/envoy.json b/tests/components/enphase_envoy/fixtures/envoy.json index c619d61a393..85d8990b1ab 100644 --- a/tests/components/enphase_envoy/fixtures/envoy.json +++ b/tests/components/enphase_envoy/fixtures/envoy.json @@ -38,9 +38,19 @@ "inverters": { "1": { "serial_number": "1", - "last_report_date": 1, - "last_report_watts": 1, - "max_report_watts": 1 + "last_report_date": 1750460765, + "last_report_watts": 116, + "max_report_watts": 325, + "dc_voltage": 33.793, + "dc_current": 3.668, + "ac_voltage": 243.438, + "ac_current": 0.504, + "ac_frequency": 50.01, + "temperature": 23, + "energy_produced": 32.254, + "energy_today": 134, + "lifetime_energy": 130209, + "last_report_duration": 903 } }, "tariff": null, diff --git a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json index 22aeca50ca0..50f320edbc2 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_1p_metered.json @@ -78,7 +78,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json index 52e812f979e..5cc35d4050c 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_acb_batt.json @@ -220,7 +220,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json index 30fbc8d0f4f..b9951a4c6fa 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json +++ b/tests/components/enphase_envoy/fixtures/envoy_eu_batt.json @@ -208,7 +208,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json index 6cfbfed1e8e..73af5af0e5d 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json +++ b/tests/components/enphase_envoy/fixtures/envoy_metered_batt_relay.json @@ -412,7 +412,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json index 8c2767e33e5..5a9ca140f8c 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json +++ b/tests/components/enphase_envoy/fixtures/envoy_nobatt_metered_3p.json @@ -227,7 +227,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json index 15cf2c173cb..48b4de87867 100644 --- a/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json +++ b/tests/components/enphase_envoy/fixtures/envoy_tot_cons_metered.json @@ -73,7 +73,17 @@ "serial_number": "1", "last_report_date": 1, "last_report_watts": 1, - "max_report_watts": 1 + "max_report_watts": 1, + "dc_voltage": null, + "dc_current": null, + "ac_voltage": null, + "ac_current": null, + "ac_frequency": null, + "temperature": null, + "energy_produced": null, + "energy_today": null, + "lifetime_energy": null, + "last_report_duration": null } }, "tariff": { diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 7eb57488d66..8eb6fcaac37 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -359,9 +359,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -419,7 +840,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, @@ -817,9 +1238,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -877,7 +1719,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, @@ -934,6 +1776,8 @@ '/ivp/meters/readings': 'Testing request replies.', '/ivp/meters/readings_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/meters_log': '{"headers":{"Hello":"World"},"code":200}', + '/ivp/pdm/device_data': 'Testing request replies.', + '/ivp/pdm/device_data_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/sc/pvlimit': 'Testing request replies.', '/ivp/sc/pvlimit_log': '{"headers":{"Hello":"World"},"code":200}', '/ivp/ss/dry_contact_settings': 'Testing request replies.', @@ -1317,9 +2161,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -1377,7 +2642,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, @@ -1447,6 +2712,9 @@ '/ivp/meters_log': dict({ 'Error': "EnvoyError('Test')", }), + '/ivp/pdm/device_data_log': dict({ + 'Error': "EnvoyError('Test')", + }), '/ivp/sc/pvlimit_log': dict({ 'Error': "EnvoyError('Test')", }), @@ -1589,9 +2857,430 @@ 'unit_of_measurement': 'W', }), 'entity_id': 'sensor.inverter_1', - 'state': '1', + 'state': '116', }), }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'voltage', + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': 'V', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'current', + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': 'A', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'frequency', + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': 'Hz', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'temperature', + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': '°C', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + 'sensor.private': dict({ + 'suggested_unit_of_measurement': 'kWh', + }), + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': 'kWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total_increasing', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': 'Wh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'duration', + 'original_icon': None, + 'original_name': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': 's', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'total', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'energy', + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': 'mWh', + }), + 'state': None, + }), + dict({ + 'entity': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': 'measurement', + }), + 'categories': dict({ + }), + 'config_entry_id': '45a36e55aaddb2007c5f6602e0c38e72', + 'config_subentry_id': None, + 'device_class': None, + 'disabled_by': 'integration', + 'domain': 'sensor', + 'entity_category': 'diagnostic', + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'labels': list([ + ]), + 'name': None, + 'options': dict({ + }), + 'original_device_class': 'power', + 'original_icon': None, + 'original_name': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': 'W', + }), + 'state': None, + }), dict({ 'entity': dict({ 'aliases': list([ @@ -1901,7 +3590,7 @@ 'inverters': dict({ '1': dict({ '__type': "", - 'repr': "EnvoyInverter(serial_number='1', last_report_date=1, last_report_watts=1, max_report_watts=1)", + 'repr': "EnvoyInverter(serial_number='1', last_report_date=1750460765, last_report_watts=116, max_report_watts=325, dc_voltage=33.793, dc_current=3.668, ac_voltage=243.438, ac_current=0.504, ac_frequency=50.01, temperature=23, lifetime_energy=130209, energy_produced=32.254, energy_today=134, last_report_duration=903)", }), }), 'system_consumption': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index d548b2a0f93..51a596eda18 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -285,7 +285,455 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '116', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.504', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '243.438', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.668', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.793', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '32.254', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '134', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.01', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '903', }) # --- # name: test_sensor[envoy][sensor.inverter_1_last_reported-entry] @@ -334,7 +782,178 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1970-01-01T00:00:01+00:00', + 'state': '2025-06-20T23:06:05+00:00', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '130.209', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '325', + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '23', }) # --- # name: test_sensor[envoy_1p_metered][sensor.envoy_1234_balanced_net_power_consumption-entry] @@ -1828,6 +2447,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_1p_metered][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1877,6 +2944,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_acb_batt][sensor.acb_1234_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6812,6 +8050,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_acb_batt][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -6861,6 +8547,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_eu_batt][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11422,6 +13279,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_eu_batt][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -11471,6 +13776,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.encharge_123456_apparent_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -19942,6 +22418,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -19991,6 +22915,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.envoy_1234_balanced_net_power_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -25787,6 +28882,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -25836,6 +29379,177 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.envoy_1234_balanced_net_power_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -26580,6 +30294,454 @@ 'state': '1', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_current-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.inverter_1_ac_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_current', + 'unique_id': '1_ac_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 AC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_voltage-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.inverter_1_ac_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'AC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ac_voltage', + 'unique_id': '1_ac_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_ac_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 AC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_ac_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_current-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.inverter_1_dc_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC current', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_current', + 'unique_id': '1_dc_current', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Inverter 1 DC current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_voltage-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.inverter_1_dc_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DC voltage', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dc_voltage', + 'unique_id': '1_dc_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_dc_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Inverter 1 DC voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_dc_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_since_previous_report-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.inverter_1_energy_produced_since_previous_report', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy produced since previous report', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_produced', + 'unique_id': '1_energy_produced', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_since_previous_report-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_today-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.inverter_1_energy_produced_today', + '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': 'Energy produced today', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_today', + 'unique_id': '1_energy_today', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_today-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Energy produced today', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_frequency-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.inverter_1_frequency', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Frequency', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_ac_frequency', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Inverter 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_last_report_duration-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.inverter_1_last_report_duration', + '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': 'Last report duration', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_report_duration', + 'unique_id': '1_last_report_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_last_report_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Inverter 1 Last report duration', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_last_report_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_last_reported-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -26629,3 +30791,174 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_produced-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.inverter_1_lifetime_energy_produced', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lifetime energy produced', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lifetime_energy', + 'unique_id': '1_lifetime_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_produced-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_maximum_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': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_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': 'Lifetime maximum power', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'max_reported', + 'unique_id': '1_max_reported', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_maximum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Inverter 1 Lifetime maximum power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_lifetime_maximum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_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': , + 'entity_id': 'sensor.inverter_1_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'enphase_envoy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '1_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Inverter 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.inverter_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index 560d0719424..a738b31c183 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -54,7 +54,7 @@ async def test_with_pre_v7_firmware( await setup_integration(hass, config_entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" @pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") @@ -85,7 +85,7 @@ async def test_token_in_config_file( await setup_integration(hass, entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" @respx.mock @@ -128,7 +128,7 @@ async def test_expired_token_in_config( await setup_integration(hass, entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" async def test_coordinator_update_error( @@ -226,7 +226,7 @@ async def test_coordinator_token_refresh_error( await setup_integration(hass, entry) assert (entity_state := hass.states.get("sensor.inverter_1")) - assert entity_state.state == "1" + assert entity_state.state == "116" async def test_config_no_unique_id( diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 89f28c74514..70bf8c99007 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -772,6 +772,70 @@ async def test_sensor_inverter_data( ) == dt_util.utc_from_timestamp(inverter.last_report_date) +@pytest.mark.parametrize( + ("mock_envoy"), + [ + "envoy", + ], + indirect=["mock_envoy"], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sensor_inverter_detailed_data( + hass: HomeAssistant, + mock_envoy: AsyncMock, + config_entry: MockConfigEntry, +) -> None: + """Test enphase_envoy inverter detailed entities values.""" + with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, config_entry) + + entity_base = f"{Platform.SENSOR}.inverter" + + for sn, inverter in mock_envoy.data.inverters.items(): + assert (dc_voltage := hass.states.get(f"{entity_base}_{sn}_dc_voltage")) + assert float(dc_voltage.state) == (inverter.dc_voltage) + assert (dc_current := hass.states.get(f"{entity_base}_{sn}_dc_current")) + assert float(dc_current.state) == (inverter.dc_current) + assert (ac_voltage := hass.states.get(f"{entity_base}_{sn}_ac_voltage")) + assert float(ac_voltage.state) == (inverter.ac_voltage) + assert (ac_current := hass.states.get(f"{entity_base}_{sn}_ac_current")) + assert float(ac_current.state) == (inverter.ac_current) + assert (frequency := hass.states.get(f"{entity_base}_{sn}_frequency")) + assert float(frequency.state) == (inverter.ac_frequency) + assert (temperature := hass.states.get(f"{entity_base}_{sn}_temperature")) + assert int(temperature.state) == (inverter.temperature) + assert ( + lifetime_energy := hass.states.get( + f"{entity_base}_{sn}_lifetime_energy_produced" + ) + ) + assert float(lifetime_energy.state) == (inverter.lifetime_energy / 1000.0) + assert ( + energy_produced_today := hass.states.get( + f"{entity_base}_{sn}_energy_produced_today" + ) + ) + assert int(energy_produced_today.state) == (inverter.energy_today) + assert ( + last_report_duration := hass.states.get( + f"{entity_base}_{sn}_last_report_duration" + ) + ) + assert int(last_report_duration.state) == (inverter.last_report_duration) + assert ( + energy_produced := hass.states.get( + f"{entity_base}_{sn}_energy_produced_since_previous_report" + ) + ) + assert float(energy_produced.state) == (inverter.energy_produced) + assert ( + lifetime_maximum_power := hass.states.get( + f"{entity_base}_{sn}_lifetime_maximum_power" + ) + ) + assert int(lifetime_maximum_power.state) == (inverter.max_report_watts) + + @pytest.mark.parametrize( ("mock_envoy"), [ @@ -797,9 +861,23 @@ async def test_sensor_inverter_disabled_by_integration( INVERTER_BASE = f"{Platform.SENSOR}.inverter" assert all( - f"{INVERTER_BASE}_{sn}_last_reported" + f"{INVERTER_BASE}_{sn}_{key}" in integration_disabled_entities(entity_registry, config_entry) for sn in mock_envoy.data.inverters + for key in ( + "dc_voltage", + "dc_current", + "ac_voltage", + "ac_current", + "frequency", + "temperature", + "lifetime_energy_produced", + "energy_produced_today", + "last_report_duration", + "energy_produced_since_previous_report", + "last_reported", + "lifetime_maximum_power", + ) ) From 512449a76d423e7093c4292170a154ed0c716786 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Jun 2025 21:01:01 +0200 Subject: [PATCH 0569/1664] Add Bluetooth connection to LaMetric (#147342) --- homeassistant/components/lametric/entity.py | 10 +++++++--- tests/components/lametric/fixtures/device.json | 2 +- .../lametric/snapshots/test_diagnostics.ambr | 2 +- tests/components/lametric/test_button.py | 12 ++++++++---- tests/components/lametric/test_number.py | 10 ++++++++-- tests/components/lametric/test_select.py | 5 ++++- tests/components/lametric/test_sensor.py | 5 ++++- tests/components/lametric/test_switch.py | 5 ++++- 8 files changed, 37 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/lametric/entity.py b/homeassistant/components/lametric/entity.py index 4764974b5e0..f0c0d14e0e4 100644 --- a/homeassistant/components/lametric/entity.py +++ b/homeassistant/components/lametric/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, CONNECTION_NETWORK_MAC, DeviceInfo, format_mac, @@ -21,10 +22,13 @@ class LaMetricEntity(CoordinatorEntity[LaMetricDataUpdateCoordinator]): def __init__(self, coordinator: LaMetricDataUpdateCoordinator) -> None: """Initialize the LaMetric entity.""" super().__init__(coordinator=coordinator) + connections = {(CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac))} + if coordinator.data.bluetooth is not None: + connections.add( + (CONNECTION_BLUETOOTH, format_mac(coordinator.data.bluetooth.address)) + ) self._attr_device_info = DeviceInfo( - connections={ - (CONNECTION_NETWORK_MAC, format_mac(coordinator.data.wifi.mac)) - }, + connections=connections, identifiers={(DOMAIN, coordinator.data.serial_number)}, manufacturer="LaMetric Inc.", model_id=coordinator.data.model, diff --git a/tests/components/lametric/fixtures/device.json b/tests/components/lametric/fixtures/device.json index a184d9f0aa1..bf2580a0c5d 100644 --- a/tests/components/lametric/fixtures/device.json +++ b/tests/components/lametric/fixtures/device.json @@ -12,7 +12,7 @@ }, "bluetooth": { "active": false, - "address": "AA:BB:CC:DD:EE:FF", + "address": "AA:BB:CC:DD:EE:EE", "available": true, "discoverable": true, "low_energy": { diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr index bc16e318a73..ea9dfdde92f 100644 --- a/tests/components/lametric/snapshots/test_diagnostics.ambr +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -15,7 +15,7 @@ }), 'bluetooth': dict({ 'active': False, - 'address': 'AA:BB:CC:DD:EE:FF', + 'address': 'AA:BB:CC:DD:EE:EE', 'available': True, 'discoverable': True, 'name': '**REDACTED**', diff --git a/tests/components/lametric/test_button.py b/tests/components/lametric/test_button.py index cf8d76ca5f3..e42e3248a73 100644 --- a/tests/components/lametric/test_button.py +++ b/tests/components/lametric/test_button.py @@ -44,7 +44,8 @@ async def test_button_app_next( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -91,7 +92,8 @@ async def test_button_app_previous( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -139,7 +141,8 @@ async def test_button_dismiss_current_notification( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -187,7 +190,8 @@ async def test_button_dismiss_all_notifications( assert device_entry assert device_entry.configuration_url == "https://127.0.0.1/" assert device_entry.connections == { - (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff") + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), } assert device_entry.entry_type is None assert device_entry.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_number.py b/tests/components/lametric/test_number.py index f34cf04aed9..dea693e86aa 100644 --- a/tests/components/lametric/test_number.py +++ b/tests/components/lametric/test_number.py @@ -56,7 +56,10 @@ async def test_brightness( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} @@ -105,7 +108,10 @@ async def test_volume( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_select.py b/tests/components/lametric/test_select.py index 177092f061e..e7a2ad52670 100644 --- a/tests/components/lametric/test_select.py +++ b/tests/components/lametric/test_select.py @@ -49,7 +49,10 @@ async def test_brightness_mode( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_sensor.py b/tests/components/lametric/test_sensor.py index a0719edfc9d..9915b31d283 100644 --- a/tests/components/lametric/test_sensor.py +++ b/tests/components/lametric/test_sensor.py @@ -42,7 +42,10 @@ async def test_wifi_signal( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} diff --git a/tests/components/lametric/test_switch.py b/tests/components/lametric/test_switch.py index 155a315881f..252ced706d3 100644 --- a/tests/components/lametric/test_switch.py +++ b/tests/components/lametric/test_switch.py @@ -51,7 +51,10 @@ async def test_bluetooth( device = device_registry.async_get(entry.device_id) assert device assert device.configuration_url == "https://127.0.0.1/" - assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff")} + assert device.connections == { + (dr.CONNECTION_NETWORK_MAC, "aa:bb:cc:dd:ee:ff"), + (dr.CONNECTION_BLUETOOTH, "aa:bb:cc:dd:ee:ee"), + } assert device.entry_type is None assert device.hw_version is None assert device.identifiers == {(DOMAIN, "SA110405124500W00BS9")} From c29879274a47a0b712bd11eb6059844bf35cdde9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Jun 2025 21:18:56 +0200 Subject: [PATCH 0570/1664] Refactor DeviceAutomationConditionProtocol (#147377) --- .../components/device_automation/condition.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 13454d416a0..92901f8e857 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -9,7 +9,10 @@ import voluptuous as vol from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.condition import ConditionProtocol, trace_condition_function +from homeassistant.helpers.condition import ( + ConditionCheckerType, + trace_condition_function, +) from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -19,13 +22,24 @@ if TYPE_CHECKING: from homeassistant.helpers import condition -class DeviceAutomationConditionProtocol(ConditionProtocol, Protocol): +class DeviceAutomationConditionProtocol(Protocol): """Define the format of device_condition modules. - Each module must define either CONDITION_SCHEMA or async_validate_condition_config - from ConditionProtocol. + Each module must define either CONDITION_SCHEMA or async_validate_condition_config. """ + CONDITION_SCHEMA: vol.Schema + + async def async_validate_condition_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + + def async_condition_from_config( + self, hass: HomeAssistant, config: ConfigType + ) -> ConditionCheckerType: + """Evaluate state based on configuration.""" + async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType ) -> dict[str, vol.Schema]: From b4fe6f3843aea749c842b0dfed0d69e34d645f68 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 23 Jun 2025 15:20:55 -0400 Subject: [PATCH 0571/1664] Add trigger based fan entities to template integration (#145497) * Add trigger based fan entities to template integration * more changes * add tests * update doc strings --------- Co-authored-by: Erik Montnemery --- homeassistant/components/template/config.py | 1 - homeassistant/components/template/fan.py | 85 +++- tests/components/template/test_fan.py | 419 ++++++++++++++------ 3 files changed, 384 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index a5aa9a3bd87..86769a0d22a 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -158,7 +158,6 @@ CONFIG_SECTION_SCHEMA = vol.All( ), ensure_domains_do_not_have_trigger_or_action( DOMAIN_BUTTON, - DOMAIN_FAN, ), ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 4837ded9029..f7b0b57cf27 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -15,6 +15,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, ENTITY_ID_FORMAT, FanEntity, FanEntityFeature, @@ -38,6 +39,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN +from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .template_entity import ( LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, @@ -46,6 +48,7 @@ from .template_entity import ( make_template_entity_common_modern_schema, rewrite_common_legacy_to_modern_conf, ) +from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -193,6 +196,13 @@ async def async_setup_platform( ) return + if "coordinator" in discovery_info: + async_add_entities( + TriggerFanEntity(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ) + return + _async_create_template_tracking_entities( async_add_entities, hass, @@ -228,7 +238,11 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES) self._attr_assumed_state = self._template is None - def _register_scripts( + self._attr_supported_features |= ( + FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON + ) + + def _iterate_scripts( self, config: dict[str, Any] ) -> Generator[tuple[str, Sequence[dict[str, Any]], FanEntityFeature | int]]: for action_id, supported_feature in ( @@ -492,10 +506,7 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): if TYPE_CHECKING: assert name is not None - self._attr_supported_features |= ( - FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON - ) - for action_id, action_config, supported_feature in self._register_scripts( + for action_id, action_config, supported_feature in self._iterate_scripts( config ): self.add_script(action_id, action_config, name, DOMAIN) @@ -551,3 +562,67 @@ class TemplateFan(TemplateEntity, AbstractTemplateFan): none_on_template_error=True, ) super()._async_setup_templates() + + +class TriggerFanEntity(TriggerEntity, AbstractTemplateFan): + """Fan entity based on trigger data.""" + + domain = FAN_DOMAIN + + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize the entity.""" + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateFan.__init__(self, config) + + self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME) + + for action_id, action_config, supported_feature in self._iterate_scripts( + config + ): + self.add_script(action_id, action_config, name, DOMAIN) + self._attr_supported_features |= supported_feature + + for key in ( + CONF_STATE, + CONF_PRESET_MODE, + CONF_PERCENTAGE, + CONF_OSCILLATING, + CONF_DIRECTION, + ): + if isinstance(config.get(key), template.Template): + self._to_render_simple.append(key) + self._parse_result.add(key) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle update of the data.""" + self._process_data() + + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + for key, updater in ( + (CONF_STATE, self._handle_state), + (CONF_PRESET_MODE, self._update_preset_mode), + (CONF_PERCENTAGE, self._update_percentage), + (CONF_OSCILLATING, self._update_oscillating), + (CONF_DIRECTION, self._update_direction), + ): + if (rendered := self._rendered.get(key)) is not None: + updater(rendered) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: + self.async_set_context(self.coordinator.data["context"]) + self.async_write_ha_state() diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py index a061ce86256..708ad6bdecd 100644 --- a/tests/components/template/test_fan.py +++ b/tests/components/template/test_fan.py @@ -28,11 +28,30 @@ from tests.components.fan import common TEST_OBJECT_ID = "test_fan" TEST_ENTITY_ID = f"fan.{TEST_OBJECT_ID}" + # Represent for fan's state _STATE_INPUT_BOOLEAN = "input_boolean.state" -# Represent for fan's state +# Represent for fan's percent +_STATE_TEST_SENSOR = "sensor.test_sensor" +# Represent for fan's availability _STATE_AVAILABILITY_BOOLEAN = "availability_boolean.state" +TEST_STATE_TRIGGER = { + "trigger": { + "trigger": "state", + "entity_id": [ + TEST_ENTITY_ID, + _STATE_INPUT_BOOLEAN, + _STATE_AVAILABILITY_BOOLEAN, + _STATE_TEST_SENSOR, + ], + }, + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], +} + OPTIMISTIC_ON_OFF_ACTIONS = { "turn_on": { "service": "test.automation", @@ -177,61 +196,22 @@ async def async_setup_modern_format( await hass.async_block_till_done() -async def async_setup_legacy_named_fan( +async def async_setup_trigger_format( hass: HomeAssistant, count: int, fan_config: dict[str, Any] -): - """Do setup of a named fan via legacy format.""" - await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) - - -async def async_setup_modern_named_fan( - hass: HomeAssistant, count: int, fan_config: dict[str, Any] -): - """Do setup of a named fan via legacy format.""" - await async_setup_modern_format(hass, count, {"name": TEST_OBJECT_ID, **fan_config}) - - -async def async_setup_legacy_format_with_attribute( - hass: HomeAssistant, - count: int, - attribute: str, - attribute_template: str, - extra_config: dict, ) -> None: - """Do setup of a legacy fan that has a single templated attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - await async_setup_legacy_format( - hass, - count, - { - TEST_OBJECT_ID: { - **extra_config, - "value_template": "{{ 1 == 1 }}", - **extra, - } - }, - ) + """Do setup of fan integration via trigger format.""" + config = {"template": {"fan": fan_config, **TEST_STATE_TRIGGER}} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) -async def async_setup_modern_format_with_attribute( - hass: HomeAssistant, - count: int, - attribute: str, - attribute_template: str, - extra_config: dict, -) -> None: - """Do setup of a modern fan that has a single templated attribute.""" - extra = {attribute: attribute_template} if attribute and attribute_template else {} - await async_setup_modern_format( - hass, - count, - { - "name": TEST_OBJECT_ID, - **extra_config, - "state": "{{ 1 == 1 }}", - **extra, - }, - ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() @pytest.fixture @@ -246,6 +226,8 @@ async def setup_fan( await async_setup_legacy_format(hass, count, fan_config) elif style == ConfigurationStyle.MODERN: await async_setup_modern_format(hass, count, fan_config) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, fan_config) @pytest.fixture @@ -257,9 +239,15 @@ async def setup_named_fan( ) -> None: """Do setup of fan integration.""" if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_named_fan(hass, count, fan_config) + await async_setup_legacy_format(hass, count, {TEST_OBJECT_ID: fan_config}) elif style == ConfigurationStyle.MODERN: - await async_setup_modern_named_fan(hass, count, fan_config) + await async_setup_modern_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config} + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config} + ) @pytest.fixture @@ -290,6 +278,15 @@ async def setup_state_fan( "state": state_template, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + }, + ) @pytest.fixture @@ -309,6 +306,10 @@ async def setup_test_fan_with_extra_config( await async_setup_modern_format( hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, count, {"name": TEST_OBJECT_ID, **fan_config, **extra_config} + ) @pytest.fixture @@ -320,12 +321,35 @@ async def setup_optimistic_fan_attribute( ) -> None: """Do setup of a non-optimistic fan with an optimistic attribute.""" if style == ConfigurationStyle.LEGACY: - await async_setup_legacy_format_with_attribute( - hass, count, "", "", extra_config + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + **extra_config, + "value_template": "{{ 1 == 1 }}", + } + }, ) elif style == ConfigurationStyle.MODERN: - await async_setup_modern_format_with_attribute( - hass, count, "", "", extra_config + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra_config, + "state": "{{ 1 == 1 }}", + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + **extra_config, + "state": "{{ 1 == 1 }}", + }, ) @@ -365,11 +389,23 @@ async def setup_single_attribute_state_fan( **extra_config, }, ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + **NAMED_ON_OFF_ACTIONS, + "state": state_template, + **extra, + **extra_config, + }, + ) @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ 'on' }}")]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_fan") async def test_missing_optional_config(hass: HomeAssistant) -> None: @@ -379,7 +415,8 @@ async def test_missing_optional_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize("count", [0]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( "fan_config", @@ -404,7 +441,8 @@ async def test_wrong_template_config(hass: HomeAssistant) -> None: ("count", "state_template"), [(1, "{{ is_state('input_boolean.state', 'on') }}")] ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_fan") async def test_state_template(hass: HomeAssistant) -> None: @@ -433,7 +471,8 @@ async def test_state_template(hass: HomeAssistant) -> None: ], ) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_state_fan") async def test_state_template_states(hass: HomeAssistant, expected: str) -> None: @@ -442,29 +481,28 @@ async def test_state_template_states(hass: HomeAssistant, expected: str) -> None @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 1 == 1}}", - "{% if states.input_boolean.state.state %}/local/switch.png{% endif %}", + "{% if is_state('sensor.test_sensor', 'on') %}/local/switch.png{% endif %}", {}, + "picture", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), - [ - (ConfigurationStyle.MODERN, "picture"), - ], + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_picture_template(hass: HomeAssistant) -> None: """Test picture template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("entity_picture") in ("", None) + assert state.attributes.get("entity_picture") == "" - hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + hass.states.async_set(_STATE_TEST_SENSOR, STATE_ON) await hass.async_block_till_done() state = hass.states.get(TEST_ENTITY_ID) @@ -472,27 +510,26 @@ async def test_picture_template(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("count", "state_template", "attribute_template", "extra_config"), + ("count", "state_template", "attribute_template", "extra_config", "attribute"), [ ( 1, "{{ 1 == 1}}", "{% if states.input_boolean.state.state %}mdi:eye{% endif %}", {}, + "icon", ) ], ) @pytest.mark.parametrize( - ("style", "attribute"), - [ - (ConfigurationStyle.MODERN, "icon"), - ], + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_single_attribute_state_fan") async def test_icon_template(hass: HomeAssistant) -> None: """Test icon template.""" state = hass.states.get(TEST_ENTITY_ID) - assert state.attributes.get("icon") in ("", None) + assert state.attributes.get("icon") == "" hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) await hass.async_block_till_done() @@ -507,7 +544,7 @@ async def test_icon_template(hass: HomeAssistant) -> None: ( 1, "{{ 1 == 1 }}", - "{{ states('sensor.percentage') }}", + "{{ states('sensor.test_sensor') }}", PERCENTAGE_ACTION, ) ], @@ -517,6 +554,7 @@ async def test_icon_template(hass: HomeAssistant) -> None: [ (ConfigurationStyle.LEGACY, "percentage_template"), (ConfigurationStyle.MODERN, "percentage"), + (ConfigurationStyle.TRIGGER, "percentage"), ], ) @pytest.mark.parametrize( @@ -534,7 +572,7 @@ async def test_percentage_template( hass: HomeAssistant, percent: str, expected: int, calls: list[ServiceCall] ) -> None: """Test templates with fan percentages from other entities.""" - hass.states.async_set("sensor.percentage", percent) + hass.states.async_set(_STATE_TEST_SENSOR, percent) await hass.async_block_till_done() _verify(hass, STATE_ON, expected, None, None, None) @@ -545,7 +583,7 @@ async def test_percentage_template( ( 1, "{{ 1 == 1 }}", - "{{ states('sensor.preset_mode') }}", + "{{ states('sensor.test_sensor') }}", {"preset_modes": ["auto", "smart"], **PRESET_MODE_ACTION}, ) ], @@ -555,6 +593,7 @@ async def test_percentage_template( [ (ConfigurationStyle.LEGACY, "preset_mode_template"), (ConfigurationStyle.MODERN, "preset_mode"), + (ConfigurationStyle.TRIGGER, "preset_mode"), ], ) @pytest.mark.parametrize( @@ -571,7 +610,7 @@ async def test_preset_mode_template( hass: HomeAssistant, preset_mode: str, expected: int ) -> None: """Test preset_mode template.""" - hass.states.async_set("sensor.preset_mode", preset_mode) + hass.states.async_set(_STATE_TEST_SENSOR, preset_mode) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, None, expected) @@ -582,7 +621,7 @@ async def test_preset_mode_template( ( 1, "{{ 1 == 1 }}", - "{{ is_state('binary_sensor.oscillating', 'on') }}", + "{{ is_state('sensor.test_sensor', 'on') }}", OSCILLATE_ACTION, ) ], @@ -592,6 +631,7 @@ async def test_preset_mode_template( [ (ConfigurationStyle.LEGACY, "oscillating_template"), (ConfigurationStyle.MODERN, "oscillating"), + (ConfigurationStyle.TRIGGER, "oscillating"), ], ) @pytest.mark.parametrize( @@ -606,7 +646,7 @@ async def test_oscillating_template( hass: HomeAssistant, oscillating: str, expected: bool | None ) -> None: """Test oscillating template.""" - hass.states.async_set("binary_sensor.oscillating", oscillating) + hass.states.async_set(_STATE_TEST_SENSOR, oscillating) await hass.async_block_till_done() _verify(hass, STATE_ON, None, expected, None, None) @@ -617,7 +657,7 @@ async def test_oscillating_template( ( 1, "{{ 1 == 1 }}", - "{{ states('sensor.direction') }}", + "{{ states('sensor.test_sensor') }}", DIRECTION_ACTION, ) ], @@ -627,6 +667,7 @@ async def test_oscillating_template( [ (ConfigurationStyle.LEGACY, "direction_template"), (ConfigurationStyle.MODERN, "direction"), + (ConfigurationStyle.TRIGGER, "direction"), ], ) @pytest.mark.parametrize( @@ -641,7 +682,7 @@ async def test_direction_template( hass: HomeAssistant, direction: str, expected: bool | None ) -> None: """Test direction template.""" - hass.states.async_set("sensor.direction", direction) + hass.states.async_set(_STATE_TEST_SENSOR, direction) await hass.async_block_till_done() _verify(hass, STATE_ON, None, None, expected, None) @@ -674,6 +715,17 @@ async def test_direction_template( "turn_off": {"service": "script.fan_off"}, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "availability": ("{{ is_state('availability_boolean.state', 'on') }}"), + "state": "{{ 'on' }}", + "oscillating": "{{ 1 == 1 }}", + "direction": "{{ 'forward' }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), ], ) @pytest.mark.usefixtures("setup_named_fan") @@ -707,6 +759,14 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_OFF, None, None, None], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'unavailable' }}", + **OPTIMISTIC_ON_OFF_ACTIONS, + }, + [STATE_OFF, None, None, None], + ), ( ConfigurationStyle.LEGACY, { @@ -733,6 +793,19 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_ON, 0, None, None], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'unavailable' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'unavailable' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 0, None, None], + ), ( ConfigurationStyle.LEGACY, { @@ -759,6 +832,19 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_ON, 66, True, DIRECTION_FORWARD], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + "percentage": "{{ 66 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 1 == 1 }}", + **OSCILLATE_ACTION, + "direction": "{{ 'forward' }}", + **DIRECTION_ACTION, + }, + [STATE_ON, 66, True, DIRECTION_FORWARD], + ), ( ConfigurationStyle.LEGACY, { @@ -785,6 +871,19 @@ async def test_availability_template_with_entities(hass: HomeAssistant) -> None: }, [STATE_OFF, 0, None, None], ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'abc' }}", + "percentage": "{{ 0 }}", + **OPTIMISTIC_PERCENTAGE_CONFIG, + "oscillating": "{{ 'xyz' }}", + **OSCILLATE_ACTION, + "direction": "{{ 'right' }}", + **DIRECTION_ACTION, + }, + [STATE_OFF, 0, None, None], + ), ], ) @pytest.mark.usefixtures("setup_named_fan") @@ -821,16 +920,33 @@ async def test_template_with_unavailable_entities(hass: HomeAssistant, states) - "turn_off": {"service": "script.fan_off"}, }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + "availability": "{{ x - 12 }}", + "preset_mode": ("{{ states('input_select.preset_mode') }}"), + "oscillating": "{{ states('input_select.osc') }}", + "direction": "{{ states('input_select.direction') }}", + "turn_on": {"service": "script.fan_on"}, + "turn_off": {"service": "script.fan_off"}, + }, + ), ], ) @pytest.mark.usefixtures("setup_named_fan") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("fan.test_fan").state != STATE_UNAVAILABLE - assert "TemplateError" in caplog_setup_text - assert "x" in caplog_setup_text + # Ensure trigger entities update. + hass.states.async_set(_STATE_INPUT_BOOLEAN, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + + err = "'x' is undefined" + assert err in caplog_setup_text or err in caplog.text @pytest.mark.parametrize(("count", "extra_config"), [(1, OPTIMISTIC_ON_OFF_ACTIONS)]) @@ -849,6 +965,12 @@ async def test_invalid_availability_template_keeps_component_available( "state": "{{ 'off' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'off' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -899,6 +1021,12 @@ async def test_on_off(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "state": "{{ 'off' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'off' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -981,6 +1109,12 @@ async def test_on_with_extra_attributes( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1008,6 +1142,12 @@ async def test_set_invalid_direction_from_initial_stage(hass: HomeAssistant) -> "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1045,6 +1185,12 @@ async def test_set_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> None: "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1082,6 +1228,12 @@ async def test_set_direction(hass: HomeAssistant, calls: list[ServiceCall]) -> N "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1117,6 +1269,12 @@ async def test_set_invalid_direction( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1154,6 +1312,12 @@ async def test_preset_modes(hass: HomeAssistant, calls: list[ServiceCall]) -> No "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1198,6 +1362,12 @@ async def test_set_percentage(hass: HomeAssistant, calls: list[ServiceCall]) -> "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1236,7 +1406,7 @@ async def test_increase_decrease_speed( ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_named_fan") async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) -> None: @@ -1307,7 +1477,8 @@ async def test_optimistic_state(hass: HomeAssistant, calls: list[ServiceCall]) - @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - "style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN] + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("extra_config", "attribute", "action", "verify_attr", "coro", "value"), @@ -1383,6 +1554,12 @@ async def test_optimistic_attributes( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1420,6 +1597,12 @@ async def test_increase_decrease_speed_default_speed_count( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1451,6 +1634,12 @@ async def test_set_invalid_osc_from_initial_state( "state": "{{ 'on' }}", }, ), + ( + ConfigurationStyle.TRIGGER, + { + "state": "{{ 'on' }}", + }, + ), ], ) @pytest.mark.usefixtures("setup_test_fan_with_extra_config") @@ -1474,24 +1663,37 @@ async def test_set_invalid_osc(hass: HomeAssistant, calls: list[ServiceCall]) -> [ ( { - "test_template_cover_01": UNIQUE_ID_CONFIG, - "test_template_cover_02": UNIQUE_ID_CONFIG, + "test_template_fan_01": UNIQUE_ID_CONFIG, + "test_template_fan_02": UNIQUE_ID_CONFIG, }, ConfigurationStyle.LEGACY, ), ( [ { - "name": "test_template_cover_01", + "name": "test_template_fan_01", **UNIQUE_ID_CONFIG, }, { - "name": "test_template_cover_02", + "name": "test_template_fan_02", **UNIQUE_ID_CONFIG, }, ], ConfigurationStyle.MODERN, ), + ( + [ + { + "name": "test_template_fan_01", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_fan_02", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) @pytest.mark.usefixtures("setup_fan") @@ -1506,7 +1708,7 @@ async def test_unique_id(hass: HomeAssistant) -> None: ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("fan_config", "percentage_step"), @@ -1529,7 +1731,7 @@ async def test_speed_percentage_step(hass: HomeAssistant, percentage_step) -> No ) @pytest.mark.parametrize( "style", - [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.usefixtures("setup_named_fan") async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: @@ -1541,25 +1743,12 @@ async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: assert attributes.get("supported_features") & FanEntityFeature.PRESET_MODE -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("style", "fan_config"), - [ - ( - ConfigurationStyle.LEGACY, - { - "turn_on": [], - "turn_off": [], - }, - ), - ( - ConfigurationStyle.MODERN, - { - "turn_on": [], - "turn_off": [], - }, - ), - ], + ("count", "fan_config"), [(1, {"turn_on": [], "turn_off": []})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( ("extra_config", "supported_features"), @@ -1590,10 +1779,10 @@ async def test_preset_mode_supported_features(hass: HomeAssistant) -> None: ), ], ) +@pytest.mark.usefixtures("setup_test_fan_with_extra_config") async def test_empty_action_config( hass: HomeAssistant, supported_features: FanEntityFeature, - setup_test_fan_with_extra_config, ) -> None: """Test configuration with empty script.""" state = hass.states.get(TEST_ENTITY_ID) From 7f99cd2d2be47424f348e5ec3b110a7daa3190e7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Jun 2025 15:45:33 -0400 Subject: [PATCH 0572/1664] Clean up start_subentry_reconfigure_flow API for tests (#147381) --- tests/common.py | 3 +- .../kitchen_sink/test_config_flow.py | 4 +-- tests/components/mqtt/test_config_flow.py | 32 +++++-------------- tests/test_config_entries.py | 8 ++--- 4 files changed, 14 insertions(+), 33 deletions(-) diff --git a/tests/common.py b/tests/common.py index d184d2b46fb..40d6e4d79d3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1184,7 +1184,6 @@ class MockConfigEntry(config_entries.ConfigEntry): async def start_subentry_reconfigure_flow( self, hass: HomeAssistant, - subentry_flow_type: str, subentry_id: str, *, show_advanced_options: bool = False, @@ -1194,6 +1193,8 @@ class MockConfigEntry(config_entries.ConfigEntry): raise ValueError( "Config entry must be added to hass to start reconfiguration flow" ) + # Derive subentry_flow_type from the subentry_id + subentry_flow_type = self.subentries[subentry_id].subentry_type return await hass.config_entries.subentries.async_init( (self.entry_id, subentry_flow_type), context={ diff --git a/tests/components/kitchen_sink/test_config_flow.py b/tests/components/kitchen_sink/test_config_flow.py index 88bacc2cb0b..bc85edc592d 100644 --- a/tests/components/kitchen_sink/test_config_flow.py +++ b/tests/components/kitchen_sink/test_config_flow.py @@ -171,9 +171,7 @@ async def test_subentry_reconfigure_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - result = await config_entry.start_subentry_reconfigure_flow( - hass, "entity", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure_sensor" diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e30aa5d50d6..a139f729cd9 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3309,9 +3309,7 @@ async def test_subentry_reconfigure_remove_entity( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3433,9 +3431,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3651,9 +3647,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3795,9 +3789,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -3924,9 +3916,7 @@ async def test_subentry_reconfigure_add_entity( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -4023,9 +4013,7 @@ async def test_subentry_reconfigure_update_device_properties( subentry_id: str subentry: ConfigSubentry subentry_id, subentry = next(iter(config_entry.subentries.items())) - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -4124,9 +4112,7 @@ async def test_subentry_reconfigure_availablity( } assert subentry.data.get("availability") == expected_availability - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" @@ -4174,9 +4160,7 @@ async def test_subentry_reconfigure_availablity( assert subentry.data.get("availability") == expected_availability # Assert we can reset the availability config - result = await config_entry.start_subentry_reconfigure_flow( - hass, "device", subentry_id - ) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) assert result["type"] is FlowResultType.MENU assert result["step_id"] == "summary_menu" result = await hass.config_entries.subentries.async_configure( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 55b8434160e..45bb956b7a1 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -6497,9 +6497,7 @@ async def test_update_subentry_and_abort( err: Exception with mock_config_flow("comp", TestFlow): try: - result = await entry.start_subentry_reconfigure_flow( - hass, "test", subentry_id - ) + result = await entry.start_subentry_reconfigure_flow(hass, subentry_id) except Exception as ex: # noqa: BLE001 err = ex @@ -6556,7 +6554,7 @@ async def test_reconfigure_subentry_create_subentry(hass: HomeAssistant) -> None mock_config_flow("comp", TestFlow), pytest.raises(ValueError, match="Source is reconfigure, expected user"), ): - await entry.start_subentry_reconfigure_flow(hass, "test", subentry_id) + await entry.start_subentry_reconfigure_flow(hass, subentry_id) await hass.async_block_till_done() @@ -8079,7 +8077,7 @@ 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, "test", subentry_id) + 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" From 8b6205be25784792e819ad842699e07d976b2483 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:46:51 +0200 Subject: [PATCH 0573/1664] Remove JuiceNet integration (#147206) --- CODEOWNERS | 2 - homeassistant/components/juicenet/__init__.py | 103 +++----------- .../components/juicenet/config_flow.py | 73 +--------- homeassistant/components/juicenet/const.py | 3 - .../components/juicenet/coordinator.py | 33 ----- homeassistant/components/juicenet/device.py | 21 --- homeassistant/components/juicenet/entity.py | 32 ----- .../components/juicenet/manifest.json | 7 +- homeassistant/components/juicenet/number.py | 93 ------------ homeassistant/components/juicenet/sensor.py | 124 ---------------- .../components/juicenet/strings.json | 41 +----- homeassistant/components/juicenet/switch.py | 53 ------- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/juicenet/test_config_flow.py | 134 ------------------ tests/components/juicenet/test_init.py | 50 +++++++ 18 files changed, 80 insertions(+), 702 deletions(-) delete mode 100644 homeassistant/components/juicenet/coordinator.py delete mode 100644 homeassistant/components/juicenet/device.py delete mode 100644 homeassistant/components/juicenet/entity.py delete mode 100644 homeassistant/components/juicenet/number.py delete mode 100644 homeassistant/components/juicenet/sensor.py delete mode 100644 homeassistant/components/juicenet/switch.py delete mode 100644 tests/components/juicenet/test_config_flow.py create mode 100644 tests/components/juicenet/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9f312c77b1e..c019f75c57a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -788,8 +788,6 @@ build.json @home-assistant/supervisor /tests/components/jellyfin/ @RunC0deRun @ctalkington /homeassistant/components/jewish_calendar/ @tsvi /tests/components/jewish_calendar/ @tsvi -/homeassistant/components/juicenet/ @jesserockz -/tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen /homeassistant/components/jvc_projector/ @SteveEasley @msavazzi diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 6cfdd85c6b7..5d2c10bcd1c 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -1,95 +1,36 @@ """The JuiceNet integration.""" -import logging - -import aiohttp -from pyjuicenet import Api, TokenError -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, Platform +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import 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 homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH] - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the JuiceNet component.""" - conf = config.get(DOMAIN) - hass.data.setdefault(DOMAIN, {}) - - if not conf: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - ) - return True +from .const import DOMAIN async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up JuiceNet from a config entry.""" - config = entry.data - - session = async_get_clientsession(hass) - - access_token = config[CONF_ACCESS_TOKEN] - api = Api(access_token, session) - - juicenet = JuiceNetApi(api) - - try: - await juicenet.setup() - except TokenError as error: - _LOGGER.error("JuiceNet Error %s", error) - return False - except aiohttp.ClientError as error: - _LOGGER.error("Could not reach the JuiceNet API %s", error) - raise ConfigEntryNotReady from error - - if not juicenet.devices: - _LOGGER.error("No JuiceNet devices found for this account") - return False - _LOGGER.debug("%d JuiceNet device(s) found", len(juicenet.devices)) - - coordinator = JuiceNetCoordinator(hass, entry, juicenet) - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = { - JUICENET_API: juicenet, - JUICENET_COORDINATOR: coordinator, - } - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - + ir.async_create_issue( + hass, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/juicenet", + }, + ) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) + + return True diff --git a/homeassistant/components/juicenet/config_flow.py b/homeassistant/components/juicenet/config_flow.py index 8bcee5677e6..a5da1c50486 100644 --- a/homeassistant/components/juicenet/config_flow.py +++ b/homeassistant/components/juicenet/config_flow.py @@ -1,82 +1,11 @@ """Config flow for JuiceNet integration.""" -import logging -from typing import Any - -import aiohttp -from pyjuicenet import Api, TokenError -import voluptuous as vol - -from homeassistant import core, exceptions -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigFlow from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}) - - -async def validate_input(hass: core.HomeAssistant, data): - """Validate the user input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - session = async_get_clientsession(hass) - juicenet = Api(data[CONF_ACCESS_TOKEN], session) - - try: - await juicenet.get_devices() - except TokenError as error: - _LOGGER.error("Token Error %s", error) - raise InvalidAuth from error - except aiohttp.ClientError as error: - _LOGGER.error("Error connecting %s", error) - raise CannotConnect from error - - # Return info that you want to store in the config entry. - return {"title": "JuiceNet"} - class JuiceNetConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for JuiceNet.""" VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: - await self.async_set_unique_id(user_input[CONF_ACCESS_TOKEN]) - self._abort_if_unique_id_configured() - - try: - info = await validate_input(self.hass, user_input) - return self.async_create_entry(title=info["title"], data=user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) - - async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: - """Handle import.""" - return await self.async_step_user(import_data) - - -class CannotConnect(exceptions.HomeAssistantError): - """Error to indicate we cannot connect.""" - - -class InvalidAuth(exceptions.HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/juicenet/const.py b/homeassistant/components/juicenet/const.py index 5dc3e5c3e27..a1072dffb87 100644 --- a/homeassistant/components/juicenet/const.py +++ b/homeassistant/components/juicenet/const.py @@ -1,6 +1,3 @@ """Constants used by the JuiceNet component.""" DOMAIN = "juicenet" - -JUICENET_API = "juicenet_api" -JUICENET_COORDINATOR = "juicenet_coordinator" diff --git a/homeassistant/components/juicenet/coordinator.py b/homeassistant/components/juicenet/coordinator.py deleted file mode 100644 index 7a89416e400..00000000000 --- a/homeassistant/components/juicenet/coordinator.py +++ /dev/null @@ -1,33 +0,0 @@ -"""The JuiceNet integration.""" - -from datetime import timedelta -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from .device import JuiceNetApi - -_LOGGER = logging.getLogger(__name__) - - -class JuiceNetCoordinator(DataUpdateCoordinator[None]): - """Coordinator for JuiceNet.""" - - def __init__( - self, hass: HomeAssistant, entry: ConfigEntry, juicenet_api: JuiceNetApi - ) -> None: - """Initialize the JuiceNet coordinator.""" - super().__init__( - hass, - _LOGGER, - config_entry=entry, - name="JuiceNet", - update_interval=timedelta(seconds=30), - ) - self.juicenet_api = juicenet_api - - async def _async_update_data(self) -> None: - for device in self.juicenet_api.devices: - await device.update_state(True) diff --git a/homeassistant/components/juicenet/device.py b/homeassistant/components/juicenet/device.py deleted file mode 100644 index b38b0efd68a..00000000000 --- a/homeassistant/components/juicenet/device.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Adapter to wrap the pyjuicenet api for home assistant.""" - -from pyjuicenet import Api, Charger - - -class JuiceNetApi: - """Represent a connection to JuiceNet.""" - - def __init__(self, api: Api) -> None: - """Create an object from the provided API instance.""" - self.api = api - self._devices: list[Charger] = [] - - async def setup(self) -> None: - """JuiceNet device setup.""" - self._devices = await self.api.get_devices() - - @property - def devices(self) -> list[Charger]: - """Get a list of devices managed by this account.""" - return self._devices diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py deleted file mode 100644 index d54ccb5accb..00000000000 --- a/homeassistant/components/juicenet/entity.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Adapter to wrap the pyjuicenet api for home assistant.""" - -from pyjuicenet import Charger - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN -from .coordinator import JuiceNetCoordinator - - -class JuiceNetEntity(CoordinatorEntity[JuiceNetCoordinator]): - """Represent a base JuiceNet device.""" - - _attr_has_entity_name = True - - def __init__( - self, device: Charger, key: str, coordinator: JuiceNetCoordinator - ) -> None: - """Initialise the sensor.""" - super().__init__(coordinator) - self.device = device - self.key = key - self._attr_unique_id = f"{device.id}-{key}" - self._attr_device_info = DeviceInfo( - configuration_url=( - f"https://home.juice.net/Portal/Details?unitID={device.id}" - ), - identifiers={(DOMAIN, device.id)}, - manufacturer="JuiceNet", - name=device.name, - ) diff --git a/homeassistant/components/juicenet/manifest.json b/homeassistant/components/juicenet/manifest.json index 979e540af01..5bdad83ac1e 100644 --- a/homeassistant/components/juicenet/manifest.json +++ b/homeassistant/components/juicenet/manifest.json @@ -1,10 +1,9 @@ { "domain": "juicenet", "name": "JuiceNet", - "codeowners": ["@jesserockz"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/juicenet", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pyjuicenet"], - "requirements": ["python-juicenet==1.1.0"] + "requirements": [] } diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py deleted file mode 100644 index ff8c357a115..00000000000 --- a/homeassistant/components/juicenet/number.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Support for controlling juicenet/juicepoint/juicebox based EVSE numbers.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from pyjuicenet import Charger - -from homeassistant.components.number import ( - DEFAULT_MAX_VALUE, - NumberEntity, - NumberEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi -from .entity import JuiceNetEntity - - -@dataclass(frozen=True, kw_only=True) -class JuiceNetNumberEntityDescription(NumberEntityDescription): - """An entity description for a JuiceNetNumber.""" - - setter_key: str - native_max_value_key: str | None = None - - -NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( - JuiceNetNumberEntityDescription( - translation_key="amperage_limit", - key="current_charging_amperage_limit", - native_min_value=6, - native_max_value_key="max_charging_amperage", - native_step=1, - setter_key="set_charging_amperage_limit", - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the JuiceNet Numbers.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: JuiceNetApi = juicenet_data[JUICENET_API] - coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] - - entities = [ - JuiceNetNumber(device, description, coordinator) - for device in api.devices - for description in NUMBER_TYPES - ] - async_add_entities(entities) - - -class JuiceNetNumber(JuiceNetEntity, NumberEntity): - """Implementation of a JuiceNet number.""" - - entity_description: JuiceNetNumberEntityDescription - - def __init__( - self, - device: Charger, - description: JuiceNetNumberEntityDescription, - coordinator: JuiceNetCoordinator, - ) -> None: - """Initialise the number.""" - super().__init__(device, description.key, coordinator) - self.entity_description = description - - @property - def native_value(self) -> float | None: - """Return the value of the entity.""" - return getattr(self.device, self.entity_description.key, None) - - @property - def native_max_value(self) -> float: - """Return the maximum value.""" - if self.entity_description.native_max_value_key is not None: - return getattr(self.device, self.entity_description.native_max_value_key) - if self.entity_description.native_max_value is not None: - return self.entity_description.native_max_value - return DEFAULT_MAX_VALUE - - async def async_set_native_value(self, value: float) -> None: - """Update the current value.""" - await getattr(self.device, self.entity_description.setter_key)(value) diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py deleted file mode 100644 index e3ae35da2ce..00000000000 --- a/homeassistant/components/juicenet/sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" - -from __future__ import annotations - -from pyjuicenet import Charger - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - UnitOfElectricCurrent, - UnitOfElectricPotential, - UnitOfEnergy, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi -from .entity import JuiceNetEntity - -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( - key="status", - name="Charging Status", - ), - SensorEntityDescription( - key="temperature", - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="voltage", - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - device_class=SensorDeviceClass.VOLTAGE, - ), - SensorEntityDescription( - key="amps", - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="watts", - native_unit_of_measurement=UnitOfPower.WATT, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - ), - SensorEntityDescription( - key="charge_time", - translation_key="charge_time", - native_unit_of_measurement=UnitOfTime.SECONDS, - icon="mdi:timer-outline", - ), - SensorEntityDescription( - key="energy_added", - translation_key="energy_added", - native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), -) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the JuiceNet Sensors.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: JuiceNetApi = juicenet_data[JUICENET_API] - coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] - - entities = [ - JuiceNetSensorDevice(device, coordinator, description) - for device in api.devices - for description in SENSOR_TYPES - ] - async_add_entities(entities) - - -class JuiceNetSensorDevice(JuiceNetEntity, SensorEntity): - """Implementation of a JuiceNet sensor.""" - - def __init__( - self, - device: Charger, - coordinator: JuiceNetCoordinator, - description: SensorEntityDescription, - ) -> None: - """Initialise the sensor.""" - super().__init__(device, description.key, coordinator) - self.entity_description = description - - @property - def icon(self): - """Return the icon of the sensor.""" - icon = None - if self.entity_description.key == "status": - status = self.device.status - if status == "standby": - icon = "mdi:power-plug-off" - elif status == "plugged": - icon = "mdi:power-plug" - elif status == "charging": - icon = "mdi:battery-positive" - else: - icon = self.entity_description.icon - return icon - - @property - def native_value(self): - """Return the state.""" - return getattr(self.device, self.entity_description.key, None) diff --git a/homeassistant/components/juicenet/strings.json b/homeassistant/components/juicenet/strings.json index 0e3732c66d2..6e25130955b 100644 --- a/homeassistant/components/juicenet/strings.json +++ b/homeassistant/components/juicenet/strings.json @@ -1,41 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "api_token": "[%key:common::config_flow::data::api_token%]" - }, - "description": "You will need the API Token from https://home.juice.net/Manage.", - "title": "Connect to JuiceNet" - } - } - }, - "entity": { - "number": { - "amperage_limit": { - "name": "Amperage limit" - } - }, - "sensor": { - "charge_time": { - "name": "Charge time" - }, - "energy_added": { - "name": "Energy added" - } - }, - "switch": { - "charge_now": { - "name": "Charge now" - } + "issues": { + "integration_removed": { + "title": "The JuiceNet integration has been removed", + "description": "Enel X has dropped support for JuiceNet in favor of JuicePass, and the JuiceNet integration has been removed from Home Assistant as it was no longer working.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing JuiceNet integration entries]({entries})." } } } diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py deleted file mode 100644 index e8a16e9da8f..00000000000 --- a/homeassistant/components/juicenet/switch.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Support for monitoring juicenet/juicepoint/juicebox based EVSE switches.""" - -from typing import Any - -from pyjuicenet import Charger - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback - -from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR -from .coordinator import JuiceNetCoordinator -from .device import JuiceNetApi -from .entity import JuiceNetEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddConfigEntryEntitiesCallback, -) -> None: - """Set up the JuiceNet switches.""" - juicenet_data = hass.data[DOMAIN][config_entry.entry_id] - api: JuiceNetApi = juicenet_data[JUICENET_API] - coordinator: JuiceNetCoordinator = juicenet_data[JUICENET_COORDINATOR] - - async_add_entities( - JuiceNetChargeNowSwitch(device, coordinator) for device in api.devices - ) - - -class JuiceNetChargeNowSwitch(JuiceNetEntity, SwitchEntity): - """Implementation of a JuiceNet switch.""" - - _attr_translation_key = "charge_now" - - def __init__(self, device: Charger, coordinator: JuiceNetCoordinator) -> None: - """Initialise the switch.""" - super().__init__(device, "charge_now", coordinator) - - @property - def is_on(self): - """Return true if switch is on.""" - return self.device.override_time != 0 - - async def async_turn_on(self, **kwargs: Any) -> None: - """Charge now.""" - await self.device.set_override(True) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Don't charge now.""" - await self.device.set_override(False) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b9dfefd3327..9a7408ad8b1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -314,7 +314,6 @@ FLOWS = { "izone", "jellyfin", "jewish_calendar", - "juicenet", "justnimbus", "jvc_projector", "kaleidescape", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b3918ac8ded..8b65ebfb66a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3181,12 +3181,6 @@ "config_flow": false, "iot_class": "cloud_push" }, - "juicenet": { - "name": "JuiceNet", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "justnimbus": { "name": "JustNimbus", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index f6de99444c8..8e827b6a4a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2449,9 +2449,6 @@ python-izone==1.2.9 # homeassistant.components.joaoapps_join python-join-api==0.0.9 -# homeassistant.components.juicenet -python-juicenet==1.1.0 - # homeassistant.components.tplink python-kasa[speedups]==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cd601e2613..20390c2ade9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2019,9 +2019,6 @@ python-homewizard-energy==9.1.1 # homeassistant.components.izone python-izone==1.2.9 -# homeassistant.components.juicenet -python-juicenet==1.1.0 - # homeassistant.components.tplink python-kasa[speedups]==0.10.2 diff --git a/tests/components/juicenet/test_config_flow.py b/tests/components/juicenet/test_config_flow.py deleted file mode 100644 index 48d63cd8cd0..00000000000 --- a/tests/components/juicenet/test_config_flow.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Test the JuiceNet config flow.""" - -from unittest.mock import MagicMock, patch - -import aiohttp -from pyjuicenet import TokenError - -from homeassistant import config_entries -from homeassistant.components.juicenet.const import DOMAIN -from homeassistant.const import CONF_ACCESS_TOKEN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - - -def _mock_juicenet_return_value(get_devices=None): - juicenet_mock = MagicMock() - type(juicenet_mock).get_devices = MagicMock(return_value=get_devices) - return juicenet_mock - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - - with ( - patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - return_value=MagicMock(), - ), - patch( - "homeassistant.components.juicenet.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.juicenet.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - await hass.async_block_till_done() - - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "JuiceNet" - assert result2["data"] == {CONF_ACCESS_TOKEN: "access_token"} - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - side_effect=TokenError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - side_effect=aiohttp.ClientError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_catch_unknown_errors(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_ACCESS_TOKEN: "access_token"} - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test that import works as expected.""" - - with ( - patch( - "homeassistant.components.juicenet.config_flow.Api.get_devices", - return_value=MagicMock(), - ), - patch( - "homeassistant.components.juicenet.async_setup", return_value=True - ) as mock_setup, - patch( - "homeassistant.components.juicenet.async_setup_entry", return_value=True - ) as mock_setup_entry, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_ACCESS_TOKEN: "access_token"}, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "JuiceNet" - assert result["data"] == {CONF_ACCESS_TOKEN: "access_token"} - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/juicenet/test_init.py b/tests/components/juicenet/test_init.py new file mode 100644 index 00000000000..8896798abe3 --- /dev/null +++ b/tests/components/juicenet/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the JuiceNet component.""" + +from homeassistant.components.juicenet import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_juicenet_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the JuiceNet configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None From dc948e3b6c12775c3e1c455410f6304a4740c649 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 24 Jun 2025 04:22:00 +0800 Subject: [PATCH 0574/1664] Add strict typing for Telegram bot integration (#147262) add strict typing --- .strict-typing | 1 + homeassistant/components/telegram_bot/bot.py | 139 ++++++++++++------ .../components/telegram_bot/polling.py | 10 +- .../components/telegram_bot/webhooks.py | 28 ++-- mypy.ini | 10 ++ 5 files changed, 128 insertions(+), 60 deletions(-) diff --git a/.strict-typing b/.strict-typing index 68d67ae85b2..a76ba3885bc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -503,6 +503,7 @@ homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.technove.* homeassistant.components.tedee.* +homeassistant.components.telegram_bot.* homeassistant.components.text.* homeassistant.components.thethingsnetwork.* homeassistant.components.threshold.* diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index f313972635f..9debc7bbbf1 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -2,8 +2,10 @@ from abc import abstractmethod import asyncio +from collections.abc import Callable, Sequence import io import logging +from ssl import SSLContext from types import MappingProxyType from typing import Any @@ -13,6 +15,7 @@ from telegram import ( CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, + InputPollOption, Message, ReplyKeyboardMarkup, ReplyKeyboardRemove, @@ -262,7 +265,9 @@ class TelegramNotificationService: return allowed_chat_ids - def _get_msg_ids(self, msg_data, chat_id): + def _get_msg_ids( + self, msg_data: dict[str, Any], chat_id: int + ) -> tuple[Any | None, int | None]: """Get the message id to edit. This can be one of (message_id, inline_message_id) from a msg dict, @@ -270,7 +275,8 @@ class TelegramNotificationService: **You can use 'last' as message_id** to edit the message last sent in the chat_id. """ - message_id = inline_message_id = None + message_id: Any | None = None + inline_message_id: int | None = None if ATTR_MESSAGEID in msg_data: message_id = msg_data[ATTR_MESSAGEID] if ( @@ -283,7 +289,7 @@ class TelegramNotificationService: inline_message_id = msg_data["inline_message_id"] return message_id, inline_message_id - def _get_target_chat_ids(self, target): + def _get_target_chat_ids(self, target: Any) -> list[int]: """Validate chat_id targets or return default target (first). :param target: optional list of integers ([12234, -12345]) @@ -302,10 +308,10 @@ class TelegramNotificationService: ) return [default_user] - def _get_msg_kwargs(self, data): + def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" - def _make_row_inline_keyboard(row_keyboard): + def _make_row_inline_keyboard(row_keyboard: Any) -> list[InlineKeyboardButton]: """Make a list of InlineKeyboardButtons. It can accept: @@ -350,7 +356,7 @@ class TelegramNotificationService: return buttons # Defaults - params = { + params: dict[str, Any] = { ATTR_PARSER: self.parse_mode, ATTR_DISABLE_NOTIF: False, ATTR_DISABLE_WEB_PREV: None, @@ -399,8 +405,14 @@ class TelegramNotificationService: return params async def _send_msg( - self, func_send, msg_error, message_tag, *args_msg, context=None, **kwargs_msg - ): + self, + func_send: Callable, + msg_error: str, + message_tag: str | None, + *args_msg: Any, + context: Context | None = None, + **kwargs_msg: Any, + ) -> Any: """Send one message.""" try: out = await func_send(*args_msg, **kwargs_msg) @@ -438,7 +450,13 @@ class TelegramNotificationService: return None return out - async def send_message(self, message="", target=None, context=None, **kwargs): + async def send_message( + self, + message: str = "", + target: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> dict[int, int]: """Send a message to one or multiple pre-allowed chat IDs.""" title = kwargs.get(ATTR_TITLE) text = f"{title}\n{message}" if title else message @@ -465,12 +483,17 @@ class TelegramNotificationService: msg_ids[chat_id] = msg.id return msg_ids - async def delete_message(self, chat_id=None, context=None, **kwargs): + async def delete_message( + self, + chat_id: int | None = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> bool: """Delete a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) - deleted = await self._send_msg( + deleted: bool = await self._send_msg( self.bot.delete_message, "Error deleting message", None, @@ -484,7 +507,13 @@ class TelegramNotificationService: self._last_message_id[chat_id] -= 1 return deleted - async def edit_message(self, type_edit, chat_id=None, context=None, **kwargs): + async def edit_message( + self, + type_edit: str, + chat_id: int | None = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> Any: """Edit a previously sent message.""" chat_id = self._get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) @@ -542,8 +571,13 @@ class TelegramNotificationService: ) async def answer_callback_query( - self, message, callback_query_id, show_alert=False, context=None, **kwargs - ): + self, + message: str | None, + callback_query_id: str, + show_alert: bool = False, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> None: """Answer a callback originated with a press in an inline keyboard.""" params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -564,16 +598,20 @@ class TelegramNotificationService: ) async def send_file( - self, file_type=SERVICE_SEND_PHOTO, target=None, context=None, **kwargs - ): + self, + file_type: str, + target: Any = None, + context: Context | None = None, + **kwargs: Any, + ) -> dict[int, int]: """Send a photo, sticker, video, or document.""" params = self._get_msg_kwargs(kwargs) file_content = await load_data( self.hass, url=kwargs.get(ATTR_URL), filepath=kwargs.get(ATTR_FILE), - username=kwargs.get(ATTR_USERNAME), - password=kwargs.get(ATTR_PASSWORD), + username=kwargs.get(ATTR_USERNAME, ""), + password=kwargs.get(ATTR_PASSWORD, ""), authentication=kwargs.get(ATTR_AUTHENTICATION), verify_ssl=( get_default_context() @@ -690,7 +728,12 @@ class TelegramNotificationService: return msg_ids - async def send_sticker(self, target=None, context=None, **kwargs) -> dict: + async def send_sticker( + self, + target: Any = None, + context: Context | None = None, + **kwargs: Any, + ) -> dict[int, int]: """Send a sticker from a telegram sticker pack.""" params = self._get_msg_kwargs(kwargs) stickerid = kwargs.get(ATTR_STICKER_ID) @@ -713,11 +756,16 @@ class TelegramNotificationService: ) msg_ids[chat_id] = msg.id return msg_ids - return await self.send_file(SERVICE_SEND_STICKER, target, **kwargs) + return await self.send_file(SERVICE_SEND_STICKER, target, context, **kwargs) async def send_location( - self, latitude, longitude, target=None, context=None, **kwargs - ): + self, + latitude: Any, + longitude: Any, + target: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> dict[int, int]: """Send a location.""" latitude = float(latitude) longitude = float(longitude) @@ -745,14 +793,14 @@ class TelegramNotificationService: async def send_poll( self, - question, - options, - is_anonymous, - allows_multiple_answers, - target=None, - context=None, - **kwargs, - ): + question: str, + options: Sequence[str | InputPollOption], + is_anonymous: bool | None, + allows_multiple_answers: bool | None, + target: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> dict[int, int]: """Send a poll.""" params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) @@ -778,7 +826,12 @@ class TelegramNotificationService: msg_ids[chat_id] = msg.id return msg_ids - async def leave_chat(self, chat_id=None, context=None, **kwargs): + async def leave_chat( + self, + chat_id: Any = None, + context: Context | None = None, + **kwargs: dict[str, Any], + ) -> Any: """Remove bot from chat.""" chat_id = self._get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) @@ -792,7 +845,7 @@ class TelegramNotificationService: reaction: str, is_big: bool = False, context: Context | None = None, - **kwargs, + **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" chat_id = self._get_target_chat_ids(chat_id)[0] @@ -878,19 +931,19 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> async def load_data( hass: HomeAssistant, - url=None, - filepath=None, - username=None, - password=None, - authentication=None, - num_retries=5, - verify_ssl=None, -): + url: str | None, + filepath: str | None, + username: str, + password: str, + authentication: str | None, + verify_ssl: SSLContext, + num_retries: int = 5, +) -> io.BytesIO: """Load data into ByteIO/File container from a source.""" if url is not None: # Load data from URL params: dict[str, Any] = {} - headers = {} + headers: dict[str, str] = {} _validate_credentials_input(authentication, username, password) if authentication == HTTP_BEARER_AUTHENTICATION: headers = {"Authorization": f"Bearer {password}"} @@ -963,7 +1016,7 @@ def _validate_credentials_input( ) -> None: if ( authentication in (HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION) - and username is None + and not username ): raise ServiceValidationError( "Username is required.", @@ -979,7 +1032,7 @@ def _validate_credentials_input( HTTP_BEARER_AUTHENTICATION, HTTP_BEARER_AUTHENTICATION, ) - and password is None + and not password ): raise ServiceValidationError( "Password is required.", diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index f9e69080939..6c38a0e53b8 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -64,16 +64,18 @@ class PollBot(BaseTelegramBot): """Shutdown the app.""" await self.stop_polling() - async def start_polling(self, event=None): + async def start_polling(self) -> None: """Start the polling task.""" _LOGGER.debug("Starting polling") await self.application.initialize() - await self.application.updater.start_polling(error_callback=error_callback) + if self.application.updater: + await self.application.updater.start_polling(error_callback=error_callback) await self.application.start() - async def stop_polling(self, event=None): + async def stop_polling(self) -> None: """Stop the polling task.""" _LOGGER.debug("Stopping polling") - await self.application.updater.stop() + if self.application.updater: + await self.application.updater.stop() await self.application.stop() await self.application.shutdown() diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 9218bcbcd67..0bfad34681a 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -6,11 +6,12 @@ import logging import secrets import string +from aiohttp.web_response import Response from telegram import Bot, Update from telegram.error import NetworkError, TelegramError -from telegram.ext import ApplicationBuilder, TypeHandler +from telegram.ext import Application, ApplicationBuilder, TypeHandler -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import HomeAssistantRequest, HomeAssistantView from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -87,7 +88,7 @@ class PushBot(BaseTelegramBot): """Shutdown the app.""" await self.stop_application() - async def _try_to_set_webhook(self): + async def _try_to_set_webhook(self) -> bool: _LOGGER.debug("Registering webhook URL: %s", self.webhook_url) retry_num = 0 while retry_num < 3: @@ -103,12 +104,12 @@ class PushBot(BaseTelegramBot): return False - async def start_application(self): + async def start_application(self) -> None: """Handle starting the Application object.""" await self.application.initialize() await self.application.start() - async def register_webhook(self): + async def register_webhook(self) -> bool: """Query telegram and register the URL for our webhook.""" current_status = await self.bot.get_webhook_info() # Some logging of Bot current status: @@ -123,13 +124,13 @@ class PushBot(BaseTelegramBot): return True - async def stop_application(self, event=None): + async def stop_application(self) -> None: """Handle gracefully stopping the Application object.""" await self.deregister_webhook() await self.application.stop() await self.application.shutdown() - async def deregister_webhook(self): + async def deregister_webhook(self) -> None: """Query telegram and deregister the URL for our webhook.""" _LOGGER.debug("Deregistering webhook URL") try: @@ -149,7 +150,7 @@ class PushBotView(HomeAssistantView): self, hass: HomeAssistant, bot: Bot, - application, + application: Application, trusted_networks: list[IPv4Network], secret_token: str, ) -> None: @@ -160,15 +161,16 @@ class PushBotView(HomeAssistantView): self.trusted_networks = trusted_networks self.secret_token = secret_token - async def post(self, request): + async def post(self, request: HomeAssistantRequest) -> Response | None: """Accept the POST from telegram.""" - real_ip = ip_address(request.remote) - if not any(real_ip in net for net in self.trusted_networks): - _LOGGER.warning("Access denied from %s", real_ip) + if not request.remote or not any( + ip_address(request.remote) in net for net in self.trusted_networks + ): + _LOGGER.warning("Access denied from %s", request.remote) return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) secret_token_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token") if secret_token_header is None or self.secret_token != secret_token_header: - _LOGGER.warning("Invalid secret token from %s", real_ip) + _LOGGER.warning("Invalid secret token from %s", request.remote) return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) try: diff --git a/mypy.ini b/mypy.ini index 72e52b67959..a6b673be03b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4788,6 +4788,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.telegram_bot.*] +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.text.*] check_untyped_defs = true disallow_incomplete_defs = true From 9b915e996b55b15e9334be5f175169758a89701c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Mon, 23 Jun 2025 22:40:46 +0200 Subject: [PATCH 0575/1664] Refactor states and strings for Miele plate power steps (#144992) * WIP * Fix type check * Empty commit --- homeassistant/components/miele/const.py | 27 ++ homeassistant/components/miele/icons.json | 45 +- homeassistant/components/miele/sensor.py | 48 +- homeassistant/components/miele/strings.json | 45 +- .../miele/snapshots/test_sensor.ambr | 450 ++++++++---------- 5 files changed, 288 insertions(+), 327 deletions(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index bda276c6d8a..fd2f8631cd2 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -1294,3 +1294,30 @@ STATE_PROGRAM_ID: dict[int, dict[int, str]] = { MieleAppliance.ROBOT_VACUUM_CLEANER: ROBOT_VACUUM_CLEANER_PROGRAM_ID, MieleAppliance.COFFEE_SYSTEM: COFFEE_SYSTEM_PROGRAM_ID, } + + +class PlatePowerStep(MieleEnum): + """Plate power settings.""" + + plate_step_0 = 0 + plate_step_warming = 110, 220 + plate_step_1 = 1 + plate_step_2 = 2 + plate_step_3 = 3 + plate_step_4 = 4 + plate_step_5 = 5 + plate_step_6 = 6 + plate_step_7 = 7 + plate_step_8 = 8 + plate_step_9 = 9 + plate_step_10 = 10 + plate_step_11 = 11 + plate_step_12 = 12 + plate_step_13 = 13 + plate_step_14 = 4 + plate_step_15 = 15 + plate_step_16 = 16 + plate_step_17 = 17 + plate_step_18 = 18 + plate_step_boost = 117, 118, 218 + missing2none = -9999 diff --git a/homeassistant/components/miele/icons.json b/homeassistant/components/miele/icons.json index 1806fe688d6..44b51a67c24 100644 --- a/homeassistant/components/miele/icons.json +++ b/homeassistant/components/miele/icons.json @@ -56,30 +56,27 @@ "plate": { "default": "mdi:circle-outline", "state": { - "0": "mdi:circle-outline", - "110": "mdi:alpha-w-circle-outline", - "220": "mdi:alpha-w-circle-outline", - "1": "mdi:circle-slice-1", - "2": "mdi:circle-slice-1", - "3": "mdi:circle-slice-2", - "4": "mdi:circle-slice-2", - "5": "mdi:circle-slice-3", - "6": "mdi:circle-slice-3", - "7": "mdi:circle-slice-4", - "8": "mdi:circle-slice-4", - "9": "mdi:circle-slice-5", - "10": "mdi:circle-slice-5", - "11": "mdi:circle-slice-5", - "12": "mdi:circle-slice-6", - "13": "mdi:circle-slice-6", - "14": "mdi:circle-slice-6", - "15": "mdi:circle-slice-7", - "16": "mdi:circle-slice-7", - "17": "mdi:circle-slice-8", - "18": "mdi:circle-slice-8", - "117": "mdi:alpha-b-circle-outline", - "118": "mdi:alpha-b-circle-outline", - "217": "mdi:alpha-b-circle-outline" + "plate_step_0": "mdi:circle-outline", + "plate_step_warming": "mdi:alpha-w-circle-outline", + "plate_step_1": "mdi:circle-slice-1", + "plate_step_2": "mdi:circle-slice-1", + "plate_step_3": "mdi:circle-slice-2", + "plate_step_4": "mdi:circle-slice-2", + "plate_step_5": "mdi:circle-slice-3", + "plate_step_6": "mdi:circle-slice-3", + "plate_step_7": "mdi:circle-slice-4", + "plate_step_8": "mdi:circle-slice-4", + "plate_step_9": "mdi:circle-slice-5", + "plate_step_10": "mdi:circle-slice-5", + "plate_step_11": "mdi:circle-slice-5", + "plate_step_12": "mdi:circle-slice-6", + "plate_step_13": "mdi:circle-slice-6", + "plate_step_14": "mdi:circle-slice-6", + "plate_step_15": "mdi:circle-slice-7", + "plate_step_16": "mdi:circle-slice-7", + "plate_step_17": "mdi:circle-slice-8", + "plate_step_18": "mdi:circle-slice-8", + "plate_step_boost": "mdi:alpha-b-circle-outline" } }, "program_type": { diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index d5085ae606f..ff72b791735 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -33,6 +33,7 @@ from .const import ( STATE_PROGRAM_PHASE, STATE_STATUS_TAGS, MieleAppliance, + PlatePowerStep, StateDryingStep, StateProgramType, StateStatus, @@ -46,34 +47,6 @@ _LOGGER = logging.getLogger(__name__) DISABLED_TEMPERATURE = -32768 -PLATE_POWERS = [ - "0", - "110", - "220", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "12", - "13", - "14", - "15", - "16", - "17", - "18", - "117", - "118", - "217", -] - - DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { @@ -543,8 +516,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_placeholders={"plate_no": str(i)}, zone=i, device_class=SensorDeviceClass.ENUM, - options=PLATE_POWERS, - value_fn=lambda value: value.state_plate_step[0].value_raw, + options=sorted(PlatePowerStep.keys()), + value_fn=lambda value: None, ), ) for i in range(1, 7) @@ -683,12 +656,19 @@ class MielePlateSensor(MieleSensor): def native_value(self) -> StateType: """Return the state of the plate sensor.""" # state_plate_step is [] if all zones are off - plate_power = ( - self.device.state_plate_step[self.entity_description.zone - 1].value_raw + + return ( + PlatePowerStep( + cast( + int, + self.device.state_plate_step[ + self.entity_description.zone - 1 + ].value_raw, + ) + ).name if self.device.state_plate_step - else 0 + else PlatePowerStep.plate_step_0 ) - return str(plate_power) class MieleStatusSensor(MieleSensor): diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json index 94aef8d6d3f..97035da6d5f 100644 --- a/homeassistant/components/miele/strings.json +++ b/homeassistant/components/miele/strings.json @@ -203,30 +203,27 @@ "plate": { "name": "Plate {plate_no}", "state": { - "0": "0", - "110": "Warming", - "220": "[%key:component::miele::entity::sensor::plate::state::110%]", - "1": "1", - "2": "1\u2022", - "3": "2", - "4": "2\u2022", - "5": "3", - "6": "3\u2022", - "7": "4", - "8": "4\u2022", - "9": "5", - "10": "5\u2022", - "11": "6", - "12": "6\u2022", - "13": "7", - "14": "7\u2022", - "15": "8", - "16": "8\u2022", - "17": "9", - "18": "9\u2022", - "117": "Boost", - "118": "[%key:component::miele::entity::sensor::plate::state::117%]", - "217": "[%key:component::miele::entity::sensor::plate::state::117%]" + "power_step_0": "0", + "power_step_warm": "Warming", + "power_step_1": "1", + "power_step_2": "1\u2022", + "power_step_3": "2", + "power_step_4": "2\u2022", + "power_step_5": "3", + "power_step_6": "3\u2022", + "power_step_7": "4", + "power_step_8": "4\u2022", + "power_step_9": "5", + "power_step_10": "5\u2022", + "power_step_11": "6", + "power_step_12": "6\u2022", + "power_step_13": "7", + "power_step_14": "7\u2022", + "power_step_15": "8", + "power_step_16": "8\u2022", + "power_step_17": "9", + "power_step_18": "9\u2022", + "power_step_boost": "Boost" } }, "drying_step": { diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index 6984fcc4c50..b1691c28b19 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -97,30 +97,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -158,30 +154,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 1', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -189,7 +181,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': 'plate_step_0', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_2-entry] @@ -199,30 +191,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -260,30 +248,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 2', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -291,7 +275,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '110', + 'state': 'plate_step_warming', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_3-entry] @@ -301,30 +285,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -362,30 +342,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 3', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -393,7 +369,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '8', + 'state': 'plate_step_8', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_4-entry] @@ -403,30 +379,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -464,30 +436,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 4', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -495,7 +463,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '15', + 'state': 'plate_step_15', }) # --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction_plate_5-entry] @@ -505,30 +473,26 @@ 'area_id': None, 'capabilities': dict({ 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'config_entry_id': , @@ -566,30 +530,26 @@ 'device_class': 'enum', 'friendly_name': 'Hob with extraction Plate 5', 'options': list([ - '0', - '110', - '220', - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '117', - '118', - '217', + 'plate_step_0', + 'plate_step_1', + 'plate_step_10', + 'plate_step_11', + 'plate_step_12', + 'plate_step_13', + 'plate_step_15', + 'plate_step_16', + 'plate_step_17', + 'plate_step_18', + 'plate_step_2', + 'plate_step_3', + 'plate_step_4', + 'plate_step_5', + 'plate_step_6', + 'plate_step_7', + 'plate_step_8', + 'plate_step_9', + 'plate_step_boost', + 'plate_step_warming', ]), }), 'context': , @@ -597,7 +557,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '117', + 'state': 'plate_step_boost', }) # --- # name: test_sensor_states[platforms0][sensor.freezer-entry] From ab0ea753e9f3c5c1942f8bec914e53f40236b72b Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:59:50 +0200 Subject: [PATCH 0576/1664] Optimize Enphase envoy translation strings. (#147389) optimize Enphase envoy translation strings. --- .../components/enphase_envoy/strings.json | 8 +- .../snapshots/test_diagnostics.ambr | 48 ++-- .../enphase_envoy/snapshots/test_sensor.ambr | 256 +++++++++--------- tests/components/enphase_envoy/test_sensor.py | 12 +- 4 files changed, 162 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 577def459f1..36319c71bc6 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -379,7 +379,7 @@ "name": "Aggregated Battery capacity" }, "aggregated_soc": { - "name": "Aggregated battery soc" + "name": "Aggregated battery SOC" }, "dc_voltage": { "name": "DC voltage" @@ -394,13 +394,13 @@ "name": "AC current" }, "lifetime_energy": { - "name": "Lifetime energy produced" + "name": "[%key:component::enphase_envoy::entity::sensor::lifetime_production::name%]" }, "energy_today": { - "name": "Energy produced today" + "name": "[%key:component::enphase_envoy::entity::sensor::daily_production::name%]" }, "energy_produced": { - "name": "Energy produced since previous report" + "name": "Energy production since previous report" }, "max_reported": { "name": "Lifetime maximum power" diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 8eb6fcaac37..7ad45ff51f3 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -606,7 +606,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -620,7 +620,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -647,7 +647,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -658,7 +658,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -723,7 +723,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -734,7 +734,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1485,7 +1485,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1499,7 +1499,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1526,7 +1526,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1537,7 +1537,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -1602,7 +1602,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1613,7 +1613,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2408,7 +2408,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2422,7 +2422,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2449,7 +2449,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2460,7 +2460,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2525,7 +2525,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2536,7 +2536,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3104,7 +3104,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3118,7 +3118,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3145,7 +3145,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3156,7 +3156,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3221,7 +3221,7 @@ 'disabled_by': 'integration', 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -3232,7 +3232,7 @@ }), 'original_device_class': 'energy', 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, diff --git a/tests/components/enphase_envoy/snapshots/test_sensor.ambr b/tests/components/enphase_envoy/snapshots/test_sensor.ambr index 51a596eda18..4a9563ce906 100644 --- a/tests/components/enphase_envoy/snapshots/test_sensor.ambr +++ b/tests/components/enphase_envoy/snapshots/test_sensor.ambr @@ -512,7 +512,7 @@ 'state': '33.793', }) # --- -# name: test_sensor[envoy][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -527,7 +527,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -542,7 +542,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -552,23 +552,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '32.254', }) # --- -# name: test_sensor[envoy][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -583,7 +583,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -598,7 +598,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -608,16 +608,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -785,7 +785,7 @@ 'state': '2025-06-20T23:06:05+00:00', }) # --- -# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -800,7 +800,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -818,7 +818,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -828,16 +828,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2671,7 +2671,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2686,7 +2686,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2701,7 +2701,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2711,23 +2711,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2742,7 +2742,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2757,7 +2757,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2767,16 +2767,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -2944,7 +2944,7 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- -# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -2959,7 +2959,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -2977,7 +2977,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2987,16 +2987,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy_1p_metered][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , @@ -3669,7 +3669,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Aggregated battery soc', + 'original_name': 'Aggregated battery SOC', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -3683,7 +3683,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Envoy 1234 Aggregated battery soc', + 'friendly_name': 'Envoy 1234 Aggregated battery SOC', 'unit_of_measurement': '%', }), 'context': , @@ -8274,7 +8274,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -8289,7 +8289,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -8304,7 +8304,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -8314,23 +8314,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -8345,7 +8345,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -8360,7 +8360,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -8370,16 +8370,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -8547,7 +8547,7 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- -# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -8562,7 +8562,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -8580,7 +8580,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -8590,16 +8590,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy_acb_batt][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , @@ -13503,7 +13503,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13518,7 +13518,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -13533,7 +13533,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -13543,23 +13543,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13574,7 +13574,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -13589,7 +13589,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -13599,16 +13599,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -13776,7 +13776,7 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- -# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -13791,7 +13791,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -13809,7 +13809,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -13819,16 +13819,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy_eu_batt][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , @@ -22642,7 +22642,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -22657,7 +22657,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -22672,7 +22672,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -22682,23 +22682,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -22713,7 +22713,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -22728,7 +22728,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -22738,16 +22738,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -22915,7 +22915,7 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -22930,7 +22930,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -22948,7 +22948,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -22958,16 +22958,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy_metered_batt_relay][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , @@ -29106,7 +29106,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -29121,7 +29121,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -29136,7 +29136,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -29146,23 +29146,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -29177,7 +29177,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -29192,7 +29192,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -29202,16 +29202,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -29379,7 +29379,7 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -29394,7 +29394,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -29412,7 +29412,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -29422,16 +29422,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy_nobatt_metered_3p][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , @@ -30518,7 +30518,7 @@ 'state': 'unknown', }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_since_previous_report-entry] +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_since_previous_report-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -30533,7 +30533,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -30548,7 +30548,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced since previous report', + 'original_name': 'Energy production since previous report', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -30558,23 +30558,23 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_since_previous_report-state] +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_since_previous_report-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced since previous report', + 'friendly_name': 'Inverter 1 Energy production since previous report', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_since_previous_report', + 'entity_id': 'sensor.inverter_1_energy_production_since_previous_report', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'unknown', }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_today-entry] +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_today-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -30589,7 +30589,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -30604,7 +30604,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy produced today', + 'original_name': 'Energy production today', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -30614,16 +30614,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_produced_today-state] +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_energy_production_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Energy produced today', + 'friendly_name': 'Inverter 1 Energy production today', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_energy_produced_today', + 'entity_id': 'sensor.inverter_1_energy_production_today', 'last_changed': , 'last_reported': , 'last_updated': , @@ -30791,7 +30791,7 @@ 'state': '1970-01-01T00:00:01+00:00', }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_produced-entry] +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -30806,7 +30806,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -30824,7 +30824,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Lifetime energy produced', + 'original_name': 'Lifetime energy production', 'platform': 'enphase_envoy', 'previous_unique_id': None, 'suggested_object_id': None, @@ -30834,16 +30834,16 @@ 'unit_of_measurement': , }) # --- -# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_produced-state] +# name: test_sensor[envoy_tot_cons_metered][sensor.inverter_1_lifetime_energy_production-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'energy', - 'friendly_name': 'Inverter 1 Lifetime energy produced', + 'friendly_name': 'Inverter 1 Lifetime energy production', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.inverter_1_lifetime_energy_produced', + 'entity_id': 'sensor.inverter_1_lifetime_energy_production', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/enphase_envoy/test_sensor.py b/tests/components/enphase_envoy/test_sensor.py index 70bf8c99007..a9ee1f370a8 100644 --- a/tests/components/enphase_envoy/test_sensor.py +++ b/tests/components/enphase_envoy/test_sensor.py @@ -806,13 +806,13 @@ async def test_sensor_inverter_detailed_data( assert int(temperature.state) == (inverter.temperature) assert ( lifetime_energy := hass.states.get( - f"{entity_base}_{sn}_lifetime_energy_produced" + f"{entity_base}_{sn}_lifetime_energy_production" ) ) assert float(lifetime_energy.state) == (inverter.lifetime_energy / 1000.0) assert ( energy_produced_today := hass.states.get( - f"{entity_base}_{sn}_energy_produced_today" + f"{entity_base}_{sn}_energy_production_today" ) ) assert int(energy_produced_today.state) == (inverter.energy_today) @@ -824,7 +824,7 @@ async def test_sensor_inverter_detailed_data( assert int(last_report_duration.state) == (inverter.last_report_duration) assert ( energy_produced := hass.states.get( - f"{entity_base}_{sn}_energy_produced_since_previous_report" + f"{entity_base}_{sn}_energy_production_since_previous_report" ) ) assert float(energy_produced.state) == (inverter.energy_produced) @@ -871,10 +871,10 @@ async def test_sensor_inverter_disabled_by_integration( "ac_current", "frequency", "temperature", - "lifetime_energy_produced", - "energy_produced_today", + "lifetime_energy_production", + "energy_production_today", "last_report_duration", - "energy_produced_since_previous_report", + "energy_production_since_previous_report", "last_reported", "lifetime_maximum_power", ) From 95abd69cc6de172ba5a5acfb37749532fbf1077d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Jun 2025 17:12:32 -0400 Subject: [PATCH 0577/1664] Add media class to media player search and play intent (#147097) Co-authored-by: Michael Hansen --- .../components/media_player/intent.py | 23 +++++- tests/components/media_player/test_intent.py | 81 +++++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index c9caa2c4a91..be365694579 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -27,7 +27,12 @@ from . import ( MediaPlayerDeviceClass, SearchMedia, ) -from .const import MediaPlayerEntityFeature, MediaPlayerState +from .const import ( + ATTR_MEDIA_FILTER_CLASSES, + MediaClass, + MediaPlayerEntityFeature, + MediaPlayerState, +) INTENT_MEDIA_PAUSE = "HassMediaPause" INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" @@ -231,6 +236,7 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): intent_type = INTENT_MEDIA_SEARCH_AND_PLAY slot_schema = { vol.Required("search_query"): cv.string, + vol.Optional("media_class"): vol.In([cls.value for cls in MediaClass]), # Optional name/area/floor slots handled by intent matcher vol.Optional("name"): cv.string, vol.Optional("area"): cv.string, @@ -285,14 +291,23 @@ class MediaSearchAndPlayHandler(intent.IntentHandler): target_entity = match_result.states[0] target_entity_id = target_entity.entity_id + # Get media class if provided + media_class_slot = slots.get("media_class", {}) + media_class_value = media_class_slot.get("value") + + # Build search service data + search_data = {"search_query": search_query} + + # Add media_filter_classes if media_class is provided + if media_class_value: + search_data[ATTR_MEDIA_FILTER_CLASSES] = [media_class_value] + # 1. Search Media try: search_response = await hass.services.async_call( DOMAIN, SERVICE_SEARCH_MEDIA, - { - "search_query": search_query, - }, + search_data, target={ "entity_id": target_entity_id, }, diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 4b08aa43158..d1dc03ed12a 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -792,3 +792,84 @@ async def test_search_and_play_media_player_intent(hass: HomeAssistant) -> None: media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, {"search_query": {"value": "error query"}}, ) + + +async def test_search_and_play_media_player_intent_with_media_class( + hass: HomeAssistant, +) -> None: + """Test HassMediaSearchAndPlay intent with media_class parameter.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = { + ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.SEARCH_MEDIA + | MediaPlayerEntityFeature.PLAY_MEDIA + } + hass.states.async_set(entity_id, STATE_IDLE, attributes=attributes) + + # Test successful search and play with media_class filter + search_result_item = BrowseMedia( + title="Test Album", + media_class=MediaClass.ALBUM, + media_content_type=MediaType.ALBUM, + media_content_id="library/album/123", + can_play=True, + can_expand=False, + ) + + # Mock service calls + search_results = [search_result_item] + search_calls = async_mock_service( + hass, + DOMAIN, + SERVICE_SEARCH_MEDIA, + response={entity_id: SearchMedia(result=search_results)}, + ) + play_calls = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + {"search_query": {"value": "test album"}, "media_class": {"value": "album"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + + # Response should contain a "media" slot with the matched item. + assert not response.speech + media = response.speech_slots.get("media") + assert media["title"] == "Test Album" + + assert len(search_calls) == 1 + search_call = search_calls[0] + assert search_call.domain == DOMAIN + assert search_call.service == SERVICE_SEARCH_MEDIA + assert search_call.data == { + "entity_id": entity_id, + "search_query": "test album", + "media_filter_classes": ["album"], + } + + assert len(play_calls) == 1 + play_call = play_calls[0] + assert play_call.domain == DOMAIN + assert play_call.service == SERVICE_PLAY_MEDIA + assert play_call.data == { + "entity_id": entity_id, + "media_content_id": search_result_item.media_content_id, + "media_content_type": search_result_item.media_content_type, + } + + # Test with invalid media_class (should raise validation error) + with pytest.raises(intent.InvalidSlotInfo): + await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_MEDIA_SEARCH_AND_PLAY, + { + "search_query": {"value": "test query"}, + "media_class": {"value": "invalid_class"}, + }, + ) From 646ddf9c2d6144cf70fb6b67eb8ba593aba67f60 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 23 Jun 2025 23:17:43 +0200 Subject: [PATCH 0578/1664] Add sensors to ntfy integration (#145262) * Add sensors * small changes * test coverage * changes * update snapshot --- homeassistant/components/ntfy/__init__.py | 11 +- homeassistant/components/ntfy/coordinator.py | 74 ++ homeassistant/components/ntfy/icons.json | 62 + homeassistant/components/ntfy/notify.py | 5 +- homeassistant/components/ntfy/sensor.py | 272 +++++ homeassistant/components/ntfy/strings.json | 82 ++ .../ntfy/snapshots/test_sensor.ambr | 1029 +++++++++++++++++ tests/components/ntfy/test_init.py | 34 + tests/components/ntfy/test_sensor.py | 42 + 9 files changed, 1603 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/ntfy/coordinator.py create mode 100644 homeassistant/components/ntfy/sensor.py create mode 100644 tests/components/ntfy/snapshots/test_sensor.ambr create mode 100644 tests/components/ntfy/test_sensor.py diff --git a/homeassistant/components/ntfy/__init__.py b/homeassistant/components/ntfy/__init__.py index cd9c35ca4e6..72dbb4d2afb 100644 --- a/homeassistant/components/ntfy/__init__.py +++ b/homeassistant/components/ntfy/__init__.py @@ -12,19 +12,16 @@ from aiontfy.exceptions import ( NtfyUnauthorizedAuthenticationError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.NOTIFY] - - -type NtfyConfigEntry = ConfigEntry[Ntfy] +PLATFORMS: list[Platform] = [Platform.NOTIFY, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool: @@ -59,7 +56,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool translation_key="timeout_error", ) from e - entry.runtime_data = ntfy + coordinator = NtfyDataUpdateCoordinator(hass, entry, ntfy) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/ntfy/coordinator.py b/homeassistant/components/ntfy/coordinator.py new file mode 100644 index 00000000000..a52f1b06f41 --- /dev/null +++ b/homeassistant/components/ntfy/coordinator.py @@ -0,0 +1,74 @@ +"""DataUpdateCoordinator for ntfy integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiontfy import Account as NtfyAccount, Ntfy +from aiontfy.exceptions import ( + NtfyConnectionError, + NtfyHTTPError, + NtfyTimeoutError, + NtfyUnauthorizedAuthenticationError, +) + +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 DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type NtfyConfigEntry = ConfigEntry[NtfyDataUpdateCoordinator] + + +class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]): + """Ntfy data update coordinator.""" + + config_entry: NtfyConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: NtfyConfigEntry, ntfy: Ntfy + ) -> None: + """Initialize the ntfy data update coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + self.ntfy = ntfy + + async def _async_update_data(self) -> NtfyAccount: + """Fetch account data from ntfy.""" + + try: + return await self.ntfy.account() + except NtfyUnauthorizedAuthenticationError as e: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="authentication_error", + ) from e + except NtfyHTTPError as e: + _LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="server_error", + translation_placeholders={"error_msg": str(e.error)}, + ) from e + except NtfyConnectionError as e: + _LOGGER.debug("Error", exc_info=True) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from e + except NtfyTimeoutError as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout_error", + ) from e diff --git a/homeassistant/components/ntfy/icons.json b/homeassistant/components/ntfy/icons.json index 9fe617880af..66489413b5b 100644 --- a/homeassistant/components/ntfy/icons.json +++ b/homeassistant/components/ntfy/icons.json @@ -4,6 +4,68 @@ "publish": { "default": "mdi:console-line" } + }, + "sensor": { + "messages": { + "default": "mdi:message-arrow-right-outline" + }, + "messages_remaining": { + "default": "mdi:message-plus-outline" + }, + "messages_limit": { + "default": "mdi:message-alert-outline" + }, + "messages_expiry_duration": { + "default": "mdi:message-text-clock" + }, + "emails": { + "default": "mdi:email-arrow-right-outline" + }, + "emails_remaining": { + "default": "mdi:email-plus-outline" + }, + "emails_limit": { + "default": "mdi:email-alert-outline" + }, + "calls": { + "default": "mdi:phone-outgoing" + }, + "calls_remaining": { + "default": "mdi:phone-plus" + }, + "calls_limit": { + "default": "mdi:phone-alert" + }, + "reservations": { + "default": "mdi:lock" + }, + "reservations_remaining": { + "default": "mdi:lock-plus" + }, + "reservations_limit": { + "default": "mdi:lock-alert" + }, + "attachment_total_size": { + "default": "mdi:database-arrow-right" + }, + "attachment_total_size_remaining": { + "default": "mdi:database-plus" + }, + "attachment_total_size_limit": { + "default": "mdi:database-alert" + }, + "attachment_expiry_duration": { + "default": "mdi:cloud-clock" + }, + "attachment_file_size": { + "default": "mdi:file-alert" + }, + "attachment_bandwidth": { + "default": "mdi:cloud-upload" + }, + "tier": { + "default": "mdi:star" + } } } } diff --git a/homeassistant/components/ntfy/notify.py b/homeassistant/components/ntfy/notify.py index 7328a1533c2..e10e64caf23 100644 --- a/homeassistant/components/ntfy/notify.py +++ b/homeassistant/components/ntfy/notify.py @@ -22,8 +22,8 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import NtfyConfigEntry from .const import CONF_TOPIC, DOMAIN +from .coordinator import NtfyConfigEntry PARALLEL_UPDATES = 0 @@ -69,9 +69,10 @@ class NtfyNotifyEntity(NotifyEntity): name=subentry.data.get(CONF_NAME, self.topic), configuration_url=URL(config_entry.data[CONF_URL]) / self.topic, identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")}, + via_device=(DOMAIN, config_entry.entry_id), ) self.config_entry = config_entry - self.ntfy = config_entry.runtime_data + self.ntfy = config_entry.runtime_data.ntfy async def async_send_message(self, message: str, title: str | None = None) -> None: """Publish a message to a topic.""" diff --git a/homeassistant/components/ntfy/sensor.py b/homeassistant/components/ntfy/sensor.py new file mode 100644 index 00000000000..0180d9fce72 --- /dev/null +++ b/homeassistant/components/ntfy/sensor.py @@ -0,0 +1,272 @@ +"""Sensor platform for ntfy integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from aiontfy import Account as NtfyAccount +from yarl import URL + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_URL, EntityCategory, UnitOfInformation, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class NtfySensorEntityDescription(SensorEntityDescription): + """Ntfy Sensor Description.""" + + value_fn: Callable[[NtfyAccount], StateType] + + +class NtfySensor(StrEnum): + """Ntfy sensors.""" + + MESSAGES = "messages" + MESSAGES_REMAINING = "messages_remaining" + MESSAGES_LIMIT = "messages_limit" + MESSAGES_EXPIRY_DURATION = "messages_expiry_duration" + EMAILS = "emails" + EMAILS_REMAINING = "emails_remaining" + EMAILS_LIMIT = "emails_limit" + CALLS = "calls" + CALLS_REMAINING = "calls_remaining" + CALLS_LIMIT = "calls_limit" + RESERVATIONS = "reservations" + RESERVATIONS_REMAINING = "reservations_remaining" + RESERVATIONS_LIMIT = "reservations_limit" + ATTACHMENT_TOTAL_SIZE = "attachment_total_size" + ATTACHMENT_TOTAL_SIZE_REMAINING = "attachment_total_size_remaining" + ATTACHMENT_TOTAL_SIZE_LIMIT = "attachment_total_size_limit" + ATTACHMENT_EXPIRY_DURATION = "attachment_expiry_duration" + ATTACHMENT_BANDWIDTH = "attachment_bandwidth" + ATTACHMENT_FILE_SIZE = "attachment_file_size" + TIER = "tier" + + +SENSOR_DESCRIPTIONS: tuple[NtfySensorEntityDescription, ...] = ( + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES, + translation_key=NtfySensor.MESSAGES, + value_fn=lambda account: account.stats.messages, + ), + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES_REMAINING, + translation_key=NtfySensor.MESSAGES_REMAINING, + value_fn=lambda account: account.stats.messages_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES_LIMIT, + translation_key=NtfySensor.MESSAGES_LIMIT, + value_fn=lambda account: account.limits.messages if account.limits else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.MESSAGES_EXPIRY_DURATION, + translation_key=NtfySensor.MESSAGES_EXPIRY_DURATION, + value_fn=( + lambda account: account.limits.messages_expiry_duration + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + ), + NtfySensorEntityDescription( + key=NtfySensor.EMAILS, + translation_key=NtfySensor.EMAILS, + value_fn=lambda account: account.stats.emails, + ), + NtfySensorEntityDescription( + key=NtfySensor.EMAILS_REMAINING, + translation_key=NtfySensor.EMAILS_REMAINING, + value_fn=lambda account: account.stats.emails_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.EMAILS_LIMIT, + translation_key=NtfySensor.EMAILS_LIMIT, + value_fn=lambda account: account.limits.emails if account.limits else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.CALLS, + translation_key=NtfySensor.CALLS, + value_fn=lambda account: account.stats.calls, + ), + NtfySensorEntityDescription( + key=NtfySensor.CALLS_REMAINING, + translation_key=NtfySensor.CALLS_REMAINING, + value_fn=lambda account: account.stats.calls_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.CALLS_LIMIT, + translation_key=NtfySensor.CALLS_LIMIT, + value_fn=lambda account: account.limits.calls if account.limits else None, + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.RESERVATIONS, + translation_key=NtfySensor.RESERVATIONS, + value_fn=lambda account: account.stats.reservations, + ), + NtfySensorEntityDescription( + key=NtfySensor.RESERVATIONS_REMAINING, + translation_key=NtfySensor.RESERVATIONS_REMAINING, + value_fn=lambda account: account.stats.reservations_remaining, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.RESERVATIONS_LIMIT, + translation_key=NtfySensor.RESERVATIONS_LIMIT, + value_fn=( + lambda account: account.limits.reservations if account.limits else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_EXPIRY_DURATION, + translation_key=NtfySensor.ATTACHMENT_EXPIRY_DURATION, + value_fn=( + lambda account: account.limits.attachment_expiry_duration + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_TOTAL_SIZE, + translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE, + value_fn=lambda account: account.stats.attachment_total_size, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING, + translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE_REMAINING, + value_fn=lambda account: account.stats.attachment_total_size_remaining, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + entity_registry_enabled_default=False, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_TOTAL_SIZE_LIMIT, + translation_key=NtfySensor.ATTACHMENT_TOTAL_SIZE_LIMIT, + value_fn=( + lambda account: account.limits.attachment_total_size + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_FILE_SIZE, + translation_key=NtfySensor.ATTACHMENT_FILE_SIZE, + value_fn=( + lambda account: account.limits.attachment_file_size + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.ATTACHMENT_BANDWIDTH, + translation_key=NtfySensor.ATTACHMENT_BANDWIDTH, + value_fn=( + lambda account: account.limits.attachment_bandwidth + if account.limits + else None + ), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.DATA_SIZE, + native_unit_of_measurement=UnitOfInformation.BYTES, + suggested_unit_of_measurement=UnitOfInformation.MEBIBYTES, + suggested_display_precision=0, + ), + NtfySensorEntityDescription( + key=NtfySensor.TIER, + translation_key=NtfySensor.TIER, + value_fn=lambda account: account.tier.name if account.tier else "free", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NtfyConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + NtfySensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class NtfySensorEntity(CoordinatorEntity[NtfyDataUpdateCoordinator], SensorEntity): + """Representation of a ntfy sensor entity.""" + + entity_description: NtfySensorEntityDescription + coordinator: NtfyDataUpdateCoordinator + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: NtfyDataUpdateCoordinator, + description: NtfySensorEntityDescription, + ) -> None: + """Initialize a sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer="ntfy LLC", + model="ntfy", + configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app", + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/ntfy/strings.json b/homeassistant/components/ntfy/strings.json index cef662d6f2f..08a0a20a30a 100644 --- a/homeassistant/components/ntfy/strings.json +++ b/homeassistant/components/ntfy/strings.json @@ -120,6 +120,88 @@ } } }, + "entity": { + "sensor": { + "messages": { + "name": "Messages published", + "unit_of_measurement": "messages" + }, + "messages_remaining": { + "name": "Messages remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::messages::unit_of_measurement%]" + }, + "messages_limit": { + "name": "Messages usage limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::messages::unit_of_measurement%]" + }, + "messages_expiry_duration": { + "name": "Messages expiry duration" + }, + "emails": { + "name": "Emails sent", + "unit_of_measurement": "emails" + }, + "emails_remaining": { + "name": "Emails remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::emails::unit_of_measurement%]" + }, + "emails_limit": { + "name": "Email usage limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::emails::unit_of_measurement%]" + }, + "calls": { + "name": "Phone calls made", + "unit_of_measurement": "calls" + }, + "calls_remaining": { + "name": "Phone calls remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::calls::unit_of_measurement%]" + }, + "calls_limit": { + "name": "Phone calls usage limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::calls::unit_of_measurement%]" + }, + "reservations": { + "name": "Reserved topics", + "unit_of_measurement": "topics" + }, + "reservations_remaining": { + "name": "Reserved topics remaining", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::reservations::unit_of_measurement%]" + }, + "reservations_limit": { + "name": "Reserved topics limit", + "unit_of_measurement": "[%key:component::ntfy::entity::sensor::reservations::unit_of_measurement%]" + }, + "attachment_total_size": { + "name": "Attachment storage" + }, + "attachment_total_size_remaining": { + "name": "Attachment storage remaining" + }, + "attachment_total_size_limit": { + "name": "Attachment storage limit" + }, + "attachment_expiry_duration": { + "name": "Attachment expiry duration" + }, + "attachment_file_size": { + "name": "Attachment file size limit" + }, + "attachment_bandwidth": { + "name": "Attachment bandwidth limit" + }, + "tier": { + "name": "Subscription tier", + "state": { + "free": "Free", + "supporter": "Supporter", + "pro": "Pro", + "business": "Business" + } + } + } + }, "exceptions": { "publish_failed_request_error": { "message": "Failed to publish notification: {error_msg}" diff --git a/tests/components/ntfy/snapshots/test_sensor.ambr b/tests/components/ntfy/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fd0dd3c4bd4 --- /dev/null +++ b/tests/components/ntfy/snapshots/test_sensor.ambr @@ -0,0 +1,1029 @@ +# serializer version: 1 +# name: test_setup[sensor.ntfy_sh_attachment_bandwidth_limit-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.ntfy_sh_attachment_bandwidth_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Attachment bandwidth limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_bandwidth', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_bandwidth_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment bandwidth limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_bandwidth_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1024.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_expiry_duration-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.ntfy_sh_attachment_expiry_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Attachment expiry duration', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_expiry_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_expiry_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'ntfy.sh Attachment expiry duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_expiry_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_file_size_limit-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.ntfy_sh_attachment_file_size_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Attachment file size limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_file_size', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_file_size_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment file size limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_file_size_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage-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.ntfy_sh_attachment_storage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Attachment storage', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_total_size', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment storage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_limit-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.ntfy_sh_attachment_storage_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Attachment storage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_total_size_limit', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment storage limit', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_remaining-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.ntfy_sh_attachment_storage_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Attachment storage remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_attachment_total_size_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_attachment_storage_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'data_size', + 'friendly_name': 'ntfy.sh Attachment storage remaining', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_attachment_storage_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_email_usage_limit-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.ntfy_sh_email_usage_limit', + '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': 'Email usage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_emails_limit', + 'unit_of_measurement': 'emails', + }) +# --- +# name: test_setup[sensor.ntfy_sh_email_usage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Email usage limit', + 'unit_of_measurement': 'emails', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_email_usage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_remaining-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.ntfy_sh_emails_remaining', + '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': 'Emails remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_emails_remaining', + 'unit_of_measurement': 'emails', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Emails remaining', + 'unit_of_measurement': 'emails', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_emails_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_sent-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.ntfy_sh_emails_sent', + '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': 'Emails sent', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_emails', + 'unit_of_measurement': 'emails', + }) +# --- +# name: test_setup[sensor.ntfy_sh_emails_sent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Emails sent', + 'unit_of_measurement': 'emails', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_emails_sent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_expiry_duration-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.ntfy_sh_messages_expiry_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Messages expiry duration', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages_expiry_duration', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_expiry_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'ntfy.sh Messages expiry duration', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_expiry_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_published-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.ntfy_sh_messages_published', + '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': 'Messages published', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_published-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Messages published', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_published', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_remaining-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.ntfy_sh_messages_remaining', + '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': 'Messages remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages_remaining', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Messages remaining', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4990', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_usage_limit-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.ntfy_sh_messages_usage_limit', + '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': 'Messages usage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_messages_limit', + 'unit_of_measurement': 'messages', + }) +# --- +# name: test_setup[sensor.ntfy_sh_messages_usage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Messages usage limit', + 'unit_of_measurement': 'messages', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_messages_usage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5000', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_made-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.ntfy_sh_phone_calls_made', + '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': 'Phone calls made', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_calls', + 'unit_of_measurement': 'calls', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_made-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Phone calls made', + 'unit_of_measurement': 'calls', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_made', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_remaining-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.ntfy_sh_phone_calls_remaining', + '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': 'Phone calls remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_calls_remaining', + 'unit_of_measurement': 'calls', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Phone calls remaining', + 'unit_of_measurement': 'calls', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_usage_limit-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.ntfy_sh_phone_calls_usage_limit', + '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': 'Phone calls usage limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_calls_limit', + 'unit_of_measurement': 'calls', + }) +# --- +# name: test_setup[sensor.ntfy_sh_phone_calls_usage_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Phone calls usage limit', + 'unit_of_measurement': 'calls', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_phone_calls_usage_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics-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.ntfy_sh_reserved_topics', + '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': 'Reserved topics', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_reservations', + 'unit_of_measurement': 'topics', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Reserved topics', + 'unit_of_measurement': 'topics', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_limit-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.ntfy_sh_reserved_topics_limit', + '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': 'Reserved topics limit', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_reservations_limit', + 'unit_of_measurement': 'topics', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Reserved topics limit', + 'unit_of_measurement': 'topics', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_remaining-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.ntfy_sh_reserved_topics_remaining', + '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': 'Reserved topics remaining', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_reservations_remaining', + 'unit_of_measurement': 'topics', + }) +# --- +# name: test_setup[sensor.ntfy_sh_reserved_topics_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Reserved topics remaining', + 'unit_of_measurement': 'topics', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_reserved_topics_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2', + }) +# --- +# name: test_setup[sensor.ntfy_sh_subscription_tier-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.ntfy_sh_subscription_tier', + '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': 'Subscription tier', + 'platform': 'ntfy', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_tier', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.ntfy_sh_subscription_tier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'ntfy.sh Subscription tier', + }), + 'context': , + 'entity_id': 'sensor.ntfy_sh_subscription_tier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'starter', + }) +# --- diff --git a/tests/components/ntfy/test_init.py b/tests/components/ntfy/test_init.py index b80badd8581..b5b73d1272c 100644 --- a/tests/components/ntfy/test_init.py +++ b/tests/components/ntfy/test_init.py @@ -65,3 +65,37 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is state + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + ( + NtfyUnauthorizedAuthenticationError( + 40101, + 401, + "unauthorized", + "https://ntfy.sh/docs/publish/#authentication", + ), + ConfigEntryState.SETUP_ERROR, + ), + (NtfyHTTPError(418001, 418, "I'm a teapot", ""), ConfigEntryState.SETUP_RETRY), + (NtfyConnectionError, ConfigEntryState.SETUP_RETRY), + (NtfyTimeoutError, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_coordinator_update_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_aiontfy: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready from update failed in _async_update_data.""" + mock_aiontfy.account.side_effect = [None, 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 state diff --git a/tests/components/ntfy/test_sensor.py b/tests/components/ntfy/test_sensor.py new file mode 100644 index 00000000000..4685cf946ee --- /dev/null +++ b/tests/components/ntfy/test_sensor.py @@ -0,0 +1,42 @@ +"""Tests for the ntfy sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.ntfy.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_aiontfy", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor 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) From c671ff3cf1ce7739a3db72f51c43f7b94c6bea3f Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Mon, 23 Jun 2025 17:46:06 -0400 Subject: [PATCH 0579/1664] Add PlayStation Network Integration (#133901) * clean pull request * Create one device per console * Requested changes * Pr/tr4nt0r/1 (#2) * clean pull request * Create one device per console * device setup * Merge PR1 - Dynamic Device Support * Merge PR1 - Dynamic Device Support --------- Co-authored-by: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> * nitpicks * Update config_flow test * Update quality_scale.yaml * repair integrations.json * minor updates * Add translation string for invalid account * misc changes post review * Minor strings updates * strengthen config_flow test * Requested changes * Applied patch to commit a358725 * migrate PlayStationNetwork helper classes to HA * Revert to standard psn library * Updates to media_player logic * add default_factory, change registered_platforms to set * Improve test coverage * Add snapshot test for media_player platform * fix token parse error * Parametrize media player test * Add PS3 support * Add PS3 support * Add concurrent console support * Adjust psnawp rate limit * Convert to package PlatformType * Update dependency to PSNAWP==3.0.0 * small improvements * Add PlayStation PC Support * Refactor active sessions list * shift async logic to helper * Implemented suggested changes * Suggested changes * Updated tests * Suggested changes * Fix test * Suggested changes * Suggested changes * Update config_flow tests * Group remaining api call in single executor --------- Co-authored-by: tr4nt0r <4445816+tr4nt0r@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 2 + homeassistant/brands/sony.json | 8 +- .../playstation_network/__init__.py | 34 ++ .../playstation_network/config_flow.py | 70 ++++ .../components/playstation_network/const.py | 15 + .../playstation_network/coordinator.py | 69 ++++ .../components/playstation_network/helpers.py | 151 ++++++++ .../components/playstation_network/icons.json | 9 + .../playstation_network/manifest.json | 11 + .../playstation_network/media_player.py | 128 +++++++ .../playstation_network/quality_scale.yaml | 72 ++++ .../playstation_network/strings.json | 32 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 6 + requirements_test_all.txt | 6 + .../playstation_network/__init__.py | 1 + .../playstation_network/conftest.py | 113 ++++++ .../snapshots/test_media_player.ambr | 321 ++++++++++++++++++ .../playstation_network/test_config_flow.py | 140 ++++++++ .../playstation_network/test_media_player.py | 123 +++++++ 21 files changed, 1317 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/playstation_network/__init__.py create mode 100644 homeassistant/components/playstation_network/config_flow.py create mode 100644 homeassistant/components/playstation_network/const.py create mode 100644 homeassistant/components/playstation_network/coordinator.py create mode 100644 homeassistant/components/playstation_network/helpers.py create mode 100644 homeassistant/components/playstation_network/icons.json create mode 100644 homeassistant/components/playstation_network/manifest.json create mode 100644 homeassistant/components/playstation_network/media_player.py create mode 100644 homeassistant/components/playstation_network/quality_scale.yaml create mode 100644 homeassistant/components/playstation_network/strings.json create mode 100644 tests/components/playstation_network/__init__.py create mode 100644 tests/components/playstation_network/conftest.py create mode 100644 tests/components/playstation_network/snapshots/test_media_player.ambr create mode 100644 tests/components/playstation_network/test_config_flow.py create mode 100644 tests/components/playstation_network/test_media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index c019f75c57a..98cea97204f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1169,6 +1169,8 @@ build.json @home-assistant/supervisor /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan +/homeassistant/components/playstation_network/ @jackjpowell +/tests/components/playstation_network/ @jackjpowell /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren /homeassistant/components/plugwise/ @CoMPaTech @bouwew diff --git a/homeassistant/brands/sony.json b/homeassistant/brands/sony.json index e35d5f4723c..27bc26a33dc 100644 --- a/homeassistant/brands/sony.json +++ b/homeassistant/brands/sony.json @@ -1,5 +1,11 @@ { "domain": "sony", "name": "Sony", - "integrations": ["braviatv", "ps4", "sony_projector", "songpal"] + "integrations": [ + "braviatv", + "ps4", + "sony_projector", + "songpal", + "playstation_network" + ] } diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py new file mode 100644 index 00000000000..c111cf8c960 --- /dev/null +++ b/homeassistant/components/playstation_network/__init__.py @@ -0,0 +1,34 @@ +"""The PlayStation Network integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .const import CONF_NPSSO +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .helpers import PlaystationNetwork + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> bool: + """Set up Playstation Network from a config entry.""" + + psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO]) + + coordinator = PlaystationNetworkCoordinator(hass, psn, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py new file mode 100644 index 00000000000..c177aa6e219 --- /dev/null +++ b/homeassistant/components/playstation_network/config_flow.py @@ -0,0 +1,70 @@ +"""Config flow for the PlayStation Network integration.""" + +import logging +from typing import Any + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPAuthenticationError, + PSNAWPError, + PSNAWPInvalidTokenError, + PSNAWPNotFoundError, +) +from psnawp_api.models.user import User +from psnawp_api.utils.misc import parse_npsso_token +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_NPSSO, DOMAIN +from .helpers import PlaystationNetwork + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_NPSSO): str}) + + +class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Playstation Network.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + npsso: str | None = None + if user_input is not None: + try: + npsso = parse_npsso_token(user_input[CONF_NPSSO]) + except PSNAWPInvalidTokenError: + errors["base"] = "invalid_account" + + if npsso: + psn = PlaystationNetwork(self.hass, npsso) + try: + user: User = await psn.get_user() + except PSNAWPAuthenticationError: + errors["base"] = "invalid_auth" + except PSNAWPNotFoundError: + errors["base"] = "invalid_account" + except PSNAWPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user.account_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user.online_id, + data={CONF_NPSSO: npsso}, + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={ + "npsso_link": "https://ca.account.sony.com/api/v1/ssocookie", + "psn_link": "https://playstation.com", + }, + ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py new file mode 100644 index 00000000000..2db43f433e6 --- /dev/null +++ b/homeassistant/components/playstation_network/const.py @@ -0,0 +1,15 @@ +"""Constants for the Playstation Network integration.""" + +from typing import Final + +from psnawp_api.models.trophies import PlatformType + +DOMAIN = "playstation_network" +CONF_NPSSO: Final = "npsso" + +SUPPORTED_PLATFORMS = { + PlatformType.PS5, + PlatformType.PS4, + PlatformType.PS3, + PlatformType.PSPC, +} diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py new file mode 100644 index 00000000000..f6fd53ccb24 --- /dev/null +++ b/homeassistant/components/playstation_network/coordinator.py @@ -0,0 +1,69 @@ +"""Coordinator for the PlayStation Network Integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from psnawp_api.core.psnawp_exceptions import ( + PSNAWPAuthenticationError, + PSNAWPServerError, +) +from psnawp_api.models.user import User + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .helpers import PlaystationNetwork, PlaystationNetworkData + +_LOGGER = logging.getLogger(__name__) + +type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator] + + +class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]): + """Data update coordinator for PSN.""" + + config_entry: PlaystationNetworkConfigEntry + user: User + + def __init__( + self, + hass: HomeAssistant, + psn: PlaystationNetwork, + config_entry: PlaystationNetworkConfigEntry, + ) -> None: + """Initialize the Coordinator.""" + super().__init__( + hass, + name=DOMAIN, + logger=_LOGGER, + config_entry=config_entry, + update_interval=timedelta(seconds=30), + ) + + self.psn = psn + + async def _async_setup(self) -> None: + """Set up the coordinator.""" + + try: + self.user = await self.psn.get_user() + except PSNAWPAuthenticationError as error: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + + async def _async_update_data(self) -> PlaystationNetworkData: + """Get the latest data from the PSN.""" + try: + return await self.psn.get_data() + except (PSNAWPAuthenticationError, PSNAWPServerError) as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py new file mode 100644 index 00000000000..38f8d5e1356 --- /dev/null +++ b/homeassistant/components/playstation_network/helpers.py @@ -0,0 +1,151 @@ +"""Helper methods for common PlayStation Network integration operations.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from functools import partial +from typing import Any + +from psnawp_api import PSNAWP +from psnawp_api.core.psnawp_exceptions import PSNAWPNotFoundError +from psnawp_api.models.client import Client +from psnawp_api.models.trophies import PlatformType +from psnawp_api.models.user import User +from pyrate_limiter import Duration, Rate + +from homeassistant.core import HomeAssistant + +from .const import SUPPORTED_PLATFORMS + +LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4} + + +@dataclass +class SessionData: + """Dataclass representing console session data.""" + + platform: PlatformType = PlatformType.UNKNOWN + title_id: str | None = None + title_name: str | None = None + format: PlatformType | None = None + media_image_url: str | None = None + status: str = "" + + +@dataclass +class PlaystationNetworkData: + """Dataclass representing data retrieved from the Playstation Network api.""" + + presence: dict[str, Any] = field(default_factory=dict) + username: str = "" + account_id: str = "" + available: bool = False + active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict) + registered_platforms: set[PlatformType] = field(default_factory=set) + + +class PlaystationNetwork: + """Helper Class to return playstation network data in an easy to use structure.""" + + def __init__(self, hass: HomeAssistant, npsso: str) -> None: + """Initialize the class with the npsso token.""" + rate = Rate(300, Duration.MINUTE * 15) + self.psn = PSNAWP(npsso, rate_limit=rate) + self.client: Client | None = None + self.hass = hass + self.user: User + self.legacy_profile: dict[str, Any] | None = None + + async def get_user(self) -> User: + """Get the user object from the PlayStation Network.""" + self.user = await self.hass.async_add_executor_job( + partial(self.psn.user, online_id="me") + ) + return self.user + + def retrieve_psn_data(self) -> PlaystationNetworkData: + """Bundle api calls to retrieve data from the PlayStation Network.""" + data = PlaystationNetworkData() + + if not self.client: + self.client = self.psn.me() + + data.registered_platforms = { + PlatformType(device["deviceType"]) + for device in self.client.get_account_devices() + } & SUPPORTED_PLATFORMS + + data.presence = self.user.get_presence() + + # check legacy platforms if owned + if LEGACY_PLATFORMS & data.registered_platforms: + self.legacy_profile = self.client.get_profile_legacy() + return data + + async def get_data(self) -> PlaystationNetworkData: + """Get title data from the PlayStation Network.""" + data = await self.hass.async_add_executor_job(self.retrieve_psn_data) + data.username = self.user.online_id + data.account_id = self.user.account_id + + data.available = ( + data.presence.get("basicPresence", {}).get("availability") + == "availableToPlay" + ) + + 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 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 + + if self.legacy_profile: + presence = self.legacy_profile["profile"].get("presences", []) + game_title_info = presence[0] if presence else {} + session = SessionData() + + # If primary console isn't online, check legacy platforms for status + if not data.available: + data.available = game_title_info["onlineStatus"] == "online" + + if "npTitleId" in game_title_info: + session.title_id = game_title_info["npTitleId"] + session.title_name = game_title_info["titleName"] + session.format = game_title_info["platform"] + session.platform = game_title_info["platform"] + session.status = game_title_info["onlineStatus"] + if PlatformType(session.format) is PlatformType.PS4: + session.media_image_url = game_title_info["npTitleIconUrl"] + elif PlatformType(session.format) is PlatformType.PS3: + try: + title = self.psn.game_title( + session.title_id, platform=PlatformType.PS3, account_id="me" + ) + except PSNAWPNotFoundError: + session.media_image_url = None + + if title: + session.media_image_url = title.get_title_icon_url() + + if game_title_info["onlineStatus"] == "online": + data.active_sessions[session.platform] = session + return data diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json new file mode 100644 index 00000000000..2ff18bf6e59 --- /dev/null +++ b/homeassistant/components/playstation_network/icons.json @@ -0,0 +1,9 @@ +{ + "entity": { + "media_player": { + "playstation": { + "default": "mdi:sony-playstation" + } + } + } +} diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json new file mode 100644 index 00000000000..f929e569b66 --- /dev/null +++ b/homeassistant/components/playstation_network/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "playstation_network", + "name": "PlayStation Network", + "codeowners": ["@jackjpowell"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/playstation_network", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.7.0"] +} diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py new file mode 100644 index 00000000000..08840fbbabd --- /dev/null +++ b/homeassistant/components/playstation_network/media_player.py @@ -0,0 +1,128 @@ +"""Media player entity for the PlayStation Network Integration.""" + +import logging + +from psnawp_api.models.trophies import PlatformType + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerState, + MediaType, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .const import DOMAIN, SUPPORTED_PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +PLATFORM_MAP = { + PlatformType.PS5: "PlayStation 5", + PlatformType.PS4: "PlayStation 4", + PlatformType.PS3: "PlayStation 3", + PlatformType.PSPC: "PlayStation PC", +} +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Media Player Entity Setup.""" + coordinator = config_entry.runtime_data + devices_added: set[PlatformType] = set() + device_reg = dr.async_get(hass) + entities = [] + + @callback + def add_entities() -> None: + nonlocal devices_added + + if not SUPPORTED_PLATFORMS - devices_added: + remove_listener() + + new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added + if new_platforms: + async_add_entities( + PsnMediaPlayerEntity(coordinator, platform_type) + for platform_type in new_platforms + ) + devices_added |= new_platforms + + for platform in SUPPORTED_PLATFORMS: + if device_reg.async_get_device( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") + } + ): + entities.append(PsnMediaPlayerEntity(coordinator, platform)) + devices_added.add(platform) + if entities: + async_add_entities(entities) + + remove_listener = coordinator.async_add_listener(add_entities) + add_entities() + + +class PsnMediaPlayerEntity( + CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity +): + """Media player entity representing currently playing game.""" + + _attr_media_image_remotely_accessible = True + _attr_media_content_type = MediaType.GAME + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_translation_key = "playstation" + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType + ) -> None: + """Initialize PSN MediaPlayer.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{platform.value}" + self.key = platform + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + name=PLATFORM_MAP[platform], + manufacturer="Sony Interactive Entertainment", + model=PLATFORM_MAP[platform], + ) + + @property + def state(self) -> MediaPlayerState: + """Media Player state getter.""" + session = self.coordinator.data.active_sessions.get(self.key) + if session and session.status == "online": + if self.coordinator.data.available and session.title_id is not None: + return MediaPlayerState.PLAYING + return MediaPlayerState.ON + return MediaPlayerState.OFF + + @property + def media_title(self) -> str | None: + """Media title getter.""" + session = self.coordinator.data.active_sessions.get(self.key) + return session.title_name if session else None + + @property + def media_content_id(self) -> str | None: + """Content ID of current playing media.""" + session = self.coordinator.data.active_sessions.get(self.key) + return session.title_id if session else None + + @property + def media_image_url(self) -> str | None: + """Media image url getter.""" + session = self.coordinator.data.active_sessions.get(self.key) + return session.media_image_url if session else None diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml new file mode 100644 index 00000000000..36c28f19145 --- /dev/null +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration has no 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: Integration has no actions + + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Integration does not register 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: Integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration options + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Discovery flow is not applicable for this integration + discovery: todo + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + # Platinum + async-dependency: todo + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json new file mode 100644 index 00000000000..01fc551d929 --- /dev/null +++ b/homeassistant/components/playstation_network/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "data": { + "npsso": "NPSSO token" + }, + "data_description": { + "npsso": "The NPSSO token is generated during successful login of your PlayStation Network account and is used to authenticate your requests from with Home Assistant." + }, + "description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token." + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "exceptions": { + "not_ready": { + "message": "Authentication to the PlayStation Network failed." + }, + "update_failed": { + "message": "Data retrieval failed when trying to access the PlayStation Network." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 9a7408ad8b1..19037ac31e8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -481,6 +481,7 @@ FLOWS = { "picnic", "ping", "plaato", + "playstation_network", "plex", "plugwise", "plum_lightpad", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8b65ebfb66a..f207191330a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6229,6 +6229,12 @@ "config_flow": true, "iot_class": "local_push", "name": "Sony Songpal" + }, + "playstation_network": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "PlayStation Network" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 8e827b6a4a3..9520d10b167 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -24,6 +24,9 @@ HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==2.0.1 +# homeassistant.components.playstation_network +PSNAWP==3.0.0 + # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload @@ -2281,6 +2284,9 @@ pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 +# homeassistant.components.playstation_network +pyrate-limiter==3.7.0 + # homeassistant.components.recswitch pyrecswitch==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20390c2ade9..f43f1a2ed0d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -24,6 +24,9 @@ HATasmota==0.10.0 # homeassistant.components.mastodon Mastodon.py==2.0.1 +# homeassistant.components.playstation_network +PSNAWP==3.0.0 + # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload @@ -1899,6 +1902,9 @@ pyrail==0.4.1 # homeassistant.components.rainbird pyrainbird==6.0.1 +# homeassistant.components.playstation_network +pyrate-limiter==3.7.0 + # homeassistant.components.risco pyrisco==0.6.7 diff --git a/tests/components/playstation_network/__init__.py b/tests/components/playstation_network/__init__.py new file mode 100644 index 00000000000..a05112b4146 --- /dev/null +++ b/tests/components/playstation_network/__init__.py @@ -0,0 +1 @@ +"""Tests for the Playstation Network integration.""" diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py new file mode 100644 index 00000000000..69e84fbaa6b --- /dev/null +++ b/tests/components/playstation_network/conftest.py @@ -0,0 +1,113 @@ +"""Common fixtures for the Playstation Network tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN + +from tests.common import MockConfigEntry + +NPSSO_TOKEN: str = "npsso-token" +NPSSO_TOKEN_INVALID_JSON: str = "{'npsso': 'npsso-token'" +PSN_ID: str = "my-psn-id" + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock PlayStation Network configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="test-user", + data={ + CONF_NPSSO: NPSSO_TOKEN, + }, + unique_id=PSN_ID, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.playstation_network.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_user() -> Generator[MagicMock]: + """Mock psnawp_api User object.""" + + with patch( + "homeassistant.components.playstation_network.helpers.User", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.account_id = PSN_ID + client.online_id = "testuser" + + client.get_presence.return_value = { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"}, + "gameTitleInfoList": [ + { + "npTitleId": "PPSA07784_00", + "titleName": "STAR WARS Jedi: Survivor™", + "format": "PS5", + "launchPlatform": "PS5", + "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", + } + ], + } + } + + yield client + + +@pytest.fixture +def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: + """Mock psnawp_api.""" + + with patch( + "homeassistant.components.playstation_network.helpers.PSNAWP", + autospec=True, + ) as mock_client: + client = mock_client.return_value + + client.user.return_value = mock_user + client.me.return_value.get_account_devices.return_value = [ + { + "deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", + "deviceType": "PS5", + "activationType": "PRIMARY", + "activationDate": "2021-01-14T18:00:00.000Z", + "accountDeviceVector": "abcdefghijklmnopqrstuv", + } + ] + yield client + + +@pytest.fixture +def mock_psnawp_npsso(mock_user: MagicMock) -> Generator[MagicMock]: + """Mock psnawp_api.""" + + with patch( + "psnawp_api.utils.misc.parse_npsso_token", + autospec=True, + ) as mock_parse_npsso_token: + npsso = mock_parse_npsso_token.return_value + npsso.parse_npsso_token.return_value = NPSSO_TOKEN + + yield npsso + + +@pytest.fixture +def mock_token() -> Generator[MagicMock]: + """Mock token generator.""" + with patch("secrets.token_hex", return_value="123456789") as token: + yield token diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..a42522592e4 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -0,0 +1,321 @@ +# serializer version: 1 +# name: test_platform[PS4_idle][media_player.playstation_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS4_idle][media_player.playstation_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation 4', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform[PS4_offline][media_player.playstation_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS4_offline][media_player.playstation_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'PlayStation 4', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform[PS4_playing][media_player.playstation_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS4_playing][media_player.playstation_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_4?token=123456789&cache=924f463745523102', + 'friendly_name': 'PlayStation 4', + 'media_content_id': 'CUSA23081_00', + 'media_content_type': , + 'media_title': 'Untitled Goose Game', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_platform[PS5_idle][media_player.playstation_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS5_idle][media_player.playstation_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation 5', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform[PS5_offline][media_player.playstation_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS5_offline][media_player.playstation_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'PlayStation 5', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform[PS5_playing][media_player.playstation_5-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_5', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PS5', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform[PS5_playing][media_player.playstation_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_5?token=123456789&cache=50dfb7140be0060b', + 'friendly_name': 'PlayStation 5', + 'media_content_id': 'PPSA07784_00', + 'media_content_type': , + 'media_title': 'STAR WARS Jedi: Survivor™', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py new file mode 100644 index 00000000000..107c92d8bff --- /dev/null +++ b/tests/components/playstation_network/test_config_flow.py @@ -0,0 +1,140 @@ +"""Test the Playstation Network config flow.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.playstation_network.config_flow import ( + PSNAWPAuthenticationError, + PSNAWPError, + PSNAWPInvalidTokenError, + PSNAWPNotFoundError, +) +from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import NPSSO_TOKEN, NPSSO_TOKEN_INVALID_JSON, PSN_ID + +from tests.common import MockConfigEntry + +MOCK_DATA_ADVANCED_STEP = {CONF_NPSSO: NPSSO_TOKEN} + + +async def test_manual_config(hass: HomeAssistant, mock_psnawpapi: MagicMock) -> None: + """Test creating via manual configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "TEST_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == PSN_ID + assert result["data"] == { + CONF_NPSSO: "TEST_NPSSO_TOKEN", + } + + +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test we abort form login when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (PSNAWPNotFoundError(), "invalid_account"), + (PSNAWPAuthenticationError(), "invalid_auth"), + (PSNAWPError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_form_failures( + hass: HomeAssistant, + mock_psnawpapi: MagicMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle a connection error. + + First we generate an error and after fixing it, we are still able to submit. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_psnawpapi.user.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["step_id"] == "user" + assert result["errors"] == {"base": text_error} + + mock_psnawpapi.user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NPSSO: NPSSO_TOKEN, + } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_parse_npsso_token_failures( + hass: HomeAssistant, + mock_psnawp_npsso: MagicMock, +) -> None: + """Test parse_npsso_token raises the correct exceptions during config flow.""" + mock_psnawp_npsso.parse_npsso_token.side_effect = PSNAWPInvalidTokenError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_NPSSO: NPSSO_TOKEN_INVALID_JSON}, + ) + assert result["errors"] == {"base": "invalid_account"} + + mock_psnawp_npsso.parse_npsso_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: NPSSO_TOKEN}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_NPSSO: NPSSO_TOKEN, + } diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py new file mode 100644 index 00000000000..f503a5ec297 --- /dev/null +++ b/tests/components/playstation_network/test_media_player.py @@ -0,0 +1,123 @@ +"""Test the Playstation Network media player platform.""" + +from collections.abc import AsyncGenerator +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +async def media_player_only() -> AsyncGenerator[None]: + """Enable only the media_player platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.MEDIA_PLAYER], + ): + yield + + +@pytest.mark.parametrize( + "presence_payload", + [ + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"}, + "gameTitleInfoList": [ + { + "npTitleId": "PPSA07784_00", + "titleName": "STAR WARS Jedi: Survivor™", + "format": "PS5", + "launchPlatform": "PS5", + "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", + } + ], + } + }, + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS4"}, + "gameTitleInfoList": [ + { + "npTitleId": "CUSA23081_00", + "titleName": "Untitled Goose Game", + "format": "PS4", + "launchPlatform": "PS4", + "npTitleIconUrl": "http://gs2-sec.ww.prod.dl.playstation.net/gs2-sec/appkgo/prod/CUSA23081_00/5/i_f5d2adec7665af80b8550fb33fe808df10d292cdd47629a991debfdf72bdee34/i/icon0.png", + } + ], + } + }, + { + "basicPresence": { + "availability": "unavailable", + "lastAvailableDate": "2025-05-02T17:47:59.392Z", + "primaryPlatformInfo": { + "onlineStatus": "offline", + "platform": "PS5", + "lastOnlineDate": "2025-05-02T17:47:59.392Z", + }, + } + }, + { + "basicPresence": { + "availability": "unavailable", + "lastAvailableDate": "2025-05-02T17:47:59.392Z", + "primaryPlatformInfo": { + "onlineStatus": "offline", + "platform": "PS4", + "lastOnlineDate": "2025-05-02T17:47:59.392Z", + }, + } + }, + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS5"}, + } + }, + { + "basicPresence": { + "availability": "availableToPlay", + "primaryPlatformInfo": {"onlineStatus": "online", "platform": "PS4"}, + } + }, + ], + ids=[ + "PS5_playing", + "PS4_playing", + "PS5_offline", + "PS4_offline", + "PS5_idle", + "PS4_idle", + ], +) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_psnawpapi: MagicMock, + presence_payload: dict[str, Any], +) -> None: + """Test setup of the PlayStation Network media_player platform.""" + + mock_psnawpapi.user().get_presence.return_value = presence_payload + 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) From 6641cb37998d0c70a9d9a2fef5a7a01cb4126090 Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Tue, 24 Jun 2025 01:52:23 +0400 Subject: [PATCH 0580/1664] Handle router initialization, connection errors, and missing interfaces in options flow (#143475) * Handle router initialization and connection errors in options flow Added checks in the Keenetic NDMS2 options flow to handle cases where the integration is not initialized or there are connection errors. Relevant user feedback and abort reasons are now provided to ensure a better user experience. * Add filtering saved/default options for interfaces before preparing an options form --- .../components/keenetic_ndms2/config_flow.py | 26 +++++-- .../components/keenetic_ndms2/strings.json | 4 ++ .../keenetic_ndms2/test_config_flow.py | 67 ++++++++++++++++++- 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 7219819b911..3862d34398f 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -146,17 +146,27 @@ class KeeneticOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + if ( + not hasattr(self.config_entry, "runtime_data") + or not self.config_entry.runtime_data + ): + return self.async_abort(reason="not_initialized") + router = self.config_entry.runtime_data - interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job( - router.client.get_interfaces - ) + try: + interfaces: list[InterfaceInfo] = await self.hass.async_add_executor_job( + router.client.get_interfaces + ) + except ConnectionException: + return self.async_abort(reason="cannot_connect") self._interface_options = { interface.name: (interface.description or interface.name) for interface in interfaces if interface.type.lower() == "bridge" } + return await self.async_step_user() async def async_step_user( @@ -182,9 +192,13 @@ class KeeneticOptionsFlowHandler(OptionsFlow): ): int, vol.Required( CONF_INTERFACES, - default=self.config_entry.options.get( - CONF_INTERFACES, [DEFAULT_INTERFACE] - ), + default=[ + item + for item in self.config_entry.options.get( + CONF_INTERFACES, [DEFAULT_INTERFACE] + ) + if item in self._interface_options + ], ): cv.multi_select(self._interface_options), vol.Optional( CONF_TRY_HOTSPOT, diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 739846de0a8..93b59be122d 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -36,6 +36,10 @@ "include_associated": "Use Wi-Fi AP associations data (ignored if hotspot data used)" } } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "not_initialized": "The integration is not initialized yet. Can't display available options." } } } diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index c8e23786e68..3293bd3d4da 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -6,10 +6,11 @@ from unittest.mock import Mock, patch from ndms2_client import ConnectionException from ndms2_client.client import InterfaceInfo, RouterInfo import pytest +import voluptuous as vol from homeassistant import config_entries from homeassistant.components import keenetic_ndms2 as keenetic -from homeassistant.components.keenetic_ndms2 import const +from homeassistant.components.keenetic_ndms2 import CONF_INTERFACES, const from homeassistant.const import CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -145,6 +146,70 @@ async def test_connection_error(hass: HomeAssistant, connect_error) -> None: assert result["errors"] == {"base": "cannot_connect"} +async def test_options_not_initialized(hass: HomeAssistant) -> None: + """Test the error when the integration is not initialized.""" + + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + # not setting entry.runtime_data + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "not_initialized" + + +async def test_options_connection_error(hass: HomeAssistant) -> None: + """Test updating options.""" + + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + def get_interfaces_error(): + raise ConnectionException("Mocked failure") + + # fake with connection error + entry.runtime_data = Mock( + client=Mock(get_interfaces=Mock(wraps=get_interfaces_error)) + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_options_interface_filter(hass: HomeAssistant) -> None: + """Test the case when the default Home interface is missing on the router.""" + + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + # fake interfaces + entry.runtime_data = Mock( + client=Mock( + get_interfaces=Mock( + return_value=[ + InterfaceInfo.from_dict({"id": name, "type": "bridge"}) + for name in ("not_a_home", "also_not_home") + ] + ) + ) + ) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.FORM + interfaces_schema = next( + i + for i, s in result["data_schema"].schema.items() + if i.schema == CONF_INTERFACES + ) + assert isinstance(interfaces_schema, vol.Required) + assert interfaces_schema.default() == [] + + async def test_ssdp_works(hass: HomeAssistant, connect) -> None: """Test host already configured and discovered.""" From 56f4039ac26fd39945dfe948c4c314237463f688 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Jun 2025 20:59:32 -0400 Subject: [PATCH 0581/1664] Migrate Google Gen AI to use subentries (#147281) * Migrate Google Gen AI to use subentries * Add reconfig successful msg * Address comments * Do not allow addin subentry when not loaded * Let HA do the migration * Use config_entries.async_setup * Remove fallback name on base entity * Fix * Fix * Fix device name assignment in entity and tts modules * Fix tests --------- Co-authored-by: Joostlek --- .../__init__.py | 80 ++++++- .../config_flow.py | 148 ++++++++---- .../const.py | 2 + .../conversation.py | 20 +- .../entity.py | 17 +- .../strings.json | 64 ++++-- .../google_generative_ai_conversation/tts.py | 1 - .../conftest.py | 23 +- .../test_config_flow.py | 122 ++++++++-- .../test_conversation.py | 26 ++- .../test_init.py | 214 ++++++++++++++++++ .../test_tts.py | 4 +- 12 files changed, 599 insertions(+), 122 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 7e9ca550275..4830e204654 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -12,7 +12,7 @@ from google.genai.types import File, FileState from requests.exceptions import Timeout import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import ( HomeAssistant, @@ -26,7 +26,11 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType @@ -56,6 +60,8 @@ type GoogleGenerativeAIConfigEntry = ConfigEntry[Client] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Google Generative AI Conversation.""" + await async_migrate_integration(hass) + async def generate_content(call: ServiceCall) -> ServiceResponse: """Generate content from text and optionally images.""" @@ -209,3 +215,73 @@ async def async_unload_entry( return False return True + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + entries = hass.config_entries.async_entries(DOMAIN) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_API_KEY] not in api_keys_entries: + use_existing = True + api_keys_entries[entry.data[CONF_API_KEY]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + entity_registry.async_update_entity( + conversation_entity, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ae0f09b1037..4b7c7a0dd47 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -4,8 +4,7 @@ from __future__ import annotations from collections.abc import Mapping import logging -from types import MappingProxyType -from typing import Any +from typing import Any, cast from google import genai from google.genai.errors import APIError, ClientError @@ -15,12 +14,14 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, @@ -45,6 +46,7 @@ from .const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -66,7 +68,7 @@ STEP_API_DATA_SCHEMA = vol.Schema( RECOMMENDED_OPTIONS = { CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, } @@ -90,7 +92,7 @@ async def validate_input(data: dict[str, Any]) -> None: class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" - VERSION = 1 + VERSION = 2 async def async_step_api( self, user_input: dict[str, Any] | None = None @@ -98,6 +100,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: + self._async_abort_entries_match(user_input) try: await validate_input(user_input) except (APIError, Timeout) as err: @@ -117,7 +120,14 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="Google Generative AI", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) return self.async_show_form( step_id="api", @@ -156,41 +166,72 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return GoogleGenerativeAIOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class GoogleGenerativeAIOptionsFlow(OptionsFlow): - """Google Generative AI config flow options handler.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) - self._genai_client = config_entry.runtime_data + last_rendered_recommended = False - async def async_step_init( + @property + def _genai_client(self) -> genai.Client: + """Return the Google Generative AI client.""" + return self._get_entry().runtime_data + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + async def async_step_set_options( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + ) -> SubentryFlowResult: + """Set conversation options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + errors: dict[str, str] = {} - if user_input is not None: + if user_input is None: + if self._is_new: + options = RECOMMENDED_OPTIONS.copy() + else: + # If this is a reconfiguration, we need to copy the existing options + # so that we can show the current values in the form. + options = self._get_reconfigure_subentry().data.copy() + + self.last_rendered_recommended = cast( + bool, options.get(CONF_RECOMMENDED, False) + ) + + else: if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) + # Don't allow to save options that enable the Google Seearch tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True ): - # Don't allow to save options that enable the Google Seearch tool with an Assist API - return self.async_create_entry(title="", data=user_input) + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option" # Re-render the options again, now with the recommended options shown/hidden @@ -199,15 +240,19 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow): options = user_input schema = await google_generative_ai_config_option_schema( - self.hass, options, self._genai_client + self.hass, self._is_new, options, self._genai_client ) return self.async_show_form( - step_id="init", data_schema=vol.Schema(schema), errors=errors + step_id="set_options", data_schema=vol.Schema(schema), errors=errors ) + async_step_reconfigure = async_step_set_options + async_step_user = async_step_set_options + async def google_generative_ai_config_option_schema( hass: HomeAssistant, + is_new: bool, options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: @@ -224,23 +269,32 @@ async def google_generative_ai_config_option_schema( ): suggested_llm_apis = [suggested_llm_apis] - schema = { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + if is_new: + schema: dict[vol.Required | vol.Optional, Any] = { + vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + } + else: + schema = {} + + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) if options.get(CONF_RECOMMENDED): return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 7e699d7c8c0..0735e9015c2 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,6 +6,8 @@ DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" +DEFAULT_CONVERSATION_NAME = "Google AI Conversation" + ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 00199f5fe1f..d8eae3f6d0d 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Literal from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -22,8 +22,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = GoogleGenerativeAIConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [GoogleGenerativeAIConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) class GoogleGenerativeAIConversationEntity( @@ -35,10 +41,10 @@ class GoogleGenerativeAIConversationEntity( _attr_supports_streaming = True - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - super().__init__(entry) - if self.entry.options.get(CONF_LLM_HASS_API): + super().__init__(entry, subentry) + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -70,7 +76,7 @@ class GoogleGenerativeAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 7eef3dbacff..d4b0ec2bbd0 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -24,7 +24,7 @@ from google.genai.types import ( from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity @@ -301,17 +301,16 @@ async def _transform_stream( class GoogleGenerativeAILLMBaseEntity(Entity): """Google Generative AI base entity.""" - _attr_has_entity_name = True - _attr_name = None - - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry + self.subentry = subentry + self._attr_name = subentry.title self._genai_client = entry.runtime_data - self._attr_unique_id = entry.entry_id + self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, @@ -322,7 +321,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - options = self.entry.options + options = self.subentry.data tools: list[Tool | Callable[..., Any]] | None = None if chat_log.llm_api: diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index a57e2f78f53..e523aecbaec 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -18,35 +18,49 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "options": { - "step": { - "init": { - "data": { - "recommended": "Recommended model settings", - "prompt": "Instructions", - "chat_model": "[%key:common::generic::model%]", - "temperature": "Temperature", - "top_p": "Top P", - "top_k": "Top K", - "max_tokens": "Maximum tokens to return in response", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", - "hate_block_threshold": "Content that is rude, disrespectful, or profane", - "sexual_block_threshold": "Contains references to sexual acts or other lewd content", - "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", - "enable_google_search_tool": "Enable Google Search tool" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "Recommended model settings", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "temperature": "Temperature", + "top_p": "Top P", + "top_k": "Top K", + "max_tokens": "Maximum tokens to return in response", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes", + "hate_block_threshold": "Content that is rude, disrespectful, or profane", + "sexual_block_threshold": "Contains references to sexual acts or other lewd content", + "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts", + "enable_google_search_tool": "Enable Google Search tool" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"." + } } + }, + "abort": { + "entry_not_loaded": "Cannot add things while the configuration is disabled.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, + "error": { + "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } - }, - "error": { - "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } }, "services": { diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 160048e4897..50baec67db2 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -113,7 +113,6 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): self._attr_unique_id = f"{entry.entry_id}_tts" self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, manufacturer="Google", model="Generative AI", entry_type=dr.DeviceEntryType.SERVICE, diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index f499f18bc15..36d99cd2764 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -5,8 +5,9 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from homeassistant.components.google_generative_ai_conversation.entity import ( +from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_CONVERSATION_NAME, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -26,6 +27,15 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + subentries_data=[ + { + "data": {}, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) entry.runtime_data = Mock() entry.add_to_hass(hass) @@ -38,8 +48,10 @@ async def mock_config_entry_with_assist( ) -> MockConfigEntry: """Mock a config entry with assist.""" with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) await hass.async_block_till_done() return mock_config_entry @@ -51,9 +63,10 @@ async def mock_config_entry_with_google_search( ) -> MockConfigEntry: """Mock a config entry with assist.""" with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_USE_GOOGLE_SEARCH_TOOL: True, }, diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 0dc0996ad30..e02d85e41c4 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -22,6 +22,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -30,7 +31,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_TOP_P, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -110,10 +111,100 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ] assert len(mock_setup_entry.mock_calls) == 1 +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we get the form.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "set_options" + assert not result["errors"] + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock name" + + processed_options = RECOMMENDED_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + def will_options_be_rendered_again(current_options, new_options) -> bool: """Determine if options will be rendered again.""" return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED) @@ -283,7 +374,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool: ], ) @pytest.mark.usefixtures("mock_init_component") -async def test_options_switching( +async def test_subentry_options_switching( hass: HomeAssistant, mock_config_entry: MockConfigEntry, current_options, @@ -292,17 +383,18 @@ async def test_options_switching( errors, ) -> None: """Test the options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( - mock_config_entry, options=current_options + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options ) await hass.async_block_till_done() with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) if will_options_be_rendered_again(current_options, new_options): retry_options = { @@ -313,7 +405,7 @@ async def test_options_switching( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - options_flow = await hass.config_entries.options.async_configure( + options_flow = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], retry_options, ) @@ -321,14 +413,15 @@ async def test_options_switching( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], new_options, ) - await hass.async_block_till_done() + await hass.async_block_till_done() if errors is None: - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == expected_options else: assert options["type"] is FlowResultType.FORM @@ -375,7 +468,10 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: """Test the reauth flow.""" hass.config.components.add("google_generative_ai_conversation") mock_config_entry = MockConfigEntry( - domain=DOMAIN, state=config_entries.ConfigEntryState.LOADED, title="Gemini" + domain=DOMAIN, + state=config_entries.ConfigEntryState.LOADED, + title="Gemini", + version=2, ) mock_config_entry.add_to_hass(hass) mock_config_entry.async_start_reauth(hass) diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index 92aa6f08d42..ff9694257f9 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -64,7 +64,7 @@ async def test_error_handling( "hello", None, Context(), - agent_id="conversation.google_generative_ai_conversation", + agent_id="conversation.google_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result @@ -82,7 +82,7 @@ async def test_function_call( mock_send_message_stream: AsyncMock, ) -> None: """Test function calling.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -212,7 +212,7 @@ async def test_google_search_tool_is_sent( mock_send_message_stream: AsyncMock, ) -> None: """Test if the Google Search tool is sent to the model.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -278,7 +278,7 @@ async def test_blocked_response( mock_send_message_stream: AsyncMock, ) -> None: """Test blocked response.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -328,7 +328,7 @@ async def test_empty_response( ) -> None: """Test empty response.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -371,7 +371,7 @@ async def test_none_response( mock_send_message_stream: AsyncMock, ) -> None: """Test None response.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -403,10 +403,12 @@ async def test_converse_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test handling ChatLog raising ConverseError.""" + subentry = next(iter(mock_config_entry.subentries.values())) with patch("google.genai.models.AsyncModels.get"): - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"}, + next(iter(mock_config_entry.subentries.values())), + data={**subentry.data, CONF_LLM_HASS_API: "invalid_llm_api"}, ) await hass.async_block_till_done() @@ -415,7 +417,7 @@ async def test_converse_error( "hello", None, Context(), - agent_id="conversation.google_generative_ai_conversation", + agent_id="conversation.google_ai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -593,7 +595,7 @@ async def test_empty_content_in_chat_history( mock_send_message_stream: AsyncMock, ) -> None: """Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -648,7 +650,7 @@ async def test_history_always_user_first_turn( ) -> None: """Test that the user is always first in the chat history.""" - agent_id = "conversation.google_generative_ai_conversation" + agent_id = "conversation.google_ai_conversation" context = Context() messages = [ @@ -674,7 +676,7 @@ async def test_history_always_user_first_turn( mock_chat_log.async_add_assistant_content_without_tools( conversation.AssistantContent( - agent_id="conversation.google_generative_ai_conversation", + agent_id="conversation.google_ai_conversation", content="Garage door left open, do you want to close it?", ) ) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 6cc0bdd5f44..dc42232fa65 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -7,9 +7,12 @@ import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion +from homeassistant.components.google_generative_ai_conversation.const import DOMAIN from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID @@ -387,3 +390,214 @@ async def test_load_entry_with_unloaded_entries( "text": stubbed_generated_content, } assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 + for subentry in entry.subentries.values(): + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + + subentry = list(entry.subentries.values())[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + + subentry = list(entry.subentries.values())[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + + +async def test_migration_from_v1_to_v2_with_multiple_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "12345"}, + options=options, + version=1, + title="Google Generative AI 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for entry in entries: + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 1 + subentry = list(entry.subentries.values())[0] + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 5ea056307b5..4f197f0535f 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -122,7 +122,9 @@ async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: """Mock config entry setup.""" default_config = {tts.CONF_LANG: "en-US"} - config_entry = MockConfigEntry(domain=DOMAIN, data=default_config | config) + config_entry = MockConfigEntry( + domain=DOMAIN, data=default_config | config, version=2 + ) client_mock = Mock() client_mock.models.get = None From 0cf795296466a0470bbaec46284993dfb978b884 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 23 Jun 2025 22:34:06 -0400 Subject: [PATCH 0582/1664] Remove duplicated subentry device update in Google Gen AI + add merge test (#147396) * late comments on Google subentries * Add test that merges 2 config entries --- .../__init__.py | 5 - .../test_init.py | 121 ++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 4830e204654..3a7d160399d 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -267,11 +267,6 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: add_config_entry_id=parent_entry.entry_id, ) if parent_entry.entry_id != entry.entry_id: - device_registry.async_update_device( - device.id, - add_config_subentry_id=subentry.subentry_id, - add_config_entry_id=parent_entry.entry_id, - ) device_registry.async_update_device( device.id, remove_config_entry_id=entry.entry_id, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index dc42232fa65..8de678213c2 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -601,3 +601,124 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + + +async def test_migration_from_v1_to_v2_with_same_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with same API keys.""" + # Create v1 config entries with the same API key + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 + for subentry in entry.subentries.values(): + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + + subentry = list(entry.subentries.values())[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + + subentry = list(entry.subentries.values())[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id From eff35e93bdc065946fd54453db3ef16702bc0ce1 Mon Sep 17 00:00:00 2001 From: Geoff <85890024+GhoweVege@users.noreply.github.com> Date: Mon, 23 Jun 2025 22:55:34 -0600 Subject: [PATCH 0583/1664] New core integration for VegeHub (#129598) * Initial commit for VegeHub integration * Moved several pieces to library, continuing. * All device contact moved to library * Updated documentation link * Fixed an error in strings.json * Removed commented out code and unused file * Removed unneeded info logging, and a few missed lines of commented code * Added/removed comments for clarity * Converted integration to use webhooks. * Update __init__.py to remove unnecessary code. Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Remove unnecessary code from config_flow.py Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> * Simplify unique_id assertion. * Switch to CONF_ constant for user input * Added explanation for passing exception. * Got rid of try-except, since I don't really handle the exceptions her anyway. * Moved data transform to vegehub library * Changed references to use HA constants. * Fixed assigning and returning _attr properties. * Moved temperature sensor transform to the library. * Moved sensor names to strings.json * Made webhook names unique to avoid collisions when multiple devices are added. * Converted to using entry.runtime_data * Removed options flow for first PR * Removed switch support to limit PR to one platform * Removed/updated outdated tests * Update homeassistant/components/vegehub/__init__.py Co-authored-by: Josef Zweck * Got rid of strings in favor of constants. * Got rid of unnecessary check * Imported constant directly. * Added custom type for entry * Expanded CONF_ constants into sensor.py * Get rid of extra `str` and `get` Co-authored-by: Josef Zweck * Added type to errors * Added try/except to MAC address retrieval * Moved functionality out of ConfigFlow that shouldn't have been there * Removed IP:MAC tracking from ConfigFlow * Added retries to VegeHub PyPI package, and implemented them in integration * Removed different sensor types for now * Fixed typo * Changed abort to error * Fixed error reporting in config flow * Further simplify sensor.py to handle all sensors the same * Added comment to clarify * Got rid of unused constants * Removed unused strings in strings.json * Added quality_scale.yaml * Fixed problems in sensor init * Moved config url and sw version storage into vegehub package * Get rid of extra declaration Co-authored-by: Josef Zweck * Removed unnecessary task * Fix type for entry * Added a test before setup * Fixed tests and got test coverage of config flow to 100% * Fixed test descriptions * Implemented a coordinator * Removed unused property * Fixed a few minor issues with the coordinator implementation * Removed unused function * Fixed some tests * Trying to fix a problem with re-initialization when server reboots. Mostly working. * Moved hub.setup from async_setup_entry to config flow to avoid running it on system reboot * Delete tests/testing_config/.storage/http.auth * Fixed errors in coordinator.py * Added IP validation for manual input IP addresses * Moved data into self._discovered to simplify * Removed redundant typing * Shortened sensor unique ID and added coordinator handler * Added call to super()._handle_coordinator_update() so state gets handled correctly * Fixed == and is * Got rid of "slot" and moved functionality to lib * Got rid of mocked aiohttp calls in favor of just mocking the vegehub library * Rewrote config flow to make more sense. * Changed order of data and data_description * Changes to sensor.py * Got rid of async_update_data in coordinator and moved async_set_updated_data into webhook callback * Changed sensor updates so that they keep using last known values if update doesn't contain data for them * Changed config flow to use homeassistant.helpers.service_info zeroconf instead of homeassistant.components zeroconf * Added types to test parameters * Changes and notes in config_flow.py * Minor fix to get existing tests working before making changes to tests * Removed unused data and simplified data passing * Fixed tests, removed unused data, moved sensor tests to snapshots * Mocked async_setup_entry and async_unload_entry * Eliminated retry step so that retries just happen in the user flow or zeroconf_confirm * Bumped the library version * Bumped library version again * Changed test-before-setup test * Improved use of coordinator * Almost done reworking tests. A few more changes still needed. * Added via device to sensor.py and key reference to strings.json * Webhook tests are almost, but not quite, working * Fully functional again * Change error to assert * made identifiers and via_device the same * made the via_device just be the mac * Fixed strings.json and updated translations * Fixed test_sensor.py * Cleaned up tests and added autouse to several fixtures to simplify * Switched from error to assert, and added exemption to quality scale. * Cleaned up some tests and added update of IP if unique ID of discovered device is the same. * Improved zeroconfig to update IP and hostname, and added a test to make sure those work. * Fixed a comment. * Improved ip/hostname update test. * Changed Hub to VegeHub in strings.json for clarity. * Switched to using a base entity to simplify and make adding platforms in the future easier. * Moved the vegehub object into the coordinator to simplify. * Removed actuators from sensors, and added unique name for battery sensor * Changed coordinator to manage its own data, changed sensors to use descriptions and return their value as a property * Updated data retrieval keys * Minor updates to several files * Fixed a few things for pytest * Reverted to explicit check for None for pytest * Fixed a comment and a variable name * Fixed a comment * Fix * Bumped depenency version to eliminate pytest from dependencies. --------- Co-authored-by: Josef Zweck <24647999+zweckj@users.noreply.github.com> Co-authored-by: Josef Zweck Co-authored-by: Joostlek --- CODEOWNERS | 2 + homeassistant/components/vegehub/__init__.py | 103 +++++ .../components/vegehub/config_flow.py | 168 ++++++++ homeassistant/components/vegehub/const.py | 9 + .../components/vegehub/coordinator.py | 52 +++ homeassistant/components/vegehub/entity.py | 28 ++ .../components/vegehub/manifest.json | 12 + .../components/vegehub/quality_scale.yaml | 84 ++++ homeassistant/components/vegehub/sensor.py | 94 +++++ homeassistant/components/vegehub/strings.json | 44 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/vegehub/__init__.py | 14 + tests/components/vegehub/conftest.py | 82 ++++ .../components/vegehub/fixtures/info_hub.json | 24 ++ .../vegehub/snapshots/test_sensor.ambr | 160 ++++++++ tests/components/vegehub/test_config_flow.py | 385 ++++++++++++++++++ tests/components/vegehub/test_sensor.py | 63 +++ 21 files changed, 1342 insertions(+) create mode 100644 homeassistant/components/vegehub/__init__.py create mode 100644 homeassistant/components/vegehub/config_flow.py create mode 100644 homeassistant/components/vegehub/const.py create mode 100644 homeassistant/components/vegehub/coordinator.py create mode 100644 homeassistant/components/vegehub/entity.py create mode 100644 homeassistant/components/vegehub/manifest.json create mode 100644 homeassistant/components/vegehub/quality_scale.yaml create mode 100644 homeassistant/components/vegehub/sensor.py create mode 100644 homeassistant/components/vegehub/strings.json create mode 100644 tests/components/vegehub/__init__.py create mode 100644 tests/components/vegehub/conftest.py create mode 100644 tests/components/vegehub/fixtures/info_hub.json create mode 100644 tests/components/vegehub/snapshots/test_sensor.ambr create mode 100644 tests/components/vegehub/test_config_flow.py create mode 100644 tests/components/vegehub/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 98cea97204f..2406606bb28 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1672,6 +1672,8 @@ build.json @home-assistant/supervisor /tests/components/vallox/ @andre-richter @slovdahl @viiru- @yozik04 /homeassistant/components/valve/ @home-assistant/core /tests/components/valve/ @home-assistant/core +/homeassistant/components/vegehub/ @ghowevege +/tests/components/vegehub/ @ghowevege /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio diff --git a/homeassistant/components/vegehub/__init__.py b/homeassistant/components/vegehub/__init__.py new file mode 100644 index 00000000000..1957ed9295b --- /dev/null +++ b/homeassistant/components/vegehub/__init__.py @@ -0,0 +1,103 @@ +"""The Vegetronix VegeHub integration.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from typing import Any + +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response +from vegehub import VegeHub + +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) +from homeassistant.const import ( + CONF_DEVICE, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import HomeAssistant + +from .const import DOMAIN, NAME, PLATFORMS +from .coordinator import VegeHubConfigEntry, VegeHubCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: VegeHubConfigEntry) -> bool: + """Set up VegeHub from a config entry.""" + + device_mac = entry.data[CONF_MAC] + + assert entry.unique_id + + vegehub = VegeHub( + entry.data[CONF_IP_ADDRESS], + device_mac, + entry.unique_id, + info=entry.data[CONF_DEVICE], + ) + + # Initialize runtime data + entry.runtime_data = VegeHubCoordinator( + hass=hass, config_entry=entry, vegehub=vegehub + ) + + async def unregister_webhook(_: Any) -> None: + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + async def register_webhook() -> None: + webhook_name = f"{NAME} {device_mac}" + + webhook_register( + hass, + DOMAIN, + webhook_name, + entry.data[CONF_WEBHOOK_ID], + get_webhook_handler(device_mac, entry.entry_id, entry.runtime_data), + allowed_methods=[METH_POST], + ) + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + ) + + # Now add in all the entities for this device. + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + await register_webhook() + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: VegeHubConfigEntry) -> bool: + """Unload a VegeHub config entry.""" + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + # Unload platforms + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def get_webhook_handler( + device_mac: str, entry_id: str, coordinator: VegeHubCoordinator +) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: + """Return webhook handler.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + # Handle http post calls to the path. + if not request.body_exists: + return HomeAssistantView.json( + result="No Body", status_code=HTTPStatus.BAD_REQUEST + ) + data = await request.json() + + if coordinator: + await coordinator.update_from_webhook(data) + + return HomeAssistantView.json(result="OK", status_code=HTTPStatus.OK) + + return async_webhook_handler diff --git a/homeassistant/components/vegehub/config_flow.py b/homeassistant/components/vegehub/config_flow.py new file mode 100644 index 00000000000..348457c99e9 --- /dev/null +++ b/homeassistant/components/vegehub/config_flow.py @@ -0,0 +1,168 @@ +"""Config flow for the VegeHub integration.""" + +import logging +from typing import Any + +from vegehub import VegeHub +import voluptuous as vol + +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_url as webhook_generate_url, +) +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, +) +from homeassistant.helpers.service_info import zeroconf +from homeassistant.util.network import is_ip_address + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class VegeHubConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for VegeHub integration.""" + + _hub: VegeHub + _hostname: str + webhook_id: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + + if user_input is not None: + if not is_ip_address(user_input[CONF_IP_ADDRESS]): + # User-supplied IP address is invalid. + errors["base"] = "invalid_ip" + + if not errors: + self._hub = VegeHub(user_input[CONF_IP_ADDRESS]) + self._hostname = self._hub.ip_address + errors = await self._setup_device() + if not errors: + # Proceed to create the config entry + return await self._create_entry() + + # Show the form to allow the user to manually enter the IP address + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + # Extract the IP address from the zeroconf discovery info + device_ip = discovery_info.host + + self._async_abort_entries_match({CONF_IP_ADDRESS: device_ip}) + + self._hostname = discovery_info.hostname.removesuffix(".local.") + config_url = f"http://{discovery_info.hostname[:-1]}:{discovery_info.port}" + + # Create a VegeHub object to interact with the device + self._hub = VegeHub(device_ip) + + try: + await self._hub.retrieve_mac_address(retries=2) + except ConnectionError: + return self.async_abort(reason="cannot_connect") + except TimeoutError: + return self.async_abort(reason="timeout_connect") + + if not self._hub.mac_address: + return self.async_abort(reason="cannot_connect") + + # Check if this device already exists + await self.async_set_unique_id(self._hub.mac_address) + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: device_ip, CONF_HOST: self._hostname} + ) + + # Add title and configuration URL to the context so that the device discovery + # tile has the correct title, and a "Visit Device" link available. + self.context.update( + { + "title_placeholders": {"host": self._hostname + " (" + device_ip + ")"}, + "configuration_url": (config_url), + } + ) + + # If the device is new, allow the user to continue setup + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle user confirmation for a discovered device.""" + errors: dict[str, str] = {} + if user_input is not None: + errors = await self._setup_device() + if not errors: + return await self._create_entry() + + # Show the confirmation form + self._set_confirm_only() + return self.async_show_form(step_id="zeroconf_confirm", errors=errors) + + async def _setup_device(self) -> dict[str, str]: + """Set up the VegeHub device.""" + errors: dict[str, str] = {} + self.webhook_id = webhook_generate_id() + webhook_url = webhook_generate_url( + self.hass, + self.webhook_id, + allow_external=False, + allow_ip=True, + ) + + # Send the webhook address to the hub as its server target. + # This step can happen in the init, because that gets executed + # every time Home Assistant starts up, and this step should + # only happen in the initial setup of the VegeHub. + try: + await self._hub.setup("", webhook_url, retries=1) + except ConnectionError: + errors["base"] = "cannot_connect" + except TimeoutError: + errors["base"] = "timeout_connect" + + if not self._hub.mac_address: + errors["base"] = "cannot_connect" + + return errors + + async def _create_entry(self) -> ConfigFlowResult: + """Create a config entry for the device.""" + + # Check if this device already exists + await self.async_set_unique_id(self._hub.mac_address) + self._abort_if_unique_id_configured() + + # Save Hub info to be used later when defining the VegeHub object + info_data = { + CONF_IP_ADDRESS: self._hub.ip_address, + CONF_HOST: self._hostname, + CONF_MAC: self._hub.mac_address, + CONF_DEVICE: self._hub.info, + CONF_WEBHOOK_ID: self.webhook_id, + } + + # Create the config entry for the new device + return self.async_create_entry(title=self._hostname, data=info_data) diff --git a/homeassistant/components/vegehub/const.py b/homeassistant/components/vegehub/const.py new file mode 100644 index 00000000000..960ea4d3a91 --- /dev/null +++ b/homeassistant/components/vegehub/const.py @@ -0,0 +1,9 @@ +"""Constants for the Vegetronix VegeHub integration.""" + +from homeassistant.const import Platform + +DOMAIN = "vegehub" +NAME = "VegeHub" +PLATFORMS = [Platform.SENSOR] +MANUFACTURER = "vegetronix" +MODEL = "VegeHub" diff --git a/homeassistant/components/vegehub/coordinator.py b/homeassistant/components/vegehub/coordinator.py new file mode 100644 index 00000000000..43fb1c40274 --- /dev/null +++ b/homeassistant/components/vegehub/coordinator.py @@ -0,0 +1,52 @@ +"""Coordinator for the Vegetronix VegeHub.""" + +from __future__ import annotations + +import logging +from typing import Any + +from vegehub import VegeHub, update_data_to_ha_dict + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +type VegeHubConfigEntry = ConfigEntry[VegeHub] + + +class VegeHubCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """The DataUpdateCoordinator for VegeHub.""" + + config_entry: VegeHubConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: VegeHubConfigEntry, vegehub: VegeHub + ) -> None: + """Initialize VegeHub data coordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"{config_entry.unique_id} DataUpdateCoordinator", + config_entry=config_entry, + ) + self.vegehub = vegehub + self.device_id = config_entry.unique_id + assert self.device_id is not None, "Config entry is missing unique_id" + + async def update_from_webhook(self, data: dict) -> None: + """Process and update data from webhook.""" + sensor_data = update_data_to_ha_dict( + data, + self.vegehub.num_sensors or 0, + self.vegehub.num_actuators or 0, + self.vegehub.is_ac or False, + ) + if self.data: + existing_data: dict = self.data + existing_data.update(sensor_data) + if sensor_data: + self.async_set_updated_data(existing_data) + else: + self.async_set_updated_data(sensor_data) diff --git a/homeassistant/components/vegehub/entity.py b/homeassistant/components/vegehub/entity.py new file mode 100644 index 00000000000..a42c1f62957 --- /dev/null +++ b/homeassistant/components/vegehub/entity.py @@ -0,0 +1,28 @@ +"""Base entity for VegeHub.""" + +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import MANUFACTURER, MODEL +from .coordinator import VegeHubCoordinator + + +class VegeHubEntity(CoordinatorEntity[VegeHubCoordinator]): + """Defines a base VegeHub entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: VegeHubCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + config_entry = coordinator.config_entry + self._mac_address = config_entry.data[CONF_MAC] + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self._mac_address)}, + name=config_entry.data[CONF_HOST], + manufacturer=MANUFACTURER, + model=MODEL, + sw_version=coordinator.vegehub.sw_version, + configuration_url=coordinator.vegehub.url, + ) diff --git a/homeassistant/components/vegehub/manifest.json b/homeassistant/components/vegehub/manifest.json new file mode 100644 index 00000000000..9ccaabb6b4b --- /dev/null +++ b/homeassistant/components/vegehub/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "vegehub", + "name": "Vegetronix VegeHub", + "codeowners": ["@ghowevege"], + "config_flow": true, + "dependencies": ["http", "webhook"], + "documentation": "https://www.home-assistant.io/integrations/vegehub", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["vegehub==0.1.24"], + "zeroconf": ["_vege._tcp.local."] +} diff --git a/homeassistant/components/vegehub/quality_scale.yaml b/homeassistant/components/vegehub/quality_scale.yaml new file mode 100644 index 00000000000..51c74033092 --- /dev/null +++ b/homeassistant/components/vegehub/quality_scale.yaml @@ -0,0 +1,84 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide additional actions. + appropriate-polling: + status: exempt + comment: | + This integration does not poll. + 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 do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: + status: exempt + comment: | + It is possible for this device to be offline at setup time and still be functioning correctly. It can not be tested at setup. + unique-config-entry: done + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: | + This integration does not require authentication. + test-coverage: todo + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + This integration has a fixed single device. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: done + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: | + This integration has a fixed single device. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/vegehub/sensor.py b/homeassistant/components/vegehub/sensor.py new file mode 100644 index 00000000000..1520a56dac4 --- /dev/null +++ b/homeassistant/components/vegehub/sensor.py @@ -0,0 +1,94 @@ +"""Sensor configuration for VegeHub integration.""" + +from itertools import count + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import UnitOfElectricPotential +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import VegeHubConfigEntry, VegeHubCoordinator +from .entity import VegeHubEntity + +SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "analog_sensor": SensorEntityDescription( + key="analog_sensor", + translation_key="analog_sensor", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=2, + ), + "battery_volts": SensorEntityDescription( + key="battery_volts", + translation_key="battery_volts", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + suggested_display_precision=1, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VegeHubConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Vegetronix sensors from a config entry.""" + sensors: list[VegeHubSensor] = [] + coordinator = config_entry.runtime_data + + sensor_index = count(0) + + # Add each analog sensor input + for _i in range(coordinator.vegehub.num_sensors): + sensor = VegeHubSensor( + index=next(sensor_index), + coordinator=coordinator, + description=SENSOR_TYPES["analog_sensor"], + ) + sensors.append(sensor) + + # Add the battery sensor + if not coordinator.vegehub.is_ac: + sensors.append( + VegeHubSensor( + index=next(sensor_index), + coordinator=coordinator, + description=SENSOR_TYPES["battery_volts"], + ) + ) + + async_add_entities(sensors) + + +class VegeHubSensor(VegeHubEntity, SensorEntity): + """Class for VegeHub Analog Sensors.""" + + def __init__( + self, + index: int, + coordinator: VegeHubCoordinator, + description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = description + # Set data key for pulling data from the coordinator + if description.key == "battery_volts": + self.data_key = "battery" + else: + self.data_key = f"analog_{index}" + self._attr_translation_placeholders = {"index": str(index + 1)} + self._attr_unique_id = f"{self._mac_address}_{self.data_key}" + self._attr_available = False + + @property + def native_value(self) -> float | None: + """Return the sensor's current value.""" + if self.coordinator.data is None: + return None + return self.coordinator.data.get(self.data_key) diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json new file mode 100644 index 00000000000..aa9b3aad227 --- /dev/null +++ b/homeassistant/components/vegehub/strings.json @@ -0,0 +1,44 @@ +{ + "title": "VegeHub", + "config": { + "flow_title": "{host}", + "step": { + "user": { + "title": "Set up VegeHub", + "description": "Do you want to set up this VegeHub?", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]" + }, + "data_description": { + "ip_address": "IP address of target VegeHub" + } + }, + "zeroconf_confirm": { + "title": "[%key:component::vegehub::config::step::user::title%]", + "description": "[%key:component::vegehub::config::step::user::description%]" + } + }, + "error": { + "cannot_connect": "Failed to connect. Ensure VegeHub is awake, and try again.", + "timeout_connect": "Timeout establishing connection. Ensure VegeHub is awake, and try again.", + "invalid_ip": "Invalid IPv4 address." + }, + "abort": { + "cannot_connect": "Failed to connect to the device. Please try again.", + "timeout_connect": "Timed out connecting. Ensure VegeHub is awake, and try again.", + "already_in_progress": "Device already detected. Check discovered devices.", + "already_configured": "Device is already configured.", + "unknown_error": "An unknown error has occurred." + } + }, + "entity": { + "sensor": { + "analog_sensor": { + "name": "Input {index}" + }, + "battery_volts": { + "name": "Battery voltage" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 19037ac31e8..97e7929d317 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -683,6 +683,7 @@ FLOWS = { "uptimerobot", "v2c", "vallox", + "vegehub", "velbus", "velux", "venstar", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f207191330a..bd88338c4b9 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7123,6 +7123,11 @@ "config_flow": false, "iot_class": "cloud_polling" }, + "vegehub": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "velbus": { "name": "Velbus", "integration_type": "hub", @@ -7922,6 +7927,7 @@ "trend", "uptime", "utility_meter", + "vegehub", "version", "waze_travel_time", "workday", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 21abaa2a579..3af4b8caa8d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -915,6 +915,11 @@ ZEROCONF = { "name": "uzg-01*", }, ], + "_vege._tcp.local.": [ + { + "domain": "vegehub", + }, + ], "_viziocast._tcp.local.": [ { "domain": "vizio", diff --git a/requirements_all.txt b/requirements_all.txt index 9520d10b167..98e26da89ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3034,6 +3034,9 @@ vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 +# homeassistant.components.vegehub +vegehub==0.1.24 + # homeassistant.components.rdw vehicle==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f43f1a2ed0d..1348f1bd428 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2499,6 +2499,9 @@ vacuum-map-parser-roborock==0.1.4 # homeassistant.components.vallox vallox-websocket-api==5.3.0 +# homeassistant.components.vegehub +vegehub==0.1.24 + # homeassistant.components.rdw vehicle==2.2.2 diff --git a/tests/components/vegehub/__init__.py b/tests/components/vegehub/__init__.py new file mode 100644 index 00000000000..4b0a4f0f098 --- /dev/null +++ b/tests/components/vegehub/__init__.py @@ -0,0 +1,14 @@ +"""Tests for the Vegetronix VegeHub integration.""" + +from homeassistant.components.vegehub.coordinator import VegeHubConfigEntry +from homeassistant.core import HomeAssistant + + +async def init_integration( + hass: HomeAssistant, + config_entry: VegeHubConfigEntry, +) -> None: + """Load the VegeHub integration.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/vegehub/conftest.py b/tests/components/vegehub/conftest.py new file mode 100644 index 00000000000..6e48feb4271 --- /dev/null +++ b/tests/components/vegehub/conftest.py @@ -0,0 +1,82 @@ +"""Fixtures and test data for VegeHub test methods.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_fixture + +TEST_IP = "192.168.0.100" +TEST_UNIQUE_ID = "aabbccddeeff" +TEST_SERVER = "http://example.com" +TEST_MAC = "A1:B2:C3:D4:E5:F6" +TEST_SIMPLE_MAC = "A1B2C3D4E5F6" +TEST_HOSTNAME = "VegeHub" +TEST_WEBHOOK_ID = "webhook_id" +HUB_DATA = { + "first_boot": False, + "page_updated": False, + "error_message": 0, + "num_channels": 2, + "num_actuators": 2, + "version": "3.4.5", + "agenda": 1, + "batt_v": 9.0, + "num_vsens": 0, + "is_ac": 0, + "has_sd": 0, + "on_ap": 0, +} + + +@pytest.fixture(autouse=True) +def mock_vegehub() -> Generator[Any, Any, Any]: + """Mock the VegeHub library.""" + with patch( + "homeassistant.components.vegehub.config_flow.VegeHub", autospec=True + ) as mock_vegehub_class: + mock_instance = mock_vegehub_class.return_value + # Simulate successful API calls + mock_instance.retrieve_mac_address = AsyncMock(return_value=True) + mock_instance.setup = AsyncMock(return_value=True) + + # Mock properties + mock_instance.ip_address = TEST_IP + mock_instance.mac_address = TEST_SIMPLE_MAC + mock_instance.unique_id = TEST_UNIQUE_ID + mock_instance.url = f"http://{TEST_IP}" + mock_instance.info = load_fixture("vegehub/info_hub.json") + mock_instance.num_sensors = 2 + mock_instance.num_actuators = 2 + mock_instance.sw_version = "3.4.5" + + yield mock_instance + + +@pytest.fixture(name="mocked_config_entry") +async def fixture_mocked_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create a mock VegeHub config entry.""" + return MockConfigEntry( + domain="vegehub", + data={ + CONF_MAC: TEST_SIMPLE_MAC, + CONF_IP_ADDRESS: TEST_IP, + CONF_HOST: TEST_HOSTNAME, + CONF_DEVICE: HUB_DATA, + CONF_WEBHOOK_ID: TEST_WEBHOOK_ID, + }, + unique_id=TEST_SIMPLE_MAC, + title="VegeHub", + entry_id="12345", + ) diff --git a/tests/components/vegehub/fixtures/info_hub.json b/tests/components/vegehub/fixtures/info_hub.json new file mode 100644 index 00000000000..f12731e881e --- /dev/null +++ b/tests/components/vegehub/fixtures/info_hub.json @@ -0,0 +1,24 @@ +{ + "hub": { + "first_boot": false, + "page_updated": false, + "error_message": 0, + "num_channels": 2, + "num_actuators": 2, + "version": "3.4.5", + "agenda": 1, + "batt_v": 9.0, + "num_vsens": 0, + "is_ac": 0, + "has_sd": 0, + "on_ap": 0 + }, + "wifi": { + "ssid": "YourWiFiName", + "strength": "-29", + "chan": "4", + "ip": "192.168.0.100", + "status": "3", + "mac_addr": "A1:B2:C3:D4:E5:F6" + } +} diff --git a/tests/components/vegehub/snapshots/test_sensor.ambr b/tests/components/vegehub/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3a9a93dc03b --- /dev/null +++ b/tests/components/vegehub/snapshots/test_sensor.ambr @@ -0,0 +1,160 @@ +# serializer version: 1 +# name: test_sensor_entities[sensor.vegehub_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.vegehub_battery_voltage', + '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': 'Battery voltage', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_volts', + 'unique_id': 'A1B2C3D4E5F6_battery', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Battery voltage', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.330000043', + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_1-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.vegehub_input_1', + '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': 'Input 1', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_0', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 1', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_2-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.vegehub_input_2', + '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': 'Input 2', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 2', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.45599997', + }) +# --- diff --git a/tests/components/vegehub/test_config_flow.py b/tests/components/vegehub/test_config_flow.py new file mode 100644 index 00000000000..1cf3924f72f --- /dev/null +++ b/tests/components/vegehub/test_config_flow.py @@ -0,0 +1,385 @@ +"""Tests for VegeHub config flow.""" + +from collections.abc import Generator +from ipaddress import ip_address +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components import zeroconf +from homeassistant.components.vegehub.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import ( + CONF_DEVICE, + CONF_HOST, + CONF_IP_ADDRESS, + CONF_MAC, + CONF_WEBHOOK_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TEST_HOSTNAME, TEST_IP, TEST_SIMPLE_MAC + +from tests.common import MockConfigEntry + +DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(TEST_IP), + ip_addresses=[ip_address(TEST_IP)], + port=80, + hostname=f"{TEST_HOSTNAME}.local.", + type="mock_type", + name="myVege", + properties={ + zeroconf.ATTR_PROPERTIES_ID: TEST_HOSTNAME, + "version": "5.1.1", + }, +) + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[Any, Any, Any]: + """Prevent the actual integration from being set up.""" + with ( + patch("homeassistant.components.vegehub.async_setup_entry", return_value=True), + ): + yield + + +# Tests for flows where the user manually inputs an IP address +async def test_user_flow_success(hass: HomeAssistant) -> None: + """Test the user flow with successful configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_IP + assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC + assert result["data"][CONF_IP_ADDRESS] == TEST_IP + assert result["data"][CONF_DEVICE] is not None + assert result["data"][CONF_WEBHOOK_ID] is not None + + # Since this is user flow, there is no hostname, so hostname should be the IP address + assert result["data"][CONF_HOST] == TEST_IP + assert result["result"].unique_id == TEST_SIMPLE_MAC + + # Confirm that the entry was created + entries = hass.config_entries.async_entries(domain=DOMAIN) + assert len(entries) == 1 + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test the user flow with bad data.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_vegehub.mac_address = "" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + mock_vegehub.mac_address = TEST_SIMPLE_MAC + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TimeoutError, "timeout_connect"), + (ConnectionError, "cannot_connect"), + ], +) +async def test_user_flow_device_bad_connection_then_success( + hass: HomeAssistant, + mock_vegehub: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test the user flow with a timeout.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + mock_vegehub.setup.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert "errors" in result + assert result["errors"] == {"base": expected_error} + + mock_vegehub.setup.side_effect = None # Clear the error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_IP + assert result["data"][CONF_IP_ADDRESS] == TEST_IP + assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC + + +async def test_user_flow_no_ip_entered(hass: HomeAssistant) -> None: + """Test the user flow with blank IP.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: ""} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_ip" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_bad_ip_entered(hass: HomeAssistant) -> None: + """Test the user flow with badly formed IP.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "192.168.0"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "invalid_ip" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_duplicate_device( + hass: HomeAssistant, mocked_config_entry: MockConfigEntry +) -> None: + """Test when user flow gets the same device twice.""" + + mocked_config_entry.add_to_hass(hass) + + 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"], {CONF_IP_ADDRESS: TEST_IP} + ) + + assert result["type"] is FlowResultType.ABORT + + +# Tests for flows that start in zeroconf +async def test_zeroconf_flow_success(hass: HomeAssistant) -> None: + """Test the zeroconf discovery flow with successful configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Display the confirmation form + result = await hass.config_entries.flow.async_configure(result["flow_id"], None) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Proceed to creating the entry + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == TEST_HOSTNAME + assert result["data"][CONF_HOST] == TEST_HOSTNAME + assert result["data"][CONF_MAC] == TEST_SIMPLE_MAC + assert result["result"].unique_id == TEST_SIMPLE_MAC + + +async def test_zeroconf_flow_abort_device_asleep( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test when zeroconf tries to contact a device that is asleep.""" + + mock_vegehub.retrieve_mac_address.side_effect = TimeoutError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "timeout_connect" + + +async def test_zeroconf_flow_abort_same_id( + hass: HomeAssistant, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test when zeroconf gets the same device twice.""" + + mocked_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + + +async def test_zeroconf_flow_abort_cannot_connect( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test when zeroconf gets bad data.""" + + mock_vegehub.mac_address = "" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_zeroconf_flow_abort_cannot_connect_404( + hass: HomeAssistant, + mock_vegehub: MagicMock, +) -> None: + """Test when zeroconf gets bad responses.""" + + mock_vegehub.retrieve_mac_address.side_effect = ConnectionError + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (TimeoutError, "timeout_connect"), + (ConnectionError, "cannot_connect"), + ], +) +async def test_zeroconf_flow_device_error_response( + hass: HomeAssistant, + mock_vegehub: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test when zeroconf detects the device, but the communication fails at setup.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=DISCOVERY_INFO + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Part way through the process, we simulate getting bad responses + mock_vegehub.setup.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == expected_error + + mock_vegehub.setup.side_effect = None + + # Proceed to creating the entry + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_zeroconf_flow_update_ip_hostname( + hass: HomeAssistant, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test when zeroconf gets the same device with a new IP and hostname.""" + + mocked_config_entry.add_to_hass(hass) + + # Use the same discovery info, but change the IP and hostname + new_ip = "192.168.0.99" + new_hostname = "new_hostname" + new_discovery_info = zeroconf.ZeroconfServiceInfo( + ip_address=ip_address(new_ip), + ip_addresses=[ip_address(new_ip)], + port=DISCOVERY_INFO.port, + hostname=f"{new_hostname}.local.", + type=DISCOVERY_INFO.type, + name=DISCOVERY_INFO.name, + properties=DISCOVERY_INFO.properties, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=new_discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + + # Check if the original config entry has been updated + entries = hass.config_entries.async_entries(domain=DOMAIN) + assert len(entries) == 1 + assert mocked_config_entry.data[CONF_IP_ADDRESS] == new_ip + assert mocked_config_entry.data[CONF_HOST] == new_hostname diff --git a/tests/components/vegehub/test_sensor.py b/tests/components/vegehub/test_sensor.py new file mode 100644 index 00000000000..b6b4533c3b9 --- /dev/null +++ b/tests/components/vegehub/test_sensor.py @@ -0,0 +1,63 @@ +"""Unit tests for the VegeHub integration's sensor.py.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration +from .conftest import TEST_SIMPLE_MAC, TEST_WEBHOOK_ID + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + +UPDATE_DATA = { + "api_key": "", + "mac": TEST_SIMPLE_MAC, + "error_code": 0, + "sensors": [ + {"slot": 1, "samples": [{"v": 1.5, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 2, "samples": [{"v": 1.45599997, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 3, "samples": [{"v": 1.330000043, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 4, "samples": [{"v": 0.075999998, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 5, "samples": [{"v": 9.314800262, "t": "2025-01-15T16:51:23Z"}]}, + ], + "send_time": 1736959883, + "wifi_str": -27, +} + + +async def test_sensor_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client_no_auth: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mocked_config_entry) + + assert TEST_WEBHOOK_ID in hass.data["webhook"], "Webhook was not registered" + + # Verify the webhook handler + webhook_info = hass.data["webhook"][TEST_WEBHOOK_ID] + assert webhook_info["handler"], "Webhook handler is not set" + + client = await hass_client_no_auth() + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Send the same update again so that the coordinator modifies existing data + # instead of creating new data. + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + assert resp.status == 200, f"Unexpected status code: {resp.status}" + await snapshot_platform( + hass, entity_registry, snapshot, mocked_config_entry.entry_id + ) From 121239bcf7a48460a0ea8b5d45a664fadd53ccd2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 24 Jun 2025 08:53:45 +0200 Subject: [PATCH 0584/1664] Fix unbound var and tests in PlayStation Network integration (#147398) fix unbound var and test mocks --- .../components/playstation_network/config_flow.py | 3 +-- .../components/playstation_network/strings.json | 2 +- tests/components/playstation_network/conftest.py | 9 +++------ tests/components/playstation_network/test_config_flow.py | 4 ++-- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index c177aa6e219..e2b402a212e 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -37,8 +37,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): npsso = parse_npsso_token(user_input[CONF_NPSSO]) except PSNAWPInvalidTokenError: errors["base"] = "invalid_account" - - if npsso: + else: psn = PlaystationNetwork(self.hass, npsso) try: user: User = await psn.get_user() diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 01fc551d929..e4581322edb 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -6,7 +6,7 @@ "npsso": "NPSSO token" }, "data_description": { - "npsso": "The NPSSO token is generated during successful login of your PlayStation Network account and is used to authenticate your requests from with Home Assistant." + "npsso": "The NPSSO token is generated upon successful login of your PlayStation Network account and is used to authenticate your requests within Home Assistant." }, "description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token." } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 69e84fbaa6b..f03b3e6f1cf 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -97,12 +97,9 @@ def mock_psnawp_npsso(mock_user: MagicMock) -> Generator[MagicMock]: """Mock psnawp_api.""" with patch( - "psnawp_api.utils.misc.parse_npsso_token", - autospec=True, - ) as mock_parse_npsso_token: - npsso = mock_parse_npsso_token.return_value - npsso.parse_npsso_token.return_value = NPSSO_TOKEN - + "homeassistant.components.playstation_network.config_flow.parse_npsso_token", + side_effect=lambda token: token, + ) as npsso: yield npsso diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 107c92d8bff..031872a7a0b 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -120,7 +120,7 @@ async def test_parse_npsso_token_failures( mock_psnawp_npsso: MagicMock, ) -> None: """Test parse_npsso_token raises the correct exceptions during config flow.""" - mock_psnawp_npsso.parse_npsso_token.side_effect = PSNAWPInvalidTokenError + mock_psnawp_npsso.side_effect = PSNAWPInvalidTokenError result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -128,7 +128,7 @@ async def test_parse_npsso_token_failures( ) assert result["errors"] == {"base": "invalid_account"} - mock_psnawp_npsso.parse_npsso_token.side_effect = None + mock_psnawp_npsso.side_effect = lambda token: token result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_NPSSO: NPSSO_TOKEN}, From e5d19baf3ee088fe61cc5ee47672bf67420470b1 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Jun 2025 09:52:21 +0200 Subject: [PATCH 0585/1664] Add container arch to system info (#147372) --- homeassistant/components/hassio/__init__.py | 10 +-- .../components/homeassistant/__init__.py | 90 +++++++++---------- .../components/homeassistant/strings.json | 1 + .../components/homeassistant/system_health.py | 1 + homeassistant/helpers/system_info.py | 18 ++++ homeassistant/package_constraints.txt | 9 +- pyproject.toml | 1 - requirements.txt | 1 - script/gen_requirements_all.py | 8 ++ .../cloud/snapshots/test_http_api.ambr | 1 + tests/components/cloud/test_http_api.py | 1 + tests/components/hassio/conftest.py | 13 --- tests/components/hassio/test_init.py | 54 +++-------- tests/components/homeassistant/test_init.py | 9 +- 14 files changed, 99 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 6772034e53f..0c15a687421 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -12,7 +12,6 @@ import re import struct from typing import Any, NamedTuple -import aiofiles from aiohasupervisor import SupervisorError import voluptuous as vol @@ -239,12 +238,6 @@ def _is_32_bit() -> bool: return size * 8 == 32 -async def _get_arch() -> str: - async with aiofiles.open("/etc/apk/arch") as arch_file: - raw_arch = await arch_file.read() - return {"x86": "i386"}.get(raw_arch, raw_arch) - - class APIEndpointSettings(NamedTuple): """Settings for API endpoint.""" @@ -566,8 +559,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() hass.data[ADDONS_COORDINATOR] = coordinator - arch = await _get_arch() - def deprecated_setup_issue() -> None: os_info = get_os_info(hass) info = get_info(hass) @@ -575,6 +566,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return is_haos = info.get("hassos") is not None board = os_info.get("board") + arch = info.get("arch", "unknown") unsupported_board = board in {"tinker", "odroid-xu4", "rpi2"} unsupported_os_on_board = board in {"rpi3", "rpi4"} if is_haos and (unsupported_board or unsupported_os_on_board): diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 4360fa9c16e..d5dabfa2e08 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -7,7 +7,6 @@ import logging import struct from typing import Any -import aiofiles import voluptuous as vol from homeassistant import config as conf_util, core_config @@ -18,6 +17,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, + EVENT_HOMEASSISTANT_STARTED, RESTART_EXIT_CODE, SERVICE_RELOAD, SERVICE_SAVE_PERSISTENT_STATES, @@ -26,6 +26,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, ) from homeassistant.core import ( + Event, HomeAssistant, ServiceCall, ServiceResponse, @@ -101,12 +102,6 @@ def _is_32_bit() -> bool: return size * 8 == 32 -async def _get_arch() -> str: - async with aiofiles.open("/etc/apk/arch") as arch_file: - raw_arch = (await arch_file.read()).strip() - return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up general services related to Home Assistant.""" @@ -411,45 +406,50 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities async_set_stop_handler(hass, _async_stop) - info = await async_get_system_info(hass) + async def _async_check_deprecation(event: Event) -> None: + """Check and create deprecation issues after startup.""" + info = await async_get_system_info(hass) - installation_type = info["installation_type"][15:] - if installation_type in {"Core", "Container"}: - deprecated_method = installation_type == "Core" - bit32 = _is_32_bit() - arch = info["arch"] - if bit32 and installation_type == "Container": - arch = await _get_arch() - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_container", - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_container", - translation_placeholders={"arch": arch}, - ) - deprecated_architecture = bit32 and installation_type != "Container" - if deprecated_method or deprecated_architecture: - issue_id = "deprecated" - if deprecated_method: - issue_id += "_method" - if deprecated_architecture: - issue_id += "_architecture" - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - learn_more_url=DEPRECATION_URL, - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "installation_type": installation_type, - "arch": arch, - }, - ) + installation_type = info["installation_type"][15:] + if installation_type in {"Core", "Container"}: + deprecated_method = installation_type == "Core" + bit32 = _is_32_bit() + arch = info["arch"] + if bit32 and installation_type == "Container": + arch = info.get("container_arch", arch) + ir.async_create_issue( + hass, + DOMAIN, + "deprecated_container", + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_container", + translation_placeholders={"arch": arch}, + ) + deprecated_architecture = bit32 and installation_type != "Container" + if deprecated_method or deprecated_architecture: + issue_id = "deprecated" + if deprecated_method: + issue_id += "_method" + if deprecated_architecture: + issue_id += "_architecture" + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + learn_more_url=DEPRECATION_URL, + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=issue_id, + translation_placeholders={ + "installation_type": installation_type, + "arch": arch, + }, + ) + + # Delay deprecation check to make sure installation method is determined correctly + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, _async_check_deprecation) return True diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 940af999c4d..7c95680076c 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -124,6 +124,7 @@ "info": { "arch": "CPU architecture", "config_dir": "Configuration directory", + "container_arch": "Container architecture", "dev": "Development", "docker": "Docker", "hassio": "Supervisor", diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index 8a51b9cd418..3f98c5ae6e0 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -27,6 +27,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: "dev": info.get("dev"), "hassio": info.get("hassio"), "docker": info.get("docker"), + "container_arch": info.get("container_arch"), "user": info.get("user"), "virtualenv": info.get("virtualenv"), "python_version": info.get("python_version"), diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 30b7616319d..1baec4df052 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -21,6 +21,7 @@ from .singleton import singleton _LOGGER = logging.getLogger(__name__) _DATA_MAC_VER = "system_info_mac_ver" +_DATA_CONTAINER_ARCH = "system_info_container_arch" @singleton(_DATA_MAC_VER) @@ -29,6 +30,22 @@ async def async_get_mac_ver(hass: HomeAssistant) -> str: return (await hass.async_add_executor_job(platform.mac_ver))[0] +@singleton(_DATA_CONTAINER_ARCH) +async def async_get_container_arch(hass: HomeAssistant) -> str: + """Return the container architecture.""" + + def _read_arch_file() -> str: + """Read the architecture from /etc/apk/arch.""" + with open("/etc/apk/arch", encoding="utf-8") as arch_file: + return arch_file.read().strip() + + try: + raw_arch = await hass.async_add_executor_job(_read_arch_file) + except FileNotFoundError: + return "unknown" + return {"x86": "i386", "x86_64": "amd64"}.get(raw_arch, raw_arch) + + # Cache the result of getuser() because it can call getpwuid() which # can do blocking I/O to look up the username in /etc/passwd. cached_get_user = cache(getuser) @@ -79,6 +96,7 @@ async def async_get_system_info(hass: HomeAssistant) -> dict[str, Any]: if info_object["docker"]: if info_object["user"] == "root" and is_official_image(): info_object["installation_type"] = "Home Assistant Container" + info_object["container_arch"] = await async_get_container_arch(hass) else: info_object["installation_type"] = "Unsupported Third Party Container" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e47c5f7d66c..0be11dfff97 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,6 @@ aiodhcpwatcher==1.2.0 aiodiscover==2.7.0 aiodns==3.5.0 -aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 @@ -201,6 +200,14 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 + # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/pyproject.toml b/pyproject.toml index f7ac7476d1b..995308bbf0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,6 @@ classifiers = [ requires-python = ">=3.13.2" dependencies = [ "aiodns==3.5.0", - "aiofiles==24.1.0", # Integrations may depend on hassio integration without listing it to # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 diff --git a/requirements.txt b/requirements.txt index 39ec6dd87dd..687e5584355 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,6 @@ # Home Assistant Core aiodns==3.5.0 -aiofiles==24.1.0 aiohasupervisor==0.3.1 aiohttp==3.12.13 aiohttp_cors==0.8.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2e3ecccf5d2..005d97175a7 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -226,6 +226,14 @@ tenacity!=8.4.0 # TypeError: 'Timeout' object does not support the context manager protocol async-timeout==4.0.3 +# aiofiles keeps getting downgraded by custom components +# causing newer methods to not be available and breaking +# some integrations at startup +# https://github.com/home-assistant/core/issues/127529 +# https://github.com/home-assistant/core/issues/122508 +# https://github.com/home-assistant/core/issues/118004 +aiofiles>=24.1.0 + # multidict < 6.4.0 has memory leaks # https://github.com/aio-libs/multidict/issues/1134 # https://github.com/aio-libs/multidict/issues/1131 diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index b15cd08c23a..c67691dfa1a 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -9,6 +9,7 @@ dev | False hassio | False docker | False + container_arch | None user | hass virtualenv | False python_version | 3.13.1 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index b5cce286ba2..79764e552c7 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1931,6 +1931,7 @@ async def test_download_support_package( "virtualenv": False, "python_version": "3.13.1", "docker": False, + "container_arch": None, "arch": "x86_64", "timezone": "US/Pacific", "os_name": "Linux", diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 56f7ffaa5b9..a71ee370b32 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -260,16 +260,3 @@ def all_setup_requests( }, }, ) - - -@pytest.fixture -def arch() -> str: - """Arch found in apk file.""" - return "amd64" - - -@pytest.fixture(autouse=True) -def mock_arch_file(arch: str) -> Generator[None]: - """Mock arch file.""" - with patch("homeassistant.components.hassio._get_arch", return_value=arch): - yield diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index f424beedc85..2874ea726dc 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1156,10 +1156,6 @@ def test_deprecated_constants( ("rpi2", "deprecated_os_armv7"), ], ) -@pytest.mark.parametrize( - "arch", - ["armv7"], -) async def test_deprecated_installation_issue_os_armv7( hass: HomeAssistant, issue_registry: ir.IssueRegistry, @@ -1170,13 +1166,6 @@ async def test_deprecated_installation_issue_os_armv7( """Test deprecated installation issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "armv7", - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=True, @@ -1185,7 +1174,8 @@ async def test_deprecated_installation_issue_os_armv7( "homeassistant.components.hassio.get_os_info", return_value={"board": board} ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": True} + "homeassistant.components.hassio.get_info", + return_value={"hassos": True, "arch": "armv7"}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1238,13 +1228,6 @@ async def test_deprecated_installation_issue_32bit_os( """Test deprecated architecture issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": arch, - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=True, @@ -1254,7 +1237,8 @@ async def test_deprecated_installation_issue_32bit_os( return_value={"board": "rpi3-64"}, ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": True} + "homeassistant.components.hassio.get_info", + return_value={"hassos": True, "arch": arch}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1305,13 +1289,6 @@ async def test_deprecated_installation_issue_32bit_supervised( """Test deprecated architecture issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=True, @@ -1321,7 +1298,8 @@ async def test_deprecated_installation_issue_32bit_supervised( return_value={"board": "rpi3-64"}, ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": None} + "homeassistant.components.hassio.get_info", + return_value={"hassos": None, "arch": arch}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1376,13 +1354,6 @@ async def test_deprecated_installation_issue_64bit_supervised( """Test deprecated architecture issue.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant Supervised", - "arch": arch, - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=False, @@ -1392,7 +1363,8 @@ async def test_deprecated_installation_issue_64bit_supervised( return_value={"board": "generic-x86-64"}, ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": None} + "homeassistant.components.hassio.get_info", + return_value={"hassos": None, "arch": arch}, ), patch("homeassistant.components.hardware.async_setup", return_value=True), ): @@ -1445,13 +1417,6 @@ async def test_deprecated_installation_issue_supported_board( """Test no deprecated installation issue for a supported board.""" with ( patch.dict(os.environ, MOCK_ENVIRON), - patch( - "homeassistant.components.homeassistant.async_get_system_info", - return_value={ - "installation_type": "Home Assistant OS", - "arch": "aarch64", - }, - ), patch( "homeassistant.components.hassio._is_32_bit", return_value=False, @@ -1460,7 +1425,8 @@ async def test_deprecated_installation_issue_supported_board( "homeassistant.components.hassio.get_os_info", return_value={"board": board} ), patch( - "homeassistant.components.hassio.get_info", return_value={"hassos": True} + "homeassistant.components.hassio.get_info", + return_value={"hassos": True, "arch": "aarch64"}, ), ): assert await async_setup_component(hass, "homeassistant", {}) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0779339cf65..80211c48eed 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, EVENT_CORE_CONFIG_UPDATE, + EVENT_HOMEASSISTANT_STARTED, SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -668,6 +669,7 @@ async def test_deprecated_installation_issue_32bit_core( ), ): assert await async_setup_component(hass, DOMAIN, {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 @@ -707,6 +709,7 @@ async def test_deprecated_installation_issue_64bit_core( ), ): assert await async_setup_component(hass, DOMAIN, {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 @@ -738,6 +741,7 @@ async def test_deprecated_installation_issue_32bit( "homeassistant.components.homeassistant.async_get_system_info", return_value={ "installation_type": "Home Assistant Container", + "container_arch": arch, "arch": arch, }, ), @@ -745,12 +749,9 @@ async def test_deprecated_installation_issue_32bit( "homeassistant.components.homeassistant._is_32_bit", return_value=True, ), - patch( - "homeassistant.components.homeassistant._get_arch", - return_value=arch, - ), ): assert await async_setup_component(hass, DOMAIN, {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() assert len(issue_registry.issues) == 1 From aefd9c9b41a10c6e13a7c22fa6b58764fcf95114 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 04:11:46 -0400 Subject: [PATCH 0586/1664] Bump universal-silabs-flasher to 0.0.31 (#147393) --- homeassistant/components/homeassistant_hardware/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json index f3a02185b83..cf9acf14a5d 100644 --- a/homeassistant/components/homeassistant_hardware/manifest.json +++ b/homeassistant/components/homeassistant_hardware/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware", "integration_type": "system", "requirements": [ - "universal-silabs-flasher==0.0.30", + "universal-silabs-flasher==0.0.31", "ha-silabs-firmware-client==0.2.0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 98e26da89ac..b4497ab6873 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3012,7 +3012,7 @@ unifi_ap==0.0.2 unifiled==0.11 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.30 +universal-silabs-flasher==0.0.31 # homeassistant.components.upb upb-lib==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1348f1bd428..417fc8d7539 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2477,7 +2477,7 @@ ultraheat-api==0.5.7 unifi-discovery==1.2.0 # homeassistant.components.homeassistant_hardware -universal-silabs-flasher==0.0.30 +universal-silabs-flasher==0.0.31 # homeassistant.components.upb upb-lib==0.6.1 From c67b497f3034c2ffb625ae8324fa0f6cdab7ffec Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Jun 2025 03:13:04 -0500 Subject: [PATCH 0587/1664] Bump intents to 2025.6.23 (#147391) --- homeassistant/components/conversation/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- tests/components/conversation/snapshots/test_http.ambr | 1 + 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 5221e89deee..ad0a4c96102 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "quality_scale": "internal", - "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.10"] + "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.6.23"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0be11dfff97..048ec41592c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ hass-nabucasa==0.103.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.3 -home-assistant-intents==2025.6.10 +home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index b4497ab6873..f3d99fcd187 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ holidays==0.75 home-assistant-frontend==20250531.3 # homeassistant.components.conversation -home-assistant-intents==2025.6.10 +home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud homematicip==2.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 417fc8d7539..6d6bb859f8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1020,7 +1020,7 @@ holidays==0.75 home-assistant-frontend==20250531.3 # homeassistant.components.conversation -home-assistant-intents==2025.6.10 +home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud homematicip==2.0.6 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 72bd1ab3e7d..afd58539853 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==2.2.3 \ - home-assistant-intents==2025.6.10 \ + home-assistant-intents==2025.6.23 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index abce735dd8a..5179409deb0 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -30,6 +30,7 @@ 'id', 'is', 'it', + 'ja', 'ka', 'ko', 'kw', From b8044f60fca0b7ad96bbec5f97a14b59e24db0fd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Jun 2025 10:13:44 +0200 Subject: [PATCH 0588/1664] Fix trigger config validation (#147408) --- homeassistant/helpers/trigger.py | 2 +- tests/helpers/test_trigger.py | 95 +++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 62aebdf6fd7..853b5aaf812 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -271,7 +271,7 @@ async def async_validate_trigger_config( if hasattr(platform, "async_get_triggers"): trigger_descriptors = await platform.async_get_triggers(hass) trigger_key: str = conf[CONF_PLATFORM] - if not (trigger := trigger_descriptors[trigger_key]): + if not (trigger := trigger_descriptors.get(trigger_key)): raise vol.Invalid(f"Invalid trigger '{trigger_key}' specified") conf = await trigger.async_validate_trigger_config(hass, conf) elif hasattr(platform, "async_validate_trigger_config"): diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 77f48be170b..f5a2b549f89 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,20 +1,32 @@ """The tests for the trigger helper.""" -from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest import voluptuous as vol -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + Context, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, + Trigger, + TriggerActionType, + TriggerInfo, _async_get_trigger_platform, async_initialize_triggers, async_validate_trigger_config, ) +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component +from tests.common import MockModule, mock_integration, mock_platform + async def test_bad_trigger_platform(hass: HomeAssistant) -> None: """Test bad trigger platform.""" @@ -428,3 +440,82 @@ async def test_pluggable_action( remove_attach_2() assert not hass.data[DATA_PLUGGABLE_ACTIONS] assert not plug_2 + + +async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: + """Test a trigger platform with multiple trigger.""" + + class MockTrigger(Trigger): + """Mock trigger.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize trigger.""" + + @classmethod + async def async_validate_trigger_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + class MockTrigger1(MockTrigger): + """Mock trigger 1.""" + + async def async_attach_trigger( + self, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + action({"trigger": "test_trigger_1"}) + + class MockTrigger2(MockTrigger): + """Mock trigger 2.""" + + async def async_attach_trigger( + self, + action: TriggerActionType, + trigger_info: TriggerInfo, + ) -> CALLBACK_TYPE: + """Attach a trigger.""" + action({"trigger": "test_trigger_2"}) + + async def async_get_triggers( + hass: HomeAssistant, + ) -> dict[str, type[Trigger]]: + return { + "test": MockTrigger1, + "test.trig_2": MockTrigger2, + } + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers)) + + config_1 = [{"platform": "test"}] + config_2 = [{"platform": "test.trig_2"}] + config_3 = [{"platform": "test.unknown_trig"}] + assert await async_validate_trigger_config(hass, config_1) == config_1 + assert await async_validate_trigger_config(hass, config_2) == config_2 + with pytest.raises( + vol.Invalid, match="Invalid trigger 'test.unknown_trig' specified" + ): + await async_validate_trigger_config(hass, config_3) + + log_cb = MagicMock() + + action_calls = [] + + @callback + def cb_action(*args): + action_calls.append([*args]) + + await async_initialize_triggers(hass, config_1, cb_action, "test", "", log_cb) + assert action_calls == [[{"trigger": "test_trigger_1"}]] + action_calls.clear() + + await async_initialize_triggers(hass, config_2, cb_action, "test", "", log_cb) + assert action_calls == [[{"trigger": "test_trigger_2"}]] + action_calls.clear() + + with pytest.raises(KeyError): + await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) From f2944f4d8e7c7676be59b12c40e6e609ff47aa30 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:14:06 +0200 Subject: [PATCH 0589/1664] Add support for v2 API for HomeWizard kWh Meter (#147214) --- homeassistant/components/homewizard/manifest.json | 2 +- homeassistant/components/homewizard/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homewizard/fixtures/v2/HWE-KWH1/batteries.json | 7 +++++++ .../homewizard/fixtures/v2/HWE-KWH1/device.json | 7 +++++++ .../fixtures/v2/HWE-KWH1/measurement.json | 13 +++++++++++++ .../homewizard/fixtures/v2/HWE-KWH1/system.json | 7 +++++++ tests/components/homewizard/test_config_flow.py | 6 +++++- tests/components/homewizard/test_init.py | 1 + 10 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json create mode 100644 tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 9fd74fa80e4..f9924a68db4 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -12,6 +12,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==9.1.1"], + "requirements": ["python-homewizard-energy==9.2.0"], "zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."] } diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 4216ece64cb..84594a440f9 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -24,7 +24,7 @@ }, "authorize": { "title": "Authorize", - "description": "Press the button on the HomeWizard Energy device, then select the button below." + "description": "Press the button on the HomeWizard Energy device for two seconds, then select the button below." }, "reconfigure": { "description": "Update configuration for {title}.", diff --git a/requirements_all.txt b/requirements_all.txt index f3d99fcd187..131c1c14b06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2444,7 +2444,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.1.1 +python-homewizard-energy==9.2.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d6bb859f8c..41ed2c454c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2020,7 +2020,7 @@ python-google-drive-api==0.1.0 python-homeassistant-analytics==0.9.0 # homeassistant.components.homewizard -python-homewizard-energy==9.1.1 +python-homewizard-energy==9.2.0 # homeassistant.components.izone python-izone==1.2.9 diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json new file mode 100644 index 00000000000..279e49606b3 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/batteries.json @@ -0,0 +1,7 @@ +{ + "mode": "zero", + "power_w": -404, + "target_power_w": -400, + "max_consumption_w": 1600, + "max_production_w": 800 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json new file mode 100644 index 00000000000..efac68ded02 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-KWH1", + "product_name": "kWh Meter 1-phase", + "serial": "5c2fafabcdef", + "firmware_version": "4.19", + "api_version": "2.0.0" +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json new file mode 100644 index 00000000000..0c52ce17516 --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/measurement.json @@ -0,0 +1,13 @@ +{ + "energy_import_kwh": 123.456, + "energy_export_kwh": 78.91, + "power_w": 123, + "voltage_v": 230, + "current_a": 1.5, + "apparent_current_a": 1.6, + "reactive_current_a": 0.5, + "apparent_power_va": 345, + "reactive_power_var": 67, + "power_factor": 0.95, + "frequency_hz": 50 +} diff --git a/tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json b/tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json new file mode 100644 index 00000000000..3ef59c93aba --- /dev/null +++ b/tests/components/homewizard/fixtures/v2/HWE-KWH1/system.json @@ -0,0 +1,7 @@ +{ + "wifi_ssid": "My Wi-Fi", + "wifi_rssi_db": -77, + "cloud_enabled": false, + "uptime_s": 356, + "api_v1_enabled": true +} diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index c39853c3f9a..feb0e8ed0f0 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -620,6 +620,7 @@ async def test_reconfigure_cannot_connect( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_manual_flow_works_with_v2_api_support( hass: HomeAssistant, mock_homewizardenergy_v2: MagicMock, @@ -659,6 +660,7 @@ async def test_manual_flow_works_with_v2_api_support( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_manual_flow_detects_failed_user_authorization( hass: HomeAssistant, mock_homewizardenergy_v2: MagicMock, @@ -704,6 +706,7 @@ async def test_manual_flow_detects_failed_user_authorization( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_reauth_flow_updates_token( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -739,6 +742,7 @@ async def test_reauth_flow_updates_token( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_reauth_flow_handles_user_not_pressing_button( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -785,9 +789,9 @@ async def test_reauth_flow_handles_user_not_pressing_button( @pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_discovery_with_v2_api_ask_authorization( hass: HomeAssistant, - # mock_setup_entry: AsyncMock, mock_homewizardenergy_v2: MagicMock, ) -> None: """Test discovery detecting missing discovery info.""" diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index be811355e1d..b0562afbb3d 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -38,6 +38,7 @@ async def test_load_unload_v1( assert weak_ref() is None +@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"]) async def test_load_unload_v2( hass: HomeAssistant, mock_config_entry_v2: MockConfigEntry, From 438aa3486d1ddb0316b7bb72e000a09c7e89427a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Jun 2025 10:16:46 +0200 Subject: [PATCH 0590/1664] Add full device snapshot tests for Shelly (#145620) --- tests/components/shelly/__init__.py | 28 + .../shelly/snapshots/test_devices.ambr | 4718 +++++++++++++++++ tests/components/shelly/test_devices.py | 37 +- 3 files changed, 4779 insertions(+), 4 deletions(-) create mode 100644 tests/components/shelly/snapshots/test_devices.ambr diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 6c835d2a636..a333e55560f 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -9,6 +9,7 @@ from unittest.mock import Mock from aioshelly.const import MODEL_25 from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.shelly.const import ( CONF_GEN, @@ -151,3 +152,30 @@ def register_device( config_entry_id=config_entry.entry_id, connections={(CONNECTION_NETWORK_MAC, format_mac(MOCK_MAC))}, ) + + +async def snapshot_device_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + config_entry_id: str, +) -> None: + """Snapshot all device entities.""" + entity_entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + assert entity_entries + + for entity_entry in entity_entries: + assert entity_entry == snapshot(name=f"{entity_entry.entity_id}-entry") + assert entity_entry.disabled_by is None, "Please enable all entities." + state = hass.states.get(entity_entry.entity_id) + assert state, f"State not found for {entity_entry.entity_id}" + assert state == snapshot(name=f"{entity_entry.entity_id}-state") + + +async def force_uptime_value( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Force time to a specific point.""" + await hass.config.async_set_time_zone("UTC") + freezer.move_to("2025-05-26 16:04:00+00:00") diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr new file mode 100644 index 00000000000..37b0d3ef11c --- /dev/null +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -0,0 +1,4718 @@ +# serializer version: 1 +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_cloud-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': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_0-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.test_name_input_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:0-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 0', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_1-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.test_name_input_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 1', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:1-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_input_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overcurrent-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': , + 'entity_id': 'binary_sensor.test_name_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overheating-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': , + 'entity_id': 'binary_sensor.test_name_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overpowering-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': , + 'entity_id': 'binary_sensor.test_name_overpowering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overpowering', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overpower', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overpowering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overpowering', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overpowering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overvoltage-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': , + 'entity_id': 'binary_sensor.test_name_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_restart_required-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': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_shelly_2pm_gen3_cover[cover.test_name-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test_name', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[cover.test_name-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'shutter', + 'friendly_name': 'Test name', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test_name', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_current-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.test_name_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-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.test_name_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_frequency-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.test_name_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_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.test_name_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_rssi-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.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-53', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_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': , + 'entity_id': 'sensor.test_name_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.4', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-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.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T15:57:39+00:00', + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_voltage-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.test_name_voltage', + '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': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cover:0-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_cover[sensor.test_name_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '217.7', + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_beta_firmware-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.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/#unreleased', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_firmware-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.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_cover[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_cloud-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': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_0-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.test_name_input_0', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 0', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:0-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 0', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_1-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.test_name_input_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 1', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-input:1-input', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_input_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Input 1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_input_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_restart_required-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': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overcurrent-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': , + 'entity_id': 'binary_sensor.test_name_switch_0_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overheating-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': , + 'entity_id': 'binary_sensor.test_name_switch_0_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overpowering-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': , + 'entity_id': 'binary_sensor.test_name_switch_0_overpowering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overpowering', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overpower', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overpowering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overpowering', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overpowering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overvoltage-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': , + 'entity_id': 'binary_sensor.test_name_switch_0_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_0_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 0 Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_0_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overcurrent-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': , + 'entity_id': 'binary_sensor.test_name_switch_1_overcurrent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overcurrent', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overcurrent', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overcurrent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overcurrent', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overcurrent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overheating-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': , + 'entity_id': 'binary_sensor.test_name_switch_1_overheating', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overheating', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overtemp', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overheating-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overheating', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overheating', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overpowering-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': , + 'entity_id': 'binary_sensor.test_name_switch_1_overpowering', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overpowering', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overpower', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overpowering-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overpowering', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overpowering', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overvoltage-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': , + 'entity_id': 'binary_sensor.test_name_switch_1_overvoltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overvoltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-overvoltage', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[binary_sensor.test_name_switch_1_overvoltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Switch 1 Overvoltage', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_switch_1_overvoltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_rssi-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.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-52', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_current-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.test_name_switch_0_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Switch 0 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-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.test_name_switch_0_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_frequency-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.test_name_switch_0_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Switch 0 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_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.test_name_switch_0_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Switch 0 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_returned_energy-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.test_name_switch_0_returned_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 0 Returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_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': , + 'entity_id': 'sensor.test_name_switch_0_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Switch 0 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.6', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_voltage-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.test_name_switch_0_voltage', + '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': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_0_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Switch 0 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_0_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.2', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_current-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.test_name_switch_1_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Switch 1 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-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.test_name_switch_1_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_frequency-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.test_name_switch_1_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Switch 1 Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_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.test_name_switch_1_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Switch 1 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_returned_energy-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.test_name_switch_1_returned_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Switch 1 Returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_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': , + 'entity_id': 'sensor.test_name_switch_1_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Switch 1 Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '40.6', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_voltage-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.test_name_switch_1_voltage', + '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': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1-voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_switch_1_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Switch 1 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_switch_1_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '216.3', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-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.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-26T16:02:17+00:00', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_0-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': None, + 'entity_id': 'switch.test_name_switch_0', + '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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:0', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_0-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Switch 0', + }), + 'context': , + 'entity_id': 'switch.test_name_switch_0', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_1-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': None, + 'entity_id': 'switch.test_name_switch_1', + '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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-switch:1', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[switch.test_name_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test name Switch 1', + }), + 'context': , + 'entity_id': 'switch.test_name_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_beta_firmware-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.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/#unreleased', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_firmware-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.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_2pm_gen3_no_relay_names[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_cloud-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': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cloud', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-cloud-cloud', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_cloud-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Test name Cloud', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_cloud', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_restart_required-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': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Restart required', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-restart', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[binary_sensor.test_name_restart_required-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Test name Restart required', + }), + 'context': , + 'entity_id': 'binary_sensor.test_name_restart_required', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[button.test_name_reboot-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.test_name_reboot', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Reboot', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC_reboot', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[button.test_name_reboot-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'restart', + 'friendly_name': 'Test name Reboot', + }), + 'context': , + 'entity_id': 'button.test_name_reboot', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_active_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.test_name_phase_a_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Phase A Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2166.2', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_apparent_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.test_name_phase_a_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Phase A Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2175.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_current-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.test_name_phase_a_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase A Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '9.592', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_frequency-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.test_name_phase_a_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Phase A Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_power_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': None, + 'entity_id': 'sensor.test_name_phase_a_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Test name Phase A Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.99', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_energy-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.test_name_phase_a_total_active_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-a_total_act_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase A Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3105.57642', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_returned_energy-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.test_name_phase_a_total_active_returned_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-a_total_act_ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase A Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_voltage-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.test_name_phase_a_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-a_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Phase A Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '227.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_active_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.test_name_phase_b_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Phase B Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.6', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_apparent_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.test_name_phase_b_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Phase B Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.1', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_current-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.test_name_phase_b_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase B Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.044', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_frequency-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.test_name_phase_b_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Phase B Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_power_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': None, + 'entity_id': 'sensor.test_name_phase_b_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Test name Phase B Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.36', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_energy-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.test_name_phase_b_total_active_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-b_total_act_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase B Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '195.76572', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_returned_energy-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.test_name_phase_b_total_active_returned_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-b_total_act_ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase B Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_voltage-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.test_name_phase_b_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-b_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Phase B Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_active_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.test_name_phase_c_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Phase C Active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '244.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_apparent_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.test_name_phase_c_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Phase C Apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '339.7', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_current-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.test_name_phase_c_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase C Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.479', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_frequency-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.test_name_phase_c_frequency', + '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': 'Frequency', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_freq', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_frequency-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'frequency', + 'friendly_name': 'Test name Phase C Frequency', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_frequency', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '49.9', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_power_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': None, + 'entity_id': 'sensor.test_name_phase_c_power_factor', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Power factor', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_pf', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_power_factor-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power_factor', + 'friendly_name': 'Test name Phase C Power factor', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_power_factor', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.72', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_energy-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.test_name_phase_c_total_active_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-c_total_act_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase C Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2114.07205', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_returned_energy-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.test_name_phase_c_total_active_returned_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-c_total_act_ret_energy', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Phase C Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_voltage-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.test_name_phase_c_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Voltage', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-c_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Test name Phase C Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '230.2', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_rssi-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.test_name_rssi', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'RSSI', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-wifi-rssi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_rssi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Test name RSSI', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.test_name_rssi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-57', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_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.test_name_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': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-temperature:0-temperature_0', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test name Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '46.3', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_energy-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.test_name_total_active_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-total_act', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Total active energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_active_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5415.41419', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_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.test_name_total_active_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-total_act_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test name Total active power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_active_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2413.825', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_returned_energy-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.test_name_total_active_returned_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total active returned energy', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-emdata:0-total_act_ret', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_active_returned_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test name Total active returned energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_active_returned_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_apparent_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.test_name_total_apparent_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total apparent power', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-total_aprt_power', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_apparent_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'apparent_power', + 'friendly_name': 'Test name Total apparent power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_apparent_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2525.779', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_current-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.test_name_total_current', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-total_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_total_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Total current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_total_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.116', + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_uptime-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.test_name_uptime', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Uptime', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-sys-uptime', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_uptime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test name Uptime', + }), + 'context': , + 'entity_id': 'sensor.test_name_uptime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-05-20T20:42:37+00:00', + }) +# --- +# name: test_shelly_pro_3em[update.test_name_beta_firmware-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.test_name_beta_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Beta firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate_beta', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[update.test_name_beta_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Beta firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/#unreleased', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_beta_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_shelly_pro_3em[update.test_name_firmware-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.test_name_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'shelly', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '123456789ABC-sys-fwupdate', + 'unit_of_measurement': None, + }) +# --- +# name: test_shelly_pro_3em[update.test_name_firmware-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'display_precision': 0, + 'entity_picture': 'https://brands.home-assistant.io/_/shelly/icon.png', + 'friendly_name': 'Test name Firmware', + 'in_progress': False, + 'installed_version': '1.6.1', + 'latest_version': '1.6.1', + 'release_summary': None, + 'release_url': 'https://shelly-api-docs.shelly.cloud/gen2/changelog/', + 'skipped_version': None, + 'supported_features': , + 'title': None, + 'update_percentage': None, + }), + 'context': , + 'entity_id': 'update.test_name_firmware', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index b24645f651d..b1703ea03e9 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -3,24 +3,29 @@ from unittest.mock import Mock from aioshelly.const import MODEL_2PM_G3, MODEL_PRO_EM3 +from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.shelly.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration +from . import force_uptime_value, init_integration, snapshot_device_entities from tests.common import async_load_json_object_fixture +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shelly_2pm_gen3_no_relay_names( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test Shelly 2PM Gen3 without relay names. @@ -32,7 +37,9 @@ async def test_shelly_2pm_gen3_no_relay_names( monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) - await init_integration(hass, gen=3, model=MODEL_2PM_G3) + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=3, model=MODEL_2PM_G3) # Relay 0 sub-device entity_id = "switch.test_name_switch_0" @@ -97,6 +104,10 @@ async def test_shelly_2pm_gen3_no_relay_names( assert device_entry assert device_entry.name == "Test name" + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) + async def test_shelly_2pm_gen3_relay_names( hass: HomeAssistant, @@ -183,12 +194,15 @@ async def test_shelly_2pm_gen3_relay_names( assert device_entry.name == "Test name" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shelly_2pm_gen3_cover( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test Shelly 2PM Gen3 with cover profile. @@ -201,7 +215,9 @@ async def test_shelly_2pm_gen3_cover( monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) - await init_integration(hass, gen=3, model=MODEL_2PM_G3) + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=3, model=MODEL_2PM_G3) entity_id = "cover.test_name" @@ -239,6 +255,10 @@ async def test_shelly_2pm_gen3_cover( assert device_entry assert device_entry.name == "Test name" + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) + async def test_shelly_2pm_gen3_cover_with_name( hass: HomeAssistant, @@ -298,12 +318,15 @@ async def test_shelly_2pm_gen3_cover_with_name( assert device_entry.name == "Test name" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_shelly_pro_3em( hass: HomeAssistant, mock_rpc_device: Mock, entity_registry: EntityRegistry, device_registry: DeviceRegistry, monkeypatch: pytest.MonkeyPatch, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test Shelly Pro 3EM. @@ -314,7 +337,9 @@ async def test_shelly_pro_3em( monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) - await init_integration(hass, gen=2, model=MODEL_PRO_EM3) + await force_uptime_value(hass, freezer) + + config_entry = await init_integration(hass, gen=2, model=MODEL_PRO_EM3) # Main device entity_id = "sensor.test_name_total_active_power" @@ -368,6 +393,10 @@ async def test_shelly_pro_3em( assert device_entry assert device_entry.name == "Test name Phase C" + await snapshot_device_entities( + hass, entity_registry, snapshot, config_entry.entry_id + ) + async def test_shelly_pro_3em_with_emeter_name( hass: HomeAssistant, From 703032ab27633567e739dd209411e248ccbf18fa Mon Sep 17 00:00:00 2001 From: CubeZ2mDeveloper Date: Tue, 24 Jun 2025 16:19:08 +0800 Subject: [PATCH 0591/1664] Added auto-discovery configuration for SONOFF Dongle Max in zha. (#140574) Co-authored-by: zetao.zheng <1050713479@qq.com> --- homeassistant/components/zha/manifest.json | 6 ++++++ homeassistant/generated/usb.py | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4908298847b..2ba35d1b1ad 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -100,6 +100,12 @@ "pid": "8B34", "description": "*bv 2010/10*", "known_devices": ["Bitron Video AV2010/10"] + }, + { + "vid": "10C4", + "pid": "EA60", + "description": "*sonoff*max*", + "known_devices": ["SONOFF Dongle Max MG24"] } ], "zeroconf": [ diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py index 8aea15df283..18623926ce2 100644 --- a/homeassistant/generated/usb.py +++ b/homeassistant/generated/usb.py @@ -137,6 +137,12 @@ USB = [ "pid": "8B34", "vid": "10C4", }, + { + "description": "*sonoff*max*", + "domain": "zha", + "pid": "EA60", + "vid": "10C4", + }, { "domain": "zwave_js", "pid": "0200", From d5187a6a405bfd5eac6bcdcbe83fd0a3bd14ea4f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 24 Jun 2025 10:49:51 +0200 Subject: [PATCH 0592/1664] Update frontend to 20250531.4 (#147414) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index efb4891debf..d996963cb9c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.3"] + "requirements": ["home-assistant-frontend==20250531.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 048ec41592c..d4fd42df379 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.103.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.3 +home-assistant-frontend==20250531.4 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 131c1c14b06..710785eed57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.3 +home-assistant-frontend==20250531.4 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41ed2c454c7..815ab30d4c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.3 +home-assistant-frontend==20250531.4 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 02e33c3551ed812fb2db0dde69e8d7e0f912ec9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Jun 2025 10:50:32 +0200 Subject: [PATCH 0593/1664] Bump sigstore/cosign-installer from 3.8.2 to 3.9.0 (#147072) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index d7bbfc8fa5e..5ac2e47789b 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.8.2 + uses: sigstore/cosign-installer@v3.9.1 with: cosign-release: "v2.2.3" From 38c7eaf70a7493b493ad9e50c91a8faf323c2ebf Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:20:08 +0200 Subject: [PATCH 0594/1664] Add reauth flow to PlayStation Network integration (#147397) * Add reauth flow to psn integration * changes * catch auth error in coordinator --- .../playstation_network/config_flow.py | 62 ++++++- .../components/playstation_network/const.py | 3 + .../playstation_network/coordinator.py | 11 +- .../playstation_network/quality_scale.yaml | 2 +- .../playstation_network/strings.json | 14 +- .../playstation_network/test_config_flow.py | 160 +++++++++++++++++- 6 files changed, 243 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index e2b402a212e..29ba8d4de90 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -1,5 +1,6 @@ """Config flow for the PlayStation Network integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -14,8 +15,9 @@ from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_NAME -from .const import CONF_NPSSO, DOMAIN +from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK from .helpers import PlaystationNetwork _LOGGER = logging.getLogger(__name__) @@ -63,7 +65,61 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=STEP_USER_DATA_SCHEMA, errors=errors, description_placeholders={ - "npsso_link": "https://ca.account.sony.com/api/v1/ssocookie", - "psn_link": "https://playstation.com", + "npsso_link": NPSSO_LINK, + "psn_link": PSN_LINK, + }, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + try: + npsso = parse_npsso_token(user_input[CONF_NPSSO]) + psn = PlaystationNetwork(self.hass, npsso) + user: User = await psn.get_user() + except PSNAWPAuthenticationError: + errors["base"] = "invalid_auth" + except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): + errors["base"] = "invalid_account" + except PSNAWPError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user.account_id) + self._abort_if_unique_id_mismatch( + description_placeholders={ + "wrong_account": user.online_id, + CONF_NAME: entry.title, + } + ) + + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_NPSSO: npsso}, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + description_placeholders={ + "npsso_link": NPSSO_LINK, + "psn_link": PSN_LINK, }, ) diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 2db43f433e6..77b43af3b73 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -13,3 +13,6 @@ SUPPORTED_PLATFORMS = { PlatformType.PS3, PlatformType.PSPC, } + +NPSSO_LINK: Final = "https://ca.account.sony.com/api/v1/ssocookie" +PSN_LINK: Final = "https://playstation.com" diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index f6fd53ccb24..2581a016feb 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -13,7 +13,7 @@ from psnawp_api.models.user import User from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -53,7 +53,7 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData try: self.user = await self.psn.get_user() except PSNAWPAuthenticationError as error: - raise ConfigEntryNotReady( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error @@ -62,7 +62,12 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData """Get the latest data from the PSN.""" try: return await self.psn.get_data() - except (PSNAWPAuthenticationError, PSNAWPServerError) as error: + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except PSNAWPServerError as error: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index 36c28f19145..d5152927b99 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -39,7 +39,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: todo # Gold diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index e4581322edb..19d61859f97 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -9,6 +9,16 @@ "npsso": "The NPSSO token is generated upon successful login of your PlayStation Network account and is used to authenticate your requests within Home Assistant." }, "description": "To obtain your NPSSO token, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token." + }, + "reauth_confirm": { + "title": "Re-authenticate {name} with PlayStation Network", + "description": "The NPSSO token for **{name}** has expired. To obtain a new one, log in to your [PlayStation account]({psn_link}) first. Then [click here]({npsso_link}) to retrieve the token.", + "data": { + "npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]" + }, + "data_description": { + "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" + } } }, "error": { @@ -18,7 +28,9 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**" } }, "exceptions": { diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 031872a7a0b..981e459d283 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -11,7 +11,7 @@ from homeassistant.components.playstation_network.config_flow import ( PSNAWPNotFoundError, ) from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -138,3 +138,161 @@ async def test_parse_npsso_token_failures( assert result["data"] == { CONF_NPSSO: NPSSO_TOKEN, } + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await 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"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (PSNAWPNotFoundError(), "invalid_account"), + (PSNAWPAuthenticationError(), "invalid_auth"), + (PSNAWPError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_reauth_errors( + hass: HomeAssistant, + mock_psnawpapi: MagicMock, + config_entry: MockConfigEntry, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawpapi.user.side_effect = raise_error + result = await 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"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_psnawpapi.user.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth_token_error( + hass: HomeAssistant, + mock_psnawp_npsso: MagicMock, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow token error.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + mock_psnawp_npsso.side_effect = PSNAWPInvalidTokenError + result = await 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"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "invalid_account"} + + mock_psnawp_npsso.side_effect = lambda token: token + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reauth_account_mismatch( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_user: MagicMock, +) -> None: + """Test reauth flow unique_id mismatch.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + mock_user.account_id = "other_account" + result = await 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"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" From 2f89317fed76d5deb6d696033cc751273c88cbca Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 24 Jun 2025 10:49:51 +0200 Subject: [PATCH 0595/1664] Update frontend to 20250531.4 (#147414) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index efb4891debf..d996963cb9c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.3"] + "requirements": ["home-assistant-frontend==20250531.4"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 706218022b3..9bca440f136 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==3.49.0 hass-nabucasa==0.101.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.3 +home-assistant-frontend==20250531.4 home-assistant-intents==2025.6.10 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2ce35c3fd8d..4426ebc94a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1164,7 +1164,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.3 +home-assistant-frontend==20250531.4 # homeassistant.components.conversation home-assistant-intents==2025.6.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8ca017a72c2..0845cd0ea1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,7 +1010,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.3 +home-assistant-frontend==20250531.4 # homeassistant.components.conversation home-assistant-intents==2025.6.10 From 94fd9d165786110061495e4f9e5e9a7021295658 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 24 Jun 2025 11:09:26 +0000 Subject: [PATCH 0596/1664] Bump version to 2025.6.3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b51f735e97..c006137a024 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 6 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index fc66f3f8d8c..946cb224943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.6.2" +version = "2025.6.3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 63ac14a19bcf0c218ef8e4bc6f04fcfcf065ba41 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 07:12:29 -0400 Subject: [PATCH 0597/1664] AI task generate_text -> generate_data (#147370) --- homeassistant/components/ai_task/__init__.py | 28 +++++++-------- homeassistant/components/ai_task/const.py | 6 ++-- homeassistant/components/ai_task/entity.py | 22 ++++++------ homeassistant/components/ai_task/http.py | 2 +- homeassistant/components/ai_task/icons.json | 2 +- .../components/ai_task/services.yaml | 4 +-- homeassistant/components/ai_task/strings.json | 6 ++-- homeassistant/components/ai_task/task.py | 33 ++++++++--------- tests/components/ai_task/conftest.py | 22 ++++++------ .../ai_task/snapshots/test_task.ambr | 2 +- tests/components/ai_task/test_entity.py | 14 ++++---- tests/components/ai_task/test_http.py | 18 +++++----- tests/components/ai_task/test_init.py | 10 +++--- tests/components/ai_task/test_task.py | 35 ++++++++++--------- 14 files changed, 104 insertions(+), 100 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 7fec89f384e..692e5d410ae 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -24,20 +24,20 @@ from .const import ( DATA_COMPONENT, DATA_PREFERENCES, DOMAIN, - SERVICE_GENERATE_TEXT, + SERVICE_GENERATE_DATA, AITaskEntityFeature, ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenTextTask, GenTextTaskResult, async_generate_text +from .task import GenDataTask, GenDataTaskResult, async_generate_data __all__ = [ "DOMAIN", "AITaskEntity", "AITaskEntityFeature", - "GenTextTask", - "GenTextTaskResult", - "async_generate_text", + "GenDataTask", + "GenDataTaskResult", + "async_generate_data", "async_setup", "async_setup_entry", "async_unload_entry", @@ -57,8 +57,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_setup_http(hass) hass.services.async_register( DOMAIN, - SERVICE_GENERATE_TEXT, - async_service_generate_text, + SERVICE_GENERATE_DATA, + async_service_generate_data, schema=vol.Schema( { vol.Required(ATTR_TASK_NAME): cv.string, @@ -82,18 +82,18 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.data[DATA_COMPONENT].async_unload_entry(entry) -async def async_service_generate_text(call: ServiceCall) -> ServiceResponse: +async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: """Run the run task service.""" - result = await async_generate_text(hass=call.hass, **call.data) - return result.as_dict() # type: ignore[return-value] + result = await async_generate_data(hass=call.hass, **call.data) + return result.as_dict() class AITaskPreferences: """AI Task preferences.""" - KEYS = ("gen_text_entity_id",) + KEYS = ("gen_data_entity_id",) - gen_text_entity_id: str | None = None + gen_data_entity_id: str | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize the preferences.""" @@ -113,11 +113,11 @@ class AITaskPreferences: def async_set_preferences( self, *, - gen_text_entity_id: str | None | UndefinedType = UNDEFINED, + gen_data_entity_id: str | None | UndefinedType = UNDEFINED, ) -> None: """Set the preferences.""" changed = False - for key, value in (("gen_text_entity_id", gen_text_entity_id),): + for key, value in (("gen_data_entity_id", gen_data_entity_id),): if value is not UNDEFINED: if getattr(self, key) != value: setattr(self, key, value) diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index b6058c11b45..8b612e90560 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -17,7 +17,7 @@ DOMAIN = "ai_task" DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") -SERVICE_GENERATE_TEXT = "generate_text" +SERVICE_GENERATE_DATA = "generate_data" ATTR_INSTRUCTIONS: Final = "instructions" ATTR_TASK_NAME: Final = "task_name" @@ -30,5 +30,5 @@ DEFAULT_SYSTEM_PROMPT = ( class AITaskEntityFeature(IntFlag): """Supported features of the AI task entity.""" - GENERATE_TEXT = 1 - """Generate text based on instructions.""" + GENERATE_DATA = 1 + """Generate data based on instructions.""" diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 88ce8144fb7..cb6094cba4e 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -18,7 +18,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature -from .task import GenTextTask, GenTextTaskResult +from .task import GenDataTask, GenDataTaskResult class AITaskEntity(RestoreEntity): @@ -56,7 +56,7 @@ class AITaskEntity(RestoreEntity): @contextlib.asynccontextmanager async def _async_get_ai_task_chat_log( self, - task: GenTextTask, + task: GenDataTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup @@ -84,20 +84,20 @@ class AITaskEntity(RestoreEntity): yield chat_log @final - async def internal_async_generate_text( + async def internal_async_generate_data( self, - task: GenTextTask, - ) -> GenTextTaskResult: - """Run a gen text task.""" + task: GenDataTask, + ) -> GenDataTaskResult: + """Run a gen data task.""" self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() async with self._async_get_ai_task_chat_log(task) as chat_log: - return await self._async_generate_text(task, chat_log) + return await self._async_generate_data(task, chat_log) - async def _async_generate_text( + async def _async_generate_data( self, - task: GenTextTask, + task: GenDataTask, chat_log: ChatLog, - ) -> GenTextTaskResult: - """Handle a gen text task.""" + ) -> GenDataTaskResult: + """Handle a gen data task.""" raise NotImplementedError diff --git a/homeassistant/components/ai_task/http.py b/homeassistant/components/ai_task/http.py index 6d44a4e8d3c..5deffa84008 100644 --- a/homeassistant/components/ai_task/http.py +++ b/homeassistant/components/ai_task/http.py @@ -36,7 +36,7 @@ def websocket_get_preferences( @websocket_api.websocket_command( { vol.Required("type"): "ai_task/preferences/set", - vol.Optional("gen_text_entity_id"): vol.Any(str, None), + vol.Optional("gen_data_entity_id"): vol.Any(str, None), } ) @websocket_api.require_admin diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json index cb09e5c8f5d..4a875e9fb11 100644 --- a/homeassistant/components/ai_task/icons.json +++ b/homeassistant/components/ai_task/icons.json @@ -1,6 +1,6 @@ { "services": { - "generate_text": { + "generate_data": { "service": "mdi:file-star-four-points-outline" } } diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 12e3975fca6..a531ca599b1 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -1,4 +1,4 @@ -generate_text: +generate_data: fields: task_name: example: "home summary" @@ -16,4 +16,4 @@ generate_text: entity: domain: ai_task supported_features: - - ai_task.AITaskEntityFeature.GENERATE_TEXT + - ai_task.AITaskEntityFeature.GENERATE_DATA diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index f994aaebe8e..877174de681 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -1,8 +1,8 @@ { "services": { - "generate_text": { - "name": "Generate text", - "description": "Use AI to run a task that generates text.", + "generate_data": { + "name": "Generate data", + "description": "Uses AI to run a task that generates data.", "fields": { "task_name": { "name": "Task name", diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 1ba5838d18b..2e546897602 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -10,16 +11,16 @@ from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature -async def async_generate_text( +async def async_generate_data( hass: HomeAssistant, *, task_name: str, entity_id: str | None = None, instructions: str, -) -> GenTextTaskResult: +) -> GenDataTaskResult: """Run a task in the AI Task integration.""" if entity_id is None: - entity_id = hass.data[DATA_PREFERENCES].gen_text_entity_id + entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id if entity_id is None: raise HomeAssistantError("No entity_id provided and no preferred entity set") @@ -28,13 +29,13 @@ async def async_generate_text( if entity is None: raise HomeAssistantError(f"AI Task entity {entity_id} not found") - if AITaskEntityFeature.GENERATE_TEXT not in entity.supported_features: + if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features: raise HomeAssistantError( - f"AI Task entity {entity_id} does not support generating text" + f"AI Task entity {entity_id} does not support generating data" ) - return await entity.internal_async_generate_text( - GenTextTask( + return await entity.internal_async_generate_data( + GenDataTask( name=task_name, instructions=instructions, ) @@ -42,8 +43,8 @@ async def async_generate_text( @dataclass(slots=True) -class GenTextTask: - """Gen text task to be processed.""" +class GenDataTask: + """Gen data task to be processed.""" name: str """Name of the task.""" @@ -53,22 +54,22 @@ class GenTextTask: def __str__(self) -> str: """Return task as a string.""" - return f"" + return f"" @dataclass(slots=True) -class GenTextTaskResult: - """Result of gen text task.""" +class GenDataTaskResult: + """Result of gen data task.""" conversation_id: str """Unique identifier for the conversation.""" - text: str - """Generated text.""" + data: Any + """Data generated by the task.""" - def as_dict(self) -> dict[str, str]: + def as_dict(self) -> dict[str, Any]: """Return result as a dict.""" return { "conversation_id": self.conversation_id, - "text": self.text, + "data": self.data, } diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index 2060c51bfa4..7efbd1ffcdb 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -6,8 +6,8 @@ from homeassistant.components.ai_task import ( DOMAIN, AITaskEntity, AITaskEntityFeature, - GenTextTask, - GenTextTaskResult, + GenDataTask, + GenDataTaskResult, ) from homeassistant.components.conversation import AssistantContent, ChatLog from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -33,24 +33,24 @@ class MockAITaskEntity(AITaskEntity): """Mock AI Task entity for testing.""" _attr_name = "Test Task Entity" - _attr_supported_features = AITaskEntityFeature.GENERATE_TEXT + _attr_supported_features = AITaskEntityFeature.GENERATE_DATA def __init__(self) -> None: """Initialize the mock entity.""" super().__init__() - self.mock_generate_text_tasks = [] + self.mock_generate_data_tasks = [] - async def _async_generate_text( - self, task: GenTextTask, chat_log: ChatLog - ) -> GenTextTaskResult: - """Mock handling of generate text task.""" - self.mock_generate_text_tasks.append(task) + async def _async_generate_data( + self, task: GenDataTask, chat_log: ChatLog + ) -> GenDataTaskResult: + """Mock handling of generate data task.""" + self.mock_generate_data_tasks.append(task) chat_log.async_add_assistant_content_without_tools( AssistantContent(self.entity_id, "Mock result") ) - return GenTextTaskResult( + return GenDataTaskResult( conversation_id=chat_log.conversation_id, - text="Mock result", + data="Mock result", ) diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 6d155c82a68..3b40b0632a6 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_run_text_task_updates_chat_log +# name: test_run_data_task_updates_chat_log list([ dict({ 'content': ''' diff --git a/tests/components/ai_task/test_entity.py b/tests/components/ai_task/test_entity.py index aa9afbf6560..3ed1c393588 100644 --- a/tests/components/ai_task/test_entity.py +++ b/tests/components/ai_task/test_entity.py @@ -2,7 +2,7 @@ from freezegun import freeze_time -from homeassistant.components.ai_task import async_generate_text +from homeassistant.components.ai_task import async_generate_data from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -12,28 +12,28 @@ from tests.common import MockConfigEntry @freeze_time("2025-06-08 16:28:13") -async def test_state_generate_text( +async def test_state_generate_data( hass: HomeAssistant, init_components: None, mock_config_entry: MockConfigEntry, mock_ai_task_entity: MockAITaskEntity, ) -> None: - """Test the state of the AI Task entity is updated when generating text.""" + """Test the state of the AI Task entity is updated when generating data.""" entity = hass.states.get(TEST_ENTITY_ID) assert entity is not None assert entity.state == STATE_UNKNOWN - result = await async_generate_text( + result = await async_generate_data( hass, task_name="Test task", entity_id=TEST_ENTITY_ID, instructions="Test prompt", ) - assert result.text == "Mock result" + assert result.data == "Mock result" entity = hass.states.get(TEST_ENTITY_ID) assert entity.state == "2025-06-08T16:28:13+00:00" - assert mock_ai_task_entity.mock_generate_text_tasks - task = mock_ai_task_entity.mock_generate_text_tasks[0] + assert mock_ai_task_entity.mock_generate_data_tasks + task = mock_ai_task_entity.mock_generate_data_tasks[0] assert task.instructions == "Test prompt" diff --git a/tests/components/ai_task/test_http.py b/tests/components/ai_task/test_http.py index 4436e1d45d5..a2eecfddf74 100644 --- a/tests/components/ai_task/test_http.py +++ b/tests/components/ai_task/test_http.py @@ -18,20 +18,20 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": None, + "gen_data_entity_id": None, } # Set preferences await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": "ai_task.summary_1", + "gen_data_entity_id": "ai_task.summary_1", } ) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_1", + "gen_data_entity_id": "ai_task.summary_1", } # Get updated preferences @@ -39,20 +39,20 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_1", + "gen_data_entity_id": "ai_task.summary_1", } # Update an existing preference await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } ) msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } # Get updated preferences @@ -60,7 +60,7 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } # No preferences set will preserve existing preferences @@ -72,7 +72,7 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } # Get updated preferences @@ -80,5 +80,5 @@ async def test_ws_preferences( msg = await client.receive_json() assert msg["success"] assert msg["result"] == { - "gen_text_entity_id": "ai_task.summary_2", + "gen_data_entity_id": "ai_task.summary_2", } diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 2f45d812b1f..fdfaaccd0a4 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -49,7 +49,7 @@ async def test_preferences_storage_load( ("set_preferences", "msg_extra"), [ ( - {"gen_text_entity_id": TEST_ENTITY_ID}, + {"gen_data_entity_id": TEST_ENTITY_ID}, {}, ), ( @@ -58,20 +58,20 @@ async def test_preferences_storage_load( ), ], ) -async def test_generate_text_service( +async def test_generate_data_service( hass: HomeAssistant, init_components: None, freezer: FrozenDateTimeFactory, set_preferences: dict[str, str | None], msg_extra: dict[str, str], ) -> None: - """Test the generate text service.""" + """Test the generate data service.""" preferences = hass.data[DATA_PREFERENCES] preferences.async_set_preferences(**set_preferences) result = await hass.services.async_call( "ai_task", - "generate_text", + "generate_data", { "task_name": "Test Name", "instructions": "Test prompt", @@ -81,4 +81,4 @@ async def test_generate_text_service( return_response=True, ) - assert result["text"] == "Mock result" + assert result["data"] == "Mock result" diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index d6e266aa02e..bed760c8a1d 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -4,7 +4,7 @@ from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_text +from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -28,7 +28,7 @@ async def test_run_task_preferred_entity( with pytest.raises( HomeAssistantError, match="No entity_id provided and no preferred entity set" ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", @@ -37,7 +37,7 @@ async def test_run_task_preferred_entity( await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": "ai_task.unknown", + "gen_data_entity_id": "ai_task.unknown", } ) msg = await client.receive_json() @@ -46,7 +46,7 @@ async def test_run_task_preferred_entity( with pytest.raises( HomeAssistantError, match="AI Task entity ai_task.unknown not found" ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", @@ -55,7 +55,7 @@ async def test_run_task_preferred_entity( await client.send_json_auto_id( { "type": "ai_task/preferences/set", - "gen_text_entity_id": TEST_ENTITY_ID, + "gen_data_entity_id": TEST_ENTITY_ID, } ) msg = await client.receive_json() @@ -65,12 +65,15 @@ async def test_run_task_preferred_entity( assert state is not None assert state.state == STATE_UNKNOWN - result = await async_generate_text( + result = await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", ) - assert result.text == "Mock result" + assert result.data == "Mock result" + as_dict = result.as_dict() + assert as_dict["conversation_id"] == result.conversation_id + assert as_dict["data"] == "Mock result" state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.state != STATE_UNKNOWN @@ -78,25 +81,25 @@ async def test_run_task_preferred_entity( mock_ai_task_entity.supported_features = AITaskEntityFeature(0) with pytest.raises( HomeAssistantError, - match="AI Task entity ai_task.test_task_entity does not support generating text", + match="AI Task entity ai_task.test_task_entity does not support generating data", ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", instructions="Test prompt", ) -async def test_run_text_task_unknown_entity( +async def test_run_data_task_unknown_entity( hass: HomeAssistant, init_components: None, ) -> None: - """Test running a text task with an unknown entity.""" + """Test running a data task with an unknown entity.""" with pytest.raises( HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found" ): - await async_generate_text( + await async_generate_data( hass, task_name="Test Task", entity_id="ai_task.unknown_entity", @@ -105,19 +108,19 @@ async def test_run_text_task_unknown_entity( @freeze_time("2025-06-14 22:59:00") -async def test_run_text_task_updates_chat_log( +async def test_run_data_task_updates_chat_log( hass: HomeAssistant, init_components: None, snapshot: SnapshotAssertion, ) -> None: - """Test that running a text task updates the chat log.""" - result = await async_generate_text( + """Test that running a data task updates the chat log.""" + result = await async_generate_data( hass, task_name="Test Task", entity_id=TEST_ENTITY_ID, instructions="Test prompt", ) - assert result.text == "Mock result" + assert result.data == "Mock result" with ( chat_session.async_get_chat_session(hass, result.conversation_id) as session, From 23b90f5984b2091be18da9546aae957ffaf4508a Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 24 Jun 2025 13:30:13 +0200 Subject: [PATCH 0598/1664] Add door state sensors to tedee (#147386) --- .../components/tedee/binary_sensor.py | 18 ++++++- tests/components/tedee/fixtures/locks.json | 6 ++- .../tedee/snapshots/test_binary_sensor.ambr | 49 +++++++++++++++++++ .../tedee/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 71 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py index 6570d9c5428..e67db7b2a9b 100644 --- a/homeassistant/components/tedee/binary_sensor.py +++ b/homeassistant/components/tedee/binary_sensor.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import dataclass from aiotedee import TedeeLock -from aiotedee.lock import TedeeLockState +from aiotedee.lock import TedeeDoorState, TedeeLockState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -29,6 +29,8 @@ class TedeeBinarySensorEntityDescription( """Describes Tedee binary sensor entity.""" is_on_fn: Callable[[TedeeLock], bool | None] + supported_fn: Callable[[TedeeLock], bool] = lambda _: True + available_fn: Callable[[TedeeLock], bool] = lambda _: True ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( @@ -61,6 +63,14 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + TedeeBinarySensorEntityDescription( + key="door_state", + is_on_fn=lambda lock: lock.door_state is TedeeDoorState.OPENED, + device_class=BinarySensorDeviceClass.DOOR, + supported_fn=lambda lock: lock.door_state is not TedeeDoorState.NOT_PAIRED, + available_fn=lambda lock: lock.door_state + not in [TedeeDoorState.UNCALIBRATED, TedeeDoorState.DISCONNECTED], + ), ) @@ -77,6 +87,7 @@ async def async_setup_entry( TedeeBinarySensorEntity(lock, coordinator, entity_description) for entity_description in ENTITIES for lock in locks + if entity_description.supported_fn(lock) ) coordinator.new_lock_callbacks.append(_async_add_new_lock) @@ -92,3 +103,8 @@ class TedeeBinarySensorEntity(TedeeDescriptionEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self.entity_description.is_on_fn(self._lock) + + @property + def available(self) -> bool: + """Return true if the binary sensor is available.""" + return self.entity_description.available_fn(self._lock) and super().available diff --git a/tests/components/tedee/fixtures/locks.json b/tests/components/tedee/fixtures/locks.json index 6a8eb77d7ee..95a1adf40ec 100644 --- a/tests/components/tedee/fixtures/locks.json +++ b/tests/components/tedee/fixtures/locks.json @@ -9,7 +9,8 @@ "is_charging": false, "state_change_result": 0, "is_enabled_pullspring": 1, - "duration_pullspring": 2 + "duration_pullspring": 2, + "door_state": 0 }, { "lock_name": "Lock-2C3D", @@ -21,6 +22,7 @@ "is_charging": false, "state_change_result": 0, "is_enabled_pullspring": 0, - "duration_pullspring": 0 + "duration_pullspring": 0, + "door_state": 2 } ] diff --git a/tests/components/tedee/snapshots/test_binary_sensor.ambr b/tests/components/tedee/snapshots/test_binary_sensor.ambr index 05d0e34037e..dbde7932a6d 100644 --- a/tests/components/tedee/snapshots/test_binary_sensor.ambr +++ b/tests/components/tedee/snapshots/test_binary_sensor.ambr @@ -242,6 +242,55 @@ 'state': 'off', }) # --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_door-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.lock_2c3d_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tedee', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '98765-door_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensors[binary_sensor.lock_2c3d_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Lock-2C3D Door', + }), + 'context': , + 'entity_id': 'binary_sensor.lock_2c3d_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_binary_sensors[binary_sensor.lock_2c3d_lock_uncalibrated-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tedee/snapshots/test_diagnostics.ambr b/tests/components/tedee/snapshots/test_diagnostics.ambr index 63707477df9..d66b2601b72 100644 --- a/tests/components/tedee/snapshots/test_diagnostics.ambr +++ b/tests/components/tedee/snapshots/test_diagnostics.ambr @@ -17,7 +17,7 @@ }), '1': dict({ 'battery_level': 70, - 'door_state': 0, + 'door_state': 2, 'duration_pullspring': 0, 'is_charging': False, 'is_connected': True, From fc62a6cd8906506b79c27298076dda8413a41e3e Mon Sep 17 00:00:00 2001 From: Parker Wahle Date: Tue, 24 Jun 2025 07:54:34 -0400 Subject: [PATCH 0599/1664] Add streaming support w/ audio to Android IP Webcam integration (#126009) * Add streaming support w/ audio to Android IP Webcam integration * ruff reformat * Fix ruff * Break long comments and strings * Add camera test * Fix docstring * Remove dead code * Call library function to get URL * Simplify --------- Co-authored-by: Shay Levy Co-authored-by: Martin Hjelmare Co-authored-by: Erik Montnemery --- .../components/android_ip_webcam/camera.py | 16 ++++++ .../android_ip_webcam/test_camera.py | 54 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 tests/components/android_ip_webcam/test_camera.py diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index 833b9a0d296..e4b0f5536a7 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -2,6 +2,7 @@ from __future__ import annotations +from homeassistant.components.camera import CameraEntityFeature from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.const import ( CONF_HOST, @@ -31,6 +32,7 @@ class IPWebcamCamera(MjpegCamera): """Representation of a IP Webcam camera.""" _attr_has_entity_name = True + _attr_supported_features = CameraEntityFeature.STREAM def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None: """Initialize the camera.""" @@ -46,3 +48,17 @@ class IPWebcamCamera(MjpegCamera): identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name=coordinator.config_entry.data[CONF_HOST], ) + self._coordinator = coordinator + + async def stream_source(self) -> str: + """Get the stream source for the Android IP camera.""" + return self._coordinator.cam.get_rtsp_url( + video_codec="h264", # most compatible & recommended + # while "opus" is compatible with more devices, + # HA's stream integration requires AAC or MP3, + # and IP webcam doesn't provide MP3 audio. + # aac is supported on select devices >= android 4.1. + # The stream will be quiet on devices that don't support aac, + # but it won't fail. + audio_codec="aac", + ) diff --git a/tests/components/android_ip_webcam/test_camera.py b/tests/components/android_ip_webcam/test_camera.py new file mode 100644 index 00000000000..0ecdb93bcbd --- /dev/null +++ b/tests/components/android_ip_webcam/test_camera.py @@ -0,0 +1,54 @@ +"""Test the Android IP Webcam camera.""" + +from typing import Any + +import pytest + +from homeassistant.components.android_ip_webcam.const import DOMAIN +from homeassistant.components.camera import async_get_stream_source +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("aioclient_mock_fixture") +@pytest.mark.parametrize( + ("config", "expected_stream_source"), + [ + ( + { + "host": "1.1.1.1", + "port": 8080, + "username": "user", + "password": "pass", + }, + "rtsp://user:pass@1.1.1.1:8080/h264_aac.sdp", + ), + ( + { + "host": "1.1.1.1", + "port": 8080, + }, + "rtsp://1.1.1.1:8080/h264_aac.sdp", + ), + ], +) +async def test_camera_stream_source( + hass: HomeAssistant, + config: dict[str, Any], + expected_stream_source: str, +) -> None: + """Test camera stream source.""" + entity_id = "camera.1_1_1_1" + entry = MockConfigEntry(domain=DOMAIN, data=config) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + + stream_source = await async_get_stream_source(hass, entity_id) + + assert stream_source == expected_stream_source From 97f3bb3da522f0463815548cf3fa9dee72066590 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 24 Jun 2025 08:27:14 -0400 Subject: [PATCH 0600/1664] Add default to from_json (#146211) --- homeassistant/helpers/template.py | 9 +++++++-- tests/helpers/test_template.py | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 34b19c07f83..85ee1e28309 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2634,9 +2634,14 @@ def ordinal(value): ) -def from_json(value): +def from_json(value, default=_SENTINEL): """Convert a JSON string to an object.""" - return json_loads(value) + try: + return json_loads(value) + except JSON_DECODE_EXCEPTIONS: + if default is _SENTINEL: + raise_no_default("from_json", value) + return default def _to_json_default(obj: Any) -> None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 15c6a4b7251..82b6434cf3f 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1494,6 +1494,15 @@ def test_from_json(hass: HomeAssistant) -> None: ).async_render() assert actual_result == expected_result + info = render_to_info(hass, "{{ 'garbage string' | from_json }}") + with pytest.raises(TemplateError, match="no default was specified"): + info.result() + + actual_result = template.Template( + "{{ 'garbage string' | from_json('Bar') }}", hass + ).async_render() + assert actual_result == expected_result + def test_average(hass: HomeAssistant) -> None: """Test the average filter.""" From 7cccdf22058b57861687d550173deecd087a5294 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 08:36:48 -0400 Subject: [PATCH 0601/1664] Add accept keyword to Media selector (#145527) * Add accept keyword to Media selector * Adjust test --- homeassistant/helpers/selector.py | 10 +++++++--- tests/helpers/test_llm.py | 2 +- tests/helpers/test_selector.py | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 322cfe34042..438998aafb8 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1020,11 +1020,15 @@ class MediaSelector(Selector[MediaSelectorConfig]): selector_type = "media" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("accept"): [str], + } + ) DATA_SCHEMA = vol.Schema( { - # Although marked as optional in frontend, this field is required - vol.Required("entity_id"): cv.entity_id_or_uuid, + # If accept is set, the entity_id field will not be present + vol.Optional("entity_id"): cv.entity_id_or_uuid, # Although marked as optional in frontend, this field is required vol.Required("media_content_id"): str, # Although marked as optional in frontend, this field is required diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 98dee920bd9..b6894505534 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1125,7 +1125,7 @@ async def test_selector_serializer( "media_content_type": {"type": "string"}, "metadata": {"type": "object", "additionalProperties": True}, }, - "required": ["entity_id", "media_content_id", "media_content_type"], + "required": ["media_content_id", "media_content_type"], } assert selector_serializer(selector.NumberSelector({"mode": "box"})) == { "type": "number" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 51ee467b029..97c02bdc837 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -817,6 +817,23 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> ), (None, "abc", {}), ), + ( + { + "accept": ["image/*"], + }, + ( + { + "media_content_id": "abc", + "media_content_type": "def", + }, + { + "media_content_id": "abc", + "media_content_type": "def", + "metadata": {}, + }, + ), + (None, "abc", {}), + ), ], ) def test_media_selector_schema(schema, valid_selections, invalid_selections) -> None: From 39c431c55c2eb7249f4acb5437bf77cdaf033907 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 24 Jun 2025 06:05:28 -0700 Subject: [PATCH 0602/1664] Add 'max_sub_interval' option to derivative sensor (#125870) * Add 'max_sub_interval' option to derivative sensor * add strings * little coverage * improve test accuracy * reimplement at dev head * string * handle unavailable * simplify * Add self to codeowner * fix on remove * Update homeassistant/components/derivative/sensor.py Co-authored-by: Erik Montnemery * Fix parenthesis * sort strings --------- Co-authored-by: Erik Montnemery --- CODEOWNERS | 4 +- .../components/derivative/config_flow.py | 4 + homeassistant/components/derivative/const.py | 1 + .../components/derivative/manifest.json | 2 +- homeassistant/components/derivative/sensor.py | 102 +++++++++- .../components/derivative/strings.json | 4 + .../components/derivative/test_config_flow.py | 4 + tests/components/derivative/test_sensor.py | 175 +++++++++++++++++- 8 files changed, 290 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 2406606bb28..419347d08a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -331,8 +331,8 @@ build.json @home-assistant/supervisor /tests/components/demo/ @home-assistant/core /homeassistant/components/denonavr/ @ol-iver @starkillerOG /tests/components/denonavr/ @ol-iver @starkillerOG -/homeassistant/components/derivative/ @afaucogney -/tests/components/derivative/ @afaucogney +/homeassistant/components/derivative/ @afaucogney @karwosts +/tests/components/derivative/ @afaucogney @karwosts /homeassistant/components/devialet/ @fwestenberg /tests/components/devialet/ @fwestenberg /homeassistant/components/device_automation/ @home-assistant/core diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 2ef2018eda8..37d54e04f7f 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.schema_config_entry_flow import ( ) from .const import ( + CONF_MAX_SUB_INTERVAL, CONF_ROUND_DIGITS, CONF_TIME_WINDOW, CONF_UNIT_PREFIX, @@ -104,6 +105,9 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict: options=TIME_UNITS, translation_key="time_unit" ), ), + vol.Optional(CONF_MAX_SUB_INTERVAL): selector.DurationSelector( + selector.DurationSelectorConfig(allow_negative=False) + ), } diff --git a/homeassistant/components/derivative/const.py b/homeassistant/components/derivative/const.py index 32f2777dc80..9166a505915 100644 --- a/homeassistant/components/derivative/const.py +++ b/homeassistant/components/derivative/const.py @@ -7,3 +7,4 @@ CONF_TIME_WINDOW = "time_window" CONF_UNIT = "unit" CONF_UNIT_PREFIX = "unit_prefix" CONF_UNIT_TIME = "unit_time" +CONF_MAX_SUB_INTERVAL = "max_sub_interval" diff --git a/homeassistant/components/derivative/manifest.json b/homeassistant/components/derivative/manifest.json index e1d8986c2dd..4c5684bae75 100644 --- a/homeassistant/components/derivative/manifest.json +++ b/homeassistant/components/derivative/manifest.json @@ -2,7 +2,7 @@ "domain": "derivative", "name": "Derivative", "after_dependencies": ["counter"], - "codeowners": ["@afaucogney"], + "codeowners": ["@afaucogney", "@karwosts"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/derivative", "integration_type": "helper", diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index f6c2b45ef9c..60f4611c5eb 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timedelta -from decimal import Decimal, DecimalException +from decimal import Decimal, DecimalException, InvalidOperation import logging import voluptuous as vol @@ -25,6 +25,7 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import ( + CALLBACK_TYPE, Event, EventStateChangedData, EventStateReportedData, @@ -40,12 +41,14 @@ from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, ) from homeassistant.helpers.event import ( + async_call_later, async_track_state_change_event, async_track_state_report_event, ) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + CONF_MAX_SUB_INTERVAL, CONF_ROUND_DIGITS, CONF_TIME_WINDOW, CONF_UNIT, @@ -89,10 +92,20 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT): cv.string, vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period, + vol.Optional(CONF_MAX_SUB_INTERVAL): cv.positive_time_period, } ) +def _is_decimal_state(state: str) -> bool: + try: + Decimal(state) + except (InvalidOperation, TypeError): + return False + else: + return True + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -114,6 +127,11 @@ async def async_setup_entry( # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None + if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): + max_sub_interval = cv.time_period(max_sub_interval_dict) + else: + max_sub_interval = None + derivative_sensor = DerivativeSensor( name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), @@ -124,6 +142,7 @@ async def async_setup_entry( unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, + max_sub_interval=max_sub_interval, ) async_add_entities([derivative_sensor]) @@ -145,6 +164,7 @@ async def async_setup_platform( unit_prefix=config[CONF_UNIT_PREFIX], unit_time=config[CONF_UNIT_TIME], unique_id=None, + max_sub_interval=config.get(CONF_MAX_SUB_INTERVAL), ) async_add_entities([derivative]) @@ -166,6 +186,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): unit_of_measurement: str | None, unit_prefix: str | None, unit_time: UnitOfTime, + max_sub_interval: timedelta | None, unique_id: str | None, device_info: DeviceInfo | None = None, ) -> None: @@ -192,6 +213,34 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] self._time_window = time_window.total_seconds() + self._max_sub_interval: timedelta | None = ( + None # disable time based derivative + if max_sub_interval is None or max_sub_interval.total_seconds() == 0 + else max_sub_interval + ) + self._cancel_max_sub_interval_exceeded_callback: CALLBACK_TYPE = ( + lambda *args: None + ) + + def _calc_derivative_from_state_list(self, current_time: datetime) -> Decimal: + def calculate_weight(start: datetime, end: datetime, now: datetime) -> float: + window_start = now - timedelta(seconds=self._time_window) + return (end - max(start, window_start)).total_seconds() / self._time_window + + derivative = Decimal("0.00") + for start, end, value in self._state_list: + weight = calculate_weight(start, end, current_time) + derivative = derivative + (value * Decimal(weight)) + + return derivative + + def _prune_state_list(self, current_time: datetime) -> None: + # filter out all derivatives older than `time_window` from our window list + self._state_list = [ + (time_start, time_end, state) + for time_start, time_end, state in self._state_list + if (current_time - time_end).total_seconds() < self._time_window + ] async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" @@ -209,13 +258,52 @@ class DerivativeSensor(RestoreSensor, SensorEntity): except SyntaxError as err: _LOGGER.warning("Could not restore last state: %s", err) + def schedule_max_sub_interval_exceeded(source_state: State | None) -> None: + """Schedule calculation using the source state and max_sub_interval. + + The callback reference is stored for possible cancellation if the source state + reports a change before max_sub_interval has passed. + If the callback is executed, meaning there was no state change reported, the + source_state is assumed constant and calculation is done using its value. + """ + if ( + self._max_sub_interval is not None + and source_state is not None + and (_is_decimal_state(source_state.state)) + ): + + @callback + def _calc_derivative_on_max_sub_interval_exceeded_callback( + now: datetime, + ) -> None: + """Calculate derivative based on time and reschedule.""" + + self._prune_state_list(now) + derivative = self._calc_derivative_from_state_list(now) + self._attr_native_value = round(derivative, self._round_digits) + + self.async_write_ha_state() + + # If derivative is now zero, don't schedule another timeout callback, as it will have no effect + if derivative != 0: + schedule_max_sub_interval_exceeded(source_state) + + self._cancel_max_sub_interval_exceeded_callback = async_call_later( + self.hass, + self._max_sub_interval, + _calc_derivative_on_max_sub_interval_exceeded_callback, + ) + @callback def on_state_reported(event: Event[EventStateReportedData]) -> None: """Handle constant sensor state.""" + self._cancel_max_sub_interval_exceeded_callback() + new_state = event.data["new_state"] if self._attr_native_value == Decimal(0): # If the derivative is zero, and the source sensor hasn't # changed state, then we know it will still be zero. return + schedule_max_sub_interval_exceeded(new_state) new_state = event.data["new_state"] if new_state is not None: calc_derivative( @@ -225,7 +313,9 @@ class DerivativeSensor(RestoreSensor, SensorEntity): @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: """Handle changed sensor state.""" + self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] + schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] if new_state is not None and old_state is not None: calc_derivative(new_state, old_state.state, old_state.last_reported) @@ -312,6 +402,16 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() + if self._max_sub_interval is not None: + source_state = self.hass.states.get(self._sensor_source_id) + schedule_max_sub_interval_exceeded(source_state) + + @callback + def on_removed() -> None: + self._cancel_max_sub_interval_exceeded_callback() + + self.async_on_remove(on_removed) + self.async_on_remove( async_track_state_change_event( self.hass, self._sensor_source_id, on_state_changed diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index f1b7375ae07..5081e7f3b35 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -6,6 +6,7 @@ "title": "Create Derivative sensor", "description": "Create a sensor that estimates the derivative of a sensor.", "data": { + "max_sub_interval": "Max sub-interval", "name": "[%key:common::config_flow::data::name%]", "round": "Precision", "source": "Input sensor", @@ -14,6 +15,7 @@ "unit_time": "Time unit" }, "data_description": { + "max_sub_interval": "If defined, derivative automatically recalculates if the source has not updated for this duration.", "round": "Controls the number of decimal digits in the output.", "time_window": "If set, the sensor's value is a time-weighted moving average of derivatives within this window.", "unit_prefix": "The output will be scaled according to the selected metric prefix and time unit of the derivative." @@ -25,6 +27,7 @@ "step": { "init": { "data": { + "max_sub_interval": "[%key:component::derivative::config::step::user::data::max_sub_interval%]", "name": "[%key:common::config_flow::data::name%]", "round": "[%key:component::derivative::config::step::user::data::round%]", "source": "[%key:component::derivative::config::step::user::data::source%]", @@ -33,6 +36,7 @@ "unit_time": "[%key:component::derivative::config::step::user::data::unit_time%]" }, "data_description": { + "max_sub_interval": "[%key:component::derivative::config::step::user::data_description::max_sub_interval%]", "round": "[%key:component::derivative::config::step::user::data_description::round%]", "time_window": "[%key:component::derivative::config::step::user::data_description::time_window%]", "unit_prefix": "[%key:component::derivative::config::step::user::data_description::unit_prefix%]" diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 3f27d2366a5..440df495995 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -36,6 +36,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "source": input_sensor_entity_id, "time_window": {"seconds": 0}, "unit_time": "min", + "max_sub_interval": {"minutes": 1}, }, ) await hass.async_block_till_done() @@ -49,6 +50,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "source": "sensor.input", "time_window": {"seconds": 0.0}, "unit_time": "min", + "max_sub_interval": {"minutes": 1.0}, } assert len(mock_setup_entry.mock_calls) == 1 @@ -60,6 +62,7 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "source": "sensor.input", "time_window": {"seconds": 0.0}, "unit_time": "min", + "max_sub_interval": {"minutes": 1.0}, } assert config_entry.title == "My derivative" @@ -78,6 +81,7 @@ async def test_options(hass: HomeAssistant, platform) -> None: "time_window": {"seconds": 0.0}, "unit_prefix": "k", "unit_time": "min", + "max_sub_interval": {"seconds": 30}, }, title="My derivative", ) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index f8d88066f16..e4e7097341c 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -9,13 +9,13 @@ from freezegun import freeze_time from homeassistant.components.derivative.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import UnitOfPower, UnitOfTime +from homeassistant.const import STATE_UNAVAILABLE, UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_state(hass: HomeAssistant) -> None: @@ -371,6 +371,177 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: previous = derivative +async def test_sub_intervals_instantaneous(hass: HomeAssistant) -> None: + """Test derivative sensor state.""" + # We simulate the following situation: + # Value changes from 0 to 10 in 5 seconds (derivative = 2) + # The max_sub_interval is 20 seconds + # After max_sub_interval elapses, derivative should change to 0 + # Value changes to 0, 35 seconds after changing to 10 (derivative = -10/35 = -0.29) + # State goes unavailable, derivative stops changing after that. + # State goes back to 0, derivative returns to 0 after a max_sub_interval + + max_sub_interval = 20 + + config, entity_id = await _setup_sensor( + hass, + { + "unit_time": UnitOfTime.SECONDS, + "round": 2, + "max_sub_interval": {"seconds": max_sub_interval}, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + freezer.move_to(base) + hass.states.async_set(entity_id, 0, {}, force_update=True) + await hass.async_block_till_done() + + now = base + timedelta(seconds=5) + freezer.move_to(now) + hass.states.async_set(entity_id, 10, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 2 + + # No change yet as sub_interval not elapsed + now += timedelta(seconds=15) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 2 + + # After 5 more seconds the sub_interval should fire and derivative should be 0 + now += timedelta(seconds=10) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 0 + + now += timedelta(seconds=10) + freezer.move_to(now) + hass.states.async_set(entity_id, 0, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=10) + freezer.move_to(now) + hass.states.async_set(entity_id, STATE_UNAVAILABLE, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=60) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=10) + freezer.move_to(now) + hass.states.async_set(entity_id, 0, {}, force_update=True) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == -0.29 + + now += timedelta(seconds=max_sub_interval + 1) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + assert derivative == 0 + + +async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None: + """Test derivative sensor state.""" + # We simulate the following situation: + # The value rises by 1 every second for 1 minute, then pauses + # The time window is 30 seconds + # The max_sub_interval is 5 seconds + # After the value stops increasing, the derivative should slowly trend back to 0 + + values = [] + for value in range(60): + values += [value] + time_window = 30 + max_sub_interval = 5 + times = values + + config, entity_id = await _setup_sensor( + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": UnitOfTime.SECONDS, + "round": 2, + "max_sub_interval": {"seconds": max_sub_interval}, + }, + ) + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + last_state_change = None + for time, value in zip(times, values, strict=False): + now = base + timedelta(seconds=time) + freezer.move_to(now) + hass.states.async_set(entity_id, value, {}, force_update=True) + last_state_change = now + await hass.async_block_till_done() + + if time_window < time: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 1% + ε + assert abs(1 - derivative) <= 0.01 + 1e-6 + + for time in range(60): + now = last_state_change + timedelta(seconds=time) + freezer.move_to(now) + + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + + def calc_expected(elapsed_seconds: int, calculation_delay: int = 0): + last_sub_interval = ( + elapsed_seconds // max_sub_interval + ) * max_sub_interval + return ( + 0 + if (last_sub_interval >= time_window) + else ( + (time_window - last_sub_interval - calculation_delay) + / time_window + ) + ) + + rounding_err = 0.01 + 1e-6 + expect_max = calc_expected(time) + rounding_err + # Allow one second of slop for internal delays + expect_min = calc_expected(time, 1) - rounding_err + + assert expect_min <= derivative <= expect_max, f"Failed at time {time}" + + async def test_prefix(hass: HomeAssistant) -> None: """Test derivative sensor state using a power source.""" config = { From 3b8d6eb851f43eef8ede6ab0994e2dfc7e2e4c9a Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 24 Jun 2025 15:24:25 +0200 Subject: [PATCH 0603/1664] Log LCN connection established with log level info (#147424) --- homeassistant/components/lcn/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index efc981b754c..43438fa64dd 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -107,7 +107,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) - f"Unable to connect to {config_entry.title}: {ex}" ) from ex - _LOGGER.debug('LCN connected to "%s"', config_entry.title) + _LOGGER.info('LCN connected to "%s"', config_entry.title) config_entry.runtime_data = LcnRuntimeData( connection=lcn_connection, device_connections={}, From 602c1c64b36dedf391281058323b3e170eaf580d Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Tue, 24 Jun 2025 16:30:12 +0300 Subject: [PATCH 0604/1664] Update ZwaveJS config flow strings (#147421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/zwave_js/strings.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index bceed10274b..d9a3b82a47c 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -31,10 +31,10 @@ }, "flow_title": "{name}", "progress": { - "install_addon": "Installation can take several minutes.", - "start_addon": "Starting add-on.", - "backup_nvm": "Please wait while the network backup completes.", - "restore_nvm": "Please wait while the network restore completes." + "install_addon": "Installation can take several minutes", + "start_addon": "Starting add-on", + "backup_nvm": "Please wait while the network backup completes", + "restore_nvm": "Please wait while the network restore completes" }, "step": { "configure_addon_user": { @@ -47,7 +47,7 @@ "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, - "description": "The add-on will generate security keys if those fields are left empty.", + "description": "Select your Z-Wave adapter", "title": "Enter the Z-Wave add-on configuration" }, "configure_addon_reconfigure": { @@ -129,7 +129,7 @@ }, "installation_type": { "title": "Set up Z-Wave", - "description": "In a few steps, we’re going to set up your Home Assistant Connect ZWA-2. Home Assistant can automatically install and configure the recommended Z-Wave setup, or you can customize it.", + "description": "In a few steps, we're going to set up your adapter. Home Assistant can automatically install and configure the recommended Z-Wave setup, or you can customize it.", "menu_options": { "intent_recommended": "Recommended installation", "intent_custom": "Custom installation" From 1cb36f4c18e0b087b175bdd3f52ddf76f6582f03 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 09:36:09 -0400 Subject: [PATCH 0605/1664] Convert Claude to use subentries (#147285) * Convert Claude to use subentries * Add latest changes from Google subentries * Revert accidental change to Google --- .../components/anthropic/__init__.py | 87 +++++- .../components/anthropic/config_flow.py | 160 +++++++---- homeassistant/components/anthropic/const.py | 2 + .../components/anthropic/conversation.py | 29 +- .../components/anthropic/strings.json | 52 ++-- tests/components/anthropic/conftest.py | 21 +- .../snapshots/test_conversation.ambr | 6 +- .../components/anthropic/test_config_flow.py | 132 +++++++-- .../components/anthropic/test_conversation.py | 101 ++++--- tests/components/anthropic/test_init.py | 268 ++++++++++++++++++ 10 files changed, 696 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index a9745d1a6a5..c13c82f0020 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -6,11 +6,16 @@ from functools import partial import anthropic -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.typing import ConfigType from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL @@ -20,13 +25,24 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Anthropic.""" + await async_migrate_integration(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: """Set up Anthropic from a config entry.""" client = await hass.async_add_executor_job( partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY]) ) try: - model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Use model from first conversation subentry for validation + subentries = list(entry.subentries.values()) + if subentries: + model_id = subentries[0].data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + else: + model_id = RECOMMENDED_CHAT_MODEL model = await client.models.retrieve(model_id=model_id, timeout=10.0) LOGGER.debug("Anthropic model: %s", model.display_name) except anthropic.AuthenticationError as err: @@ -45,3 +61,68 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Anthropic.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + entries = hass.config_entries.async_entries(DOMAIN) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_API_KEY] not in api_keys_entries: + use_existing = True + api_keys_entries[entry.data[CONF_API_KEY]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + entity_registry.async_update_entity( + conversation_entity, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index ebad206af61..6a18cb693cd 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -5,20 +5,21 @@ from __future__ import annotations from collections.abc import Mapping from functools import partial import logging -from types import MappingProxyType -from typing import Any +from typing import Any, cast import anthropic import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( NumberSelector, @@ -36,6 +37,7 @@ from .const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -72,7 +74,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -81,6 +83,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: + self._async_abort_entries_match(user_input) try: await validate_input(self.hass, user_input) except anthropic.APITimeoutError: @@ -102,57 +105,93 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="Claude", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors or None ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return AnthropicOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class AnthropicOptionsFlow(OptionsFlow): - """Anthropic config flow options handler.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.last_rendered_recommended = config_entry.options.get( - CONF_RECOMMENDED, False - ) + last_rendered_recommended = False - async def async_step_init( + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + async def async_step_set_options( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options + ) -> SubentryFlowResult: + """Set conversation options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + errors: dict[str, str] = {} - if user_input is not None: - if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: - if not user_input.get(CONF_LLM_HASS_API): - user_input.pop(CONF_LLM_HASS_API, None) - if user_input.get( - CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET - ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): - errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" - - if not errors: - return self.async_create_entry(title="", data=user_input) + if user_input is None: + if self._is_new: + options = RECOMMENDED_OPTIONS.copy() else: - # Re-render the options again, now with the recommended options shown/hidden - self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + # If this is a reconfiguration, we need to copy the existing options + # so that we can show the current values in the form. + options = self._get_reconfigure_subentry().data.copy() - options = { - CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], - CONF_PROMPT: user_input[CONF_PROMPT], - CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), - } + self.last_rendered_recommended = cast( + bool, options.get(CONF_RECOMMENDED, False) + ) + + elif user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: + if not user_input.get(CONF_LLM_HASS_API): + user_input.pop(CONF_LLM_HASS_API, None) + if user_input.get( + CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET + ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS): + errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large" + + if not errors: + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) + + options = user_input + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + else: + # Re-render the options again, now with the recommended options shown/hidden + self.last_rendered_recommended = user_input[CONF_RECOMMENDED] + + options = { + CONF_RECOMMENDED: user_input[CONF_RECOMMENDED], + CONF_PROMPT: user_input[CONF_PROMPT], + CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API), + } suggested_values = options.copy() if not suggested_values.get(CONF_PROMPT): @@ -163,19 +202,25 @@ class AnthropicOptionsFlow(OptionsFlow): suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis] schema = self.add_suggested_values_to_schema( - vol.Schema(anthropic_config_option_schema(self.hass, options)), + vol.Schema( + anthropic_config_option_schema(self.hass, self._is_new, options) + ), suggested_values, ) return self.async_show_form( - step_id="init", + step_id="set_options", data_schema=schema, errors=errors or None, ) + async_step_user = async_step_set_options + async_step_reconfigure = async_step_set_options + def anthropic_config_option_schema( hass: HomeAssistant, + is_new: bool, options: Mapping[str, Any], ) -> dict: """Return a schema for Anthropic completion options.""" @@ -187,15 +232,24 @@ def anthropic_config_option_schema( for api in llm.async_get_apis(hass) ] - schema = { - vol.Optional(CONF_PROMPT): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + if is_new: + schema: dict[vol.Required | vol.Optional, Any] = { + vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + } + else: + schema = {} + + schema.update( + { + vol.Optional(CONF_PROMPT): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) if options.get(CONF_RECOMMENDED): return schema diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index 69789b9a64a..d7e10dd7af2 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -5,6 +5,8 @@ import logging DOMAIN = "anthropic" LOGGER = logging.getLogger(__package__) +DEFAULT_CONVERSATION_NAME = "Claude conversation" + CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index f17294fe0e7..f34d9ed97b6 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -38,7 +38,7 @@ from anthropic.types import ( from voluptuous_openapi import convert from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -72,8 +72,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = AnthropicConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [AnthropicConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) def _format_tool( @@ -326,21 +332,22 @@ class AnthropicConversationEntity( ): """Anthropic conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True - def __init__(self, entry: AnthropicConfigEntry) -> None: + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry - self._attr_unique_id = entry.entry_id + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, manufacturer="Anthropic", model="Claude", entry_type=dr.DeviceEntryType.SERVICE, ) - if self.entry.options.get(CONF_LLM_HASS_API): + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -363,7 +370,7 @@ class AnthropicConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( @@ -393,7 +400,7 @@ class AnthropicConversationEntity( chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - options = self.entry.options + options = self.subentry.data tools: list[ToolParam] | None = None if chat_log.llm_api: diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index c2caf3a6666..098b4d5fa74 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -12,28 +12,44 @@ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "authentication_error": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "chat_model": "[%key:common::generic::model%]", - "max_tokens": "Maximum tokens to return in response", - "temperature": "Temperature", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings", - "thinking_budget_tokens": "Thinking budget" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "Maximum tokens to return in response", + "temperature": "Temperature", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings", + "thinking_budget_tokens": "Thinking budget" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking." + } } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." + }, + "error": { + "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } - }, - "error": { - "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget." } } } diff --git a/tests/components/anthropic/conftest.py b/tests/components/anthropic/conftest.py index 7419ea6c28f..53e00447a2e 100644 --- a/tests/components/anthropic/conftest.py +++ b/tests/components/anthropic/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components.anthropic import CONF_CHAT_MODEL +from homeassistant.components.anthropic.const import DEFAULT_CONVERSATION_NAME from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -23,6 +24,15 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + subentries_data=[ + { + "data": {}, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) entry.add_to_hass(hass) return entry @@ -33,8 +43,10 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) return mock_config_entry @@ -44,9 +56,10 @@ def mock_config_entry_with_extended_thinking( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "claude-3-7-sonnet-latest", }, diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index ea4ce5a980d..09618b135db 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -16,7 +16,7 @@ 'role': 'user', }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'content': 'Certainly, calling it now!', 'role': 'assistant', 'tool_calls': list([ @@ -30,14 +30,14 @@ ]), }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'role': 'tool_result', 'tool_call_id': 'toolu_0123456789AbCdEfGhIjKlM', 'tool_name': 'test_tool', 'tool_result': 'Test response', }), dict({ - 'agent_id': 'conversation.claude', + 'agent_id': 'conversation.claude_conversation', 'content': 'I have successfully called the function', 'role': 'assistant', 'tool_calls': None, diff --git a/tests/components/anthropic/test_config_flow.py b/tests/components/anthropic/test_config_flow.py index 1f41b7df2c7..2eac125f5c3 100644 --- a/tests/components/anthropic/test_config_flow.py +++ b/tests/components/anthropic/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Anthropic config flow.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from anthropic import ( APIConnectionError, @@ -22,12 +22,13 @@ from homeassistant.components.anthropic.const import ( CONF_RECOMMENDED, CONF_TEMPERATURE, CONF_THINKING_BUDGET, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_THINKING_BUDGET, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -71,39 +72,103 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ] assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + with patch( + "anthropic.resources.models.AsyncModels.retrieve", + return_value=Mock(display_name="Claude 3.5 Sonnet"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test creating a conversation subentry.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], - { - "prompt": "Speak like a pirate", - "max_tokens": 200, - }, + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "set_options" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"]["prompt"] == "Speak like a pirate" - assert options["data"]["max_tokens"] == 200 - assert options["data"][CONF_CHAT_MODEL] == RECOMMENDED_CHAT_MODEL + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock name" + + processed_options = RECOMMENDED_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options -async def test_options_thinking_budget_more_than_max( +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "anthropic.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_subentry_options_thinking_budget_more_than_max( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test error about thinking budget being more than max tokens.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + subentry = next(iter(mock_config_entry.subentries.values())) + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { "prompt": "Speak like a pirate", @@ -111,6 +176,7 @@ async def test_options_thinking_budget_more_than_max( "chat_model": "claude-3-7-sonnet-latest", "temperature": 1, "thinking_budget": 16384, + "recommended": False, }, ) await hass.async_block_till_done() @@ -252,7 +318,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ), ], ) -async def test_options_switching( +async def test_subentry_options_switching( hass: HomeAssistant, mock_config_entry, mock_init_component, @@ -260,23 +326,29 @@ async def test_options_switching( new_options, expected_options, ) -> None: - """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options + ) + await hass.async_block_till_done() + + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) if current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED): - options_flow = await hass.config_entries.options.async_configure( + options_flow = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { **current_options, CONF_RECOMMENDED: new_options[CONF_RECOMMENDED], }, ) - options = await hass.config_entries.options.async_configure( + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], new_options, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == expected_options diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 6aadcf3eeb4..3ae44e552cc 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -180,21 +180,23 @@ async def test_entity( mock_init_component, ) -> None: """Test entity properties.""" - state = hass.states.get("conversation.claude") + state = hass.states.get("conversation.claude_conversation") assert state assert state.attributes["supported_features"] == 0 - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "assist", }, ) with patch("anthropic.resources.models.AsyncModels.retrieve"): await hass.config_entries.async_reload(mock_config_entry.entry_id) - state = hass.states.get("conversation.claude") + state = hass.states.get("conversation.claude_conversation") assert state assert ( state.attributes["supported_features"] @@ -218,7 +220,7 @@ async def test_error_handling( ), ): result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -229,9 +231,11 @@ async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that template error handling works.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -244,7 +248,7 @@ async def test_template_error( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -260,9 +264,11 @@ async def test_template_variables( mock_user.id = "12345" mock_user.name = "Test User" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." @@ -286,7 +292,7 @@ async def test_template_variables( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", None, context, agent_id="conversation.claude" + hass, "hello", None, context, agent_id="conversation.claude_conversation" ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE @@ -304,7 +310,9 @@ async def test_conversation_agent( mock_init_component, ) -> None: """Test Anthropic Agent.""" - agent = conversation.agent_manager.async_get_agent(hass, "conversation.claude") + agent = conversation.agent_manager.async_get_agent( + hass, "conversation.claude_conversation" + ) assert agent.supported_languages == "*" @@ -332,7 +340,7 @@ async def test_function_call( expected_call_tool_args: dict[str, Any], ) -> None: """Test function call from the assistant.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -430,7 +438,7 @@ async def test_function_exception( mock_init_component, ) -> None: """Test function call with exception.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -536,7 +544,7 @@ async def test_assist_api_tools_conversion( ): assert await async_setup_component(hass, component, {}) - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" with patch( "anthropic.resources.messages.AsyncMessages.create", new_callable=AsyncMock, @@ -561,17 +569,19 @@ async def test_unknown_hass_api( mock_init_component, ) -> None: """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "non-existing", }, ) await hass.async_block_till_done() result = await conversation.async_converse( - hass, "hello", "1234", Context(), agent_id="conversation.claude" + hass, "hello", "1234", Context(), agent_id="conversation.claude_conversation" ) assert result == snapshot @@ -597,17 +607,25 @@ async def test_conversation_id( side_effect=create_stream_generator, ): result = await conversation.async_converse( - hass, "hello", "1234", Context(), agent_id="conversation.claude" + hass, + "hello", + "1234", + Context(), + agent_id="conversation.claude_conversation", ) result = await conversation.async_converse( - hass, "hello", None, None, agent_id="conversation.claude" + hass, "hello", None, None, agent_id="conversation.claude_conversation" ) conversation_id = result.conversation_id result = await conversation.async_converse( - hass, "hello", conversation_id, None, agent_id="conversation.claude" + hass, + "hello", + conversation_id, + None, + agent_id="conversation.claude_conversation", ) assert result.conversation_id == conversation_id @@ -615,13 +633,13 @@ async def test_conversation_id( unknown_id = ulid_util.ulid() result = await conversation.async_converse( - hass, "hello", unknown_id, None, agent_id="conversation.claude" + hass, "hello", unknown_id, None, agent_id="conversation.claude_conversation" ) assert result.conversation_id != unknown_id result = await conversation.async_converse( - hass, "hello", "koala", None, agent_id="conversation.claude" + hass, "hello", "koala", None, agent_id="conversation.claude_conversation" ) assert result.conversation_id == "koala" @@ -654,7 +672,7 @@ async def test_refusal( "2631EDCF22E8CCC1FB35B501C9C86", None, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR @@ -695,7 +713,7 @@ async def test_extended_thinking( ), ): result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.claude" + hass, "hello", None, Context(), agent_id="conversation.claude_conversation" ) chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( @@ -732,7 +750,7 @@ async def test_redacted_thinking( "8432ECCCE4C1253D5E2D82641AC0E52CC2876CB", None, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) chat_log = hass.data.get(conversation.chat_log.DATA_CHAT_LOGS).get( @@ -751,7 +769,7 @@ async def test_extended_thinking_tool_call( snapshot: SnapshotAssertion, ) -> None: """Test that thinking blocks and their order are preserved in with tool calls.""" - agent_id = "conversation.claude" + agent_id = "conversation.claude_conversation" context = Context() mock_tool = AsyncMock() @@ -841,7 +859,8 @@ async def test_extended_thinking_tool_call( conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("What shape is a donut?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), ], [ @@ -849,10 +868,11 @@ async def test_extended_thinking_tool_call( conversation.chat_log.UserContent("What shape is a donut?"), conversation.chat_log.UserContent("Can you tell me?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="Hope this helps." + agent_id="conversation.claude_conversation", content="Hope this helps." ), ], [ @@ -861,20 +881,21 @@ async def test_extended_thinking_tool_call( conversation.chat_log.UserContent("Can you tell me?"), conversation.chat_log.UserContent("Please?"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="A donut is a torus." + agent_id="conversation.claude_conversation", + content="A donut is a torus.", ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="Hope this helps." + agent_id="conversation.claude_conversation", content="Hope this helps." ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", content="You are welcome." + agent_id="conversation.claude_conversation", content="You are welcome." ), ], [ conversation.chat_log.SystemContent("You are a helpful assistant."), conversation.chat_log.UserContent("Turn off the lights and make me coffee"), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", content="Sure.", tool_calls=[ llm.ToolInput( @@ -891,19 +912,19 @@ async def test_extended_thinking_tool_call( ), conversation.chat_log.UserContent("Thank you"), conversation.chat_log.ToolResultContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id", tool_name="HassTurnOff", tool_result={"success": True, "response": "Lights are off."}, ), conversation.chat_log.ToolResultContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", tool_call_id="mock-tool-call-id-2", tool_name="MakeCoffee", tool_result={"success": False, "response": "Not enough milk."}, ), conversation.chat_log.AssistantContent( - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", content="Should I add milk to the shopping list?", ), ], @@ -940,7 +961,7 @@ async def test_history_conversion( "Are you sure?", conversation_id, Context(), - agent_id="conversation.claude", + agent_id="conversation.claude_conversation", ) assert mock_create.mock_calls[0][2]["messages"] == snapshot diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 305e442f52d..6295bac67cb 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -11,7 +11,9 @@ from anthropic import ( from httpx import URL, Request, Response import pytest +from homeassistant.components.anthropic.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -61,3 +63,269 @@ async def test_init_error( assert await async_setup_component(hass, "anthropic", {}) await hass.async_block_till_done() assert error in caplog.text + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + OPTIONS = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=OPTIONS, + version=1, + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.version == 2 + assert mock_config_entry.data == {"api_key": "1234"} + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.unique_id is None + assert subentry.title == "Claude" + assert subentry.subentry_type == "conversation" + assert subentry.data == OPTIONS + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.unique_id == subentry.subentry_id + + # Check device migration + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + migrated_device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert migrated_device.id == device.id + + +async def test_migration_from_v1_to_v2_with_multiple_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with different API keys.""" + # Create two v1 config entries with different API keys + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="Claude 1", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "12345"}, + options=options, + version=1, + title="Claude 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude 1", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude_1", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude 2", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for idx, entry in enumerate(entries): + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 1 + subentry = list(entry.subentries.values())[0] + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert subentry.title == f"Claude {idx + 1}" + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None + + +async def test_migration_from_v1_to_v2_with_same_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with same API keys consolidates entries.""" + # Create two v1 config entries with the same API key + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, # Same API key + options=options, + version=1, + title="Claude 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="claude", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should have only one entry left (consolidated) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 # Two subentries from the two original entries + + # Check both subentries exist with correct data + subentries = list(entry.subentries.values()) + titles = [sub.title for sub in subentries] + assert "Claude" in titles + assert "Claude 2" in titles + + for subentry in subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + + # Check devices were migrated correctly + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None From cfdd7fbbcedf7a85c177360a433efcb72fbd702f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 24 Jun 2025 15:54:06 +0200 Subject: [PATCH 0606/1664] Add fields and multiple support to object selector (#147215) * Add schema supports to object selector * Update format * Update homeassistant/helpers/selector.py Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- homeassistant/helpers/selector.py | 30 +++++++++++++++++++++++++++++- tests/helpers/test_selector.py | 23 ++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 438998aafb8..6f8df828c37 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1117,9 +1117,23 @@ class NumberSelector(Selector[NumberSelectorConfig]): return value +class ObjectSelectorField(TypedDict): + """Class to represent an object selector fields dict.""" + + label: str + required: bool + selector: dict[str, Any] + + class ObjectSelectorConfig(BaseSelectorConfig): """Class to represent an object selector config.""" + fields: dict[str, ObjectSelectorField] + multiple: bool + label_field: str + description_field: bool + translation_key: str + @SELECTORS.register("object") class ObjectSelector(Selector[ObjectSelectorConfig]): @@ -1127,7 +1141,21 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): selector_type = "object" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + { + vol.Optional("fields"): { + str: { + vol.Required("selector"): dict, + vol.Optional("required"): bool, + vol.Optional("label"): str, + } + }, + vol.Optional("multiple", default=False): bool, + vol.Optional("label_field"): str, + vol.Optional("description_field"): str, + vol.Optional("translation_key"): str, + } + ) def __init__(self, config: ObjectSelectorConfig | None = None) -> None: """Instantiate a selector.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 97c02bdc837..8947ea8099c 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -590,7 +590,28 @@ def test_action_selector_schema(schema, valid_selections, invalid_selections) -> @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), - [({}, ("abc123",), ())], + [ + ({}, ("abc123",), ()), + ( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {}}, + }, + }, + "multiple": True, + "label_field": "name", + "description_field": "percentage", + }, + (), + (), + ), + ], + [], ) def test_object_selector_schema(schema, valid_selections, invalid_selections) -> None: """Test object selector.""" From 4ca39ec7c3c215623e17e7e0973300466b5b7d0d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 24 Jun 2025 16:00:03 +0200 Subject: [PATCH 0607/1664] Add range icons for wind_direction sensor device class (#147090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Franck Nijhof Co-authored-by: Abílio Costa --- homeassistant/components/sensor/icons.json | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index f412b5de253..05311868fc6 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -176,7 +176,18 @@ "default": "mdi:weight" }, "wind_direction": { - "default": "mdi:compass-rose" + "default": "mdi:compass-rose", + "range": { + "0": "mdi:arrow-down", + "22.5": "mdi:arrow-bottom-left", + "67.5": "mdi:arrow-left", + "112.5": "mdi:arrow-top-left", + "157.5": "mdi:arrow-up", + "202.5": "mdi:arrow-top-right", + "247.5": "mdi:arrow-right", + "292.5": "mdi:arrow-bottom-right", + "337.5": "mdi:arrow-down" + } }, "wind_speed": { "default": "mdi:weather-windy" From 6ce594539fa935ca233948187652d0d55150b4ef Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Jun 2025 09:28:09 -0500 Subject: [PATCH 0608/1664] Bump wyoming to 1.7.1 (#147385) * Bump wyoming to 1.7.0 * Bump to 1.7.1 for Python version fix * Address mypy errors --- homeassistant/components/wyoming/assist_satellite.py | 2 +- homeassistant/components/wyoming/conversation.py | 6 +++--- homeassistant/components/wyoming/manifest.json | 2 +- homeassistant/components/wyoming/wake_word.py | 6 ++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 75c227f8537..1a1a67bf1de 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -739,7 +739,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): timestamp=timestamp, ) await self._client.write_event(chunk.event()) - timestamp += chunk.seconds + timestamp += chunk.milliseconds total_seconds += chunk.seconds await self._client.write_event(AudioStop(timestamp=timestamp).event()) diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py index 5760d04bfc2..988cf3c9045 100644 --- a/homeassistant/components/wyoming/conversation.py +++ b/homeassistant/components/wyoming/conversation.py @@ -149,21 +149,21 @@ class WyomingConversationEntity( not_recognized = NotRecognized.from_event(event) intent_response.async_set_error( intent.IntentResponseErrorCode.NO_INTENT_MATCH, - not_recognized.text, + not_recognized.text or "", ) break if Handled.is_type(event.type): # Success handled = Handled.from_event(event) - intent_response.async_set_speech(handled.text) + intent_response.async_set_speech(handled.text or "") break if NotHandled.is_type(event.type): not_handled = NotHandled.from_event(event) intent_response.async_set_error( intent.IntentResponseErrorCode.FAILED_TO_HANDLE, - not_handled.text, + not_handled.text or "", ) break diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json index d75b70dffa8..31adb17d7f5 100644 --- a/homeassistant/components/wyoming/manifest.json +++ b/homeassistant/components/wyoming/manifest.json @@ -13,6 +13,6 @@ "documentation": "https://www.home-assistant.io/integrations/wyoming", "integration_type": "service", "iot_class": "local_push", - "requirements": ["wyoming==1.5.4"], + "requirements": ["wyoming==1.7.1"], "zeroconf": ["_wyoming._tcp.local."] } diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index 2a21b7303e5..091b400a6c7 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -147,8 +147,10 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): queued_audio = [audio_task.result()] return wake_word.DetectionResult( - wake_word_id=detection.name, - wake_word_phrase=self._get_phrase(detection.name), + wake_word_id=detection.name or "", + wake_word_phrase=self._get_phrase( + detection.name or "" + ), timestamp=detection.timestamp, queued_audio=queued_audio, ) diff --git a/requirements_all.txt b/requirements_all.txt index 710785eed57..80f543f790f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3123,7 +3123,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.5.4 +wyoming==1.7.1 # homeassistant.components.xbox xbox-webapi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 815ab30d4c3..e1a546dfe2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2573,7 +2573,7 @@ wolf-comm==0.0.23 wsdot==0.0.1 # homeassistant.components.wyoming -wyoming==1.5.4 +wyoming==1.7.1 # homeassistant.components.xbox xbox-webapi==2.1.0 From 160163b0cc3a2b27b29e9ca854d3032305f9022d Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 24 Jun 2025 22:46:31 +0800 Subject: [PATCH 0609/1664] Remove deprecated proxy params from Telegram bot integration (#147288) --- .../components/telegram_bot/__init__.py | 2 - homeassistant/components/telegram_bot/bot.py | 47 +------------------ .../components/telegram_bot/const.py | 2 - .../components/telegram_bot/strings.json | 4 -- 4 files changed, 1 insertion(+), 54 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 554ddd8fc4e..5bdc670d69c 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -72,7 +72,6 @@ from .const import ( CONF_ALLOWED_CHAT_IDS, CONF_BOT_COUNT, CONF_CONFIG_ENTRY_ID, - CONF_PROXY_PARAMS, CONF_PROXY_URL, CONF_TRUSTED_NETWORKS, DEFAULT_TRUSTED_NETWORKS, @@ -117,7 +116,6 @@ CONFIG_SCHEMA = vol.Schema( ), vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string, vol.Optional(CONF_PROXY_URL): cv.string, - vol.Optional(CONF_PROXY_PARAMS): dict, # webhooks vol.Optional(CONF_URL): cv.url, vol.Optional( diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 9debc7bbbf1..4a00aff8d3f 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -37,7 +37,6 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import issue_registry as ir from homeassistant.util.ssl import get_default_context, get_default_no_verify_context from .const import ( @@ -77,7 +76,6 @@ from .const import ( ATTR_USERNAME, ATTR_VERIFY_SSL, CONF_CHAT_ID, - CONF_PROXY_PARAMS, CONF_PROXY_URL, DOMAIN, EVENT_TELEGRAM_CALLBACK, @@ -877,52 +875,9 @@ def initialize_bot(hass: HomeAssistant, p_config: MappingProxyType[str, Any]) -> """Initialize telegram bot with proxy support.""" api_key: str = p_config[CONF_API_KEY] proxy_url: str | None = p_config.get(CONF_PROXY_URL) - proxy_params: dict | None = p_config.get(CONF_PROXY_PARAMS) if proxy_url is not None: - auth = None - if proxy_params is None: - # CONF_PROXY_PARAMS has been kept for backwards compatibility. - proxy_params = {} - elif "username" in proxy_params and "password" in proxy_params: - # Auth can actually be stuffed into the URL, but the docs have previously - # indicated to put them here. - auth = proxy_params.pop("username"), proxy_params.pop("password") - ir.create_issue( - hass, - DOMAIN, - "proxy_params_auth_deprecation", - breaks_in_ha_version="2024.10.0", - is_persistent=False, - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_placeholders={ - "proxy_params": CONF_PROXY_PARAMS, - "proxy_url": CONF_PROXY_URL, - "telegram_bot": "Telegram bot", - }, - translation_key="proxy_params_auth_deprecation", - learn_more_url="https://github.com/home-assistant/core/pull/112778", - ) - else: - ir.create_issue( - hass, - DOMAIN, - "proxy_params_deprecation", - breaks_in_ha_version="2024.10.0", - is_persistent=False, - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_placeholders={ - "proxy_params": CONF_PROXY_PARAMS, - "proxy_url": CONF_PROXY_URL, - "httpx": "httpx", - "telegram_bot": "Telegram bot", - }, - translation_key="proxy_params_deprecation", - learn_more_url="https://github.com/home-assistant/core/pull/112778", - ) - proxy = httpx.Proxy(proxy_url, auth=auth, **proxy_params) + proxy = httpx.Proxy(proxy_url) request = HTTPXRequest(connection_pool_size=8, proxy=proxy) else: request = HTTPXRequest(connection_pool_size=8) diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index 4abdbaf9738..d6da96d9a28 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -13,8 +13,6 @@ SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_BOT_COUNT = "bot_count" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_CONFIG_ENTRY_ID = "config_entry_id" -CONF_PROXY_PARAMS = "proxy_params" - CONF_PROXY_URL = "proxy_url" CONF_TRUSTED_NETWORKS = "trusted_networks" diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 9fcc0740970..e932d010894 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -924,10 +924,6 @@ "proxy_params_auth_deprecation": { "title": "{telegram_bot}: Proxy authentication should be moved to the URL", "description": "Authentication details for the the proxy configured in the {telegram_bot} integration should be moved into the {proxy_url} instead. Please update your configuration and restart Home Assistant to fix this issue.\n\nThe {proxy_params} config key will be removed in a future release." - }, - "proxy_params_deprecation": { - "title": "{telegram_bot}: Proxy params option will be removed", - "description": "The {proxy_params} config key for the {telegram_bot} integration will be removed in a future release.\n\nAuthentication can now be provided through the {proxy_url} key.\n\nThe underlying library has changed to {httpx} which is incompatible with previous parameters. If you still need this functionality for other options, please leave a comment on the learn more link.\n\nPlease update your configuration to remove the {proxy_params} key and restart Home Assistant to fix this issue." } } } From cefde2114043bcbd7222358e5ac855f0c34d6ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 24 Jun 2025 16:08:27 +0100 Subject: [PATCH 0610/1664] Update Shelly test snapshots (#147429) --- .../shelly/snapshots/test_devices.ambr | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 37b0d3ef11c..0b8ec71771b 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -27,6 +27,7 @@ 'original_name': 'Cloud', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cloud-cloud', @@ -75,6 +76,7 @@ 'original_name': 'Input 0', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:0-input', @@ -123,6 +125,7 @@ 'original_name': 'Input 1', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:1-input', @@ -171,6 +174,7 @@ 'original_name': 'Overcurrent', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overcurrent', @@ -219,6 +223,7 @@ 'original_name': 'Overheating', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overtemp', @@ -267,6 +272,7 @@ 'original_name': 'Overpowering', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overpower', @@ -315,6 +321,7 @@ 'original_name': 'Overvoltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-overvoltage', @@ -363,6 +370,7 @@ 'original_name': 'Restart required', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-restart', @@ -411,6 +419,7 @@ 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', @@ -459,6 +468,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-cover:0', @@ -504,12 +514,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-current', @@ -568,6 +582,7 @@ 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-energy', @@ -623,6 +638,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-freq', @@ -669,12 +685,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-power', @@ -727,6 +747,7 @@ 'original_name': 'RSSI', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-wifi-rssi', @@ -782,6 +803,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-temperature', @@ -832,6 +854,7 @@ 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-uptime', @@ -885,6 +908,7 @@ 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cover:0-voltage', @@ -935,6 +959,7 @@ 'original_name': 'Beta firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate_beta', @@ -995,6 +1020,7 @@ 'original_name': 'Firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate', @@ -1055,6 +1081,7 @@ 'original_name': 'Cloud', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cloud-cloud', @@ -1103,6 +1130,7 @@ 'original_name': 'Input 0', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:0-input', @@ -1151,6 +1179,7 @@ 'original_name': 'Input 1', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-input:1-input', @@ -1199,6 +1228,7 @@ 'original_name': 'Restart required', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-restart', @@ -1247,6 +1277,7 @@ 'original_name': 'Overcurrent', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overcurrent', @@ -1295,6 +1326,7 @@ 'original_name': 'Overheating', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overtemp', @@ -1343,6 +1375,7 @@ 'original_name': 'Overpowering', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overpower', @@ -1391,6 +1424,7 @@ 'original_name': 'Overvoltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-overvoltage', @@ -1439,6 +1473,7 @@ 'original_name': 'Overcurrent', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overcurrent', @@ -1487,6 +1522,7 @@ 'original_name': 'Overheating', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overtemp', @@ -1535,6 +1571,7 @@ 'original_name': 'Overpowering', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overpower', @@ -1583,6 +1620,7 @@ 'original_name': 'Overvoltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-overvoltage', @@ -1631,6 +1669,7 @@ 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', @@ -1681,6 +1720,7 @@ 'original_name': 'RSSI', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-wifi-rssi', @@ -1727,12 +1767,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-current', @@ -1791,6 +1835,7 @@ 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-energy', @@ -1846,6 +1891,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-freq', @@ -1892,12 +1938,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-power', @@ -1956,6 +2006,7 @@ 'original_name': 'Returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-ret_energy', @@ -2011,6 +2062,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-temperature', @@ -2066,6 +2118,7 @@ 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0-voltage', @@ -2112,12 +2165,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-current', @@ -2176,6 +2233,7 @@ 'original_name': 'Energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-energy', @@ -2231,6 +2289,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-freq', @@ -2277,12 +2336,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-power', @@ -2341,6 +2404,7 @@ 'original_name': 'Returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-ret_energy', @@ -2396,6 +2460,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-temperature', @@ -2451,6 +2516,7 @@ 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1-voltage', @@ -2501,6 +2567,7 @@ 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-uptime', @@ -2549,6 +2616,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:0', @@ -2596,6 +2664,7 @@ 'original_name': None, 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-switch:1', @@ -2643,6 +2712,7 @@ 'original_name': 'Beta firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate_beta', @@ -2703,6 +2773,7 @@ 'original_name': 'Firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate', @@ -2763,6 +2834,7 @@ 'original_name': 'Cloud', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-cloud-cloud', @@ -2811,6 +2883,7 @@ 'original_name': 'Restart required', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-restart', @@ -2859,6 +2932,7 @@ 'original_name': 'Reboot', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC_reboot', @@ -2903,12 +2977,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_act_power', @@ -2955,12 +3033,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_aprt_power', @@ -3007,12 +3089,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_current', @@ -3068,6 +3154,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_freq', @@ -3120,6 +3207,7 @@ 'original_name': 'Power factor', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_pf', @@ -3177,6 +3265,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-a_total_act_energy', @@ -3235,6 +3324,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-a_total_act_ret_energy', @@ -3281,12 +3371,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-a_voltage', @@ -3333,12 +3427,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_act_power', @@ -3385,12 +3483,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_aprt_power', @@ -3437,12 +3539,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_current', @@ -3498,6 +3604,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_freq', @@ -3550,6 +3657,7 @@ 'original_name': 'Power factor', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_pf', @@ -3607,6 +3715,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-b_total_act_energy', @@ -3665,6 +3774,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-b_total_act_ret_energy', @@ -3711,12 +3821,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-b_voltage', @@ -3763,12 +3877,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_act_power', @@ -3815,12 +3933,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_aprt_power', @@ -3867,12 +3989,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_current', @@ -3928,6 +4054,7 @@ 'original_name': 'Frequency', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_freq', @@ -3980,6 +4107,7 @@ 'original_name': 'Power factor', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_pf', @@ -4037,6 +4165,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-c_total_act_energy', @@ -4095,6 +4224,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-c_total_act_ret_energy', @@ -4141,12 +4271,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Voltage', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-c_voltage', @@ -4199,6 +4333,7 @@ 'original_name': 'RSSI', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-wifi-rssi', @@ -4254,6 +4389,7 @@ 'original_name': 'Temperature', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-temperature:0-temperature_0', @@ -4312,6 +4448,7 @@ 'original_name': 'Total active energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-total_act', @@ -4358,12 +4495,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total active power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-total_act_power', @@ -4422,6 +4563,7 @@ 'original_name': 'Total active returned energy', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-emdata:0-total_act_ret', @@ -4468,12 +4610,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total apparent power', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-total_aprt_power', @@ -4520,12 +4666,16 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), 'original_device_class': , 'original_icon': None, 'original_name': 'Total current', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-em:0-total_current', @@ -4576,6 +4726,7 @@ 'original_name': 'Uptime', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '123456789ABC-sys-uptime', @@ -4624,6 +4775,7 @@ 'original_name': 'Beta firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate_beta', @@ -4684,6 +4836,7 @@ 'original_name': 'Firmware', 'platform': 'shelly', 'previous_unique_id': None, + 'suggested_object_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '123456789ABC-sys-fwupdate', From d5a8fa9c5c7ef6db08c4c0990a9a4c41be417d84 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 24 Jun 2025 17:17:02 +0200 Subject: [PATCH 0611/1664] Add DHCP discovery to PlayStation Network integration (#147422) Add DHCP discovery for PSN --- .../playstation_network/manifest.json | 59 ++++++++++++++ .../playstation_network/quality_scale.yaml | 2 +- homeassistant/generated/dhcp.py | 76 +++++++++++++++++++ 3 files changed, 136 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index f929e569b66..bdcb77f92c3 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -3,6 +3,65 @@ "name": "PlayStation Network", "codeowners": ["@jackjpowell"], "config_flow": true, + "dhcp": [ + { + "macaddress": "AC8995*" + }, + { + "macaddress": "1C98C1*" + }, + { + "macaddress": "5C843C*" + }, + { + "macaddress": "605BB4*" + }, + { + "macaddress": "8060B7*" + }, + { + "macaddress": "78C881*" + }, + { + "macaddress": "00D9D1*" + }, + { + "macaddress": "00E421*" + }, + { + "macaddress": "0CFE45*" + }, + { + "macaddress": "2CCC44*" + }, + { + "macaddress": "BC60A7*" + }, + { + "macaddress": "C863F1*" + }, + { + "macaddress": "F8461C*" + }, + { + "macaddress": "70662A*" + }, + { + "macaddress": "09E29*" + }, + { + "macaddress": "B40AD8*" + }, + { + "macaddress": "A8474A*" + }, + { + "macaddress": "280DFC*" + }, + { + "macaddress": "D44B5E*" + } + ], "documentation": "https://www.home-assistant.io/integrations/playstation_network", "integration_type": "service", "iot_class": "cloud_polling", diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index d5152927b99..e173c4a710c 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -48,7 +48,7 @@ rules: discovery-update-info: status: exempt comment: Discovery flow is not applicable for this integration - discovery: todo + discovery: done docs-data-update: done docs-examples: todo docs-known-limitations: done diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 6213af63229..b253c5a553d 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -463,6 +463,82 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "palazzetti", "registered_devices": True, }, + { + "domain": "playstation_network", + "macaddress": "AC8995*", + }, + { + "domain": "playstation_network", + "macaddress": "1C98C1*", + }, + { + "domain": "playstation_network", + "macaddress": "5C843C*", + }, + { + "domain": "playstation_network", + "macaddress": "605BB4*", + }, + { + "domain": "playstation_network", + "macaddress": "8060B7*", + }, + { + "domain": "playstation_network", + "macaddress": "78C881*", + }, + { + "domain": "playstation_network", + "macaddress": "00D9D1*", + }, + { + "domain": "playstation_network", + "macaddress": "00E421*", + }, + { + "domain": "playstation_network", + "macaddress": "0CFE45*", + }, + { + "domain": "playstation_network", + "macaddress": "2CCC44*", + }, + { + "domain": "playstation_network", + "macaddress": "BC60A7*", + }, + { + "domain": "playstation_network", + "macaddress": "C863F1*", + }, + { + "domain": "playstation_network", + "macaddress": "F8461C*", + }, + { + "domain": "playstation_network", + "macaddress": "70662A*", + }, + { + "domain": "playstation_network", + "macaddress": "09E29*", + }, + { + "domain": "playstation_network", + "macaddress": "B40AD8*", + }, + { + "domain": "playstation_network", + "macaddress": "A8474A*", + }, + { + "domain": "playstation_network", + "macaddress": "280DFC*", + }, + { + "domain": "playstation_network", + "macaddress": "D44B5E*", + }, { "domain": "powerwall", "hostname": "1118431-*", From af6c2b5c8a2910414bfb2ff7bd7e1d3d5c685059 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Tue, 24 Jun 2025 17:25:16 +0200 Subject: [PATCH 0612/1664] Add device class to wind direction sensors for AEMET (#147430) --- homeassistant/components/aemet/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 9077b2bc44d..a3aeab9deb9 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -185,6 +185,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], name="Daily forecast wind bearing", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( entity_registry_enabled_default=False, @@ -192,6 +193,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION], name="Hourly forecast wind bearing", native_unit_of_measurement=DEGREE, + device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( entity_registry_enabled_default=False, @@ -335,6 +337,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( name="Wind bearing", native_unit_of_measurement=DEGREE, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( key=ATTR_API_WIND_MAX_SPEED, From 657a0680877bdfa927fd1605b84d87c4a6fff169 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 24 Jun 2025 09:22:13 -0700 Subject: [PATCH 0613/1664] Cleanup some duplicated code (#147439) --- homeassistant/components/derivative/sensor.py | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 60f4611c5eb..0639826b1ee 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -336,13 +336,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "" if unit is None else unit ) - # filter out all derivatives older than `time_window` from our window list - self._state_list = [ - (time_start, time_end, state) - for time_start, time_end, state in self._state_list - if (new_state.last_reported - time_end).total_seconds() - < self._time_window - ] + self._prune_state_list(new_state.last_reported) try: elapsed_time = ( @@ -380,25 +374,14 @@ class DerivativeSensor(RestoreSensor, SensorEntity): (old_last_reported, new_state.last_reported, new_derivative) ) - def calculate_weight( - start: datetime, end: datetime, now: datetime - ) -> float: - window_start = now - timedelta(seconds=self._time_window) - if start < window_start: - weight = (end - window_start).total_seconds() / self._time_window - else: - weight = (end - start).total_seconds() / self._time_window - return weight - # If outside of time window just report derivative (is the same as modeling it in the window), # otherwise take the weighted average with the previous derivatives if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = Decimal("0.00") - for start, end, value in self._state_list: - weight = calculate_weight(start, end, new_state.last_reported) - derivative = derivative + (value * Decimal(weight)) + derivative = self._calc_derivative_from_state_list( + new_state.last_reported + ) self._attr_native_value = round(derivative, self._round_digits) self.async_write_ha_state() From 54e5107c34fadafc3d616fbf2df648178e344df9 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 24 Jun 2025 10:24:15 -0600 Subject: [PATCH 0614/1664] Add total cycles sensor for Litter-Robot (#147435) * Add total cycles sensor for Litter-Robot * Add translatable unit of measurement cycles --- homeassistant/components/litterrobot/icons.json | 3 +++ homeassistant/components/litterrobot/sensor.py | 8 ++++++++ homeassistant/components/litterrobot/strings.json | 4 ++++ tests/components/litterrobot/test_sensor.py | 10 +++++++++- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 163ad80c0a8..2e0cafe43d9 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -46,6 +46,9 @@ "motor_fault_short": "mdi:flash-off", "motor_ot_amps": "mdi:flash-alert" } + }, + "total_cycles": { + "default": "mdi:counter" } }, "switch": { diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index cdd9a1c08a5..b7ddf3c3249 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -115,6 +115,14 @@ ROBOT_SENSOR_MAP: dict[type[Robot], list[RobotSensorEntityDescription]] = { lambda robot: status.lower() if (status := robot.status_code) else None ), ), + RobotSensorEntityDescription[LitterRobot]( + key="total_cycles", + translation_key="total_cycles", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda robot: robot.cycle_count, + ), ], LitterRobot4: [ RobotSensorEntityDescription[LitterRobot4]( diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index ba5472918d3..d9931d71a0d 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -118,6 +118,10 @@ "spf": "Pinch detect at startup" } }, + "total_cycles": { + "name": "Total cycles", + "unit_of_measurement": "cycles" + }, "waste_drawer": { "name": "Waste drawer" } diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index bbc6274e56b..76c567f5417 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -5,7 +5,11 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.litterrobot.sensor import icon_for_gauge_level -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN, SensorDeviceClass +from homeassistant.components.sensor import ( + DOMAIN as PLATFORM_DOMAIN, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import PERCENTAGE, STATE_UNKNOWN, UnitOfMass from homeassistant.core import HomeAssistant @@ -70,6 +74,7 @@ async def test_gauge_icon() -> None: @pytest.mark.freeze_time("2022-09-18 23:00:44+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_litter_robot_sensor( hass: HomeAssistant, mock_account_with_litterrobot_4: MagicMock ) -> None: @@ -94,6 +99,9 @@ async def test_litter_robot_sensor( sensor = hass.states.get("sensor.test_pet_weight") assert sensor.state == "12.0" assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS + sensor = hass.states.get("sensor.test_total_cycles") + assert sensor.state == "158" + assert sensor.attributes["state_class"] == SensorStateClass.TOTAL_INCREASING async def test_feeder_robot_sensor( From 0f112bb9c42efe379091a29a6d45645abe35666d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 24 Jun 2025 17:37:05 +0100 Subject: [PATCH 0615/1664] Use non-autospec mock for Reolink service tests (#147440) --- tests/components/reolink/test_services.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/reolink/test_services.py b/tests/components/reolink/test_services.py index 6ae9a2d9729..38819bbd51d 100644 --- a/tests/components/reolink/test_services.py +++ b/tests/components/reolink/test_services.py @@ -20,8 +20,8 @@ from tests.common import MockConfigEntry async def test_play_chime_service_entity( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime play service.""" @@ -37,14 +37,14 @@ async def test_play_chime_service_entity( device_id = entity.device_id # Test chime play service with device - test_chime.play = AsyncMock() + reolink_chime.play = AsyncMock() await hass.services.async_call( DOMAIN, "play_chime", {ATTR_DEVICE_ID: [device_id], ATTR_RINGTONE: "attraction"}, blocking=True, ) - test_chime.play.assert_called_once() + reolink_chime.play.assert_called_once() # Test errors with pytest.raises(ServiceValidationError): @@ -55,7 +55,7 @@ async def test_play_chime_service_entity( blocking=True, ) - test_chime.play = AsyncMock(side_effect=ReolinkError("Test error")) + reolink_chime.play = AsyncMock(side_effect=ReolinkError("Test error")) with pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, @@ -64,7 +64,7 @@ async def test_play_chime_service_entity( blocking=True, ) - test_chime.play = AsyncMock(side_effect=InvalidParameterError("Test error")) + reolink_chime.play = AsyncMock(side_effect=InvalidParameterError("Test error")) with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, @@ -73,7 +73,7 @@ async def test_play_chime_service_entity( blocking=True, ) - reolink_connect.chime.return_value = None + reolink_host.chime.return_value = None with pytest.raises(ServiceValidationError): await hass.services.async_call( DOMAIN, @@ -86,8 +86,8 @@ async def test_play_chime_service_entity( async def test_play_chime_service_unloaded( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, entity_registry: er.EntityRegistry, ) -> None: """Test chime play service when config entry is unloaded.""" From 3dc8676b99669b925ee6e6e5a8f5122f6e0ec7f4 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Jun 2025 12:00:02 -0500 Subject: [PATCH 0616/1664] Add TTS streaming to Wyoming satellites (#147438) * Add TTS streaming using intent-progress * Handle incomplete header --- .../components/wyoming/assist_satellite.py | 132 ++++++++++--- tests/components/wyoming/test_satellite.py | 181 ++++++++++++++++++ 2 files changed, 284 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py index 1a1a67bf1de..03470dbe555 100644 --- a/homeassistant/components/wyoming/assist_satellite.py +++ b/homeassistant/components/wyoming/assist_satellite.py @@ -132,6 +132,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): # Used to ensure TTS timeout is acted on correctly. self._run_loop_id: str | None = None + # TTS streaming + self._tts_stream_token: str | None = None + self._is_tts_streaming: bool = False + @property def pipeline_entity_id(self) -> str | None: """Return the entity ID of the pipeline to use for the next conversation.""" @@ -179,11 +183,20 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): """Set state based on pipeline stage.""" assert self._client is not None - if event.type == assist_pipeline.PipelineEventType.RUN_END: + if event.type == assist_pipeline.PipelineEventType.RUN_START: + if event.data and (tts_output := event.data["tts_output"]): + # Get stream token early. + # If "tts_start_streaming" is True in INTENT_PROGRESS event, we + # can start streaming TTS before the TTS_END event. + self._tts_stream_token = tts_output["token"] + self._is_tts_streaming = False + elif event.type == assist_pipeline.PipelineEventType.RUN_END: # Pipeline run is complete self._is_pipeline_running = False self._pipeline_ended_event.set() self.device.set_is_active(False) + self._tts_stream_token = None + self._is_tts_streaming = False elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START: self.config_entry.async_create_background_task( self.hass, @@ -245,6 +258,20 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self._client.write_event(Transcript(text=stt_text).event()), f"{self.entity_id} {event.type}", ) + elif event.type == assist_pipeline.PipelineEventType.INTENT_PROGRESS: + if ( + event.data + and event.data.get("tts_start_streaming") + and self._tts_stream_token + and (stream := tts.async_get_stream(self.hass, self._tts_stream_token)) + ): + # Start streaming TTS early (before TTS_END). + self._is_tts_streaming = True + self.config_entry.async_create_background_task( + self.hass, + self._stream_tts(stream), + f"{self.entity_id} {event.type}", + ) elif event.type == assist_pipeline.PipelineEventType.TTS_START: # Text-to-speech text if event.data: @@ -267,8 +294,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): if ( event.data and (tts_output := event.data["tts_output"]) + and not self._is_tts_streaming and (stream := tts.async_get_stream(self.hass, tts_output["token"])) ): + # Send TTS only if we haven't already started streaming it in INTENT_PROGRESS. self.config_entry.async_create_background_task( self.hass, self._stream_tts(stream), @@ -711,39 +740,62 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): start_time = time.monotonic() try: - data = b"".join([chunk async for chunk in tts_result.async_stream_result()]) + header_data = b"" + header_complete = False + sample_rate: int | None = None + sample_width: int | None = None + sample_channels: int | None = None + timestamp = 0 - with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: - sample_rate = wav_file.getframerate() - sample_width = wav_file.getsampwidth() - sample_channels = wav_file.getnchannels() - _LOGGER.debug("Streaming %s TTS sample(s)", wav_file.getnframes()) + async for data_chunk in tts_result.async_stream_result(): + if not header_complete: + # Accumulate data until we can parse the header and get + # sample rate, etc. + header_data += data_chunk + # Most WAVE headers are 44 bytes in length + if (len(header_data) >= 44) and ( + audio_info := _try_parse_wav_header(header_data) + ): + # Overwrite chunk with audio after header + sample_rate, sample_width, sample_channels, data_chunk = ( + audio_info + ) + await self._client.write_event( + AudioStart( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + timestamp=timestamp, + ).event() + ) + header_complete = True - timestamp = 0 - await self._client.write_event( - AudioStart( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - timestamp=timestamp, - ).event() + if not data_chunk: + # No audio after header + continue + else: + # Header is incomplete + continue + + # Streaming audio + assert sample_rate is not None + assert sample_width is not None + assert sample_channels is not None + + audio_chunk = AudioChunk( + rate=sample_rate, + width=sample_width, + channels=sample_channels, + audio=data_chunk, + timestamp=timestamp, ) - # Stream audio chunks - while audio_bytes := wav_file.readframes(_SAMPLES_PER_CHUNK): - chunk = AudioChunk( - rate=sample_rate, - width=sample_width, - channels=sample_channels, - audio=audio_bytes, - timestamp=timestamp, - ) - await self._client.write_event(chunk.event()) - timestamp += chunk.milliseconds - total_seconds += chunk.seconds + await self._client.write_event(audio_chunk.event()) + timestamp += audio_chunk.milliseconds + total_seconds += audio_chunk.seconds - await self._client.write_event(AudioStop(timestamp=timestamp).event()) - _LOGGER.debug("TTS streaming complete") + await self._client.write_event(AudioStop(timestamp=timestamp).event()) + _LOGGER.debug("TTS streaming complete") finally: send_duration = time.monotonic() - start_time timeout_seconds = max(0, total_seconds - send_duration + _TTS_TIMEOUT_EXTRA) @@ -812,3 +864,25 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity): self.config_entry.async_create_background_task( self.hass, self._client.write_event(event), "wyoming timer event" ) + + +def _try_parse_wav_header(header_data: bytes) -> tuple[int, int, int, bytes] | None: + """Try to parse a WAV header from a buffer. + + If successful, return (rate, width, channels, audio). + """ + try: + with io.BytesIO(header_data) as wav_io: + wav_file: wave.Wave_read = wave.open(wav_io, "rb") + with wav_file: + return ( + wav_file.getframerate(), + wav_file.getsampwidth(), + wav_file.getnchannels(), + wav_file.readframes(wav_file.getnframes()), + ) + except wave.Error: + # Ignore errors and return None + pass + + return None diff --git a/tests/components/wyoming/test_satellite.py b/tests/components/wyoming/test_satellite.py index dec5d6cbebd..870e2696601 100644 --- a/tests/components/wyoming/test_satellite.py +++ b/tests/components/wyoming/test_satellite.py @@ -1472,3 +1472,184 @@ async def test_tts_timeout( # Stop the satellite await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_satellite_tts_streaming(hass: HomeAssistant) -> None: + """Test running a streaming TTS pipeline with a satellite.""" + assert await async_setup_component(hass, assist_pipeline.DOMAIN, {}) + + events = [ + RunPipeline(start_stage=PipelineStage.ASR, end_stage=PipelineStage.TTS).event(), + ] + + pipeline_kwargs: dict[str, Any] = {} + pipeline_event_callback: Callable[[assist_pipeline.PipelineEvent], None] | None = ( + None + ) + run_pipeline_called = asyncio.Event() + audio_chunk_received = asyncio.Event() + + async def async_pipeline_from_audio_stream( + hass: HomeAssistant, + context, + event_callback, + stt_metadata, + stt_stream, + **kwargs, + ) -> None: + nonlocal pipeline_kwargs, pipeline_event_callback + pipeline_kwargs = kwargs + pipeline_event_callback = event_callback + + run_pipeline_called.set() + async for chunk in stt_stream: + if chunk: + audio_chunk_received.set() + break + + with ( + patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=SATELLITE_INFO, + ), + patch( + "homeassistant.components.wyoming.assist_satellite.AsyncTcpClient", + SatelliteAsyncTcpClient(events), + ) as mock_client, + patch( + "homeassistant.components.assist_satellite.entity.async_pipeline_from_audio_stream", + async_pipeline_from_audio_stream, + ), + patch("homeassistant.components.wyoming.assist_satellite._PING_SEND_DELAY", 0), + ): + entry = await setup_config_entry(hass) + device: SatelliteDevice = hass.data[wyoming.DOMAIN][entry.entry_id].device + assert device is not None + + async with asyncio.timeout(1): + await mock_client.connect_event.wait() + await mock_client.run_satellite_event.wait() + + async with asyncio.timeout(1): + await run_pipeline_called.wait() + + assert pipeline_event_callback is not None + assert pipeline_kwargs.get("device_id") == device.device_id + + # Send TTS info early + mock_tts_result_stream = MockResultStream(hass, "wav", get_test_wav()) + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.RUN_START, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + + # Speech-to-text started + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_START, + {"metadata": {"language": "en"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcribe_event.wait() + + # Push in some audio + mock_client.inject_event( + AudioChunk(rate=16000, width=2, channels=1, audio=bytes(1024)).event() + ) + + # User started speaking + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_START, {"timestamp": 1234} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_started_event.wait() + + # User stopped speaking + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_VAD_END, {"timestamp": 5678} + ) + ) + async with asyncio.timeout(1): + await mock_client.voice_stopped_event.wait() + + # Speech-to-text transcription + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.STT_END, + {"stt_output": {"text": "test transcript"}}, + ) + ) + async with asyncio.timeout(1): + await mock_client.transcript_event.wait() + + # Intent progress starts TTS streaming early with info received in the + # run-start event. + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.INTENT_PROGRESS, + {"tts_start_streaming": True}, + ) + ) + + # TTS events are sent now. In practice, these would be streamed as text + # chunks are generated. + async with asyncio.timeout(1): + await mock_client.tts_audio_start_event.wait() + await mock_client.tts_audio_chunk_event.wait() + await mock_client.tts_audio_stop_event.wait() + + # Verify audio chunk from test WAV + assert mock_client.tts_audio_chunk is not None + assert mock_client.tts_audio_chunk.rate == 22050 + assert mock_client.tts_audio_chunk.width == 2 + assert mock_client.tts_audio_chunk.channels == 1 + assert mock_client.tts_audio_chunk.audio == b"1234" + + # Text-to-speech text + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_START, + { + "tts_input": "test text to speak", + "voice": "test voice", + }, + ) + ) + + # synthesize event is sent with complete message for non-streaming clients + async with asyncio.timeout(1): + await mock_client.synthesize_event.wait() + + assert mock_client.synthesize is not None + assert mock_client.synthesize.text == "test text to speak" + assert mock_client.synthesize.voice is not None + assert mock_client.synthesize.voice.name == "test voice" + + # Because we started streaming TTS after intent progress, we should not + # stream it again on tts-end. + with patch( + "homeassistant.components.wyoming.assist_satellite.WyomingAssistSatellite._stream_tts" + ) as mock_stream_tts: + pipeline_event_callback( + assist_pipeline.PipelineEvent( + assist_pipeline.PipelineEventType.TTS_END, + {"tts_output": {"token": mock_tts_result_stream.token}}, + ) + ) + + mock_stream_tts.assert_not_called() + + # Pipeline finished + pipeline_event_callback( + assist_pipeline.PipelineEvent(assist_pipeline.PipelineEventType.RUN_END) + ) + + # Stop the satellite + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From cefc8822b6abaeaadb5aa109a0ff31c405becbbc Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Jun 2025 12:04:40 -0500 Subject: [PATCH 0617/1664] Support streaming TTS in wyoming (#147392) * Support streaming TTS in wyoming * Add test * Refactor to avoid repeated task creation * Manually manage client lifecycle --- homeassistant/components/wyoming/tts.py | 108 +++++++++++++++++- tests/components/wyoming/__init__.py | 29 +++++ tests/components/wyoming/conftest.py | 15 +++ .../wyoming/snapshots/test_tts.ambr | 37 ++++++ tests/components/wyoming/test_tts.py | 60 +++++++++- 5 files changed, 242 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index 79e431fee98..cf088c04d9f 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -1,13 +1,21 @@ """Support for Wyoming text-to-speech services.""" from collections import defaultdict +from collections.abc import AsyncGenerator import io import logging import wave -from wyoming.audio import AudioChunk, AudioStop +from wyoming.audio import AudioChunk, AudioStart, AudioStop from wyoming.client import AsyncTcpClient -from wyoming.tts import Synthesize, SynthesizeVoice +from wyoming.tts import ( + Synthesize, + SynthesizeChunk, + SynthesizeStart, + SynthesizeStop, + SynthesizeStopped, + SynthesizeVoice, +) from homeassistant.components import tts from homeassistant.config_entries import ConfigEntry @@ -45,6 +53,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): service: WyomingService, ) -> None: """Set up provider.""" + self.config_entry = config_entry self.service = service self._tts_service = next(tts for tts in service.info.tts if tts.installed) @@ -150,3 +159,98 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): return (None, None) return ("wav", data) + + def async_supports_streaming_input(self) -> bool: + """Return if the TTS engine supports streaming input.""" + return self._tts_service.supports_synthesize_streaming + + async def async_stream_tts_audio( + self, request: tts.TTSAudioRequest + ) -> tts.TTSAudioResponse: + """Generate speech from an incoming message.""" + voice_name: str | None = request.options.get(tts.ATTR_VOICE) + voice_speaker: str | None = request.options.get(ATTR_SPEAKER) + voice: SynthesizeVoice | None = None + if voice_name is not None: + voice = SynthesizeVoice(name=voice_name, speaker=voice_speaker) + + client = AsyncTcpClient(self.service.host, self.service.port) + await client.connect() + + # Stream text chunks to client + self.config_entry.async_create_background_task( + self.hass, + self._write_tts_message(request.message_gen, client, voice), + "wyoming tts write", + ) + + async def data_gen(): + # Stream audio bytes from client + try: + async for data_chunk in self._read_tts_audio(client): + yield data_chunk + finally: + await client.disconnect() + + return tts.TTSAudioResponse("wav", data_gen()) + + async def _write_tts_message( + self, + message_gen: AsyncGenerator[str], + client: AsyncTcpClient, + voice: SynthesizeVoice | None, + ) -> None: + """Write text chunks to the client.""" + try: + # Start stream + await client.write_event(SynthesizeStart(voice=voice).event()) + + # Accumulate entire message for synthesize event. + message = "" + async for message_chunk in message_gen: + message += message_chunk + + await client.write_event(SynthesizeChunk(text=message_chunk).event()) + + # Send entire message for backwards compatibility + await client.write_event(Synthesize(text=message, voice=voice).event()) + + # End stream + await client.write_event(SynthesizeStop().event()) + except (OSError, WyomingError): + # Disconnected + _LOGGER.warning("Unexpected disconnection from TTS client") + + async def _read_tts_audio(self, client: AsyncTcpClient) -> AsyncGenerator[bytes]: + """Read audio events from the client and yield WAV audio chunks. + + The WAV header is sent first with a frame count of 0 to indicate that + we're streaming and don't know the number of frames ahead of time. + """ + wav_header_sent = False + + try: + while event := await client.read_event(): + if wav_header_sent and AudioChunk.is_type(event.type): + # PCM audio + yield AudioChunk.from_event(event).audio + elif (not wav_header_sent) and AudioStart.is_type(event.type): + # WAV header with nframes = 0 for streaming + audio_start = AudioStart.from_event(event) + with io.BytesIO() as wav_io: + wav_file: wave.Wave_write = wave.open(wav_io, "wb") + with wav_file: + wav_file.setframerate(audio_start.rate) + wav_file.setsampwidth(audio_start.width) + wav_file.setnchannels(audio_start.channels) + + wav_io.seek(0) + yield wav_io.getvalue() + + wav_header_sent = True + elif SynthesizeStopped.is_type(event.type): + # All TTS audio has been received + break + except (OSError, WyomingError): + # Disconnected + _LOGGER.warning("Unexpected disconnection from TTS client") diff --git a/tests/components/wyoming/__init__.py b/tests/components/wyoming/__init__.py index 4540cdaabfd..de82dc08719 100644 --- a/tests/components/wyoming/__init__.py +++ b/tests/components/wyoming/__init__.py @@ -69,6 +69,29 @@ TTS_INFO = Info( ) ] ) +TTS_STREAMING_INFO = Info( + tts=[ + TtsProgram( + name="Test Streaming TTS", + description="Test Streaming TTS", + installed=True, + attribution=TEST_ATTR, + voices=[ + TtsVoice( + name="Test Voice", + description="Test Voice", + installed=True, + attribution=TEST_ATTR, + languages=["en-US"], + speakers=[TtsVoiceSpeaker(name="Test Speaker")], + version=None, + ) + ], + version=None, + supports_synthesize_streaming=True, + ) + ] +) WAKE_WORD_INFO = Info( wake=[ WakeProgram( @@ -155,9 +178,15 @@ class MockAsyncTcpClient: self.port: int | None = None self.written: list[Event] = [] self.responses = responses + self.is_connected: bool | None = None async def connect(self) -> None: """Connect.""" + self.is_connected = True + + async def disconnect(self) -> None: + """Disconnect.""" + self.is_connected = False async def write_event(self, event: Event): """Send.""" diff --git a/tests/components/wyoming/conftest.py b/tests/components/wyoming/conftest.py index 125edc547c6..2974bb4b013 100644 --- a/tests/components/wyoming/conftest.py +++ b/tests/components/wyoming/conftest.py @@ -19,6 +19,7 @@ from . import ( SATELLITE_INFO, STT_INFO, TTS_INFO, + TTS_STREAMING_INFO, WAKE_WORD_INFO, ) @@ -148,6 +149,20 @@ async def init_wyoming_tts( return tts_config_entry +@pytest.fixture +async def init_wyoming_streaming_tts( + hass: HomeAssistant, tts_config_entry: ConfigEntry +) -> ConfigEntry: + """Initialize Wyoming streaming TTS.""" + with patch( + "homeassistant.components.wyoming.data.load_wyoming_info", + return_value=TTS_STREAMING_INFO, + ): + await hass.config_entries.async_setup(tts_config_entry.entry_id) + + return tts_config_entry + + @pytest.fixture async def init_wyoming_wake_word( hass: HomeAssistant, wake_word_config_entry: ConfigEntry diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 7ca5204e66c..53cc02eaacf 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -32,6 +32,43 @@ }), ]) # --- +# name: test_get_tts_audio_streaming + list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello ', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), + dict({ + 'data': dict({ + 'text': 'Word.', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), + dict({ + 'data': dict({ + 'text': 'Hello Word.', + }), + 'payload': None, + 'type': 'synthesize', + }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), + ]) +# --- # name: test_voice_speaker list([ dict({ diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index c658bff1d0c..3374328f411 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -8,7 +8,8 @@ import wave import pytest from syrupy.assertion import SnapshotAssertion -from wyoming.audio import AudioChunk, AudioStop +from wyoming.audio import AudioChunk, AudioStart, AudioStop +from wyoming.tts import SynthesizeStopped from homeassistant.components import tts, wyoming from homeassistant.core import HomeAssistant @@ -43,11 +44,11 @@ async def test_get_tts_audio( hass: HomeAssistant, init_wyoming_tts, snapshot: SnapshotAssertion ) -> None: """Test get audio.""" + entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_tts") + assert entity is not None + assert not entity.async_supports_streaming_input() + audio = bytes(100) - audio_events = [ - AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), - AudioStop().event(), - ] # Verify audio audio_events = [ @@ -215,3 +216,52 @@ async def test_voice_speaker( ), ) assert mock_client.written == snapshot + + +async def test_get_tts_audio_streaming( + hass: HomeAssistant, init_wyoming_streaming_tts, snapshot: SnapshotAssertion +) -> None: + """Test get audio with streaming.""" + entity = hass.data[DATA_INSTANCES]["tts"].get_entity("tts.test_streaming_tts") + assert entity is not None + assert entity.async_supports_streaming_input() + + audio = bytes(100) + + # Verify audio + audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), + AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), + AudioStop().event(), + SynthesizeStopped().event(), + ] + + async def message_gen(): + yield "Hello " + yield "Word." + + with patch( + "homeassistant.components.wyoming.tts.AsyncTcpClient", + MockAsyncTcpClient(audio_events), + ) as mock_client: + stream = tts.async_create_stream( + hass, + "tts.test_streaming_tts", + "en-US", + options={tts.ATTR_PREFERRED_FORMAT: "wav"}, + ) + stream.async_set_message_stream(message_gen()) + data = b"".join([chunk async for chunk in stream.async_stream_result()]) + + # Ensure client was disconnected properly + assert mock_client.is_connected is False + + assert data is not None + with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file: + assert wav_file.getframerate() == 16000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 1 + assert wav_file.getnframes() == 0 # streaming + assert data[44:] == audio # WAV header is 44 bytes + + assert mock_client.written == snapshot From fe4ff4f83563da69ce14f4f8b06f52b4060862d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 24 Jun 2025 18:19:41 +0100 Subject: [PATCH 0618/1664] Use non-autospec mock for Reolink switch tests (#147441) --- tests/components/reolink/conftest.py | 3 + tests/components/reolink/test_switch.py | 90 +++++++++++-------------- 2 files changed, 42 insertions(+), 51 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 2f37fca251a..4238651f763 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -77,6 +77,9 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.get_stream_source = AsyncMock() host_mock.get_snapshot = AsyncMock() host_mock.get_encoding = AsyncMock(return_value="h264") + host_mock.pull_point_request = AsyncMock() + host_mock.set_audio = AsyncMock() + host_mock.set_email = AsyncMock() host_mock.ONVIF_event_callback = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False diff --git a/tests/components/reolink/test_switch.py b/tests/components/reolink/test_switch.py index 2b2c33f0e8f..9c0f2295a20 100644 --- a/tests/components/reolink/test_switch.py +++ b/tests/components/reolink/test_switch.py @@ -33,11 +33,11 @@ async def test_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.audio_record.return_value = True + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.audio_record.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -47,7 +47,7 @@ async def test_switch( entity_id = f"{Platform.SWITCH}.{TEST_CAM_NAME}_record_audio" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.audio_record.return_value = False + reolink_host.audio_record.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -61,9 +61,9 @@ async def test_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_audio.assert_called_with(0, True) + reolink_host.set_audio.assert_called_with(0, True) - reolink_connect.set_audio.side_effect = ReolinkError("Test error") + reolink_host.set_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -73,16 +73,16 @@ async def test_switch( ) # test switch turn off - reolink_connect.set_audio.reset_mock(side_effect=True) + reolink_host.set_audio.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_audio.assert_called_with(0, False) + reolink_host.set_audio.assert_called_with(0, False) - reolink_connect.set_audio.side_effect = ReolinkError("Test error") + reolink_host.set_audio.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -91,29 +91,27 @@ async def test_switch( blocking=True, ) - reolink_connect.set_audio.reset_mock(side_effect=True) + reolink_host.set_audio.reset_mock(side_effect=True) - reolink_connect.camera_online.return_value = False + reolink_host.camera_online.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - reolink_connect.camera_online.return_value = True - async def test_host_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host switch entity.""" - reolink_connect.camera_name.return_value = TEST_CAM_NAME - reolink_connect.email_enabled.return_value = True - reolink_connect.is_hub = False - reolink_connect.supported.return_value = True + reolink_host.camera_name.return_value = TEST_CAM_NAME + reolink_host.email_enabled.return_value = True + reolink_host.is_hub = False + reolink_host.supported.return_value = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -123,7 +121,7 @@ async def test_host_switch( entity_id = f"{Platform.SWITCH}.{TEST_NVR_NAME}_email_on_event" assert hass.states.get(entity_id).state == STATE_ON - reolink_connect.email_enabled.return_value = False + reolink_host.email_enabled.return_value = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -137,9 +135,9 @@ async def test_host_switch( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_email.assert_called_with(None, True) + reolink_host.set_email.assert_called_with(None, True) - reolink_connect.set_email.side_effect = ReolinkError("Test error") + reolink_host.set_email.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -149,16 +147,16 @@ async def test_host_switch( ) # test switch turn off - reolink_connect.set_email.reset_mock(side_effect=True) + reolink_host.set_email.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_email.assert_called_with(None, False) + reolink_host.set_email.assert_called_with(None, False) - reolink_connect.set_email.side_effect = ReolinkError("Test error") + reolink_host.set_email.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( SWITCH_DOMAIN, @@ -167,15 +165,13 @@ async def test_host_switch( blocking=True, ) - reolink_connect.set_email.reset_mock(side_effect=True) - async def test_chime_switch( hass: HomeAssistant, config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, - test_chime: Chime, + reolink_host: MagicMock, + reolink_chime: Chime, ) -> None: """Test host switch entity.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): @@ -186,7 +182,7 @@ async def test_chime_switch( entity_id = f"{Platform.SWITCH}.test_chime_led" assert hass.states.get(entity_id).state == STATE_ON - test_chime.led_state = False + reolink_chime.led_state = False freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -194,16 +190,16 @@ async def test_chime_switch( assert hass.states.get(entity_id).state == STATE_OFF # test switch turn on - test_chime.set_option = AsyncMock() + reolink_chime.set_option = AsyncMock() await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - test_chime.set_option.assert_called_with(led=True) + reolink_chime.set_option.assert_called_with(led=True) - 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( SWITCH_DOMAIN, @@ -213,16 +209,16 @@ async def test_chime_switch( ) # test switch turn off - test_chime.set_option.reset_mock(side_effect=True) + reolink_chime.set_option.reset_mock(side_effect=True) await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - test_chime.set_option.assert_called_with(led=False) + reolink_chime.set_option.assert_called_with(led=False) - 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( SWITCH_DOMAIN, @@ -231,8 +227,6 @@ async def test_chime_switch( blocking=True, ) - test_chime.set_option.reset_mock(side_effect=True) - @pytest.mark.parametrize( ( @@ -265,7 +259,7 @@ async def test_chime_switch( async def test_cleanup_hub_switches( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, original_id: str, capability: str, @@ -279,9 +273,9 @@ async def test_cleanup_hub_switches( domain = Platform.SWITCH - reolink_connect.channels = [0] - reolink_connect.is_hub = True - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.is_hub = True + reolink_host.supported = mock_supported entity_registry.async_get_or_create( domain=domain, @@ -301,9 +295,6 @@ async def test_cleanup_hub_switches( assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) is None - reolink_connect.is_hub = False - reolink_connect.supported.return_value = True - @pytest.mark.parametrize( ( @@ -336,7 +327,7 @@ async def test_cleanup_hub_switches( async def test_hub_switches_repair_issue( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, issue_registry: ir.IssueRegistry, original_id: str, @@ -351,9 +342,9 @@ async def test_hub_switches_repair_issue( domain = Platform.SWITCH - reolink_connect.channels = [0] - reolink_connect.is_hub = True - reolink_connect.supported = mock_supported + reolink_host.channels = [0] + reolink_host.is_hub = True + reolink_host.supported = mock_supported entity_registry.async_get_or_create( domain=domain, @@ -373,6 +364,3 @@ async def test_hub_switches_repair_issue( assert entity_registry.async_get_entity_id(domain, DOMAIN, original_id) assert (DOMAIN, "hub_switch_deprecated") in issue_registry.issues - - reolink_connect.is_hub = False - reolink_connect.supported.return_value = True From abfb7afcb73689f1dc6e9ed79260f582a53370f7 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 24 Jun 2025 11:26:35 -0600 Subject: [PATCH 0619/1664] Bump pylitterbot to 2024.2.1 (#147443) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 81f987f8c1f..a8945e482bf 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.2.0"] + "requirements": ["pylitterbot==2024.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 80f543f790f..ff7c2a041ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2121,7 +2121,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.0 +pylitterbot==2024.2.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1a546dfe2f..8bf1b3c3183 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1763,7 +1763,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.0 +pylitterbot==2024.2.1 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 From 314871986458e22d7d9de60c1cc8becebfd2784f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 24 Jun 2025 19:06:42 +0100 Subject: [PATCH 0620/1664] Use newer mock in recent Reolink test (#147448) --- tests/components/reolink/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index ed71314e961..21d1eff2fe1 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -1156,11 +1156,11 @@ async def test_camera_wake_callback( async def test_baichaun_only( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test initializing a baichuan only device.""" - reolink_connect.baichuan_only = True + reolink_host.baichuan_only = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.SWITCH]): assert await hass.config_entries.async_setup(config_entry.entry_id) From e8a534be9cbf4e3c827266cea5bff76429ff7fc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 24 Jun 2025 19:06:54 +0100 Subject: [PATCH 0621/1664] Add missing method mock to Reolink chime test (#147447) --- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_init.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 4238651f763..6d5e7d2688e 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -274,6 +274,7 @@ def reolink_chime(reolink_host: MagicMock) -> None: "people": {"switch": 0, "musicId": 1}, "visitor": {"switch": 1, "musicId": 2}, } + TEST_CHIME.remove = AsyncMock() reolink_host.chime_list = [TEST_CHIME] reolink_host.chime.return_value = TEST_CHIME diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index 21d1eff2fe1..e439d3dff93 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest -from reolink_aio.api import Chime from reolink_aio.exceptions import ( CredentialsInvalidError, LoginPrivacyModeError, @@ -270,22 +269,25 @@ async def test_removing_disconnected_cams( @pytest.mark.parametrize( - ("attr", "value", "expected_models"), + ("attr", "value", "expected_models", "expected_remove_call_count"), [ ( None, None, [TEST_HOST_MODEL, TEST_CAM_MODEL, CHIME_MODEL], + 1, ), ( "connect_state", -1, [TEST_HOST_MODEL, TEST_CAM_MODEL], + 0, ), ( "remove", -1, [TEST_HOST_MODEL, TEST_CAM_MODEL], + 1, ), ], ) @@ -294,12 +296,13 @@ async def test_removing_chime( hass_ws_client: WebSocketGenerator, config_entry: MockConfigEntry, reolink_host: MagicMock, - reolink_chime: Chime, + reolink_chime: MagicMock, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, attr: str | None, value: Any, expected_models: list[str], + expected_remove_call_count: int, ) -> None: """Test removing a chime.""" reolink_host.channels = [0] @@ -324,7 +327,7 @@ async def test_removing_chime( """Remove chime.""" reolink_chime.connect_state = -1 - reolink_chime.remove = test_remove_chime + reolink_chime.remove = AsyncMock(side_effect=test_remove_chime) elif attr is not None: setattr(reolink_chime, attr, value) @@ -334,6 +337,7 @@ async def test_removing_chime( if device.model == CHIME_MODEL: response = await client.remove_device(device.id, config_entry.entry_id) assert response["success"] == expected_success + assert reolink_chime.remove.call_count == expected_remove_call_count device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id From 4d9843172bbea9ec09f89e4f02abfd20771ae3d1 Mon Sep 17 00:00:00 2001 From: Sven Naumann <3747263+sVnsation@users.noreply.github.com> Date: Tue, 24 Jun 2025 20:09:45 +0200 Subject: [PATCH 0622/1664] Fix nfandroidtv service notify disappears when restarting home assistant (#128958) * move connect to android tv host from init to short before sending a message * Don't swallow exceptions * use string literals for exception --------- Co-authored-by: Erik Montnemery --- .../components/nfandroidtv/__init__.py | 11 +--- .../components/nfandroidtv/notify.py | 51 ++++++++++++------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 50674a7ed46..bdda0d30356 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,11 +1,8 @@ """The NFAndroidTV integration.""" -from notifications_android_tv.notifications import ConnectError, Notifications - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -25,14 +22,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" - try: - await hass.async_add_executor_job(Notifications, entry.data[CONF_HOST]) - except ConnectError as ex: - raise ConfigEntryNotReady( - f"Failed to connect to host: {entry.data[CONF_HOST]}" - ) from ex - hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data[CONF_HOST] hass.async_create_task( discovery.async_load_platform( diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index f6d9bcde432..c1c19a600b9 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -6,7 +6,7 @@ from io import BufferedReader import logging from typing import Any -from notifications_android_tv import Notifications +from notifications_android_tv.notifications import ConnectError, Notifications import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol @@ -19,7 +19,7 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -59,9 +59,9 @@ async def async_get_service( """Get the NFAndroidTV notification service.""" if discovery_info is None: return None - notify = await hass.async_add_executor_job(Notifications, discovery_info[CONF_HOST]) + return NFAndroidTVNotificationService( - notify, + discovery_info[CONF_HOST], hass.config.is_allowed_path, ) @@ -71,15 +71,24 @@ class NFAndroidTVNotificationService(BaseNotificationService): def __init__( self, - notify: Notifications, + host: str, is_allowed_path: Any, ) -> None: """Initialize the service.""" - self.notify = notify + self.host = host self.is_allowed_path = is_allowed_path + self.notify: Notifications | None = None def send_message(self, message: str, **kwargs: Any) -> None: - """Send a message to a Android TV device.""" + """Send a message to an Android TV device.""" + if self.notify is None: + try: + self.notify = Notifications(self.host) + except ConnectError as err: + raise HomeAssistantError( + f"Failed to connect to host: {self.host}" + ) from err + data: dict | None = kwargs.get(ATTR_DATA) title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) duration = None @@ -178,18 +187,22 @@ class NFAndroidTVNotificationService(BaseNotificationService): translation_key="invalid_notification_icon", translation_placeholders={"type": type(icondata).__name__}, ) - self.notify.send( - message, - title=title, - duration=duration, - fontsize=fontsize, - position=position, - bkgcolor=bkgcolor, - transparency=transparency, - interrupt=interrupt, - icon=icon, - image_file=image_file, - ) + + try: + self.notify.send( + message, + title=title, + duration=duration, + fontsize=fontsize, + position=position, + bkgcolor=bkgcolor, + transparency=transparency, + interrupt=interrupt, + icon=icon, + image_file=image_file, + ) + except ConnectError as err: + raise HomeAssistantError(f"Failed to connect to host: {self.host}") from err def load_file( self, From 8eb906fad94961c06104904f8f57e64887ccdebc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 15:00:05 -0400 Subject: [PATCH 0623/1664] Migrate OpenAI to config subentries (#147282) * Migrate OpenAI to config subentries * Add latest changes from Google subentries * Update homeassistant/components/openai_conversation/__init__.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../openai_conversation/__init__.py | 99 ++++++- .../openai_conversation/config_flow.py | 142 ++++++--- .../components/openai_conversation/const.py | 2 + .../openai_conversation/conversation.py | 32 ++- .../openai_conversation/strings.json | 84 +++--- .../openai_conversation/conftest.py | 16 +- .../snapshots/test_conversation.ambr | 16 +- .../openai_conversation/test_config_flow.py | 204 +++++++++---- .../openai_conversation/test_conversation.py | 36 +-- .../openai_conversation/test_init.py | 270 ++++++++++++++++++ 10 files changed, 734 insertions(+), 167 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 71effe83884..a5b13ded375 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -19,7 +19,7 @@ from openai.types.responses import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import ( HomeAssistant, @@ -32,7 +32,12 @@ from homeassistant.exceptions import ( HomeAssistantError, ServiceValidationError, ) -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, + selector, +) from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.typing import ConfigType @@ -73,6 +78,7 @@ def encode_file(file_path: str) -> tuple[str, str]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" + await async_migrate_integration(hass) async def render_image(call: ServiceCall) -> ServiceResponse: """Render an image with dall-e.""" @@ -118,7 +124,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: translation_placeholders={"config_entry": entry_id}, ) - model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + # Get first conversation subentry for options + conversation_subentry = next( + ( + sub + for sub in entry.subentries.values() + if sub.subentry_type == "conversation" + ), + None, + ) + if not conversation_subentry: + raise ServiceValidationError("No conversation configuration found") + + model: str = conversation_subentry.data.get( + CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL + ) client: openai.AsyncClient = entry.runtime_data content: ResponseInputMessageContentListParam = [ @@ -169,11 +189,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: model_args = { "model": model, "input": messages, - "max_output_tokens": entry.options.get( + "max_output_tokens": conversation_subentry.data.get( CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS ), - "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": entry.options.get( + "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": conversation_subentry.data.get( CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE ), "user": call.context.user_id, @@ -182,7 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if model.startswith("o"): model_args["reasoning"] = { - "effort": entry.options.get( + "effort": conversation_subentry.data.get( CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) } @@ -269,3 +289,68 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload OpenAI.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + entries = hass.config_entries.async_entries(DOMAIN) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_API_KEY] not in api_keys_entries: + use_existing = True + api_keys_entries[entry.data[CONF_API_KEY]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + entity_registry.async_update_entity( + conversation_entity, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 60d81bf6745..a9a444cf3dd 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -13,17 +13,20 @@ from voluptuous_openapi import convert from homeassistant.components.zone import ENTITY_ID_HOME from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, CONF_LLM_HASS_API, + CONF_NAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.selector import ( @@ -52,6 +55,7 @@ from .const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, @@ -94,7 +98,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -107,6 +111,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} + self._async_abort_entries_match(user_input) try: await validate_input(self.hass, user_input) except openai.APIConnectionError: @@ -120,32 +125,61 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title="ChatGPT", data=user_input, - options=RECOMMENDED_OPTIONS, + subentries=[ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return OpenAIOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class OpenAIOptionsFlow(OptionsFlow): - """OpenAI config flow options handler.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.options = config_entry.options.copy() + last_rendered_recommended = False + options: dict[str, Any] + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Add a subentry.""" + self.options = RECOMMENDED_OPTIONS.copy() + return await self.async_step_init() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle reconfiguration of a subentry.""" + self.options = self._get_reconfigure_subentry().data.copy() + return await self.async_step_init() async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Manage initial options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") options = self.options hass_apis: list[SelectOptionDict] = [ @@ -160,25 +194,47 @@ class OpenAIOptionsFlow(OptionsFlow): ): options[CONF_LLM_HASS_API] = [suggested_llm_apis] - step_schema: VolDictType = { - vol.Optional( - CONF_PROMPT, - description={"suggested_value": llm.DEFAULT_INSTRUCTIONS_PROMPT}, - ): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API): SelectSelector( - SelectSelectorConfig(options=hass_apis, multiple=True) - ), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } + step_schema: VolDictType = {} + + if self._is_new: + step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = ( + str + ) + + step_schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + vol.Required( + CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) + ): bool, + } + ) if user_input is not None: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) if user_input[CONF_RECOMMENDED]: - return self.async_create_entry(title="", data=user_input) + if self._is_new: + return self.async_create_entry( + title=user_input.pop(CONF_NAME), + data=user_input, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, + ) options.update(user_input) if CONF_LLM_HASS_API in options and CONF_LLM_HASS_API not in user_input: @@ -194,7 +250,7 @@ class OpenAIOptionsFlow(OptionsFlow): async def async_step_advanced( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Manage advanced options.""" options = self.options errors: dict[str, str] = {} @@ -236,7 +292,7 @@ class OpenAIOptionsFlow(OptionsFlow): async def async_step_model( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Manage model-specific options.""" options = self.options errors: dict[str, str] = {} @@ -303,7 +359,16 @@ class OpenAIOptionsFlow(OptionsFlow): } if not step_schema: - return self.async_create_entry(title="", data=options) + if self._is_new: + return self.async_create_entry( + title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + data=options, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=options, + ) if user_input is not None: if user_input.get(CONF_WEB_SEARCH): @@ -316,7 +381,16 @@ class OpenAIOptionsFlow(OptionsFlow): options.pop(CONF_WEB_SEARCH_TIMEZONE, None) options.update(user_input) - return self.async_create_entry(title="", data=options) + if self._is_new: + return self.async_create_entry( + title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + data=options, + ) + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=options, + ) return self.async_show_form( step_id="model", @@ -332,7 +406,7 @@ class OpenAIOptionsFlow(OptionsFlow): zone_home = self.hass.states.get(ENTITY_ID_HOME) if zone_home is not None: client = openai.AsyncOpenAI( - api_key=self.config_entry.data[CONF_API_KEY], + api_key=self._get_entry().data[CONF_API_KEY], http_client=get_async_client(self.hass), ) location_schema = vol.Schema( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f022b4840eb..f90c05eed79 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -5,6 +5,8 @@ import logging DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) +DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" + CONF_CHAT_MODEL = "chat_model" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 8fea4613ce0..e63bbf32c35 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -34,7 +34,7 @@ from openai.types.responses.web_search_tool_param import UserLocation from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -76,8 +76,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = OpenAIConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [OpenAIConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) def _format_tool( @@ -229,22 +235,22 @@ class OpenAIConversationEntity( ): """OpenAI conversation agent.""" - _attr_has_entity_name = True - _attr_name = None _attr_supports_streaming = True - def __init__(self, entry: OpenAIConfigEntry) -> None: + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry - self._attr_unique_id = entry.entry_id + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - name=entry.title, + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, manufacturer="OpenAI", - model="ChatGPT", + model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), entry_type=dr.DeviceEntryType.SERVICE, ) - if self.entry.options.get(CONF_LLM_HASS_API): + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -276,7 +282,7 @@ class OpenAIConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Process the user input and call the API.""" - options = self.entry.options + options = self.subentry.data try: await chat_log.async_provide_llm_data( @@ -304,7 +310,7 @@ class OpenAIConversationEntity( chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - options = self.entry.options + options = self.subentry.data tools: list[ToolParam] | None = None if chat_log.llm_api: diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 351e82ec11f..ffbe84337b7 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -11,47 +11,63 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "recommended": "Recommended model settings" + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + + "step": { + "init": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "recommended": "Recommended model settings" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template." + } }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template." + "advanced": { + "title": "Advanced settings", + "data": { + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "Maximum tokens to return in response", + "temperature": "Temperature", + "top_p": "Top P" + } + }, + "model": { + "title": "Model-specific options", + "data": { + "reasoning_effort": "Reasoning effort", + "web_search": "Enable web search", + "search_context_size": "Search context size", + "user_location": "Include home location" + }, + "data_description": { + "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", + "web_search": "Allow the model to search the web for the latest information before generating a response", + "search_context_size": "High level guidance for the amount of context window space to use for the search", + "user_location": "Refine search results based on geography" + } } }, - "advanced": { - "title": "Advanced settings", - "data": { - "chat_model": "[%key:common::generic::model%]", - "max_tokens": "Maximum tokens to return in response", - "temperature": "Temperature", - "top_p": "Top P" - } + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." }, - "model": { - "title": "Model-specific options", - "data": { - "reasoning_effort": "Reasoning effort", - "web_search": "Enable web search", - "search_context_size": "Search context size", - "user_location": "Include home location" - }, - "data_description": { - "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", - "web_search": "Allow the model to search the web for the latest information before generating a response", - "search_context_size": "High level guidance for the amount of context window space to use for the search", - "user_location": "Refine search results based on geography" - } + "error": { + "model_not_supported": "This model is not supported, please select a different model" } - }, - "error": { - "model_not_supported": "This model is not supported, please select a different model" } }, "selector": { diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 4639d0dc8e0..aa17c333a79 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.openai_conversation.const import DEFAULT_CONVERSATION_NAME from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -21,6 +22,15 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: data={ "api_key": "bla", }, + version=2, + subentries_data=[ + { + "data": {}, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ], ) entry.add_to_hass(hass) return entry @@ -31,8 +41,10 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) return mock_config_entry diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 0f874969aff..48ad0878b2f 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -6,7 +6,7 @@ 'role': 'user', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': None, 'role': 'assistant', 'tool_calls': list([ @@ -20,14 +20,14 @@ ]), }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', 'tool_result': 'value1', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': None, 'role': 'assistant', 'tool_calls': list([ @@ -41,14 +41,14 @@ ]), }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'role': 'tool_result', 'tool_call_id': 'call_call_2', 'tool_name': 'test_tool', 'tool_result': 'value2', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': 'Cool', 'role': 'assistant', 'tool_calls': None, @@ -62,7 +62,7 @@ 'role': 'user', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': None, 'role': 'assistant', 'tool_calls': list([ @@ -76,14 +76,14 @@ ]), }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'role': 'tool_result', 'tool_call_id': 'call_call_1', 'tool_name': 'test_tool', 'tool_result': 'value1', }), dict({ - 'agent_id': 'conversation.openai', + 'agent_id': 'conversation.openai_conversation', 'content': 'Cool', 'role': 'assistant', 'tool_calls': None, diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index ad5bbffaed3..b77542fbab3 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -24,12 +24,13 @@ from homeassistant.components.openai_conversation.const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_CONVERSATION_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_P, ) -from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -72,42 +73,132 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["data"] == { "api_key": "bla", } - assert result2["options"] == RECOMMENDED_OPTIONS + assert result2["options"] == {} + assert result2["subentries"] == [ + { + "subentry_type": "conversation", + "data": RECOMMENDED_OPTIONS, + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + } + ] assert len(mock_setup_entry.mock_calls) == 1 -async def test_options_recommended( +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" + MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "bla"}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "bla", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_creating_conversation_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "init" + assert not result["errors"] + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"name": "My Custom Agent", **RECOMMENDED_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "My Custom Agent" + + processed_options = RECOMMENDED_OPTIONS.copy() + processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + + assert result2["data"] == processed_options + + +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( + "homeassistant.components.openai_conversation.config_flow.openai.resources.models.AsyncModels.list", + return_value=[], + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + +async def test_subentry_recommended( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options flow with recommended settings.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry flow with recommended settings.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - options = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + options = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { "prompt": "Speak like a pirate", "recommended": True, }, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"]["prompt"] == "Speak like a pirate" + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data["prompt"] == "Speak like a pirate" -async def test_options_unsupported_model( +async def test_subentry_unsupported_model( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form giving error about models not supported.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry form giving error about models not supported.""" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - assert options_flow["type"] == FlowResultType.FORM - assert options_flow["step_id"] == "init" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "init" # Configure initial step - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", @@ -115,19 +206,19 @@ async def test_options_unsupported_model( }, ) await hass.async_block_till_done() - assert options_flow["type"] == FlowResultType.FORM - assert options_flow["step_id"] == "advanced" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "advanced" # Configure advanced step - options_flow = await hass.config_entries.options.async_configure( - options_flow["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_CHAT_MODEL: "o1-mini", }, ) await hass.async_block_till_done() - assert options_flow["type"] is FlowResultType.FORM - assert options_flow["errors"] == {"chat_model": "model_not_supported"} + assert subentry_flow["type"] is FlowResultType.FORM + assert subentry_flow["errors"] == {"chat_model": "model_not_supported"} @pytest.mark.parametrize( @@ -494,7 +585,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non ), ], ) -async def test_options_switching( +async def test_subentry_switching( hass: HomeAssistant, mock_config_entry, mock_init_component, @@ -502,16 +593,22 @@ async def test_options_switching( new_options, expected_options, ) -> None: - """Test the options form.""" - hass.config_entries.async_update_entry(mock_config_entry, options=current_options) - options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert options["step_id"] == "init" + """Test the subentry form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data=current_options + ) + await hass.async_block_till_done() + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["step_id"] == "init" for step_options in new_options: - assert options["type"] == FlowResultType.FORM + assert subentry_flow["type"] == FlowResultType.FORM # Test that current options are showed as suggested values: - for key in options["data_schema"].schema: + for key in subentry_flow["data_schema"].schema: if ( isinstance(key.description, dict) and "suggested_value" in key.description @@ -523,38 +620,42 @@ async def test_options_switching( assert key.description["suggested_value"] == current_option # Configure current step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], step_options, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == expected_options + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + assert subentry.data == expected_options -async def test_options_web_search_user_location( +async def test_subentry_web_search_user_location( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: """Test fetching user location.""" - options = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - assert options["type"] == FlowResultType.FORM - assert options["step_id"] == "init" + subentry = next(iter(mock_config_entry.subentries.values())) + subentry_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "init" # Configure initial step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", }, ) - assert options["type"] == FlowResultType.FORM - assert options["step_id"] == "advanced" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "advanced" # Configure advanced step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_TEMPERATURE: 1.0, CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, @@ -563,8 +664,8 @@ async def test_options_web_search_user_location( }, ) await hass.async_block_till_done() - assert options["type"] == FlowResultType.FORM - assert options["step_id"] == "model" + assert subentry_flow["type"] == FlowResultType.FORM + assert subentry_flow["step_id"] == "model" hass.config.country = "US" hass.config.time_zone = "America/Los_Angeles" @@ -601,8 +702,8 @@ async def test_options_web_search_user_location( ) # Configure model step - options = await hass.config_entries.options.async_configure( - options["flow_id"], + subentry_flow = await hass.config_entries.subentries.async_configure( + subentry_flow["flow_id"], { CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "medium", @@ -614,8 +715,9 @@ async def test_options_web_search_user_location( mock_create.call_args.kwargs["input"][0]["content"] == "Where are the following" " coordinates located: (37.7749, -122.4194)?" ) - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == { + assert subentry_flow["type"] is FlowResultType.ABORT + assert subentry_flow["reason"] == "reconfigure_successful" + assert subentry.data == { CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: 1.0, diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 99559cb3b61..8621465bd14 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -153,20 +153,18 @@ async def test_entity( mock_init_component, ) -> None: """Test entity properties.""" - state = hass.states.get("conversation.openai") + state = hass.states.get("conversation.openai_conversation") assert state assert state.attributes["supported_features"] == 0 - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, - CONF_LLM_HASS_API: "assist", - }, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: "assist"}, ) await hass.config_entries.async_reload(mock_config_entry.entry_id) - state = hass.states.get("conversation.openai") + state = hass.states.get("conversation.openai_conversation") assert state assert ( state.attributes["supported_features"] @@ -261,7 +259,7 @@ async def test_incomplete_response( "Please tell me a big story", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -285,7 +283,7 @@ async def test_incomplete_response( "please tell me a big story", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -324,7 +322,7 @@ async def test_failed_response( "next natural number please", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ERROR, result @@ -583,7 +581,7 @@ async def test_function_call( "Please call the test function", mock_chat_log.conversation_id, Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert mock_create_stream.call_args.kwargs["input"][2] == { @@ -630,7 +628,7 @@ async def test_function_call_without_reasoning( "Please call the test function", mock_chat_log.conversation_id, Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert result.response.response_type == intent.IntentResponseType.ACTION_DONE @@ -686,7 +684,7 @@ async def test_function_call_invalid( "Please call the test function", "mock-conversation-id", Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) @@ -720,7 +718,7 @@ async def test_assist_api_tools_conversion( ] await conversation.async_converse( - hass, "hello", None, Context(), agent_id="conversation.openai" + hass, "hello", None, Context(), agent_id="conversation.openai_conversation" ) tools = mock_create_stream.mock_calls[0][2]["tools"] @@ -735,10 +733,12 @@ async def test_web_search( mock_chat_log: MockChatLog, # noqa: F811 ) -> None: """Test web_search_tool.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -764,7 +764,7 @@ async def test_web_search( "What's on the latest news?", mock_chat_log.conversation_id, Context(), - agent_id="conversation.openai", + agent_id="conversation.openai_conversation", ) assert mock_create_stream.mock_calls[0][2]["tools"] == [ diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index b4f816707e9..d209554e8d3 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -15,8 +15,10 @@ from openai.types.responses import Response, ResponseOutputMessage, ResponseOutp import pytest from homeassistant.components.openai_conversation import CONF_FILENAMES +from homeassistant.components.openai_conversation.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -536,3 +538,271 @@ async def test_generate_content_service_error( blocking=True, return_response=True, ) + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + OPTIONS = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=OPTIONS, + version=1, + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="google_generative_ai_conversation", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.version == 2 + assert mock_config_entry.data == {"api_key": "1234"} + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.unique_id is None + assert subentry.title == "ChatGPT" + assert subentry.subentry_type == "conversation" + assert subentry.data == OPTIONS + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.unique_id == subentry.subentry_id + + # Check device migration + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + migrated_device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert migrated_device.id == device.id + + +async def test_migration_from_v1_to_v2_with_multiple_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with different API keys.""" + # Create two v1 config entries with different API keys + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="ChatGPT 1", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "12345"}, + options=options, + version=1, + title="ChatGPT 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT 1", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="chatgpt_1", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT 2", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for idx, entry in enumerate(entries): + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 1 + subentry = list(entry.subentries.values())[0] + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert subentry.title == f"ChatGPT {idx + 1}" + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None + + +async def test_migration_from_v1_to_v2_with_same_keys( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with same API keys consolidates entries.""" + # Create two v1 config entries with the same API key + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + options=options, + version=1, + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, # Same API key + options=options, + version=1, + title="ChatGPT 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="chatgpt", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Should have only one entry left (consolidated) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 # Two subentries from the two original entries + + # Check both subentries exist with correct data + subentries = list(entry.subentries.values()) + titles = [sub.title for sub in subentries] + assert "ChatGPT" in titles + assert "ChatGPT 2" in titles + + for subentry in subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + + # Check devices were migrated correctly + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None From 7322fe40da30cfc021561d3d099ef7a663ec8da2 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 24 Jun 2025 21:00:14 +0200 Subject: [PATCH 0624/1664] Define fields for assist ask_question action (#147219) * Define fields for assist ask_question action * Update hassfest --------- Co-authored-by: Paulus Schoutsen --- .../components/assist_satellite/services.yaml | 14 ++++++++++++++ .../components/assist_satellite/strings.json | 8 ++++++++ script/hassfest/translations.py | 5 +++-- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index c5484e22dad..6beb0991861 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -86,3 +86,17 @@ ask_question: required: false selector: object: + label_field: sentences + description_field: id + multiple: true + translation_key: answers + fields: + id: + required: true + selector: + text: + sentences: + required: true + selector: + text: + multiple: true diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json index e0bf2bcfb94..52df2492480 100644 --- a/homeassistant/components/assist_satellite/strings.json +++ b/homeassistant/components/assist_satellite/strings.json @@ -90,5 +90,13 @@ } } } + }, + "selector": { + "answers": { + "fields": { + "id": "Answer ID", + "sentences": "Sentences" + } + } } } diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index f4c05f504ca..34c06abb451 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -306,10 +306,11 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: ), vol.Optional("selector"): cv.schema_with_slug_keys( { - "options": cv.schema_with_slug_keys( + vol.Optional("options"): cv.schema_with_slug_keys( translation_value_validator, slug_validator=translation_key_validator, - ) + ), + vol.Optional("fields"): cv.schema_with_slug_keys(str), }, slug_validator=vol.Any("_", cv.slug), ), From 265de91fba07fa6daee4575a08cd86f58bb149c5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 15:13:51 -0400 Subject: [PATCH 0625/1664] Add type for wiz (#147454) --- homeassistant/components/wiz/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 0e986aaefa2..43a9b863d20 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -63,12 +63,12 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener(hass: HomeAssistant, entry: WizConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: """Set up the wiz integration from a config entry.""" ip_address = entry.data[CONF_HOST] _LOGGER.debug("Get bulb with IP: %s", ip_address) @@ -145,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WizConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): await entry.runtime_data.bulb.async_close() From ad4fae7f59e37b9b768efd74ac2c4873f9cea37c Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Tue, 24 Jun 2025 20:25:40 +0100 Subject: [PATCH 0626/1664] Custom sentence triggers should be marked as processed locally (#145704) * Mark custom sentence triggers a local agent * Don't change agent ID * adds tests to confirm processed_locally is True * move asserts to after null check --- homeassistant/components/assist_pipeline/pipeline.py | 1 + tests/components/assist_pipeline/test_pipeline.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 93e857f4b2b..a1b6ea53445 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -1119,6 +1119,7 @@ class PipelineRun: ) is not None: # Sentence trigger matched agent_id = "sentence_trigger" + processed_locally = True intent_response = intent.IntentResponse( self.pipeline.conversation_language ) diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 9ea3802d9f6..1302925dab9 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1110,6 +1110,7 @@ async def test_sentence_trigger_overrides_conversation_agent( None, ) assert (intent_end_event is not None) and intent_end_event.data + assert intent_end_event.data["processed_locally"] is True assert ( intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ "speech" @@ -1192,6 +1193,7 @@ async def test_prefer_local_intents( None, ) assert (intent_end_event is not None) and intent_end_event.data + assert intent_end_event.data["processed_locally"] is True assert ( intent_end_event.data["intent_output"]["response"]["speech"]["plain"][ "speech" From b9fc198a7e7a3222af7bf38a9778267cf2d3fa92 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:25:53 +0200 Subject: [PATCH 0627/1664] =?UTF-8?q?Set=20quality=20scale=20to=20?= =?UTF-8?q?=F0=9F=A5=87=20gold=20for=20ista=20EcoTrend=20integration=20(#1?= =?UTF-8?q?43462)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/ista_ecotrend/manifest.json | 1 + .../ista_ecotrend/quality_scale.yaml | 20 +++++++++++++------ script/hassfest/quality_scale.py | 1 - 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/manifest.json b/homeassistant/components/ista_ecotrend/manifest.json index baa5fbde9c0..53638ac9a29 100644 --- a/homeassistant/components/ista_ecotrend/manifest.json +++ b/homeassistant/components/ista_ecotrend/manifest.json @@ -7,5 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/ista_ecotrend", "iot_class": "cloud_polling", "loggers": ["pyecotrend_ista"], + "quality_scale": "gold", "requirements": ["pyecotrend-ista==3.3.1"] } diff --git a/homeassistant/components/ista_ecotrend/quality_scale.yaml b/homeassistant/components/ista_ecotrend/quality_scale.yaml index a06aef7297f..ef665b04d41 100644 --- a/homeassistant/components/ista_ecotrend/quality_scale.yaml +++ b/homeassistant/components/ista_ecotrend/quality_scale.yaml @@ -50,14 +50,18 @@ rules: discovery: status: exempt comment: The integration is a web service, there are no discoverable devices. - docs-data-update: todo - docs-examples: todo + docs-data-update: done + docs-examples: + status: done + comment: describes how to use the integration with the statistics dashboard docs-known-limitations: done docs-supported-devices: done docs-supported-functions: done - docs-troubleshooting: todo + docs-troubleshooting: done docs-use-cases: done - dynamic-devices: todo + dynamic-devices: + status: exempt + comment: changes are very rare (usually takes years) entity-category: status: done comment: The default category is appropriate. @@ -67,8 +71,12 @@ rules: exception-translations: done icon-translations: done reconfiguration-flow: done - repair-issues: todo - stale-devices: todo + repair-issues: + status: exempt + comment: integration has no repairs + stale-devices: + status: exempt + comment: integration has no stale devices # Platinum async-dependency: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 73505e805bc..49e952c7d55 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1573,7 +1573,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "iqvia", "irish_rail_transport", "isal", - "ista_ecotrend", "iskra", "islamic_prayer_times", "israel_rail", From 5ef054f2e0bb3c9e0fa0d345e7cfe04d5564f1a4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 24 Jun 2025 22:41:39 +0300 Subject: [PATCH 0628/1664] Add quality scale bronze to SamsungTV (#142288) --- .../components/samsungtv/manifest.json | 1 + .../components/samsungtv/quality_scale.yaml | 96 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/samsungtv/quality_scale.yaml diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index dc8133a1b1f..a2ab8e6e466 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -34,6 +34,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], + "quality_scale": "bronze", "requirements": [ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", diff --git a/homeassistant/components/samsungtv/quality_scale.yaml b/homeassistant/components/samsungtv/quality_scale.yaml new file mode 100644 index 00000000000..845ebfe6e46 --- /dev/null +++ b/homeassistant/components/samsungtv/quality_scale.yaml @@ -0,0 +1,96 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no custom 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: no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: no 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: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: no configuration options so far + docs-installation-parameters: done + entity-unavailable: + status: todo + comment: check super().unavailable + 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: + status: todo + comment: add info about polling the bridge every 10 seconds + docs-examples: done + docs-known-limitations: done + docs-supported-devices: + status: todo + comment: be more specific about supported devices + docs-supported-functions: + status: todo + comment: be more specific about supported functions + docs-troubleshooting: + status: todo + comment: split that up to proper troubleshooting and known limitations section + docs-use-cases: done + dynamic-devices: + status: exempt + comment: device type integration + entity-category: + status: exempt + comment: no config or diagnostic entities + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: only 2 main entities + entity-translations: + status: exempt + comment: using only device name + exception-translations: done + icon-translations: + status: done + comment: no custom icons, only default icons + reconfiguration-flow: + status: todo + comment: handle at least host change + repair-issues: + status: exempt + comment: no known repair use case so far + stale-devices: + status: exempt + comment: device type integration + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: + status: todo + comment: Requirements 'getmac==0.9.5', 'samsungctl[websocket]==0.7.1' and 'wakeonlan==2.1.0' appear untyped diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 49e952c7d55..ff6fbcad85e 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -865,7 +865,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "ruuvitag_ble", "rympro", "saj", - "samsungtv", "sanix", "satel_integra", "schlage", @@ -1925,7 +1924,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "ruuvitag_ble", "rympro", "saj", - "samsungtv", "sanix", "satel_integra", "schlage", From 5a20ef3f3f454562583e8d1ddb175d40610c06ed Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Jun 2025 22:03:22 +0200 Subject: [PATCH 0629/1664] Bump aioshelly to version 13.7.0 (#147453) --- 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 78e01e6d8a6..c6a255b1bbb 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.6.0"], + "requirements": ["aioshelly==13.7.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index ff7c2a041ca..b9712860354 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.6.0 +aioshelly==13.7.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8bf1b3c3183..d2055bba4ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.6.0 +aioshelly==13.7.0 # homeassistant.components.skybell aioskybell==22.7.0 From f735331699aecafbec69cfd51ecdf347f324e808 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 16:13:34 -0400 Subject: [PATCH 0630/1664] Convert Ollama to subentries (#147286) * Convert Ollama to subentries * Add latest changes from Google subentries * Move config entry type to init --- homeassistant/components/ollama/__init__.py | 88 +++++- .../components/ollama/config_flow.py | 207 +++++++++------ .../components/ollama/conversation.py | 46 ++-- homeassistant/components/ollama/strings.json | 47 ++-- tests/components/ollama/conftest.py | 16 +- tests/components/ollama/test_config_flow.py | 70 ++++- tests/components/ollama/test_conversation.py | 39 +-- tests/components/ollama/test_init.py | 251 ++++++++++++++++++ 8 files changed, 625 insertions(+), 139 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index c828ee0af9f..90d2012766d 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -8,11 +8,16 @@ import logging import httpx import ollama -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.helpers.typing import ConfigType from homeassistant.util.ssl import get_default_context from .const import ( @@ -42,8 +47,16 @@ __all__ = [ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = (Platform.CONVERSATION,) +type OllamaConfigEntry = ConfigEntry[ollama.AsyncClient] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Ollama.""" + await async_migrate_integration(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool: """Set up Ollama from a config entry.""" settings = {**entry.data, **entry.options} client = ollama.AsyncClient(host=settings[CONF_URL], verify=get_default_context()) @@ -53,8 +66,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (TimeoutError, httpx.ConnectError) as err: raise ConfigEntryNotReady(err) from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client - + entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -63,5 +75,69 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Ollama.""" if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): return False - hass.data[DOMAIN].pop(entry.entry_id) return True + + +async def async_migrate_integration(hass: HomeAssistant) -> None: + """Migrate integration entry structure.""" + + entries = hass.config_entries.async_entries(DOMAIN) + if not any(entry.version == 1 for entry in entries): + return + + api_keys_entries: dict[str, ConfigEntry] = {} + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for entry in entries: + use_existing = False + subentry = ConfigSubentry( + data=entry.options, + subentry_type="conversation", + title=entry.title, + unique_id=None, + ) + if entry.data[CONF_URL] not in api_keys_entries: + use_existing = True + api_keys_entries[entry.data[CONF_URL]] = entry + + parent_entry = api_keys_entries[entry.data[CONF_URL]] + + hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( + "conversation", + DOMAIN, + entry.entry_id, + ) + if conversation_entity is not None: + entity_registry.async_update_entity( + conversation_entity, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + new_unique_id=subentry.subentry_id, + ) + + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + if device is not None: + device_registry.async_update_device( + device.id, + new_identifiers={(DOMAIN, subentry.subentry_id)}, + add_config_subentry_id=subentry.subentry_id, + add_config_entry_id=parent_entry.entry_id, + ) + if parent_entry.entry_id != entry.entry_id: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + if not use_existing: + await hass.config_entries.async_remove(entry.entry_id) + else: + hass.config_entries.async_update_entry( + entry, + options={}, + version=2, + ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index b94a0fc621d..58b557549e1 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -14,12 +14,14 @@ import voluptuous as vol from homeassistant.config_entries import ( ConfigEntry, + ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + ConfigSubentryFlow, + SubentryFlowResult, ) -from homeassistant.const import CONF_LLM_HASS_API, CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import llm from homeassistant.helpers.selector import ( BooleanSelector, @@ -70,7 +72,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" - VERSION = 1 + VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" @@ -94,6 +96,8 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} + self._async_abort_entries_match({CONF_URL: self.url}) + try: self.client = ollama.AsyncClient( host=self.url, verify=get_default_context() @@ -146,8 +150,16 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_download() return self.async_create_entry( - title=_get_title(self.model), + title=self.url, data={CONF_URL: self.url, CONF_MODEL: self.model}, + subentries=[ + { + "subentry_type": "conversation", + "data": {}, + "title": _get_title(self.model), + "unique_id": None, + } + ], ) async def async_step_download( @@ -189,6 +201,14 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=_get_title(self.model), data={CONF_URL: self.url, CONF_MODEL: self.model}, + subentries=[ + { + "subentry_type": "conversation", + "data": {}, + "title": _get_title(self.model), + "unique_id": None, + } + ], ) async def async_step_failed( @@ -197,41 +217,62 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Step after model downloading has failed.""" return self.async_abort(reason="download_failed") - @staticmethod - def async_get_options_flow( - config_entry: ConfigEntry, - ) -> OptionsFlow: - """Create the options flow.""" - return OllamaOptionsFlow(config_entry) + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} -class OllamaOptionsFlow(OptionsFlow): - """Ollama options flow.""" +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.url: str = config_entry.data[CONF_URL] - self.model: str = config_entry.data[CONF_MODEL] + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" - async def async_step_init( + async def async_step_set_options( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Manage the options.""" - if user_input is not None: + ) -> SubentryFlowResult: + """Set conversation options.""" + # abort if entry is not loaded + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + errors: dict[str, str] = {} + + if user_input is None: + if self._is_new: + options = {} + else: + options = self._get_reconfigure_subentry().data.copy() + + elif self._is_new: return self.async_create_entry( - title=_get_title(self.model), data=user_input + title=user_input.pop(CONF_NAME), + data=user_input, + ) + else: + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, ) - options: Mapping[str, Any] = self.config_entry.options or {} - schema = ollama_config_option_schema(self.hass, options) + schema = ollama_config_option_schema(self.hass, self._is_new, options) return self.async_show_form( - step_id="init", - data_schema=vol.Schema(schema), + step_id="set_options", data_schema=vol.Schema(schema), errors=errors ) + async_step_user = async_step_set_options + async_step_reconfigure = async_step_set_options + def ollama_config_option_schema( - hass: HomeAssistant, options: Mapping[str, Any] + hass: HomeAssistant, is_new: bool, options: Mapping[str, Any] ) -> dict: """Ollama options schema.""" hass_apis: list[SelectOptionDict] = [ @@ -242,54 +283,72 @@ def ollama_config_option_schema( for api in llm.async_get_apis(hass) ] - return { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + if is_new: + schema: dict[vol.Required | vol.Optional, Any] = { + vol.Required(CONF_NAME, default="Ollama Conversation"): str, + } + else: + schema = {} + + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": options.get(CONF_LLM_HASS_API)}, + ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + vol.Optional( + CONF_NUM_CTX, + description={ + "suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX) + }, + ): NumberSelector( + NumberSelectorConfig( + min=MIN_NUM_CTX, + max=MAX_NUM_CTX, + step=1, + mode=NumberSelectorMode.BOX, ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), - vol.Optional( - CONF_NUM_CTX, - description={"suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - ): NumberSelector( - NumberSelectorConfig( - min=MIN_NUM_CTX, max=MAX_NUM_CTX, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Optional( - CONF_MAX_HISTORY, - description={ - "suggested_value": options.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY) - }, - ): NumberSelector( - NumberSelectorConfig( - min=0, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Optional( - CONF_KEEP_ALIVE, - description={ - "suggested_value": options.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE) - }, - ): NumberSelector( - NumberSelectorConfig( - min=-1, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX - ) - ), - vol.Optional( - CONF_THINK, - description={ - "suggested_value": options.get("think", DEFAULT_THINK), - }, - ): BooleanSelector(), - } + ), + vol.Optional( + CONF_MAX_HISTORY, + description={ + "suggested_value": options.get( + CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY + ) + }, + ): NumberSelector( + NumberSelectorConfig( + min=0, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Optional( + CONF_KEEP_ALIVE, + description={ + "suggested_value": options.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE) + }, + ): NumberSelector( + NumberSelectorConfig( + min=-1, max=sys.maxsize, step=1, mode=NumberSelectorMode.BOX + ) + ), + vol.Optional( + CONF_THINK, + description={ + "suggested_value": options.get("think", DEFAULT_THINK), + }, + ): BooleanSelector(), + } + ) + + return schema def _get_title(model: str) -> str: diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index 1717d0b24b2..beedb61f942 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, Callable +from collections.abc import AsyncGenerator, AsyncIterator, Callable import json import logging from typing import Any, Literal @@ -11,13 +11,14 @@ import ollama from voluptuous_openapi import convert from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, @@ -40,12 +41,18 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OllamaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up conversation entities.""" - agent = OllamaConversationEntity(config_entry) - async_add_entities([agent]) + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "conversation": + continue + + async_add_entities( + [OllamaConversationEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) def _format_tool( @@ -130,7 +137,7 @@ def _convert_content( async def _transform_stream( - result: AsyncGenerator[ollama.Message], + result: AsyncIterator[ollama.ChatResponse], ) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: """Transform the response stream into HA format. @@ -174,17 +181,22 @@ class OllamaConversationEntity( ): """Ollama conversation agent.""" - _attr_has_entity_name = True _attr_supports_streaming = True - def __init__(self, entry: ConfigEntry) -> None: + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" self.entry = entry - - # conversation id -> message history - self._attr_name = entry.title - self._attr_unique_id = entry.entry_id - if self.entry.options.get(CONF_LLM_HASS_API): + 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)}, + name=subentry.title, + manufacturer="Ollama", + model=entry.data[CONF_MODEL], + entry_type=dr.DeviceEntryType.SERVICE, + ) + if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL ) @@ -216,7 +228,7 @@ class OllamaConversationEntity( chat_log: conversation.ChatLog, ) -> conversation.ConversationResult: """Call the API.""" - settings = {**self.entry.data, **self.entry.options} + settings = {**self.entry.data, **self.subentry.data} try: await chat_log.async_provide_llm_data( @@ -248,9 +260,9 @@ class OllamaConversationEntity( chat_log: conversation.ChatLog, ) -> None: """Generate an answer for the chat log.""" - settings = {**self.entry.data, **self.entry.options} + settings = {**self.entry.data, **self.subentry.data} - client = self.hass.data[DOMAIN][self.entry.entry_id] + client = self.entry.runtime_data model = settings[CONF_MODEL] tools: list[dict[str, Any]] | None = None diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index c60b0ef7ebd..74a5eaff454 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -12,7 +12,8 @@ } }, "abort": { - "download_failed": "Model downloading failed" + "download_failed": "Model downloading failed", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -22,23 +23,35 @@ "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } }, - "options": { - "step": { - "init": { - "data": { - "prompt": "Instructions", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", - "max_history": "Max history messages", - "num_ctx": "Context window size", - "keep_alive": "Keep alive", - "think": "Think before responding" - }, - "data_description": { - "prompt": "Instruct how the LLM should respond. This can be a template.", - "keep_alive": "Duration in seconds for Ollama to keep model in memory. -1 = indefinite, 0 = never.", - "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", - "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." + "config_subentries": { + "conversation": { + "initiate_flow": { + "user": "Add conversation agent", + "reconfigure": "Reconfigure conversation agent" + }, + "entry_type": "Conversation agent", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", + "max_history": "Max history messages", + "num_ctx": "Context window size", + "keep_alive": "Keep alive", + "think": "Think before responding" + }, + "data_description": { + "prompt": "Instruct how the LLM should respond. This can be a template.", + "keep_alive": "Duration in seconds for Ollama to keep model in memory. -1 = indefinite, 0 = never.", + "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", + "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." + } } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "Cannot add things while the configuration is disabled." } } } diff --git a/tests/components/ollama/conftest.py b/tests/components/ollama/conftest.py index 7658d1cbfab..c99f586a5d4 100644 --- a/tests/components/ollama/conftest.py +++ b/tests/components/ollama/conftest.py @@ -30,7 +30,15 @@ def mock_config_entry( entry = MockConfigEntry( domain=ollama.DOMAIN, data=TEST_USER_DATA, - options=mock_config_entry_options, + version=2, + subentries_data=[ + { + "data": mock_config_entry_options, + "subentry_type": "conversation", + "title": "Ollama Conversation", + "unique_id": None, + } + ], ) entry.add_to_hass(hass) return entry @@ -41,8 +49,10 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" - hass.config_entries.async_update_entry( - mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST} + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, ) return mock_config_entry diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 34282f25e90..4b78df9bce2 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -63,6 +63,37 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_duplicate_entry(hass: HomeAssistant) -> None: + """Test we abort on duplicate config entry.""" + MockConfigEntry( + domain=ollama.DOMAIN, + data={ + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + with patch( + "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", + return_value={"models": [{"model": "test_model"}]}, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + ollama.CONF_URL: "http://localhost:11434", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_form_need_download(hass: HomeAssistant) -> None: """Test flow when a model needs to be downloaded.""" # Pretend we already set up a config entry. @@ -155,14 +186,21 @@ async def test_form_need_download(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_options( +async def test_subentry_options( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: - """Test the options form.""" - options_flow = await hass.config_entries.options.async_init( - mock_config_entry.entry_id + """Test the subentry options form.""" + subentry = next(iter(mock_config_entry.subentries.values())) + + # Test reconfiguration + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - options = await hass.config_entries.options.async_configure( + + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["step_id"] == "set_options" + + options = await hass.config_entries.subentries.async_configure( options_flow["flow_id"], { ollama.CONF_PROMPT: "test prompt", @@ -172,8 +210,10 @@ async def test_options( }, ) await hass.async_block_till_done() - assert options["type"] is FlowResultType.CREATE_ENTRY - assert options["data"] == { + + assert options["type"] is FlowResultType.ABORT + assert options["reason"] == "reconfigure_successful" + assert subentry.data == { ollama.CONF_PROMPT: "test prompt", ollama.CONF_MAX_HISTORY: 100, ollama.CONF_NUM_CTX: 32768, @@ -181,6 +221,22 @@ async def test_options( } +async def test_creating_conversation_subentry_not_loaded( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation subentry when entry is not loaded.""" + await hass.config_entries.async_unload(mock_config_entry.entry_id) + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "entry_not_loaded" + + @pytest.mark.parametrize( ("side_effect", "error"), [ diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index e83c2a3495f..cebb185bd08 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -35,7 +35,7 @@ async def stream_generator(response: dict | list[dict]) -> AsyncGenerator[dict]: yield msg -@pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) +@pytest.mark.parametrize("agent_id", [None, "conversation.ollama_conversation"]) async def test_chat( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -149,9 +149,11 @@ async def test_template_variables( mock_user.id = "12345" mock_user.name = "Test User" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": ( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." @@ -382,10 +384,12 @@ async def test_unknown_hass_api( mock_init_component, ) -> None: """Test when we reference an API that no longer exists.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ - **mock_config_entry.options, + subentry, + data={ + **subentry.data, CONF_LLM_HASS_API: "non-existing", }, ) @@ -518,8 +522,9 @@ async def test_message_history_unlimited( with ( patch("ollama.AsyncClient.chat", side_effect=stream) as mock_chat, ): - hass.config_entries.async_update_entry( - mock_config_entry, options={ollama.CONF_MAX_HISTORY: 0} + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, subentry, data={ollama.CONF_MAX_HISTORY: 0} ) for i in range(100): result = await conversation.async_converse( @@ -563,9 +568,11 @@ async def test_template_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test that template error handling works.""" - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -593,7 +600,7 @@ async def test_conversation_agent( ) assert agent.supported_languages == MATCH_ALL - state = hass.states.get("conversation.mock_title") + state = hass.states.get("conversation.ollama_conversation") assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -609,7 +616,7 @@ async def test_conversation_agent_with_assist( ) assert agent.supported_languages == MATCH_ALL - state = hass.states.get("conversation.mock_title") + state = hass.states.get("conversation.ollama_conversation") assert state assert ( state.attributes[ATTR_SUPPORTED_FEATURES] @@ -642,7 +649,7 @@ async def test_options( "test message", None, Context(), - agent_id="conversation.mock_title", + agent_id="conversation.ollama_conversation", ) assert mock_chat.call_count == 1 @@ -667,9 +674,11 @@ async def test_reasoning_filter( entry = MockConfigEntry() entry.add_to_hass(hass) - hass.config_entries.async_update_entry( + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + subentry, + data={ ollama.CONF_THINK: think, }, ) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index d1074226837..e11eb98451a 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -6,9 +6,13 @@ from httpx import ConnectError import pytest from homeassistant.components import ollama +from homeassistant.components.ollama.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component +from . import TEST_OPTIONS, TEST_USER_DATA + from tests.common import MockConfigEntry @@ -34,3 +38,250 @@ async def test_init_error( assert await async_setup_component(hass, ollama.DOMAIN, {}) await hass.async_block_till_done() assert error in caplog.text + + +async def test_migration_from_v1_to_v2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2.""" + # Create a v1 config entry with conversation options and an entity + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_USER_DATA, + options=TEST_OPTIONS, + version=1, + title="llama-3.2-8b", + ) + mock_config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="llama_3_2_8b", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.version == 2 + assert mock_config_entry.data == TEST_USER_DATA + assert mock_config_entry.options == {} + + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.unique_id is None + assert subentry.title == "llama-3.2-8b" + assert subentry.subentry_type == "conversation" + assert subentry.data == TEST_OPTIONS + + migrated_entity = entity_registry.async_get(entity.entity_id) + assert migrated_entity is not None + assert migrated_entity.config_entry_id == mock_config_entry.entry_id + assert migrated_entity.config_subentry_id == subentry.subentry_id + assert migrated_entity.unique_id == subentry.subentry_id + + # Check device migration + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + migrated_device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert migrated_device.id == device.id + + +async def test_migration_from_v1_to_v2_with_multiple_urls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with different URLs.""" + # Create two v1 config entries with different URLs + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=TEST_OPTIONS, + version=1, + title="Ollama 1", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11435", "model": "llama3.2:latest"}, + options=TEST_OPTIONS, + version=1, + title="Ollama 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama 1", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="ollama_1", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama 2", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + for idx, entry in enumerate(entries): + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 1 + subentry = list(entry.subentries.values())[0] + assert subentry.subentry_type == "conversation" + assert subentry.data == TEST_OPTIONS + assert subentry.title == f"Ollama {idx + 1}" + + dev = device_registry.async_get_device( + identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + ) + assert dev is not None + + +async def test_migration_from_v1_to_v2_with_same_urls( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 1 to version 2 with same URLs consolidates entries.""" + # Create two v1 config entries with the same URL + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=TEST_OPTIONS, + version=1, + title="Ollama", + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, # Same URL + options=TEST_OPTIONS, + version=1, + title="Ollama 2", + ) + mock_config_entry_2.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device.id, + suggested_object_id="ollama", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Should have only one entry left (consolidated) + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + entry = entries[0] + assert entry.version == 2 + assert not entry.options + assert len(entry.subentries) == 2 # Two subentries from the two original entries + + # Check both subentries exist with correct data + subentries = list(entry.subentries.values()) + titles = [sub.title for sub in subentries] + assert "Ollama" in titles + assert "Ollama 2" in titles + + for subentry in subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == TEST_OPTIONS + + # Check devices were migrated correctly + dev = device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + assert dev is not None From 9e7c7ec97e5982a040487dbb049b7596d18ef193 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:21:02 -0400 Subject: [PATCH 0631/1664] Flash ZBT-1 and Yellow firmwares from Core instead of using addons (#145019) * Make `async_flash_firmware` a public helper * [ZBT-1] Implement flashing for Zigbee and Thread within the config flow * WIP: Begin fixing unit tests * WIP: Unit tests, pass 2 * WIP: pass 3 * Fix hardware unit tests * Have the individual hardware integrations depend on the firmware flasher * Break out firmware filter into its own helper * Mirror to Yellow * Simplify * Simplify * Revert "Have the individual hardware integrations depend on the firmware flasher" This reverts commit 096f4297dc3b0a0529ed6288c8baea92fcfbfb11. * Move `async_flash_silabs_firmware` into `util` * Fix existing unit tests * Unconditionally upgrade Zigbee firmware during installation * Fix failing error case unit tests * Fix remaining failing unit tests * Increase test coverage * 100% test coverage * Remove old translation strings * Add new translation strings * Do not probe OTBR firmware when completing the flow * More translation strings * Probe OTBR firmware info before starting the addon --- .../firmware_config_flow.py | 460 +++++++-------- .../homeassistant_hardware/strings.json | 30 +- .../homeassistant_hardware/update.py | 88 +-- .../components/homeassistant_hardware/util.py | 55 +- .../homeassistant_sky_connect/config_flow.py | 51 +- .../homeassistant_sky_connect/strings.json | 46 +- .../homeassistant_yellow/config_flow.py | 61 +- .../homeassistant_yellow/strings.json | 26 +- .../test_config_flow.py | 549 +++++++++--------- .../test_config_flow_failures.py | 494 ++++------------ .../homeassistant_hardware/test_update.py | 75 +-- .../homeassistant_hardware/test_util.py | 207 ++++++- .../test_config_flow.py | 76 ++- .../homeassistant_yellow/test_config_flow.py | 45 +- 14 files changed, 1084 insertions(+), 1179 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 1b4840e5a98..7519e0ae394 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,6 +7,8 @@ import asyncio import logging from typing import Any +from ha_silabs_firmware_client import FirmwareUpdateClient + from homeassistant.components.hassio import ( AddonError, AddonInfo, @@ -22,17 +24,17 @@ from homeassistant.config_entries import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio -from . import silabs_multiprotocol_addon from .const import OTBR_DOMAIN, ZHA_DOMAIN from .util import ( ApplicationType, FirmwareInfo, OwningAddon, OwningIntegration, + async_flash_silabs_firmware, get_otbr_addon_manager, - get_zigbee_flasher_addon_manager, guess_firmware_info, guess_hardware_owners, probe_silabs_firmware_info, @@ -61,6 +63,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self.addon_install_task: asyncio.Task | None = None self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None + self.firmware_install_task: asyncio.Task | None = None def _get_translation_placeholders(self) -> dict[str, str]: """Shared translation placeholders.""" @@ -77,22 +80,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): return placeholders - async def _async_set_addon_config( - self, config: dict, addon_manager: AddonManager - ) -> None: - """Set add-on config.""" - try: - await addon_manager.async_set_addon_options(config) - except AddonError as err: - _LOGGER.error(err) - raise AbortFlow( - "addon_set_config_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": addon_manager.addon_name, - }, - ) from err - async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo: """Return add-on info.""" try: @@ -150,6 +137,54 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) ) + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + assert self._device is not None + + if not self.firmware_install_task: + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + manifest = await client.async_update_data() + + fw_meta = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + + fw_data = await client.async_fetch_firmware(fw_meta) + self.firmware_install_task = self.hass.async_create_task( + async_flash_silabs_firmware( + hass=self.hass, + device=self._device, + fw_data=fw_data, + expected_installed_firmware_type=expected_installed_firmware_type, + bootloader_reset_type=None, + progress_callback=lambda offset, total: self.async_update_progress( + offset / total + ), + ), + f"Flash {firmware_name} firmware", + ) + + if not self.firmware_install_task.done(): + return self.async_show_progress( + step_id=step_id, + progress_action="install_firmware", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + progress_task=self.firmware_install_task, + ) + + return self.async_show_progress_done(next_step_id=next_step_id) + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -160,68 +195,133 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): description_placeholders=self._get_translation_placeholders(), ) - # Allow the stick to be used with ZHA without flashing - if ( - self._probed_firmware_info is not None - and self._probed_firmware_info.firmware_type == ApplicationType.EZSP - ): - return await self.async_step_confirm_zigbee() + return await self.async_step_install_zigbee_firmware() - if not is_hassio(self.hass): - return self.async_abort( - reason="not_hassio", - description_placeholders=self._get_translation_placeholders(), - ) + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + raise NotImplementedError - # Only flash new firmware if we need to - fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(fw_flasher_manager) - - if addon_info.state == AddonState.NOT_INSTALLED: - return await self.async_step_install_zigbee_flasher_addon() - - if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_run_zigbee_flasher_addon() - - # If the addon is already installed and running, fail + async def async_step_addon_operation_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when add-on installation or start failed.""" return self.async_abort( - reason="addon_already_running", + reason=self._failed_addon_reason, description_placeholders={ **self._get_translation_placeholders(), - "addon_name": fw_flasher_manager.addon_name, + "addon_name": self._failed_addon_name, }, ) - async def async_step_install_zigbee_flasher_addon( + async def async_step_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Show progress dialog for installing the Zigbee flasher addon.""" - return await self._install_addon( - get_zigbee_flasher_addon_manager(self.hass), - "install_zigbee_flasher_addon", - "run_zigbee_flasher_addon", + """Confirm Zigbee setup.""" + assert self._device is not None + assert self._hardware_name is not None + + if user_input is None: + return self.async_show_form( + step_id="confirm_zigbee", + description_placeholders=self._get_translation_placeholders(), + ) + + if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + await self.hass.config_entries.flow.async_init( + ZHA_DOMAIN, + context={"source": "hardware"}, + data={ + "name": self._hardware_name, + "port": { + "path": self._device, + "baudrate": 115200, + "flow_control": "hardware", + }, + "radio_type": "ezsp", + }, ) - async def _install_addon( - self, - addon_manager: silabs_multiprotocol_addon.WaitingAddonManager, - step_id: str, - next_step_id: str, + return self._async_flow_finished() + + async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None: + """Ensure the OTBR addon is set up and not running.""" + + # We install the OTBR addon no matter what, since it is required to use Thread + if not is_hassio(self.hass): + return self.async_abort( + reason="not_hassio_thread", + description_placeholders=self._get_translation_placeholders(), + ) + + otbr_manager = get_otbr_addon_manager(self.hass) + addon_info = await self._async_get_addon_info(otbr_manager) + + if addon_info.state == AddonState.NOT_INSTALLED: + return await self.async_step_install_otbr_addon() + + if addon_info.state == AddonState.RUNNING: + # We only fail setup if we have an instance of OTBR running *and* it's + # pointing to different hardware + if addon_info.options["device"] != self._device: + return self.async_abort( + reason="otbr_addon_already_running", + description_placeholders={ + **self._get_translation_placeholders(), + "addon_name": otbr_manager.addon_name, + }, + ) + + # Otherwise, stop the addon before continuing to flash firmware + await otbr_manager.async_stop_addon() + + return None + + async def async_step_pick_firmware_thread( + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Show progress dialog for installing an addon.""" + """Pick Thread firmware.""" + if not await self._probe_firmware_info(): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + if result := await self._ensure_thread_addon_setup(): + return result + + return await self.async_step_install_thread_firmware() + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + raise NotImplementedError + + async def async_step_install_otbr_addon( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Show progress dialog for installing the OTBR addon.""" + addon_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(addon_manager) - _LOGGER.debug("Flasher addon state: %s", addon_info) + _LOGGER.debug("OTBR addon info: %s", addon_info) if not self.addon_install_task: self.addon_install_task = self.hass.async_create_task( addon_manager.async_install_addon_waiting(), - "Addon install", + "OTBR addon install", ) if not self.addon_install_task.done(): return self.async_show_progress( - step_id=step_id, + step_id="install_otbr_addon", progress_action="install_addon", description_placeholders={ **self._get_translation_placeholders(), @@ -240,208 +340,50 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_install_task = None - return self.async_show_progress_done(next_step_id=next_step_id) - - async def async_step_addon_operation_failed( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Abort when add-on installation or start failed.""" - return self.async_abort( - reason=self._failed_addon_reason, - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": self._failed_addon_name, - }, - ) - - async def async_step_run_zigbee_flasher_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Configure the flasher addon to point to the SkyConnect and run it.""" - fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(fw_flasher_manager) - - assert self._device is not None - new_addon_config = { - **addon_info.options, - "device": self._device, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - - _LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config) - await self._async_set_addon_config(new_addon_config, fw_flasher_manager) - - if not self.addon_start_task: - - async def start_and_wait_until_done() -> None: - await fw_flasher_manager.async_start_addon_waiting() - # Now that the addon is running, wait for it to finish - await fw_flasher_manager.async_wait_until_addon_state( - AddonState.NOT_RUNNING - ) - - self.addon_start_task = self.hass.async_create_task( - start_and_wait_until_done() - ) - - if not self.addon_start_task.done(): - return self.async_show_progress( - step_id="run_zigbee_flasher_addon", - progress_action="run_zigbee_flasher_addon", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": fw_flasher_manager.addon_name, - }, - progress_task=self.addon_start_task, - ) - - try: - await self.addon_start_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - self._failed_addon_name = fw_flasher_manager.addon_name - self._failed_addon_reason = "addon_start_failed" - return self.async_show_progress_done(next_step_id="addon_operation_failed") - finally: - self.addon_start_task = None - - return self.async_show_progress_done( - next_step_id="uninstall_zigbee_flasher_addon" - ) - - async def async_step_uninstall_zigbee_flasher_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Uninstall the flasher addon.""" - fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass) - - if not self.addon_uninstall_task: - _LOGGER.debug("Uninstalling flasher addon") - self.addon_uninstall_task = self.hass.async_create_task( - fw_flasher_manager.async_uninstall_addon_waiting() - ) - - if not self.addon_uninstall_task.done(): - return self.async_show_progress( - step_id="uninstall_zigbee_flasher_addon", - progress_action="uninstall_zigbee_flasher_addon", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": fw_flasher_manager.addon_name, - }, - progress_task=self.addon_uninstall_task, - ) - - try: - await self.addon_uninstall_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - # The uninstall failing isn't critical so we can just continue - finally: - self.addon_uninstall_task = None - - return self.async_show_progress_done(next_step_id="confirm_zigbee") - - async def async_step_confirm_zigbee( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm Zigbee setup.""" - assert self._device is not None - assert self._hardware_name is not None - - if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - - if user_input is not None: - await self.hass.config_entries.flow.async_init( - ZHA_DOMAIN, - context={"source": "hardware"}, - data={ - "name": self._hardware_name, - "port": { - "path": self._device, - "baudrate": 115200, - "flow_control": "hardware", - }, - "radio_type": "ezsp", - }, - ) - - return self._async_flow_finished() - - return self.async_show_form( - step_id="confirm_zigbee", - description_placeholders=self._get_translation_placeholders(), - ) - - async def async_step_pick_firmware_thread( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Pick Thread firmware.""" - if not await self._probe_firmware_info(): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - - # We install the OTBR addon no matter what, since it is required to use Thread - if not is_hassio(self.hass): - return self.async_abort( - reason="not_hassio_thread", - description_placeholders=self._get_translation_placeholders(), - ) - - otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - - if addon_info.state == AddonState.NOT_INSTALLED: - return await self.async_step_install_otbr_addon() - - if addon_info.state == AddonState.NOT_RUNNING: - return await self.async_step_start_otbr_addon() - - # If the addon is already installed and running, fail - return self.async_abort( - reason="otbr_addon_already_running", - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": otbr_manager.addon_name, - }, - ) - - async def async_step_install_otbr_addon( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Show progress dialog for installing the OTBR addon.""" - return await self._install_addon( - get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon" - ) + return self.async_show_progress_done(next_step_id="install_thread_firmware") async def async_step_start_otbr_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure OTBR to point to the SkyConnect and run the addon.""" otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - - assert self._device is not None - new_addon_config = { - **addon_info.options, - "device": self._device, - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": True, - } - - _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config) - await self._async_set_addon_config(new_addon_config, otbr_manager) if not self.addon_start_task: + # Before we start the addon, confirm that the correct firmware is running + # and populate `self._probed_firmware_info` with the correct information + if not await self._probe_firmware_info( + probe_methods=(ApplicationType.SPINEL,) + ): + return self.async_abort( + reason="unsupported_firmware", + description_placeholders=self._get_translation_placeholders(), + ) + + addon_info = await self._async_get_addon_info(otbr_manager) + + assert self._device is not None + new_addon_config = { + **addon_info.options, + "device": self._device, + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + } + + _LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config) + + try: + await otbr_manager.async_set_addon_options(new_addon_config) + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow( + "addon_set_config_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "addon_name": otbr_manager.addon_name, + }, + ) from err + self.addon_start_task = self.hass.async_create_task( otbr_manager.async_start_addon_waiting() ) @@ -475,20 +417,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Confirm OTBR setup.""" assert self._device is not None - if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)): - return self.async_abort( - reason="unsupported_firmware", + if user_input is None: + return self.async_show_form( + step_id="confirm_otbr", description_placeholders=self._get_translation_placeholders(), ) - if user_input is not None: - # OTBR discovery is done automatically via hassio - return self._async_flow_finished() - - return self.async_show_form( - step_id="confirm_otbr", - description_placeholders=self._get_translation_placeholders(), - ) + # OTBR discovery is done automatically via hassio + return self._async_flow_finished() @abstractmethod def _async_flow_finished(self) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index e184f9b3a85..99172c963b8 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -10,22 +10,6 @@ "pick_firmware_thread": "Thread" } }, - "install_zigbee_flasher_addon": { - "title": "Installing flasher", - "description": "Installing the Silicon Labs Flasher add-on." - }, - "run_zigbee_flasher_addon": { - "title": "Installing Zigbee firmware", - "description": "Installing Zigbee firmware. This will take about a minute." - }, - "uninstall_zigbee_flasher_addon": { - "title": "Removing flasher", - "description": "Removing the Silicon Labs Flasher add-on." - }, - "zigbee_flasher_failed": { - "title": "Zigbee installation failed", - "description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again." - }, "confirm_zigbee": { "title": "Zigbee setup complete", "description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration." @@ -55,9 +39,7 @@ "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." }, "progress": { - "install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.", - "run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.", - "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed." + "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." } } }, @@ -110,16 +92,6 @@ "data": { "disable_multi_pan": "Disable multiprotocol support" } - }, - "install_flasher_addon": { - "title": "The Silicon Labs Flasher add-on installation has started" - }, - "configure_flasher_addon": { - "title": "The Silicon Labs Flasher add-on installation has started" - }, - "start_flasher_addon": { - "title": "Installing firmware", - "description": "Zigbee firmware is now being installed. This will take a few minutes." } }, "error": { diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py index 1b0f15ca021..831d9f3f4da 100644 --- a/homeassistant/components/homeassistant_hardware/update.py +++ b/homeassistant/components/homeassistant_hardware/update.py @@ -2,15 +2,12 @@ from __future__ import annotations -from collections.abc import AsyncIterator, Callable -from contextlib import AsyncExitStack, asynccontextmanager +from collections.abc import Callable from dataclasses import dataclass import logging from typing import Any, cast from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata -from universal_silabs_flasher.firmware import parse_firmware_image -from universal_silabs_flasher.flasher import Flasher from yarl import URL from homeassistant.components.update import ( @@ -20,18 +17,12 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.restore_state import ExtraStoredData from homeassistant.helpers.update_coordinator import CoordinatorEntity from .coordinator import FirmwareUpdateCoordinator from .helpers import async_register_firmware_info_callback -from .util import ( - ApplicationType, - FirmwareInfo, - guess_firmware_info, - probe_silabs_firmware_info, -) +from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware _LOGGER = logging.getLogger(__name__) @@ -249,19 +240,11 @@ class BaseFirmwareUpdateEntity( self._attr_update_percentage = round((offset * 100) / total_size) self.async_write_ha_state() - @asynccontextmanager - async def _temporarily_stop_hardware_owners( - self, device: str - ) -> AsyncIterator[None]: - """Temporarily stop addons and integrations communicating with the device.""" - firmware_info = await guess_firmware_info(self.hass, device) - _LOGGER.debug("Identified firmware info: %s", firmware_info) - - async with AsyncExitStack() as stack: - for owner in firmware_info.owners: - await stack.enter_async_context(owner.temporarily_stop(self.hass)) - - yield + # Switch to an indeterminate progress bar after installation is complete, since + # we probe the firmware after flashing + if offset == total_size: + self._attr_update_percentage = None + self.async_write_ha_state() async def async_install( self, version: str | None, backup: bool, **kwargs: Any @@ -278,49 +261,18 @@ class BaseFirmwareUpdateEntity( fw_data = await self.coordinator.client.async_fetch_firmware( self._latest_firmware ) - fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data) - device = self._current_device + try: + firmware_info = await async_flash_silabs_firmware( + hass=self.hass, + device=self._current_device, + fw_data=fw_data, + expected_installed_firmware_type=self.entity_description.expected_firmware_type, + bootloader_reset_type=self.bootloader_reset_type, + progress_callback=self._update_progress, + ) + finally: + self._attr_in_progress = False + self.async_write_ha_state() - flasher = Flasher( - device=device, - probe_methods=( - ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), - ApplicationType.EZSP.as_flasher_application_type(), - ApplicationType.SPINEL.as_flasher_application_type(), - ApplicationType.CPC.as_flasher_application_type(), - ), - bootloader_reset=self.bootloader_reset_type, - ) - - async with self._temporarily_stop_hardware_owners(device): - try: - try: - # Enter the bootloader with indeterminate progress - await flasher.enter_bootloader() - - # Flash the firmware, with progress - await flasher.flash_firmware( - fw_image, progress_callback=self._update_progress - ) - except Exception as err: - raise HomeAssistantError("Failed to flash firmware") from err - - # Probe the running application type with indeterminate progress - self._attr_update_percentage = None - self.async_write_ha_state() - - firmware_info = await probe_silabs_firmware_info( - device, - probe_methods=(self.entity_description.expected_firmware_type,), - ) - - if firmware_info is None: - raise HomeAssistantError( - "Failed to probe the firmware after flashing" - ) - - self._firmware_info_callback(firmware_info) - finally: - self._attr_in_progress = False - self.async_write_ha_state() + self._firmware_info_callback(firmware_info) diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index 64f363e4f23..d84f4f75ff7 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -4,18 +4,20 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import AsyncIterator, Iterable -from contextlib import asynccontextmanager +from collections.abc import AsyncIterator, Callable, Iterable +from contextlib import AsyncExitStack, asynccontextmanager from dataclasses import dataclass from enum import StrEnum import logging from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.firmware import parse_firmware_image from universal_silabs_flasher.flasher import Flasher from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.singleton import singleton @@ -333,3 +335,52 @@ async def probe_silabs_firmware_type( return None return fw_info.firmware_type + + +async def async_flash_silabs_firmware( + hass: HomeAssistant, + device: str, + fw_data: bytes, + expected_installed_firmware_type: ApplicationType, + bootloader_reset_type: str | None = None, + progress_callback: Callable[[int, int], None] | None = None, +) -> FirmwareInfo: + """Flash firmware to the SiLabs device.""" + firmware_info = await guess_firmware_info(hass, device) + _LOGGER.debug("Identified firmware info: %s", firmware_info) + + fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data) + + flasher = Flasher( + device=device, + probe_methods=( + ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(), + ApplicationType.EZSP.as_flasher_application_type(), + ApplicationType.SPINEL.as_flasher_application_type(), + ApplicationType.CPC.as_flasher_application_type(), + ), + bootloader_reset=bootloader_reset_type, + ) + + async with AsyncExitStack() as stack: + for owner in firmware_info.owners: + await stack.enter_async_context(owner.temporarily_stop(hass)) + + try: + # Enter the bootloader with indeterminate progress + await flasher.enter_bootloader() + + # Flash the firmware, with progress + await flasher.flash_firmware(fw_image, progress_callback=progress_callback) + except Exception as err: + raise HomeAssistantError("Failed to flash firmware") from err + + probed_firmware_info = await probe_silabs_firmware_info( + device, + probe_methods=(expected_installed_firmware_type,), + ) + + if probed_firmware_info is None: + raise HomeAssistantError("Failed to probe the firmware after flashing") + + return probed_firmware_info diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index eb5ea214b3e..997edb54b18 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -32,6 +32,7 @@ from .const import ( FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, + NABU_CASA_FIRMWARE_RELEASES_URL, PID, PRODUCT, SERIAL_NUMBER, @@ -45,19 +46,29 @@ _LOGGER = logging.getLogger(__name__) if TYPE_CHECKING: - class TranslationPlaceholderProtocol(Protocol): - """Protocol describing `BaseFirmwareInstallFlow`'s translation placeholders.""" + class FirmwareInstallFlowProtocol(Protocol): + """Protocol describing `BaseFirmwareInstallFlow` for a mixin.""" def _get_translation_placeholders(self) -> dict[str, str]: return {} + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: ... + else: # Multiple inheritance with `Protocol` seems to break - TranslationPlaceholderProtocol = object + FirmwareInstallFlowProtocol = object -class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProtocol): - """Translation placeholder mixin for Home Assistant SkyConnect.""" +class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): + """Mixin for Home Assistant SkyConnect firmware methods.""" context: ConfigFlowContext @@ -72,9 +83,35 @@ class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProt return placeholders + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="skyconnect_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="skyconnect_openthread_rcp", + firmware_name="OpenThread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + class HomeAssistantSkyConnectConfigFlow( - SkyConnectTranslationMixin, + SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareConfigFlow, domain=DOMAIN, ): @@ -207,7 +244,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( class HomeAssistantSkyConnectOptionsFlowHandler( - SkyConnectTranslationMixin, firmware_config_flow.BaseFirmwareOptionsFlow + SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow ): """Zigbee and Thread options flow handlers.""" diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index a990f025e8d..08c8a56c30d 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -48,16 +48,6 @@ "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" } }, - "install_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]" - }, - "configure_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]" - }, - "start_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" - }, "pick_firmware": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", @@ -66,18 +56,6 @@ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" } }, - "install_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]" - }, - "run_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]" - }, - "zigbee_flasher_failed": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]" - }, "confirm_zigbee": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" @@ -120,9 +98,7 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", - "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", - "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" } }, "config": { @@ -136,22 +112,6 @@ "pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]" } }, - "install_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]" - }, - "run_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]" - }, - "uninstall_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::description%]" - }, - "zigbee_flasher_failed": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]" - }, "confirm_zigbee": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" @@ -191,9 +151,7 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", - "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", - "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" } }, "exceptions": { diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 1fac6bcac96..db844d0b0e9 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio import logging -from typing import Any, final +from typing import TYPE_CHECKING, Any, Protocol, final import aiohttp import voluptuous as vol @@ -31,6 +31,7 @@ from homeassistant.components.homeassistant_hardware.util import ( from homeassistant.config_entries import ( SOURCE_HARDWARE, ConfigEntry, + ConfigEntryBaseFlow, ConfigFlowResult, OptionsFlow, ) @@ -41,6 +42,7 @@ from .const import ( DOMAIN, FIRMWARE, FIRMWARE_VERSION, + NABU_CASA_FIRMWARE_RELEASES_URL, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA, @@ -57,8 +59,59 @@ STEP_HW_SETTINGS_SCHEMA = vol.Schema( } ) +if TYPE_CHECKING: -class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN): + class FirmwareInstallFlowProtocol(Protocol): + """Protocol describing `BaseFirmwareInstallFlow` for a mixin.""" + + async def _install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: ... + +else: + # Multiple inheritance with `Protocol` seems to break + FirmwareInstallFlowProtocol = object + + +class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): + """Mixin for Home Assistant Yellow firmware methods.""" + + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="yellow_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL, + fw_type="yellow_openthread_rcp", + firmware_name="OpenThread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + + +class HomeAssistantYellowConfigFlow( + YellowFirmwareMixin, BaseFirmwareConfigFlow, domain=DOMAIN +): """Handle a config flow for Home Assistant Yellow.""" VERSION = 1 @@ -275,7 +328,9 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler( class HomeAssistantYellowOptionsFlowHandler( - BaseHomeAssistantYellowOptionsFlow, BaseFirmwareOptionsFlow + YellowFirmwareMixin, + BaseHomeAssistantYellowOptionsFlow, + BaseFirmwareOptionsFlow, ): """Handle a firmware options flow for Home Assistant Yellow.""" diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 41c1438b234..980052f9ffb 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -71,16 +71,6 @@ "disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]" } }, - "install_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]" - }, - "configure_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]" - }, - "start_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]" - }, "pick_firmware": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]", @@ -89,18 +79,6 @@ "pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]" } }, - "install_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]" - }, - "run_zigbee_flasher_addon": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]" - }, - "zigbee_flasher_failed": { - "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]", - "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]" - }, "confirm_zigbee": { "title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]", "description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]" @@ -145,9 +123,7 @@ "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", "start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", "start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]", - "install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]", - "run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]", - "uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]" + "install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]" } }, "entity": { diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 2d5067bea3e..530308fdf41 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -4,9 +4,15 @@ import asyncio from collections.abc import Awaitable, Callable, Generator, Iterator import contextlib from typing import Any -from unittest.mock import AsyncMock, Mock, call, patch +from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from ha_silabs_firmware_client import ( + FirmwareManifest, + FirmwareMetadata, + FirmwareUpdateClient, +) import pytest +from yarl import URL from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( @@ -19,12 +25,13 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, get_otbr_addon_manager, - get_zigbee_flasher_addon_manager, ) from homeassistant.config_entries import ConfigEntry, ConfigFlowResult, OptionsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow from tests.common import ( MockConfigEntry, @@ -37,6 +44,7 @@ from tests.common import ( TEST_DOMAIN = "test_firmware_domain" TEST_DEVICE = "/dev/SomeDevice123" TEST_HARDWARE_NAME = "Some Hardware Name" +TEST_RELEASES_URL = URL("http://invalid/releases") class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): @@ -62,6 +70,32 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): return await self.async_step_confirm() + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self._install_firmware_step( + fw_update_url=TEST_RELEASES_URL, + fw_type="fake_zigbee_ncp", + firmware_name="Zigbee", + expected_installed_firmware_type=ApplicationType.EZSP, + step_id="install_zigbee_firmware", + next_step_id="confirm_zigbee", + ) + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self._install_firmware_step( + fw_update_url=TEST_RELEASES_URL, + fw_type="fake_openthread_rcp", + firmware_name="Thread", + expected_installed_firmware_type=ApplicationType.SPINEL, + step_id="install_thread_firmware", + next_step_id="start_otbr_addon", + ) + def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" assert self._device is not None @@ -99,6 +133,18 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): # Regenerate the translation placeholders self._get_translation_placeholders() + async def async_step_install_zigbee_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Zigbee firmware.""" + return await self.async_step_confirm_zigbee() + + async def async_step_install_thread_firmware( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Install Thread firmware.""" + return await self.async_step_start_otbr_addon() + def _async_flow_finished(self) -> ConfigFlowResult: """Create the config entry.""" assert self._probed_firmware_info is not None @@ -146,12 +192,22 @@ def delayed_side_effect() -> Callable[..., Awaitable[None]]: return side_effect +def create_mock_owner() -> Mock: + """Mock for OwningAddon / OwningIntegration.""" + owner = Mock() + owner.is_running = AsyncMock(return_value=True) + owner.temporarily_stop = MagicMock() + owner.temporarily_stop.return_value.__aenter__.return_value = AsyncMock() + + return owner + + @contextlib.contextmanager -def mock_addon_info( +def mock_firmware_info( hass: HomeAssistant, *, is_hassio: bool = True, - app_type: ApplicationType | None = ApplicationType.EZSP, + probe_app_type: ApplicationType | None = ApplicationType.EZSP, otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -160,29 +216,9 @@ def mock_addon_info( update_available=False, version=None, ), - flasher_addon_info: AddonInfo = AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.NOT_INSTALLED, - update_available=False, - version=None, - ), + flash_app_type: ApplicationType = ApplicationType.EZSP, ) -> Iterator[tuple[Mock, Mock]]: """Mock the main addon states for the config flow.""" - mock_flasher_manager = Mock(spec_set=get_zigbee_flasher_addon_manager(hass)) - mock_flasher_manager.addon_name = "Silicon Labs Flasher" - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_get_addon_info.return_value = flasher_addon_info - mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) mock_otbr_manager.addon_name = "OpenThread Border Router" mock_otbr_manager.async_install_addon_waiting = AsyncMock( @@ -196,17 +232,73 @@ def mock_addon_info( ) mock_otbr_manager.async_get_addon_info.return_value = otbr_addon_info - if app_type is None: - firmware_info_result = None + mock_update_client = AsyncMock(spec_set=FirmwareUpdateClient) + mock_update_client.async_update_data.return_value = FirmwareManifest( + url=TEST_RELEASES_URL, + html_url=TEST_RELEASES_URL / "html", + created_at=utcnow(), + firmwares=[ + FirmwareMetadata( + filename="fake_openthread_rcp_7.4.4.0_variant.gbl", + checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + size=123, + release_notes="Some release notes", + metadata={}, + url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl", + ), + FirmwareMetadata( + filename="fake_zigbee_ncp_7.4.4.0_variant.gbl", + checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + size=123, + release_notes="Some release notes", + metadata={}, + url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", + ), + ], + ) + + if probe_app_type is None: + probed_firmware_info = None else: - firmware_info_result = FirmwareInfo( + probed_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", # Not used - firmware_type=app_type, + firmware_type=probe_app_type, firmware_version=None, owners=[], source="probe", ) + if flash_app_type is None: + flashed_firmware_info = None + else: + flashed_firmware_info = FirmwareInfo( + device=TEST_DEVICE, + firmware_type=flash_app_type, + firmware_version="7.4.4.0", + owners=[create_mock_owner()], + source="probe", + ) + + async def mock_flash_firmware( + hass: HomeAssistant, + device: str, + fw_data: bytes, + expected_installed_firmware_type: ApplicationType, + bootloader_reset_type: str | None = None, + progress_callback: Callable[[int, int], None] | None = None, + ) -> FirmwareInfo: + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + + if flashed_firmware_info is None: + raise HomeAssistantError("Failed to probe the firmware after flashing") + + return flashed_firmware_info + with ( patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_otbr_addon_manager", @@ -216,10 +308,6 @@ def mock_addon_info( "homeassistant.components.homeassistant_hardware.util.get_otbr_addon_manager", return_value=mock_otbr_manager, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.get_zigbee_flasher_addon_manager", - return_value=mock_flasher_manager, - ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.is_hassio", return_value=is_hassio, @@ -229,81 +317,85 @@ def mock_addon_info( return_value=is_hassio, ), patch( + # We probe once before installation and once after "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=firmware_info_result, + side_effect=(probed_firmware_info, flashed_firmware_info), + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.FirmwareUpdateClient", + return_value=mock_update_client, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware", + side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager, mock_flasher_manager + yield mock_otbr_manager + + +async def consume_progress_flow( + hass: HomeAssistant, + flow_id: str, + valid_step_ids: tuple[str], +) -> ConfigFlowResult: + """Consume a progress flow until it is done.""" + while True: + result = await hass.config_entries.flow.async_configure(flow_id) + flow_id = result["flow_id"] + + if result["type"] != FlowResultType.SHOW_PROGRESS: + break + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] in valid_step_ids + + await asyncio.sleep(0.1) + + return result async def test_config_flow_zigbee(hass: HomeAssistant) -> None: """Test the config flow.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - # Pick the menu option: we are now installing the addon - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + probe_app_type=ApplicationType.SPINEL, + flash_app_type=ApplicationType.EZSP, + ): + # Pick the menu option: we are flashing the firmware + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_zigbee_flasher_addon" - assert result["description_placeholders"]["firmware_type"] == "spinel" - await hass.async_block_till_done(wait_background_tasks=True) + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_firmware" + assert pick_result["step_id"] == "install_zigbee_firmware" - # Progress the flow, we are now configuring the addon and running it - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "run_zigbee_flasher_addon" - assert result["progress_action"] == "run_zigbee_flasher_addon" - assert mock_flasher_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - ) - ] - - await hass.async_block_till_done(wait_background_tasks=True) - - # Progress the flow, we are now uninstalling the addon - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "uninstall_zigbee_flasher_addon" - assert result["progress_action"] == "uninstall_zigbee_flasher_addon" - - await hass.async_block_till_done(wait_background_tasks=True) - - # We are finally done with the addon - assert mock_flasher_manager.async_uninstall_addon_waiting.mock_calls == [call()] - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - with mock_addon_info( - hass, - app_type=ApplicationType.EZSP, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + confirm_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=("install_zigbee_firmware",), ) - assert result["type"] is FlowResultType.CREATE_ENTRY - config_entry = result["result"] + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] assert config_entry.data == { "firmware": "ezsp", "device": TEST_DEVICE, @@ -328,52 +420,20 @@ async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> assert result["type"] is FlowResultType.MENU assert result["step_id"] == "pick_firmware" - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - flasher_addon_info=AddonInfo( - available=True, - hostname=None, - options={ - "device": "", - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - }, - state=AddonState.NOT_RUNNING, - update_available=False, - version="1.2.3", - ), - ) as (mock_otbr_manager, mock_flasher_manager): + with mock_firmware_info(hass, probe_app_type=ApplicationType.SPINEL): # Pick the menu option: we skip installation, instead we directly run it result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "run_zigbee_flasher_addon" - assert result["progress_action"] == "run_zigbee_flasher_addon" - assert result["description_placeholders"]["firmware_type"] == "spinel" - assert mock_flasher_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - ) - ] - - # Uninstall the addon - await hass.async_block_till_done(wait_background_tasks=True) + # Confirm result = await hass.config_entries.flow.async_configure(result["flow_id"]) # Done - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, ): await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -409,28 +469,29 @@ async def test_config_flow_auto_confirm_if_running(hass: HomeAssistant) -> None: async def test_config_flow_thread(hass: HomeAssistant) -> None: """Test the config flow.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" + assert init_result["type"] is FlowResultType.MENU + assert init_result["step_id"] == "pick_firmware" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, + ) as mock_otbr_manager: # Pick the menu option - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_otbr_addon" - assert result["description_placeholders"]["firmware_type"] == "ezsp" - assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME + assert pick_result["type"] is FlowResultType.SHOW_PROGRESS + assert pick_result["progress_action"] == "install_addon" + assert pick_result["step_id"] == "install_otbr_addon" + assert pick_result["description_placeholders"]["firmware_type"] == "ezsp" + assert pick_result["description_placeholders"]["model"] == TEST_HARDWARE_NAME await hass.async_block_till_done(wait_background_tasks=True) @@ -441,19 +502,37 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: "device": "", "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, }, state=AddonState.NOT_RUNNING, update_available=False, version="1.2.3", ) - # Progress the flow, it is now configuring the addon and running it - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + # Progress the flow, it is now installing firmware + confirm_otbr_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_otbr_addon", + "install_thread_firmware", + "start_otbr_addon", + ), + ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_otbr_addon" - assert result["progress_action"] == "start_otbr_addon" + # Installation will conclude with the config entry being created + create_result = await hass.config_entries.flow.async_configure( + confirm_otbr_result["flow_id"], user_input={} + ) + assert create_result["type"] is FlowResultType.CREATE_ENTRY + + config_entry = create_result["result"] + assert config_entry.data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } assert mock_otbr_manager.async_set_addon_options.mock_calls == [ call( @@ -461,44 +540,22 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: "device": TEST_DEVICE, "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, } ) ] - await hass.async_block_till_done(wait_background_tasks=True) - - # The addon is now running - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - config_entry = result["result"] - assert config_entry.data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } - async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) -> None: """Test the Thread config flow, addon is already installed.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, otbr_addon_info=AddonInfo( available=True, hostname=None, @@ -507,81 +564,50 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - update_available=False, version=None, ), - ) as (mock_otbr_manager, mock_flasher_manager): + ) as mock_otbr_manager: # Pick the menu option - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_otbr_addon" - assert result["progress_action"] == "start_otbr_addon" + # Progress + confirm_otbr_result = await consume_progress_flow( + hass, + flow_id=pick_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), + ) + + # We're now waiting to confirm OTBR + assert confirm_otbr_result["type"] is FlowResultType.FORM + assert confirm_otbr_result["step_id"] == "confirm_otbr" + + # The addon has been installed assert mock_otbr_manager.async_set_addon_options.mock_calls == [ call( { "device": TEST_DEVICE, "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, # And firmware flashing is disabled } ) ] - await hass.async_block_till_done(wait_background_tasks=True) - - # The addon is now running - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + # Finally, create the config entry + create_result = await hass.config_entries.flow.async_configure( + confirm_otbr_result["flow_id"], user_input={} ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - -async def test_config_flow_zigbee_not_hassio(hass: HomeAssistant) -> None: - """Test when the stick is used with a non-hassio setup.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - is_hassio=False, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY - - config_entry = result["result"] - assert config_entry.data == { - "firmware": "ezsp", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } - - # Ensure a ZHA discovery flow has been created - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - zha_flow = flows[0] - assert zha_flow["handler"] == "zha" - assert zha_flow["context"]["source"] == "hardware" - assert zha_flow["step_id"] == "confirm" + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"].data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } @pytest.mark.usefixtures("addon_store_info") @@ -601,10 +627,11 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, + ) as mock_otbr_manager: # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU @@ -630,7 +657,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: "device": "", "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, }, state=AddonState.NOT_RUNNING, update_available=False, @@ -650,7 +677,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: "device": TEST_DEVICE, "baudrate": 460800, "flow_control": True, - "autoflash_firmware": True, + "autoflash_firmware": False, } ) ] @@ -662,10 +689,6 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_otbr" - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ): # We are now done result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={} @@ -700,57 +723,23 @@ async def test_options_flow_thread_to_zigbee(hass: HomeAssistant) -> None: assert result["description_placeholders"]["firmware_type"] == "spinel" assert result["description_placeholders"]["model"] == TEST_HARDWARE_NAME - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.SPINEL, + ): # Pick the menu option: we are now installing the addon result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["progress_action"] == "install_addon" - assert result["step_id"] == "install_zigbee_flasher_addon" - - await hass.async_block_till_done(wait_background_tasks=True) - - # Progress the flow, we are now configuring the addon and running it - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "run_zigbee_flasher_addon" - assert result["progress_action"] == "run_zigbee_flasher_addon" - assert mock_flasher_manager.async_set_addon_options.mock_calls == [ - call( - { - "device": TEST_DEVICE, - "baudrate": 115200, - "bootloader_baudrate": 115200, - "flow_control": True, - } - ) - ] - - await hass.async_block_till_done(wait_background_tasks=True) - - # Progress the flow, we are now uninstalling the addon - result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "uninstall_zigbee_flasher_addon" - assert result["progress_action"] == "uninstall_zigbee_flasher_addon" - - await hass.async_block_till_done(wait_background_tasks=True) - - # We are finally done with the addon - assert mock_flasher_manager.async_uninstall_addon_waiting.mock_calls == [call()] result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm_zigbee" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, ): # We are now done result = await hass.config_entries.options.async_configure( diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 38c2696a62a..65a5f58b17d 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -21,8 +21,8 @@ from .test_config_flow import ( TEST_DEVICE, TEST_DOMAIN, TEST_HARDWARE_NAME, - delayed_side_effect, - mock_addon_info, + consume_progress_flow, + mock_firmware_info, mock_test_firmware_platform, # noqa: F401 ) @@ -51,10 +51,10 @@ async def test_config_flow_cannot_probe_firmware( ) -> None: """Test failure case when firmware cannot be probed.""" - with mock_addon_info( + with mock_firmware_info( hass, - app_type=None, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=None, + ): # Start the flow result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} @@ -69,283 +69,6 @@ async def test_config_flow_cannot_probe_firmware( assert result["reason"] == "unsupported_firmware" -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_not_hassio_wrong_firmware( - hass: HomeAssistant, -) -> None: - """Test when the stick is used with a non-hassio setup but the firmware is bad.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - is_hassio=False, - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "not_hassio" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_already_running( - hass: HomeAssistant, -) -> None: - """Test failure case when flasher addon is already running.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - flasher_addon_info=AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - # Cannot get addon info - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_already_running" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_info_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon cannot be installed.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - flasher_addon_info=AddonInfo( - available=True, - hostname=None, - options={}, - state=AddonState.RUNNING, - update_available=False, - version="1.0.0", - ), - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_get_addon_info.side_effect = AddonError() - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - # Cannot get addon info - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_info_failed" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_install_fails( - hass: HomeAssistant, -) -> None: - """Test failure case when flasher addon cannot be installed.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - # Cannot install addon - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_install_failed" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_addon_set_config_fails( - hass: HomeAssistant, -) -> None: - """Test failure case when flasher addon cannot be configured.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_install_addon_waiting = AsyncMock( - side_effect=delayed_side_effect() - ) - mock_flasher_manager.async_set_addon_options = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_set_config_failed" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_flasher_run_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon fails to run.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_start_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_start_failed" - - -async def test_config_flow_zigbee_flasher_uninstall_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon uninstall fails.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.SPINEL, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_flasher_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=AddonError() - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - # Uninstall failure isn't critical - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_config_flow_zigbee_confirmation_fails(hass: HomeAssistant) -> None: - """Test the config flow failing due to Zigbee firmware not being detected.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - - with mock_addon_info( - hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - # Pick the menu option: we are now installing the addon - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_zigbee" - - with mock_addon_info( - hass, - app_type=None, # Probing fails - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -356,11 +79,11 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, is_hassio=False, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) @@ -383,10 +106,10 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ) as mock_otbr_manager: mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -405,24 +128,26 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: "ignore_translations_for_mock_domains", ["test_firmware_domain"], ) -async def test_config_flow_thread_addon_already_running(hass: HomeAssistant) -> None: +async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) -> None: """Test failure case when the Thread addon is already running.""" result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, + probe_app_type=ApplicationType.EZSP, otbr_addon_info=AddonInfo( available=True, hostname=None, - options={}, + options={ + "device": TEST_DEVICE + "2", # A different device + }, state=AddonState.RUNNING, update_available=False, version="1.0.0", ), - ) as (mock_otbr_manager, mock_flasher_manager): + ) as mock_otbr_manager: mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -450,10 +175,10 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ) as mock_otbr_manager: mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -477,29 +202,51 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No ) async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon cannot be configured.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + ) as mock_otbr_manager: + + async def install_addon() -> None: + mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname=None, + options={"device": TEST_DEVICE}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + + mock_otbr_manager.async_install_addon_waiting = AsyncMock( + side_effect=install_addon + ) mock_otbr_manager.async_set_addon_options = AsyncMock(side_effect=AddonError()) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + + pick_thread_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_set_config_failed" + pick_thread_progress_result = await consume_progress_flow( + hass, + flow_id=pick_thread_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), + ) + + assert pick_thread_progress_result["type"] == FlowResultType.ABORT + assert pick_thread_progress_result["reason"] == "addon_set_config_failed" @pytest.mark.parametrize( @@ -508,63 +255,45 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> ) async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None: """Test failure case when flasher addon fails to run.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): + probe_app_type=ApplicationType.EZSP, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={"device": TEST_DEVICE}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ), + ) as mock_otbr_manager: mock_otbr_manager.async_start_addon_waiting = AsyncMock( side_effect=AddonError() ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_thread_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "addon_start_failed" - - -async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) -> None: - """Test failure case when flasher addon uninstall fails.""" - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - with mock_addon_info( - hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - mock_otbr_manager.async_uninstall_addon_waiting = AsyncMock( - side_effect=AddonError() + pick_thread_progress_result = await consume_progress_flow( + hass, + flow_id=pick_thread_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} - ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - # Uninstall failure isn't critical - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" + assert pick_thread_progress_result["type"] == FlowResultType.ABORT + assert pick_thread_progress_result["reason"] == "addon_start_failed" @pytest.mark.parametrize( @@ -573,40 +302,43 @@ async def test_config_flow_thread_flasher_uninstall_fails(hass: HomeAssistant) - ) async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> None: """Test the config flow failing due to OpenThread firmware not being detected.""" - result = await hass.config_entries.flow.async_init( + init_result = await hass.config_entries.flow.async_init( TEST_DOMAIN, context={"source": "hardware"} ) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.EZSP, - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + probe_app_type=ApplicationType.EZSP, + flash_app_type=None, + otbr_addon_info=AddonInfo( + available=True, + hostname=None, + options={"device": TEST_DEVICE}, + state=AddonState.RUNNING, + update_available=False, + version="1.0.0", + ), + ): + confirm_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], user_input={} ) - result = await hass.config_entries.flow.async_configure( - result["flow_id"], + pick_thread_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - await hass.async_block_till_done(wait_background_tasks=True) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - - with mock_addon_info( - hass, - app_type=None, # Probing fails - ) as (mock_otbr_manager, mock_flasher_manager): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={} + pick_thread_progress_result = await consume_progress_flow( + hass, + flow_id=pick_thread_result["flow_id"], + valid_step_ids=( + "pick_firmware_thread", + "install_thread_firmware", + "start_otbr_addon", + ), ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" + + assert pick_thread_progress_result["type"] is FlowResultType.ABORT + assert pick_thread_progress_result["reason"] == "unsupported_firmware" @pytest.mark.parametrize( @@ -683,9 +415,9 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( # Confirm options flow result = await hass.config_entries.options.async_init(config_entry.entry_id) - with mock_addon_info( + with mock_firmware_info( hass, - app_type=ApplicationType.SPINEL, + probe_app_type=ApplicationType.SPINEL, otbr_addon_info=AddonInfo( available=True, hostname=None, @@ -694,7 +426,7 @@ async def test_options_flow_thread_to_zigbee_otbr_configured( update_available=False, version="1.0.0", ), - ) as (mock_otbr_manager, mock_flasher_manager): + ): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, diff --git a/tests/components/homeassistant_hardware/test_update.py b/tests/components/homeassistant_hardware/test_update.py index 81c6f2e0459..aacc064e4f2 100644 --- a/tests/components/homeassistant_hardware/test_update.py +++ b/tests/components/homeassistant_hardware/test_update.py @@ -3,10 +3,10 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable import dataclasses import logging -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import Mock, patch import aiohttp from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata @@ -355,10 +355,14 @@ async def test_update_entity_installation( "https://example.org/release_notes" ) - mock_firmware = Mock() - mock_flasher = AsyncMock() - - async def mock_flash_firmware(fw_image, progress_callback): + async def mock_flash_firmware( + hass: HomeAssistant, + device: str, + fw_data: bytes, + expected_installed_firmware_type: ApplicationType, + bootloader_reset_type: str | None = None, + progress_callback: Callable[[int, int], None] | None = None, + ) -> FirmwareInfo: await asyncio.sleep(0) progress_callback(0, 100) await asyncio.sleep(0) @@ -366,31 +370,20 @@ async def test_update_entity_installation( await asyncio.sleep(0) progress_callback(100, 100) - mock_flasher.flash_firmware = mock_flash_firmware + return FirmwareInfo( + device=TEST_DEVICE, + firmware_type=ApplicationType.EZSP, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) # When we install it, the other integration is reloaded with ( patch( - "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", - return_value=mock_firmware, + "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware", + side_effect=mock_flash_firmware, ), - patch( - "homeassistant.components.homeassistant_hardware.update.Flasher", - return_value=mock_flasher, - ), - patch( - "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=TEST_DEVICE, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), - ), - patch.object( - owning_config_entry, "async_unload", wraps=owning_config_entry.async_unload - ) as owning_config_entry_unload, ): state_changes: list[Event[EventStateChangedData]] = async_capture_events( hass, EVENT_STATE_CHANGED @@ -423,9 +416,6 @@ async def test_update_entity_installation( assert state_changes[6].data["new_state"].attributes["update_percentage"] is None assert state_changes[6].data["new_state"].attributes["in_progress"] is False - # The owning integration was unloaded and is again running - assert len(owning_config_entry_unload.mock_calls) == 1 - # After the firmware update, the entity has the new version and the correct state state_after_install = hass.states.get(TEST_UPDATE_ENTITY_ID) assert state_after_install is not None @@ -456,19 +446,10 @@ async def test_update_entity_installation_failure( assert state_before_install.attributes["installed_version"] == "7.3.1.0" assert state_before_install.attributes["latest_version"] == "7.4.4.0" - mock_flasher = AsyncMock() - mock_flasher.flash_firmware.side_effect = RuntimeError( - "Something broke during flashing!" - ) - with ( patch( - "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", - return_value=Mock(), - ), - patch( - "homeassistant.components.homeassistant_hardware.update.Flasher", - return_value=mock_flasher, + "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware", + side_effect=HomeAssistantError("Failed to flash firmware"), ), pytest.raises(HomeAssistantError, match="Failed to flash firmware"), ): @@ -511,16 +492,10 @@ async def test_update_entity_installation_probe_failure( with ( patch( - "homeassistant.components.homeassistant_hardware.update.parse_firmware_image", - return_value=Mock(), - ), - patch( - "homeassistant.components.homeassistant_hardware.update.Flasher", - return_value=AsyncMock(), - ), - patch( - "homeassistant.components.homeassistant_hardware.update.probe_silabs_firmware_info", - return_value=None, + "homeassistant.components.homeassistant_hardware.update.async_flash_silabs_firmware", + side_effect=HomeAssistantError( + "Failed to probe the firmware after flashing" + ), ), pytest.raises( HomeAssistantError, match="Failed to probe the firmware after flashing" diff --git a/tests/components/homeassistant_hardware/test_util.py b/tests/components/homeassistant_hardware/test_util.py index 1b7bfe4a8ac..048bf998d13 100644 --- a/tests/components/homeassistant_hardware/test_util.py +++ b/tests/components/homeassistant_hardware/test_util.py @@ -1,10 +1,13 @@ """Test hardware utilities.""" -from unittest.mock import AsyncMock, MagicMock, patch +import asyncio +from collections.abc import Callable +from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest from universal_silabs_flasher.common import Version as FlasherVersion from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType +from universal_silabs_flasher.firmware import GBLImage from homeassistant.components.hassio import ( AddonError, @@ -20,6 +23,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, OwningAddon, OwningIntegration, + async_flash_silabs_firmware, get_otbr_addon_firmware_info, guess_firmware_info, probe_silabs_firmware_info, @@ -27,8 +31,11 @@ from homeassistant.components.homeassistant_hardware.util import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from .test_config_flow import create_mock_owner + from tests.common import MockConfigEntry ZHA_CONFIG_ENTRY = MockConfigEntry( @@ -526,3 +533,201 @@ async def test_probe_silabs_firmware_type( ): result = await probe_silabs_firmware_type("/dev/ttyUSB0") assert result == expected + + +async def test_async_flash_silabs_firmware(hass: HomeAssistant) -> None: + """Test async_flash_silabs_firmware.""" + owner1 = create_mock_owner() + owner2 = create_mock_owner() + + progress_callback = Mock() + + async def mock_flash_firmware( + fw_image: GBLImage, progress_callback: Callable[[int, int], None] + ) -> None: + """Mock flash firmware function.""" + await asyncio.sleep(0) + progress_callback(0, 100) + await asyncio.sleep(0) + progress_callback(50, 100) + await asyncio.sleep(0) + progress_callback(100, 100) + await asyncio.sleep(0) + + mock_flasher = Mock() + mock_flasher.enter_bootloader = AsyncMock() + mock_flasher.flash_firmware = AsyncMock(side_effect=mock_flash_firmware) + + expected_firmware_info = FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.SPINEL, + firmware_version=None, + source="probe", + owners=[], + ) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[owner1, owner2], + ), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + patch( + "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", + return_value=expected_firmware_info, + ), + ): + after_flash_info = await async_flash_silabs_firmware( + hass=hass, + device="/dev/ttyUSB0", + fw_data=b"firmware contents", + expected_installed_firmware_type=ApplicationType.SPINEL, + bootloader_reset_type=None, + progress_callback=progress_callback, + ) + + assert progress_callback.mock_calls == [call(0, 100), call(50, 100), call(100, 100)] + assert after_flash_info == expected_firmware_info + + # Both owning integrations/addons are stopped and restarted + assert owner1.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] + + assert owner2.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] + + +async def test_async_flash_silabs_firmware_flash_failure(hass: HomeAssistant) -> None: + """Test async_flash_silabs_firmware flash failure.""" + owner1 = create_mock_owner() + owner2 = create_mock_owner() + + mock_flasher = Mock() + mock_flasher.enter_bootloader = AsyncMock() + mock_flasher.flash_firmware = AsyncMock(side_effect=RuntimeError("Failure!")) + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[owner1, owner2], + ), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + pytest.raises(HomeAssistantError, match="Failed to flash firmware") as exc, + ): + await async_flash_silabs_firmware( + hass=hass, + device="/dev/ttyUSB0", + fw_data=b"firmware contents", + expected_installed_firmware_type=ApplicationType.SPINEL, + bootloader_reset_type=None, + ) + + # Both owning integrations/addons are stopped and restarted + assert owner1.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), + ] + assert owner2.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, HomeAssistantError, exc.value, ANY), + ] + + +async def test_async_flash_silabs_firmware_probe_failure(hass: HomeAssistant) -> None: + """Test async_flash_silabs_firmware probe failure.""" + owner1 = create_mock_owner() + owner2 = create_mock_owner() + + mock_flasher = Mock() + mock_flasher.enter_bootloader = AsyncMock() + mock_flasher.flash_firmware = AsyncMock() + + with ( + patch( + "homeassistant.components.homeassistant_hardware.util.guess_firmware_info", + return_value=FirmwareInfo( + device="/dev/ttyUSB0", + firmware_type=ApplicationType.EZSP, + firmware_version=None, + source="unknown", + owners=[owner1, owner2], + ), + ), + patch( + "homeassistant.components.homeassistant_hardware.util.Flasher", + return_value=mock_flasher, + ), + patch( + "homeassistant.components.homeassistant_hardware.util.parse_firmware_image" + ), + patch( + "homeassistant.components.homeassistant_hardware.util.probe_silabs_firmware_info", + return_value=None, + ), + pytest.raises( + HomeAssistantError, match="Failed to probe the firmware after flashing" + ), + ): + await async_flash_silabs_firmware( + hass=hass, + device="/dev/ttyUSB0", + fw_data=b"firmware contents", + expected_installed_firmware_type=ApplicationType.SPINEL, + bootloader_reset_type=None, + ) + + # Both owning integrations/addons are stopped and restarted + assert owner1.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] + assert owner2.temporarily_stop.mock_calls == [ + call(hass), + # pylint: disable-next=unnecessary-dunder-call + call().__aenter__(ANY), + # pylint: disable-next=unnecessary-dunder-call + call().__aexit__(ANY, None, None, None), + ] diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 44a5e0029c3..9dcac0732c9 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from homeassistant.components.hassio import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( @@ -18,6 +19,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, ) from homeassistant.components.homeassistant_sky_connect.const import DOMAIN +from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.usb import UsbServiceInfo @@ -28,14 +30,31 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - ("usb_data", "model"), + ("step", "usb_data", "model", "fw_type", "fw_version"), [ - (USB_DATA_SKY, "Home Assistant SkyConnect"), - (USB_DATA_ZBT1, "Home Assistant Connect ZBT-1"), + ( + STEP_PICK_FIRMWARE_ZIGBEE, + USB_DATA_SKY, + "Home Assistant SkyConnect", + ApplicationType.EZSP, + "7.4.4.0 build 0", + ), + ( + STEP_PICK_FIRMWARE_THREAD, + USB_DATA_ZBT1, + "Home Assistant Connect ZBT-1", + ApplicationType.SPINEL, + "2.4.4.0", + ), ], ) async def test_config_flow( - usb_data: UsbServiceInfo, model: str, hass: HomeAssistant + step: str, + usb_data: UsbServiceInfo, + model: str, + fw_type: ApplicationType, + fw_version: str, + hass: HomeAssistant, ) -> None: """Test the config flow for SkyConnect.""" result = await hass.config_entries.flow.async_init( @@ -46,21 +65,36 @@ async def test_config_flow( assert result["step_id"] == "pick_firmware" assert result["description_placeholders"]["model"] == model - async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + async def mock_install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + if next_step_id == "start_otbr_addon": + next_step_id = "confirm_otbr" + + return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow.async_step_pick_firmware_zigbee", + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._install_firmware_step", autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + side_effect=mock_install_firmware_step, ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", return_value=FirmwareInfo( device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", + firmware_type=fw_type, + firmware_version=fw_version, owners=[], source="probe", ), @@ -68,15 +102,15 @@ async def test_config_flow( ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + user_input={"next_step_id": step}, ) assert result["type"] is FlowResultType.CREATE_ENTRY config_entry = result["result"] assert config_entry.data == { - "firmware": "ezsp", - "firmware_version": "7.4.4.0 build 0", + "firmware": fw_type.value, + "firmware_version": fw_version, "device": usb_data.device, "manufacturer": usb_data.manufacturer, "pid": usb_data.pid, @@ -86,13 +120,17 @@ async def test_config_flow( "vid": usb_data.vid, } - # Ensure a ZHA discovery flow has been created flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - zha_flow = flows[0] - assert zha_flow["handler"] == "zha" - assert zha_flow["context"]["source"] == "hardware" - assert zha_flow["step_id"] == "confirm" + + if step == STEP_PICK_FIRMWARE_ZIGBEE: + # Ensure a ZHA discovery flow has been created + assert len(flows) == 1 + zha_flow = flows[0] + assert zha_flow["handler"] == "zha" + assert zha_flow["context"]["source"] == "hardware" + assert zha_flow["step_id"] == "confirm" + else: + assert len(flows) == 0 @pytest.mark.parametrize( diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 1d5a64eafb9..cd4a1941050 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.hassio import ( AddonState, ) from homeassistant.components.homeassistant_hardware.firmware_config_flow import ( + STEP_PICK_FIRMWARE_THREAD, STEP_PICK_FIRMWARE_ZIGBEE, ) from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( @@ -23,6 +24,7 @@ from homeassistant.components.homeassistant_hardware.util import ( FirmwareInfo, ) from homeassistant.components.homeassistant_yellow.const import DOMAIN, RADIO_DEVICE +from homeassistant.config_entries import ConfigFlowResult from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component @@ -305,7 +307,16 @@ async def test_option_flow_led_settings_fail_2( assert result["reason"] == "write_hw_settings_error" -async def test_firmware_options_flow(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("step", "fw_type", "fw_version"), + [ + (STEP_PICK_FIRMWARE_ZIGBEE, ApplicationType.EZSP, "7.4.4.0 build 0"), + (STEP_PICK_FIRMWARE_THREAD, ApplicationType.SPINEL, "2.4.4.0"), + ], +) +async def test_firmware_options_flow( + step: str, fw_type: ApplicationType, fw_version: str, hass: HomeAssistant +) -> None: """Test the firmware options flow for Yellow.""" mock_integration(hass, MockModule("hassio")) await async_setup_component(hass, HASSIO_DOMAIN, {}) @@ -339,18 +350,36 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: async def mock_async_step_pick_firmware_zigbee(self, data): return await self.async_step_confirm_zigbee(user_input={}) + async def mock_install_firmware_step( + self, + fw_update_url: str, + fw_type: str, + firmware_name: str, + expected_installed_firmware_type: ApplicationType, + step_id: str, + next_step_id: str, + ) -> ConfigFlowResult: + if next_step_id == "start_otbr_addon": + next_step_id = "confirm_otbr" + + return await getattr(self, f"async_step_{next_step_id}")(user_input={}) + with ( patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", + return_value=None, + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._install_firmware_step", autospec=True, - side_effect=mock_async_step_pick_firmware_zigbee, + side_effect=mock_install_firmware_step, ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", return_value=FirmwareInfo( device=RADIO_DEVICE, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", + firmware_type=fw_type, + firmware_version=fw_version, owners=[], source="probe", ), @@ -358,15 +387,15 @@ async def test_firmware_options_flow(hass: HomeAssistant) -> None: ): result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + user_input={"next_step_id": step}, ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["result"] is True assert config_entry.data == { - "firmware": "ezsp", - "firmware_version": "7.4.4.0 build 0", + "firmware": fw_type.value, + "firmware_version": fw_version, } From 19b773df856720944fe555ab56f009ee34e2ba25 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 24 Jun 2025 15:35:38 -0500 Subject: [PATCH 0632/1664] Only send ESPHome intent progress when necessary (#147458) * Only send intent progress when necessary * cover * Fix logic --------- Co-authored-by: J. Nick Koston --- .../components/esphome/assist_satellite.py | 14 ++-- .../esphome/test_assist_satellite.py | 72 +++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py index f6367165400..adddacd3998 100644 --- a/homeassistant/components/esphome/assist_satellite.py +++ b/homeassistant/components/esphome/assist_satellite.py @@ -284,11 +284,15 @@ class EsphomeAssistSatellite( assert event.data is not None data_to_send = {"text": event.data["stt_output"]["text"]} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS: - data_to_send = { - "tts_start_streaming": "1" - if (event.data and event.data.get("tts_start_streaming")) - else "0", - } + if ( + not event.data + or ("tts_start_streaming" not in event.data) + or (not event.data["tts_start_streaming"]) + ): + # ESPHome only needs to know if early TTS streaming is available + return + + data_to_send = {"tts_start_streaming": "1"} elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END: assert event.data is not None data_to_send = { diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index 3acdc1f2029..bfcc35b2e6a 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -1776,6 +1776,78 @@ async def test_get_set_configuration( assert satellite.async_get_configuration() == updated_config +async def test_intent_progress_optimization( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that intent progress events are only sent when early TTS streaming is available.""" + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "voice_assistant_feature_flags": VoiceAssistantFeature.VOICE_ASSISTANT + }, + ) + await hass.async_block_till_done() + + satellite = get_satellite_entity(hass, mock_device.device_info.mac_address) + assert satellite is not None + + # Test that intent progress without tts_start_streaming is not sent + mock_client.send_voice_assistant_event.reset_mock() + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"some_other_key": "value"}, + ) + ) + mock_client.send_voice_assistant_event.assert_not_called() + + # Test that intent progress with tts_start_streaming=False is not sent + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": False}, + ) + ) + mock_client.send_voice_assistant_event.assert_not_called() + + # Test that intent progress with tts_start_streaming=True is sent + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": True}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, + {"tts_start_streaming": "1"}, + ) + + # Test that intent progress with tts_start_streaming as string "1" is sent + mock_client.send_voice_assistant_event.reset_mock() + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data={"tts_start_streaming": "1"}, + ) + ) + assert mock_client.send_voice_assistant_event.call_args_list[-1].args == ( + VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS, + {"tts_start_streaming": "1"}, + ) + + # Test that intent progress with no data is *not* sent + mock_client.send_voice_assistant_event.reset_mock() + satellite.on_pipeline_event( + PipelineEvent( + type=PipelineEventType.INTENT_PROGRESS, + data=None, + ) + ) + mock_client.send_voice_assistant_event.assert_not_called() + + async def test_wake_word_select( hass: HomeAssistant, mock_client: APIClient, From c93e45c0f294b31207928ecfa2133b42b7343867 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 16:37:35 -0400 Subject: [PATCH 0633/1664] Add missing config entry type for Husqvarna (#147455) Add missing type for husqvarna --- .../components/husqvarna_automower_ble/__init__.py | 6 ++++-- .../components/husqvarna_automower_ble/coordinator.py | 9 +++++---- .../components/husqvarna_automower_ble/lawn_mower.py | 6 +++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/husqvarna_automower_ble/__init__.py b/homeassistant/components/husqvarna_automower_ble/__init__.py index ca07d1ab8d2..f168e84be4c 100644 --- a/homeassistant/components/husqvarna_automower_ble/__init__.py +++ b/homeassistant/components/husqvarna_automower_ble/__init__.py @@ -15,12 +15,14 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import LOGGER from .coordinator import HusqvarnaCoordinator +type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator] + PLATFORMS = [ Platform.LAWN_MOWER, ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: """Set up Husqvarna Autoconnect Bluetooth from a config entry.""" address = entry.data[CONF_ADDRESS] channel_id = entry.data[CONF_CLIENT_ID] @@ -54,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): coordinator: HusqvarnaCoordinator = entry.runtime_data diff --git a/homeassistant/components/husqvarna_automower_ble/coordinator.py b/homeassistant/components/husqvarna_automower_ble/coordinator.py index dde3462c081..c7781becd76 100644 --- a/homeassistant/components/husqvarna_automower_ble/coordinator.py +++ b/homeassistant/components/husqvarna_automower_ble/coordinator.py @@ -3,30 +3,31 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING from automower_ble.mower import Mower from bleak import BleakError from bleak_retry_connector import close_stale_connections_by_address from homeassistant.components import bluetooth -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN, LOGGER +if TYPE_CHECKING: + from . import HusqvarnaConfigEntry + SCAN_INTERVAL = timedelta(seconds=60) class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]): """Class to manage fetching data.""" - config_entry: ConfigEntry - def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HusqvarnaConfigEntry, mower: Mower, address: str, channel_id: str, diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py index 4b239394c2d..4b4a16ba1db 100644 --- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py @@ -10,10 +10,10 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntity, LawnMowerEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import HusqvarnaConfigEntry from .const import LOGGER from .coordinator import HusqvarnaCoordinator from .entity import HusqvarnaAutomowerBleEntity @@ -21,11 +21,11 @@ from .entity import HusqvarnaAutomowerBleEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: HusqvarnaConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up AutomowerLawnMower integration from a config entry.""" - coordinator: HusqvarnaCoordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data address = coordinator.address async_add_entities( From c270ea4e0c88372db53e4d1a5675eb0c0deb029a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 24 Jun 2025 16:41:43 -0400 Subject: [PATCH 0634/1664] Fix media accept config type (#147445) --- homeassistant/helpers/selector.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 6f8df828c37..acb91ddc148 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1010,9 +1010,11 @@ class LocationSelector(Selector[LocationSelectorConfig]): return location -class MediaSelectorConfig(BaseSelectorConfig): +class MediaSelectorConfig(BaseSelectorConfig, total=False): """Class to represent a media selector config.""" + accept: list[str] + @SELECTORS.register("media") class MediaSelector(Selector[MediaSelectorConfig]): From 7b8ebb08034044ee3adc282213846afc8b8097d6 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Tue, 24 Jun 2025 22:42:42 +0200 Subject: [PATCH 0635/1664] Move DevoloMultiLevelSwitchDeviceEntity in devolo Home Control (#147450) --- .../components/devolo_home_control/climate.py | 2 +- .../components/devolo_home_control/cover.py | 2 +- .../devolo_multi_level_switch.py | 27 ------------------- .../components/devolo_home_control/entity.py | 21 +++++++++++++++ .../components/devolo_home_control/light.py | 2 +- .../components/devolo_home_control/siren.py | 2 +- 6 files changed, 25 insertions(+), 31 deletions(-) delete mode 100644 homeassistant/components/devolo_home_control/devolo_multi_level_switch.py diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 3fdfa60870a..95db596c3ef 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index f23244f1b50..bafef2b02c9 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py b/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py deleted file mode 100644 index 3e2d551d1f8..00000000000 --- a/homeassistant/components/devolo_home_control/devolo_multi_level_switch.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Base class for multi level switches in devolo Home Control.""" - -from devolo_home_control_api.devices.zwave import Zwave -from devolo_home_control_api.homecontrol import HomeControl - -from .entity import DevoloDeviceEntity - - -class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): - """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" - - _attr_name = None - - def __init__( - self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str - ) -> None: - """Initialize a multi level switch within devolo Home Control.""" - super().__init__( - homecontrol=homecontrol, - device_instance=device_instance, - element_uid=element_uid, - ) - self._multi_level_switch_property = device_instance.multi_level_switch_property[ - element_uid - ] - - self._value = self._multi_level_switch_property.value diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index 26b450a2cf2..dbe53c21412 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -90,3 +90,24 @@ class DevoloDeviceEntity(Entity): self._attr_available = self._device_instance.is_online() else: _LOGGER.debug("No valid message received: %s", message) + + +class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity): + """Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat.""" + + _attr_name = None + + def __init__( + self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str + ) -> None: + """Initialize a multi level switch within devolo Home Control.""" + super().__init__( + homecontrol=homecontrol, + device_instance=device_instance, + element_uid=element_uid, + ) + self._multi_level_switch_property = device_instance.multi_level_switch_property[ + element_uid + ] + + self._value = self._multi_level_switch_property.value diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 8a88081ed05..907a46ec27b 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( diff --git a/homeassistant/components/devolo_home_control/siren.py b/homeassistant/components/devolo_home_control/siren.py index 5e4df944b3c..e3f91ca4d7d 100644 --- a/homeassistant/components/devolo_home_control/siren.py +++ b/homeassistant/components/devolo_home_control/siren.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import DevoloHomeControlConfigEntry -from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity +from .entity import DevoloMultiLevelSwitchDeviceEntity async def async_setup_entry( From 42aaa888a1c1cacade3d97f82bd042756d916557 Mon Sep 17 00:00:00 2001 From: natepugh <131211580+natepugh@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:47:56 -0600 Subject: [PATCH 0636/1664] Bump pyairnow to 1.3.1 (#147388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/airnow/coordinator.py | 2 +- homeassistant/components/airnow/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airnow/snapshots/test_diagnostics.ambr | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 1e73bc7551e..12085f1188e 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - data = {} + data: dict[str, Any] = {} try: obs = await self.airnow.observations.latLong( self.latitude, diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json index 28dada485b2..41df51715fc 100644 --- a/homeassistant/components/airnow/manifest.json +++ b/homeassistant/components/airnow/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airnow", "iot_class": "cloud_polling", "loggers": ["pyairnow"], - "requirements": ["pyairnow==1.2.1"] + "requirements": ["pyairnow==1.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b9712860354..3fa6c315b15 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1832,7 +1832,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.2.1 +pyairnow==1.3.1 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2055bba4ef..4319f4e42a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1534,7 +1534,7 @@ pyaehw4a1==0.3.9 pyaftership==21.11.0 # homeassistant.components.airnow -pyairnow==1.2.1 +pyairnow==1.3.1 # homeassistant.components.airvisual # homeassistant.components.airvisual_pro diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 73ba6a7123f..d711f9c2eba 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -12,7 +12,7 @@ 'Longitude': '**REDACTED**', 'O3': 0.048, 'PM10': 12, - 'PM2.5': 8.9, + 'PM2.5': 6.7, 'Pollutant': 'O3', 'ReportingArea': '**REDACTED**', 'StateCode': '**REDACTED**', From 91e7b75a44d1859b5944e4db822976fb7987168f Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 25 Jun 2025 06:48:45 +0200 Subject: [PATCH 0637/1664] Fix errors in legacy platform in PlayStation Network integration (#147471) fix legacy platform presence --- .../components/playstation_network/helpers.py | 50 +++++++++---------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 38f8d5e1356..a106ef1d8f4 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -7,7 +7,6 @@ from functools import partial from typing import Any from psnawp_api import PSNAWP -from psnawp_api.core.psnawp_exceptions import PSNAWPNotFoundError from psnawp_api.models.client import Client from psnawp_api.models.trophies import PlatformType from psnawp_api.models.user import User @@ -120,32 +119,31 @@ class PlaystationNetwork: if self.legacy_profile: presence = self.legacy_profile["profile"].get("presences", []) - game_title_info = presence[0] if presence else {} - session = SessionData() + if (game_title_info := presence[0] if presence else {}) and game_title_info[ + "onlineStatus" + ] == "online": + data.available = True - # If primary console isn't online, check legacy platforms for status - if not data.available: - data.available = game_title_info["onlineStatus"] == "online" + platform = PlatformType(game_title_info["platform"]) - if "npTitleId" in game_title_info: - session.title_id = game_title_info["npTitleId"] - session.title_name = game_title_info["titleName"] - session.format = game_title_info["platform"] - session.platform = game_title_info["platform"] - session.status = game_title_info["onlineStatus"] - if PlatformType(session.format) is PlatformType.PS4: - session.media_image_url = game_title_info["npTitleIconUrl"] - elif PlatformType(session.format) is PlatformType.PS3: - try: - title = self.psn.game_title( - session.title_id, platform=PlatformType.PS3, account_id="me" - ) - except PSNAWPNotFoundError: - session.media_image_url = None + if platform is PlatformType.PS4: + media_image_url = game_title_info.get("npTitleIconUrl") + elif platform is PlatformType.PS3 and game_title_info.get("npTitleId"): + media_image_url = self.psn.game_title( + game_title_info["npTitleId"], + platform=PlatformType.PS3, + account_id="me", + np_communication_id="", + ).get_title_icon_url() + else: + media_image_url = None - if title: - session.media_image_url = title.get_title_icon_url() - - if game_title_info["onlineStatus"] == "online": - data.active_sessions[session.platform] = session + data.active_sessions[platform] = SessionData( + platform=platform, + title_id=game_title_info.get("npTitleId"), + title_name=game_title_info.get("titleName"), + format=platform, + media_image_url=media_image_url, + status=game_title_info["onlineStatus"], + ) return data From 10d1affd8154717c2f61c2ff8ce9563c81e043ca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:48:20 +0200 Subject: [PATCH 0638/1664] Migrate lyric to use runtime_data (#147475) --- homeassistant/components/lyric/__init__.py | 15 +++++---------- homeassistant/components/lyric/climate.py | 8 +++----- homeassistant/components/lyric/coordinator.py | 6 ++++-- homeassistant/components/lyric/sensor.py | 8 +++----- 4 files changed, 15 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 8ec9785cef2..c221b03a891 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from aiolyric import Lyric -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -19,14 +18,14 @@ from .api import ( OAuth2SessionLyric, ) from .const import DOMAIN -from .coordinator import LyricDataUpdateCoordinator +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LyricConfigEntry) -> bool: """Set up Honeywell Lyric from a config entry.""" implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( @@ -53,17 +52,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: LyricConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 4aeccf991d5..e71c81774af 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -24,7 +24,6 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, @@ -38,7 +37,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import VolDictType from .const import ( - DOMAIN, LYRIC_EXCEPTIONS, PRESET_HOLD_UNTIL, PRESET_NO_HOLD, @@ -46,7 +44,7 @@ from .const import ( PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) -from .coordinator import LyricDataUpdateCoordinator +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator from .entity import LyricDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -121,11 +119,11 @@ SCHEMA_HOLD_TIME: VolDictType = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LyricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric climate platform based on a config entry.""" - coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( ( diff --git a/homeassistant/components/lyric/coordinator.py b/homeassistant/components/lyric/coordinator.py index c177e233516..b9b36e56133 100644 --- a/homeassistant/components/lyric/coordinator.py +++ b/homeassistant/components/lyric/coordinator.py @@ -20,16 +20,18 @@ from .api import OAuth2SessionLyric _LOGGER = logging.getLogger(__name__) +type LyricConfigEntry = ConfigEntry[LyricDataUpdateCoordinator] + class LyricDataUpdateCoordinator(DataUpdateCoordinator[Lyric]): """Data update coordinator for Honeywell Lyric.""" - config_entry: ConfigEntry + config_entry: LyricConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LyricConfigEntry, oauth_session: OAuth2SessionLyric, lyric: Lyric, ) -> None: diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index ffebb8056cd..f0a8d572353 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,14 +23,13 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util from .const import ( - DOMAIN, PRESET_HOLD_UNTIL, PRESET_NO_HOLD, PRESET_PERMANENT_HOLD, PRESET_TEMPORARY_HOLD, PRESET_VACATION_HOLD, ) -from .coordinator import LyricDataUpdateCoordinator +from .coordinator import LyricConfigEntry, LyricDataUpdateCoordinator from .entity import LyricAccessoryEntity, LyricDeviceEntity LYRIC_SETPOINT_STATUS_NAMES = { @@ -159,11 +157,11 @@ def get_datetime_from_future_time(time_str: str) -> datetime: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LyricConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Honeywell Lyric sensor platform based on a config entry.""" - coordinator: LyricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( LyricSensor( From 2bcdc036616c2d66a31e66f96b8a37dbaf4519bf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:48:30 +0200 Subject: [PATCH 0639/1664] Migrate lupusec to use runtime_data (#147476) --- homeassistant/components/lupusec/__init__.py | 8 ++++---- homeassistant/components/lupusec/alarm_control_panel.py | 8 ++++---- homeassistant/components/lupusec/binary_sensor.py | 7 +++---- homeassistant/components/lupusec/switch.py | 7 +++---- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index c0593674972..cd883a65a24 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -12,8 +12,6 @@ from homeassistant.core import HomeAssistant _LOGGER = logging.getLogger(__name__) -DOMAIN = "lupusec" - NOTIFICATION_ID = "lupusec_notification" NOTIFICATION_TITLE = "Lupusec Security Setup" @@ -24,8 +22,10 @@ PLATFORMS: list[Platform] = [ Platform.SWITCH, ] +type LupusecConfigEntry = ConfigEntry[lupupy.Lupusec] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: LupusecConfigEntry) -> bool: """Set up this integration using UI.""" host = entry.data[CONF_HOST] @@ -43,7 +43,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Failed to connect to Lupusec device at %s", host) return False - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = lupusec_system + entry.runtime_data = lupusec_system await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 03feabae0dc..69f1cfacf33 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -11,12 +11,12 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntityFeature, AlarmControlPanelState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from . import LupusecConfigEntry +from .const import DOMAIN from .entity import LupusecDevice SCAN_INTERVAL = timedelta(seconds=2) @@ -24,11 +24,11 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LupusecConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up an alarm control panel for a Lupusec device.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data alarm = await hass.async_add_executor_job(data.get_alarm) diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py index bcd21adc1aa..356ec9ab99b 100644 --- a/homeassistant/components/lupusec/binary_sensor.py +++ b/homeassistant/components/lupusec/binary_sensor.py @@ -12,11 +12,10 @@ 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 . import DOMAIN +from . import LupusecConfigEntry from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) @@ -26,12 +25,12 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LupusecConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a binary sensors for a Lupusec device.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data device_types = CONST.TYPE_OPENING + CONST.TYPE_SENSOR diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py index a70df90f8e7..346d1a35703 100644 --- a/homeassistant/components/lupusec/switch.py +++ b/homeassistant/components/lupusec/switch.py @@ -9,11 +9,10 @@ from typing import Any import lupupy.constants as CONST from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN +from . import LupusecConfigEntry from .entity import LupusecBaseSensor SCAN_INTERVAL = timedelta(seconds=2) @@ -21,12 +20,12 @@ SCAN_INTERVAL = timedelta(seconds=2) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LupusecConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Lupusec switch devices.""" - data = hass.data[DOMAIN][config_entry.entry_id] + data = config_entry.runtime_data device_types = CONST.TYPE_SWITCH From f22b6239684e0a4a964eda2bdb9cae501ed347d9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:48:56 +0200 Subject: [PATCH 0640/1664] Move luftdaten coordinator to separate module (#147477) --- .../components/luftdaten/__init__.py | 33 +---------- .../components/luftdaten/coordinator.py | 56 +++++++++++++++++++ .../components/luftdaten/diagnostics.py | 6 +- homeassistant/components/luftdaten/sensor.py | 10 ++-- 4 files changed, 65 insertions(+), 40 deletions(-) create mode 100644 homeassistant/components/luftdaten/coordinator.py diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index 37f0f27d2d8..bba84471767 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -6,20 +6,14 @@ the integration name. from __future__ import annotations -import logging -from typing import Any - from luftdaten import Luftdaten -from luftdaten.exceptions import LuftdatenError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_SENSOR_ID, DOMAIN +from .coordinator import LuftdatenDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -35,28 +29,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sensor_community = Luftdaten(entry.data[CONF_SENSOR_ID]) - async def async_update() -> dict[str, float | int]: - """Update sensor/binary sensor data.""" - try: - await sensor_community.get_data() - except LuftdatenError as err: - raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err - - if not sensor_community.values: - raise UpdateFailed("Did not receive sensor data from Sensor.Community") - - data: dict[str, float | int] = sensor_community.values - data.update(sensor_community.meta) - return data - - coordinator: DataUpdateCoordinator[dict[str, Any]] = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"{DOMAIN}_{sensor_community.sensor_id}", - update_interval=DEFAULT_SCAN_INTERVAL, - update_method=async_update, - ) + coordinator = LuftdatenDataUpdateCoordinator(hass, entry, sensor_community) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/luftdaten/coordinator.py b/homeassistant/components/luftdaten/coordinator.py new file mode 100644 index 00000000000..6c42d8b8866 --- /dev/null +++ b/homeassistant/components/luftdaten/coordinator.py @@ -0,0 +1,56 @@ +"""Support for Sensor.Community stations. + +Sensor.Community was previously called Luftdaten, hence the domain differs from +the integration name. +""" + +from __future__ import annotations + +import logging + +from luftdaten import Luftdaten +from luftdaten.exceptions import LuftdatenError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class LuftdatenDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float | int]]): + """Data update coordinator for Sensor.Community.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + sensor_community: Luftdaten, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{sensor_community.sensor_id}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self._sensor_community = sensor_community + + async def _async_update_data(self) -> dict[str, float | int]: + """Update sensor/binary sensor data.""" + try: + await self._sensor_community.get_data() + except LuftdatenError as err: + raise UpdateFailed("Unable to retrieve data from Sensor.Community") from err + + if not self._sensor_community.values: + raise UpdateFailed("Did not receive sensor data from Sensor.Community") + + data: dict[str, float | int] = self._sensor_community.values + data.update(self._sensor_community.meta) + return data diff --git a/homeassistant/components/luftdaten/diagnostics.py b/homeassistant/components/luftdaten/diagnostics.py index a1bbcbcadd7..3be4239c2ef 100644 --- a/homeassistant/components/luftdaten/diagnostics.py +++ b/homeassistant/components/luftdaten/diagnostics.py @@ -8,9 +8,9 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONF_SENSOR_ID, DOMAIN +from .coordinator import LuftdatenDataUpdateCoordinator TO_REDACT = { CONF_LATITUDE, @@ -23,7 +23,5 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: DataUpdateCoordinator[dict[str, Any]] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: LuftdatenDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] return async_redact_data(coordinator.data, TO_REDACT) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 2189386a4bb..9e66982a421 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -23,12 +23,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN +from .coordinator import LuftdatenDataUpdateCoordinator SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -77,7 +75,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sensor.Community sensor based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: LuftdatenDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( SensorCommunitySensor( @@ -101,7 +99,7 @@ class SensorCommunitySensor(CoordinatorEntity, SensorEntity): def __init__( self, *, - coordinator: DataUpdateCoordinator, + coordinator: LuftdatenDataUpdateCoordinator, description: SensorEntityDescription, sensor_id: int, show_on_map: bool, From 51da1bc25aceae0e28b5ffacddc3037afa83645f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:07:17 +0200 Subject: [PATCH 0641/1664] Migrate loqed to use runtime_data (#147478) * Migrate loqed to use runtime_data * Fix tests --- homeassistant/components/loqed/__init__.py | 24 ++++++------------- homeassistant/components/loqed/coordinator.py | 10 +++++--- homeassistant/components/loqed/lock.py | 10 +++----- homeassistant/components/loqed/sensor.py | 8 +++---- tests/components/loqed/conftest.py | 3 +-- tests/components/loqed/test_lock.py | 4 +--- 6 files changed, 22 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index b308e2c0f1d..94bcd2ec332 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -2,28 +2,22 @@ from __future__ import annotations -import logging import re import aiohttp from loqedAPI import loqed -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import LoqedDataCoordinator +from .coordinator import LoqedConfigEntry, LoqedDataCoordinator -PLATFORMS: list[str] = [Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LoqedConfigEntry) -> bool: """Set up loqed from a config entry.""" websession = async_get_clientsession(hass) host = entry.data["bridge_ip"] @@ -49,19 +43,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: LoqedConfigEntry) -> bool: """Unload a config entry.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][entry.entry_id] - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - await coordinator.remove_webhooks() + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + await entry.runtime_data.remove_webhooks() return unload_ok diff --git a/homeassistant/components/loqed/coordinator.py b/homeassistant/components/loqed/coordinator.py index 7b60385a759..af7667197a1 100644 --- a/homeassistant/components/loqed/coordinator.py +++ b/homeassistant/components/loqed/coordinator.py @@ -17,6 +17,8 @@ from .const import CONF_CLOUDHOOK_URL, DOMAIN _LOGGER = logging.getLogger(__name__) +type LoqedConfigEntry = ConfigEntry[LoqedDataCoordinator] + class BatteryMessage(TypedDict): """Properties in a battery update message.""" @@ -71,12 +73,12 @@ class StatusMessage(TypedDict): class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): """Data update coordinator for the loqed platform.""" - config_entry: ConfigEntry + config_entry: LoqedConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LoqedConfigEntry, api: loqed.LoqedAPI, lock: loqed.Lock, ) -> None: @@ -166,7 +168,9 @@ class LoqedDataCoordinator(DataUpdateCoordinator[StatusMessage]): await self.lock.deleteWebhook(webhook_index) -async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: +async def async_cloudhook_generate_url( + hass: HomeAssistant, entry: LoqedConfigEntry +) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_url = await cloud.async_create_cloudhook( diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py index 2064537df52..be44d3ef09f 100644 --- a/homeassistant/components/loqed/lock.py +++ b/homeassistant/components/loqed/lock.py @@ -6,12 +6,10 @@ import logging from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import LoqedDataCoordinator -from .const import DOMAIN +from .coordinator import LoqedConfigEntry, LoqedDataCoordinator from .entity import LoqedEntity WEBHOOK_API_ENDPOINT = "/api/loqed/webhook" @@ -21,13 +19,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LoqedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Loqed lock platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - - async_add_entities([LoqedLock(coordinator)]) + async_add_entities([LoqedLock(entry.runtime_data)]) class LoqedLock(LoqedEntity, LockEntity): diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py index c28b55b4f98..a325e61d049 100644 --- a/homeassistant/components/loqed/sensor.py +++ b/homeassistant/components/loqed/sensor.py @@ -8,7 +8,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -17,8 +16,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import LoqedDataCoordinator, StatusMessage +from .coordinator import LoqedConfigEntry, LoqedDataCoordinator, StatusMessage from .entity import LoqedEntity SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( @@ -43,11 +41,11 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LoqedConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Loqed lock platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities(LoqedSensor(coordinator, sensor) for sensor in SENSORS) diff --git a/tests/components/loqed/conftest.py b/tests/components/loqed/conftest.py index edfc1e880f9..b74d9ef16e7 100644 --- a/tests/components/loqed/conftest.py +++ b/tests/components/loqed/conftest.py @@ -8,8 +8,7 @@ from unittest.mock import AsyncMock, Mock, patch from loqedAPI import loqed import pytest -from homeassistant.components.loqed import DOMAIN -from homeassistant.components.loqed.const import CONF_CLOUDHOOK_URL +from homeassistant.components.loqed.const import CONF_CLOUDHOOK_URL, DOMAIN from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component diff --git a/tests/components/loqed/test_lock.py b/tests/components/loqed/test_lock.py index 89a7888571a..54e7f30bf51 100644 --- a/tests/components/loqed/test_lock.py +++ b/tests/components/loqed/test_lock.py @@ -3,8 +3,6 @@ from loqedAPI import loqed from homeassistant.components.lock import LockState -from homeassistant.components.loqed import LoqedDataCoordinator -from homeassistant.components.loqed.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_LOCK, @@ -33,7 +31,7 @@ async def test_lock_responds_to_bolt_state_updates( hass: HomeAssistant, integration: MockConfigEntry, lock: loqed.Lock ) -> None: """Tests the lock responding to updates.""" - coordinator: LoqedDataCoordinator = hass.data[DOMAIN][integration.entry_id] + coordinator = integration.runtime_data lock.bolt_state = "night_lock" coordinator.async_update_listeners() From 909d950b50081f348359a14964ac82403bf52ca8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:07:34 +0200 Subject: [PATCH 0642/1664] Migrate luftdaten to use runtime_data (#147480) --- homeassistant/components/luftdaten/__init__.py | 15 ++++++--------- homeassistant/components/luftdaten/coordinator.py | 6 ++++-- homeassistant/components/luftdaten/diagnostics.py | 9 ++++----- homeassistant/components/luftdaten/sensor.py | 7 +++---- tests/components/luftdaten/test_config_flow.py | 3 +-- 5 files changed, 18 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index bba84471767..bb1c80b5a58 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -8,17 +8,16 @@ from __future__ import annotations from luftdaten import Luftdaten -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import CONF_SENSOR_ID, DOMAIN -from .coordinator import LuftdatenDataUpdateCoordinator +from .const import CONF_SENSOR_ID +from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LuftdatenConfigEntry) -> bool: """Set up Sensor.Community as config entry.""" # For backwards compat, set unique ID @@ -32,14 +31,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = LuftdatenDataUpdateCoordinator(hass, entry, sensor_community) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: LuftdatenConfigEntry) -> bool: """Unload an Sensor.Community config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/luftdaten/coordinator.py b/homeassistant/components/luftdaten/coordinator.py index 6c42d8b8866..2c311bb6409 100644 --- a/homeassistant/components/luftdaten/coordinator.py +++ b/homeassistant/components/luftdaten/coordinator.py @@ -19,16 +19,18 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) +type LuftdatenConfigEntry = ConfigEntry[LuftdatenDataUpdateCoordinator] + class LuftdatenDataUpdateCoordinator(DataUpdateCoordinator[dict[str, float | int]]): """Data update coordinator for Sensor.Community.""" - config_entry: ConfigEntry + config_entry: LuftdatenConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LuftdatenConfigEntry, sensor_community: Luftdaten, ) -> None: """Initialize the coordinator.""" diff --git a/homeassistant/components/luftdaten/diagnostics.py b/homeassistant/components/luftdaten/diagnostics.py index 3be4239c2ef..3affde44387 100644 --- a/homeassistant/components/luftdaten/diagnostics.py +++ b/homeassistant/components/luftdaten/diagnostics.py @@ -5,12 +5,11 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from .const import CONF_SENSOR_ID, DOMAIN -from .coordinator import LuftdatenDataUpdateCoordinator +from .const import CONF_SENSOR_ID +from .coordinator import LuftdatenConfigEntry TO_REDACT = { CONF_LATITUDE, @@ -20,8 +19,8 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: LuftdatenConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: LuftdatenDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return async_redact_data(coordinator.data, TO_REDACT) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 9e66982a421..07500f2e10c 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -26,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_SENSOR_ID, CONF_SENSOR_ID, DOMAIN -from .coordinator import LuftdatenDataUpdateCoordinator +from .coordinator import LuftdatenConfigEntry, LuftdatenDataUpdateCoordinator SENSORS: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -71,11 +70,11 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: LuftdatenConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up a Sensor.Community sensor based on a config entry.""" - coordinator: LuftdatenDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensorCommunitySensor( diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py index ea9b6211823..46514529cbb 100644 --- a/tests/components/luftdaten/test_config_flow.py +++ b/tests/components/luftdaten/test_config_flow.py @@ -5,8 +5,7 @@ from unittest.mock import MagicMock from luftdaten.exceptions import LuftdatenConnectionError import pytest -from homeassistant.components.luftdaten import DOMAIN -from homeassistant.components.luftdaten.const import CONF_SENSOR_ID +from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_SHOW_ON_MAP from homeassistant.core import HomeAssistant From 69bf79d3bd16b7c43cd92a6f6a9869097969e96a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 08:47:29 +0200 Subject: [PATCH 0643/1664] Migrate local_calendar to use runtime_data (#147481) --- .../components/local_calendar/__init__.py | 29 +++++++++---------- .../components/local_calendar/calendar.py | 8 ++--- .../components/local_calendar/diagnostics.py | 7 ++--- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/local_calendar/__init__.py b/homeassistant/components/local_calendar/__init__.py index baebeba4f26..f95e27d31c2 100644 --- a/homeassistant/components/local_calendar/__init__.py +++ b/homeassistant/components/local_calendar/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from pathlib import Path from homeassistant.config_entries import ConfigEntry @@ -11,19 +10,18 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.util import slugify -from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, DOMAIN, STORAGE_PATH +from .const import CONF_CALENDAR_NAME, CONF_STORAGE_KEY, STORAGE_PATH from .store import LocalCalendarStore -_LOGGER = logging.getLogger(__name__) - - PLATFORMS: list[Platform] = [Platform.CALENDAR] +type LocalCalendarConfigEntry = ConfigEntry[LocalCalendarStore] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, entry: LocalCalendarConfigEntry +) -> bool: """Set up Local Calendar from a config entry.""" - hass.data.setdefault(DOMAIN, {}) - if CONF_STORAGE_KEY not in entry.data: hass.config_entries.async_update_entry( entry, @@ -40,22 +38,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except OSError as err: raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err - hass.data[DOMAIN][entry.entry_id] = store + entry.runtime_data = store 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, entry: LocalCalendarConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry( + hass: HomeAssistant, entry: LocalCalendarConfigEntry +) -> None: """Handle removal of an entry.""" key = slugify(entry.data[CONF_CALENDAR_NAME]) path = Path(hass.config.path(STORAGE_PATH.format(key=key))) diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py index 639cf5234d1..c8f906c6d54 100644 --- a/homeassistant/components/local_calendar/calendar.py +++ b/homeassistant/components/local_calendar/calendar.py @@ -23,13 +23,13 @@ from homeassistant.components.calendar import ( CalendarEntityFeature, CalendarEvent, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .const import CONF_CALENDAR_NAME, DOMAIN +from . import LocalCalendarConfigEntry +from .const import CONF_CALENDAR_NAME from .store import LocalCalendarStore _LOGGER = logging.getLogger(__name__) @@ -39,11 +39,11 @@ PRODID = "-//homeassistant.io//local_calendar 1.0//EN" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LocalCalendarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the local calendar platform.""" - store = hass.data[DOMAIN][config_entry.entry_id] + store = config_entry.runtime_data ics = await store.async_load() calendar: Calendar = await hass.async_add_executor_job( IcsCalendarStream.calendar_from_ics, ics diff --git a/homeassistant/components/local_calendar/diagnostics.py b/homeassistant/components/local_calendar/diagnostics.py index 52c685e4929..b408b77ead9 100644 --- a/homeassistant/components/local_calendar/diagnostics.py +++ b/homeassistant/components/local_calendar/diagnostics.py @@ -5,15 +5,14 @@ from typing import Any from ical.diagnostics import redact_ics -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .const import DOMAIN +from . import LocalCalendarConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: LocalCalendarConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" payload: dict[str, Any] = { @@ -21,7 +20,7 @@ async def async_get_config_entry_diagnostics( "timezone": str(dt_util.get_default_time_zone()), "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), } - store = hass.data[DOMAIN][config_entry.entry_id] + store = config_entry.runtime_data ics = await store.async_load() payload["ics"] = "\n".join(redact_ics(ics)) return payload From 7031167895c4b63746527df3316922a93977c6a0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Jun 2025 08:59:28 +0200 Subject: [PATCH 0644/1664] Set has entity name to True in Meater (#146954) * Set has entity name to True in Meater * Fix * Fix --- homeassistant/components/meater/sensor.py | 3 +- .../meater/snapshots/test_init.ambr | 2 +- .../meater/snapshots/test_sensor.ambr | 550 +++++++++--------- 3 files changed, 282 insertions(+), 273 deletions(-) diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 61833babd47..4a71c23759a 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -188,6 +188,7 @@ async def async_setup_entry( class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]): """Meater Temperature Sensor Entity.""" + _attr_has_entity_name = True entity_description: MeaterSensorEntityDescription def __init__( @@ -202,7 +203,7 @@ class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]) }, manufacturer="Apption Labs", model="Meater Probe", - name=f"Meater Probe {device_id}", + name=f"Meater Probe {device_id[:8]}", ) self._attr_unique_id = f"{device_id}-{description.key}" diff --git a/tests/components/meater/snapshots/test_init.ambr b/tests/components/meater/snapshots/test_init.ambr index 582fd68efb1..68e4ba32a4a 100644 --- a/tests/components/meater/snapshots/test_init.ambr +++ b/tests/components/meater/snapshots/test_init.ambr @@ -23,7 +23,7 @@ 'manufacturer': 'Apption Labs', 'model': 'Meater Probe', 'model_id': None, - 'name': 'Meater Probe 40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58', + 'name': 'Meater Probe 40a72384', 'name_by_user': None, 'primary_config_entry': , 'serial_number': None, diff --git a/tests/components/meater/snapshots/test_sensor.ambr b/tests/components/meater/snapshots/test_sensor.ambr index aaec1db296a..f66bc854e2c 100644 --- a/tests/components/meater/snapshots/test_sensor.ambr +++ b/tests/components/meater/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-entry] +# name: test_entities[sensor.meater_probe_40a72384_ambient_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -14,8 +14,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', - 'has_entity_name': False, + 'entity_id': 'sensor.meater_probe_40a72384_ambient_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -29,7 +29,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': 'Ambient temperature', 'platform': 'meater', 'previous_unique_id': None, 'suggested_object_id': None, @@ -39,124 +39,23 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient-state] +# name: test_entities[sensor.meater_probe_40a72384_ambient_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Ambient temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_ambient', + 'entity_id': 'sensor.meater_probe_40a72384_ambient_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '28.0', }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_name', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_name', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'Whole chicken', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', - 'has_entity_name': False, - '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': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_peak_temp', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_peak_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '27.0', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-entry] +# name: test_entities[sensor.meater_probe_40a72384_cook_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -181,8 +80,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', - 'has_entity_name': False, + 'entity_id': 'sensor.meater_probe_40a72384_cook_state', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -193,7 +92,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, + 'original_name': 'Cook state', 'platform': 'meater', 'previous_unique_id': None, 'suggested_object_id': None, @@ -203,10 +102,11 @@ 'unit_of_measurement': None, }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state-state] +# name: test_entities[sensor.meater_probe_40a72384_cook_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'enum', + 'friendly_name': 'Meater Probe 40a72384 Cook state', 'options': list([ 'not_started', 'configured', @@ -220,14 +120,62 @@ ]), }), 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_state', + 'entity_id': 'sensor.meater_probe_40a72384_cook_state', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'started', }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-entry] +# name: test_entities[sensor.meater_probe_40a72384_cooking-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.meater_probe_40a72384_cooking', + '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': 'Cooking', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_name', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_name', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_cooking-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Meater Probe 40a72384 Cooking', + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_cooking', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'Whole chicken', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_internal_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -242,8 +190,8 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', - 'has_entity_name': False, + 'entity_id': 'sensor.meater_probe_40a72384_internal_temperature', + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -257,158 +205,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_target_temp', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', - 'unit_of_measurement': , - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_target_temp', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25.0', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_time_elapsed', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_elapsed', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-10-20T23:59:28+00:00', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'meater', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cook_time_remaining', - 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', - 'unit_of_measurement': None, - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', - }), - 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_cook_time_remaining', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2023-10-21T00:00:32+00:00', - }) -# --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-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.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', - 'has_entity_name': False, - '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': None, + 'original_name': 'Internal temperature', 'platform': 'meater', 'previous_unique_id': None, 'suggested_object_id': None, @@ -418,18 +215,229 @@ 'unit_of_measurement': , }) # --- -# name: test_entities[sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal-state] +# name: test_entities[sensor.meater_probe_40a72384_internal_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Internal temperature', 'state_class': , 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.meater_40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58_internal', + 'entity_id': 'sensor.meater_probe_40a72384_internal_temperature', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '26.0', }) # --- +# name: test_entities[sensor.meater_probe_40a72384_peak_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.meater_probe_40a72384_peak_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': 'Peak temperature', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_peak_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_peak_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_peak_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Peak temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_peak_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '27.0', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_target_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.meater_probe_40a72384_target_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': 'Target temperature', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_target_temp', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_target_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Meater Probe 40a72384 Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_elapsed-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.meater_probe_40a72384_time_elapsed', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time elapsed', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_elapsed', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_elapsed', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_elapsed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Meater Probe 40a72384 Time elapsed', + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_time_elapsed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-20T23:59:28+00:00', + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_remaining-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.meater_probe_40a72384_time_remaining', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Time remaining', + 'platform': 'meater', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time_remaining', + 'unique_id': '40a72384fa80349314dfd97c84b73a5c1c9da57b59e26d67b573d618fe0d6e58-cook_time_remaining', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.meater_probe_40a72384_time_remaining-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Meater Probe 40a72384 Time remaining', + }), + 'context': , + 'entity_id': 'sensor.meater_probe_40a72384_time_remaining', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2023-10-21T00:00:32+00:00', + }) +# --- From 066e840e0676f868772364613226ba7b0f2e3444 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 25 Jun 2025 09:17:43 +0200 Subject: [PATCH 0645/1664] Migrate lookin to use runtime_data (#147479) --- homeassistant/components/lookin/__init__.py | 14 ++++++-------- homeassistant/components/lookin/climate.py | 9 ++++----- homeassistant/components/lookin/coordinator.py | 9 ++++++--- homeassistant/components/lookin/light.py | 9 ++++----- homeassistant/components/lookin/media_player.py | 9 ++++----- homeassistant/components/lookin/models.py | 4 ++++ homeassistant/components/lookin/sensor.py | 8 +++----- 7 files changed, 31 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 247282309e4..7eff68703a5 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -19,7 +19,6 @@ from aiolookin import ( ) from aiolookin.models import UDPCommandType, UDPEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady @@ -34,7 +33,7 @@ from .const import ( TYPE_TO_PLATFORM, ) from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -91,7 +90,7 @@ class LookinUDPManager: self._subscriptions = None -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool: """Set up lookin from a config entry.""" domain_data = hass.data.setdefault(DOMAIN, {}) host = entry.data[CONF_HOST] @@ -172,7 +171,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - hass.data[DOMAIN][entry.entry_id] = LookinData( + entry.runtime_data = LookinData( host=host, lookin_udp_subs=lookin_udp_subs, lookin_device=lookin_device, @@ -187,10 +186,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: LookinConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if not hass.config_entries.async_loaded_entries(DOMAIN): manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] @@ -199,7 +197,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove lookin config entry from a device.""" data: LookinData = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py index 9cef56bcf9f..6b92032e4ab 100644 --- a/homeassistant/components/lookin/climate.py +++ b/homeassistant/components/lookin/climate.py @@ -20,7 +20,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_WHOLE, @@ -30,10 +29,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TYPE_TO_PLATFORM +from .const import TYPE_TO_PLATFORM from .coordinator import LookinDataUpdateCoordinator from .entity import LookinCoordinatorEntity -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOOKIN_FAN_MODE_IDX_TO_HASS: Final = [FAN_AUTO, FAN_LOW, FAN_MIDDLE, FAN_HIGH] LOOKIN_SWING_MODE_IDX_TO_HASS: Final = [SWING_OFF, SWING_BOTH] @@ -64,11 +63,11 @@ LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the climate platform for lookin from a config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data entities = [] for remote in lookin_data.devices: diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index a74cd0e4861..fd3f73120a2 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -6,13 +6,16 @@ from collections.abc import Awaitable, Callable from datetime import timedelta import logging import time +from typing import TYPE_CHECKING -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS +if TYPE_CHECKING: + from .models import LookinConfigEntry + _LOGGER = logging.getLogger(__name__) @@ -44,12 +47,12 @@ class LookinPushCoordinator: class LookinDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]): """DataUpdateCoordinator to gather data for a specific lookin devices.""" - config_entry: ConfigEntry + config_entry: LookinConfigEntry def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, push_coordinator: LookinPushCoordinator, name: str, update_interval: timedelta | None = None, diff --git a/homeassistant/components/lookin/light.py b/homeassistant/components/lookin/light.py index d46cb96d6c0..6e467871428 100644 --- a/homeassistant/components/lookin/light.py +++ b/homeassistant/components/lookin/light.py @@ -6,25 +6,24 @@ import logging from typing import Any from homeassistant.components.light import ColorMode, LightEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TYPE_TO_PLATFORM +from .const import TYPE_TO_PLATFORM from .entity import LookinPowerPushRemoteEntity -from .models import LookinData +from .models import LookinConfigEntry LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the light platform for lookin from a config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data entities = [] for remote in lookin_data.devices: diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py index a3568d9f155..f395c2b3885 100644 --- a/homeassistant/components/lookin/media_player.py +++ b/homeassistant/components/lookin/media_player.py @@ -12,15 +12,14 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, TYPE_TO_PLATFORM +from .const import TYPE_TO_PLATFORM from .coordinator import LookinDataUpdateCoordinator from .entity import LookinPowerPushRemoteEntity -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -43,11 +42,11 @@ _FUNCTION_NAME_TO_FEATURE = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the media_player platform for lookin from a config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data entities = [] for remote in lookin_data.devices: diff --git a/homeassistant/components/lookin/models.py b/homeassistant/components/lookin/models.py index 3bf6ae9d862..622efb834c0 100644 --- a/homeassistant/components/lookin/models.py +++ b/homeassistant/components/lookin/models.py @@ -13,8 +13,12 @@ from aiolookin import ( Remote, ) +from homeassistant.config_entries import ConfigEntry + from .coordinator import LookinDataUpdateCoordinator +type LookinConfigEntry = ConfigEntry[LookinData] + @dataclass class LookinData: diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py index 89e1ed6aa69..e53ff135b2f 100644 --- a/homeassistant/components/lookin/sensor.py +++ b/homeassistant/components/lookin/sensor.py @@ -10,14 +10,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN from .entity import LookinDeviceCoordinatorEntity -from .models import LookinData +from .models import LookinConfigEntry, LookinData LOGGER = logging.getLogger(__name__) @@ -42,11 +40,11 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: LookinConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up lookin sensors from the config entry.""" - lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id] + lookin_data = config_entry.runtime_data if lookin_data.lookin_device.model >= 2: async_add_entities( From 51fb1ab8b6e05c5ee87c96048ad3555d4c1fe3a2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Jun 2025 09:23:27 +0200 Subject: [PATCH 0646/1664] Refactor Meater availability (#146956) * Refactor Meater availability * Fix * Fix --- homeassistant/components/meater/sensor.py | 57 +++++++++++++---------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 4a71c23759a..2369ab30b16 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -42,8 +42,8 @@ COOK_STATES = { class MeaterSensorEntityDescription(SensorEntityDescription): """Describes meater sensor entity.""" - available: Callable[[MeaterProbe | None], bool] value: Callable[[MeaterProbe], datetime | float | str | None] + unavailable_when_not_cooking: bool = False def _elapsed_time_to_timestamp(probe: MeaterProbe) -> datetime | None: @@ -72,7 +72,6 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None, value=lambda probe: probe.ambient_temperature, ), # Internal temperature (probe tip) @@ -82,20 +81,19 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None, value=lambda probe: probe.internal_temperature, ), # Name of selected meat in user language or user given custom name MeaterSensorEntityDescription( key="cook_name", translation_key="cook_name", - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, value=lambda probe: probe.cook.name if probe.cook else None, ), MeaterSensorEntityDescription( key="cook_state", translation_key="cook_state", - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, device_class=SensorDeviceClass.ENUM, options=list(COOK_STATES.values()), value=lambda probe: COOK_STATES.get(probe.cook.state) if probe.cook else None, @@ -107,10 +105,12 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.target_temperature - if probe.cook and hasattr(probe.cook, "target_temperature") - else None, + unavailable_when_not_cooking=True, + value=( + lambda probe: probe.cook.target_temperature + if probe.cook and hasattr(probe.cook, "target_temperature") + else None + ), ), # Peak temperature MeaterSensorEntityDescription( @@ -119,10 +119,12 @@ SENSOR_TYPES = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - available=lambda probe: probe is not None and probe.cook is not None, - value=lambda probe: probe.cook.peak_temperature - if probe.cook and hasattr(probe.cook, "peak_temperature") - else None, + unavailable_when_not_cooking=True, + value=( + lambda probe: probe.cook.peak_temperature + if probe.cook and hasattr(probe.cook, "peak_temperature") + else None + ), ), # Remaining time in seconds. When unknown/calculating default is used. Default: -1 # Exposed as a TIMESTAMP sensor where the timestamp is current time + remaining time. @@ -130,7 +132,7 @@ SENSOR_TYPES = ( key="cook_time_remaining", translation_key="cook_time_remaining", device_class=SensorDeviceClass.TIMESTAMP, - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, value=_remaining_time_to_timestamp, ), # Time since the start of cook in seconds. Default: 0. Exposed as a TIMESTAMP sensor @@ -139,7 +141,7 @@ SENSOR_TYPES = ( key="cook_time_elapsed", translation_key="cook_time_elapsed", device_class=SensorDeviceClass.TIMESTAMP, - available=lambda probe: probe is not None and probe.cook is not None, + unavailable_when_not_cooking=True, value=_elapsed_time_to_timestamp, ), ) @@ -192,7 +194,10 @@ class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]) entity_description: MeaterSensorEntityDescription def __init__( - self, coordinator, device_id, description: MeaterSensorEntityDescription + self, + coordinator: MeaterCoordinator, + device_id: str, + description: MeaterSensorEntityDescription, ) -> None: """Initialise the sensor.""" super().__init__(coordinator) @@ -211,20 +216,24 @@ class MeaterProbeTemperature(SensorEntity, CoordinatorEntity[MeaterCoordinator]) self.entity_description = description @property - def native_value(self): - """Return the temperature of the probe.""" - if not (device := self.coordinator.data.get(self.device_id)): - return None + def probe(self) -> MeaterProbe: + """Return the probe.""" + return self.coordinator.data[self.device_id] - return self.entity_description.value(device) + @property + def native_value(self) -> datetime | float | str | None: + """Return the temperature of the probe.""" + return self.entity_description.value(self.probe) @property def available(self) -> bool: """Return if entity is available.""" # See if the device was returned from the API. If not, it's offline return ( - self.coordinator.last_update_success - and self.entity_description.available( - self.coordinator.data.get(self.device_id) + super().available + and self.device_id in self.coordinator.data + and ( + not self.entity_description.unavailable_when_not_cooking + or self.probe.cook is not None ) ) From 85e9919bbd75635222c192f0785301648bdd8786 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 25 Jun 2025 09:28:37 +0200 Subject: [PATCH 0647/1664] Add entity category option to entities set up via an MQTT subentry (#146776) * Add entity category option to entities set up via an MQTT subentry * Rephrase * typo * Move entity category to entity details - remove service to action * Move entity category to entity platform config flow step --- homeassistant/components/mqtt/config_flow.py | 46 ++++++- homeassistant/components/mqtt/entity.py | 5 + homeassistant/components/mqtt/strings.json | 8 ++ tests/components/mqtt/common.py | 12 ++ tests/components/mqtt/test_config_flow.py | 121 +++++++++++-------- 5 files changed, 135 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ca15a899c01..2ef881ceaf4 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -66,6 +66,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DISCOVERY, CONF_EFFECT, + CONF_ENTITY_CATEGORY, CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, @@ -84,6 +85,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + EntityCategory, ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section @@ -411,6 +413,14 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( ): TEXT_SELECTOR, } ) +ENTITY_CATEGORY_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[category.value for category in EntityCategory], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ENTITY_CATEGORY, + sort=True, + ) +) # Sensor specific selectors SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( @@ -429,6 +439,15 @@ BINARY_SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( sort=True, ) ) +SENSOR_ENTITY_CATEGORY_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[EntityCategory.DIAGNOSTIC.value], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_ENTITY_CATEGORY, + sort=True, + ) +) + BUTTON_DEVICE_CLASS_SELECTOR = SelectSelector( SelectSelectorConfig( options=[device_class.value for device_class in ButtonDeviceClass], @@ -735,12 +754,25 @@ COMMON_ENTITY_FIELDS: dict[str, PlatformField] = { ), } +SHARED_PLATFORM_ENTITY_FIELDS: dict[str, PlatformField] = { + CONF_ENTITY_CATEGORY: PlatformField( + selector=ENTITY_CATEGORY_SELECTOR, + required=False, + default=None, + ), +} + PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { Platform.BINARY_SENSOR.value: { CONF_DEVICE_CLASS: PlatformField( selector=BINARY_SENSOR_DEVICE_CLASS_SELECTOR, required=False, ), + CONF_ENTITY_CATEGORY: PlatformField( + selector=SENSOR_ENTITY_CATEGORY_SELECTOR, + required=False, + default=None, + ), }, Platform.BUTTON.value: { CONF_DEVICE_CLASS: PlatformField( @@ -804,6 +836,11 @@ PLATFORM_ENTITY_FIELDS: dict[str, dict[str, PlatformField]] = { required=False, conditions=({"device_class": "enum"},), ), + CONF_ENTITY_CATEGORY: PlatformField( + selector=SENSOR_ENTITY_CATEGORY_SELECTOR, + required=False, + default=None, + ), }, Platform.SWITCH.value: { CONF_DEVICE_CLASS: PlatformField( @@ -2070,8 +2107,6 @@ def data_schema_from_fields( if field_details.section == schema_section and field_details.exclude_from_reconfig } - if not data_element_options: - continue if schema_section is None: data_schema.update(data_schema_element) continue @@ -2834,7 +2869,9 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): assert self._component_id is not None component_data = self._subentry_data["components"][self._component_id] platform = component_data[CONF_PLATFORM] - data_schema_fields = PLATFORM_ENTITY_FIELDS[platform] + data_schema_fields = ( + SHARED_PLATFORM_ENTITY_FIELDS | PLATFORM_ENTITY_FIELDS[platform] + ) errors: dict[str, str] = {} data_schema = data_schema_from_fields( @@ -2845,8 +2882,6 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): component_data=component_data, user_input=user_input, ) - if not data_schema.schema: - return await self.async_step_mqtt_platform_config() if user_input is not None: # Test entity fields against the validator merged_user_input, errors = validate_user_input( @@ -2940,6 +2975,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): platform = component_data[CONF_PLATFORM] platform_fields: dict[str, PlatformField] = ( COMMON_ENTITY_FIELDS + | SHARED_PLATFORM_ENTITY_FIELDS | PLATFORM_ENTITY_FIELDS[platform] | PLATFORM_MQTT_FIELDS[platform] ) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index b62d42a80d0..338779f32cb 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -313,6 +313,11 @@ def async_setup_entity_entry_helper( component_config.pop("platform") component_config.update(availability_config) component_config.update(device_mqtt_options) + if ( + CONF_ENTITY_CATEGORY in component_config + and component_config[CONF_ENTITY_CATEGORY] is None + ): + component_config.pop(CONF_ENTITY_CATEGORY) try: config = platform_schema_modern(component_config) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 16652c498f3..ed7da6fc112 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -210,6 +210,7 @@ "description": "Please configure specific details for {platform} entity \"{entity}\":", "data": { "device_class": "Device class", + "entity_category": "Entity category", "fan_feature_speed": "Speed support", "fan_feature_preset_modes": "Preset modes support", "fan_feature_oscillation": "Oscillation support", @@ -222,6 +223,7 @@ }, "data_description": { "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", + "entity_category": "Allow marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configiuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", "fan_feature_speed": "The fan supports multiple speeds.", "fan_feature_preset_modes": "The fan supports preset modes.", "fan_feature_oscillation": "The fan supports oscillation.", @@ -883,6 +885,12 @@ "switch": "[%key:component::switch::title%]" } }, + "entity_category": { + "options": { + "config": "Config", + "diagnostic": "Diagnostic" + } + }, "light_schema": { "options": { "basic": "Default schema", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index b985a8caffe..3e87925c1cd 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -71,6 +71,7 @@ MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = { "platform": "binary_sensor", "name": "Hatch", "device_class": "door", + "entity_category": None, "state_topic": "test-topic", "payload_on": "ON", "payload_off": "OFF", @@ -86,6 +87,7 @@ MOCK_SUBENTRY_BUTTON_COMPONENT = { "name": "Restart", "device_class": "restart", "command_topic": "test-topic", + "entity_category": None, "payload_press": "PRESS", "command_template": "{{ value }}", "retain": False, @@ -97,6 +99,7 @@ MOCK_SUBENTRY_COVER_COMPONENT = { "platform": "cover", "name": "Blind", "device_class": "blind", + "entity_category": None, "command_topic": "test-topic", "payload_stop": None, "payload_stop_tilt": "STOP", @@ -132,6 +135,7 @@ MOCK_SUBENTRY_FAN_COMPONENT = { "platform": "fan", "name": "Breezer", "command_topic": "test-topic", + "entity_category": None, "state_topic": "test-topic", "command_template": "{{ value }}", "value_template": "{{ value_json.value }}", @@ -169,6 +173,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "363a7ecad6be4a19b939a016ea93e994": { "platform": "notify", "name": "Milkman alert", + "entity_category": None, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", @@ -179,6 +184,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = { "6494827dac294fa0827c54b02459d309": { "platform": "notify", "name": "The second notifier", + "entity_category": None, "command_topic": "test-topic2", "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", }, @@ -187,6 +193,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "5269352dd9534c908d22812ea5d714cd": { "platform": "notify", "name": None, + "entity_category": None, "command_topic": "test-topic", "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", @@ -198,6 +205,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", "name": "Energy", + "entity_category": None, "device_class": "enum", "state_topic": "test-topic", "options": ["low", "medium", "high"], @@ -210,6 +218,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { "a0f85790a95d4889924602effff06b6e": { "platform": "sensor", "name": "Energy", + "entity_category": None, "state_class": "measurement", "state_topic": "test-topic", "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", @@ -219,6 +228,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { "e9261f6feed443e7b7d5f3fbe2a47412": { "platform": "sensor", "name": "Energy", + "entity_category": None, "state_class": "total", "last_reset_value_template": "{{ value_json.value }}", "state_topic": "test-topic", @@ -229,6 +239,7 @@ MOCK_SUBENTRY_SWITCH_COMPONENT = { "3faf1318016c46c5aea26707eeb6f12e": { "platform": "switch", "name": "Outlet", + "entity_category": None, "device_class": "outlet", "command_topic": "test-topic", "state_topic": "test-topic", @@ -250,6 +261,7 @@ MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = { "payload_off": "OFF", "payload_on": "ON", "command_topic": "test-topic", + "entity_category": None, "schema": "basic", "state_topic": "test-topic", "color_temp_kelvin": True, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index a139f729cd9..2177a7de8e1 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2941,8 +2941,8 @@ async def test_migrate_of_incompatible_config_entry( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, {"name": "Milkman alert"}, - None, - None, + {}, + (), { "command_topic": "test-topic", "command_template": "{{ value }}", @@ -2960,8 +2960,8 @@ async def test_migrate_of_incompatible_config_entry( MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {}, - None, - None, + {}, + (), { "command_topic": "test-topic", "command_template": "{{ value }}", @@ -3220,37 +3220,32 @@ async def test_subentry_configflow( "url": learn_more_url(component["platform"]), } - # Process extra step if the platform supports it - if mock_entity_details_user_input is not None: - # Extra entity details flow step - assert result["step_id"] == "entity_platform_config" + # Process entity details setep + assert result["step_id"] == "entity_platform_config" - # First test validators if set of test - for failed_user_input, failed_errors in mock_entity_details_failed_user_input: - # Test an invalid entity details user input case - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=failed_user_input, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == failed_errors - - # Now try again with valid data + # First test validators if set of test + for failed_user_input, failed_errors in mock_entity_details_failed_user_input: + # Test an invalid entity details user input case result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input=mock_entity_details_user_input, + user_input=failed_user_input, ) assert result["type"] is FlowResultType.FORM - assert result["errors"] == {} - assert result["description_placeholders"] == { - "mqtt_device": device_name, - "platform": component["platform"], - "entity": entity_name, - "url": learn_more_url(component["platform"]), - } - else: - # No details form step - assert result["step_id"] == "mqtt_platform_config" + assert result["errors"] == failed_errors + + # Now try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_entity_details_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } # Process mqtt platform config flow # Test an invalid mqtt user input case @@ -3501,6 +3496,16 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( }, ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the platform specific entity data with changed entity_category + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={ + "entity_category": "config", + }, + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data @@ -3547,7 +3552,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( ), ), (), - None, + {}, { "command_topic": "test-topic1-updated", "command_template": "{{ value }}", @@ -3608,8 +3613,8 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( title="Mock subentry", ), ), - None, - None, + (), + {}, { "command_topic": "test-topic1-updated", "state_topic": "test-topic1-updated", @@ -3636,7 +3641,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( tuple[dict[str, Any], dict[str, str] | None], ... ] | None, - user_input_platform_config: dict[str, Any] | None, + user_input_platform_config: dict[str, Any], user_input_mqtt: dict[str, Any], component_data: dict[str, Any], removed_options: tuple[str, ...], @@ -3694,28 +3699,25 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" - if user_input_platform_config is None: - # Skip entity flow step - assert result["step_id"] == "mqtt_platform_config" - else: - # Additional entity flow step - assert result["step_id"] == "entity_platform_config" - for entity_validation_config, errors in user_input_platform_config_validation: - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=entity_validation_config, - ) - assert result["step_id"] == "entity_platform_config" - assert result.get("errors") == errors - assert result["type"] is FlowResultType.FORM - + # entity platform config flow step + assert result["step_id"] == "entity_platform_config" + for entity_validation_config, errors in user_input_platform_config_validation: result = await hass.config_entries.subentries.async_configure( result["flow_id"], - user_input=user_input_platform_config, + user_input=entity_validation_config, ) + assert result["step_id"] == "entity_platform_config" + assert result.get("errors") == errors assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_platform_config, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data, result = await hass.config_entries.subentries.async_configure( @@ -3880,7 +3882,12 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( @pytest.mark.parametrize( - ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"), + ( + "mqtt_config_subentries_data", + "user_input_entity", + "user_input_entity_platform_config", + "user_input_mqtt", + ), [ ( ( @@ -3895,6 +3902,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( "name": "The second notifier", "entity_picture": "https://example.com", }, + {"entity_category": "diagnostic"}, { "command_topic": "test-topic2", }, @@ -3908,6 +3916,7 @@ async def test_subentry_reconfigure_add_entity( device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, user_input_entity: dict[str, Any], + user_input_entity_platform_config: dict[str, Any], user_input_mqtt: dict[str, Any], ) -> None: """Test the subentry ConfigFlow reconfigure and add an entity.""" @@ -3960,6 +3969,14 @@ async def test_subentry_reconfigure_add_entity( user_input=user_input_entity, ) assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the new entity platform config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity_platform_config, + ) + assert result["type"] is FlowResultType.FORM assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data From d0b2d1dc92e72489468f4adf78515329e432fc7e Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:32:33 +0800 Subject: [PATCH 0648/1664] Add evaporative humidifier for switchbot integration (#146235) * add support for evaporative humidifier * add evaporative humidifier unit test * clear the humidifier action in pyswitchbot * fix ruff * fix Sentence-casing issue * add icon translation * remove last run success * use icon translations for water level * remove the translation for last run success --- .../components/switchbot/__init__.py | 2 + homeassistant/components/switchbot/const.py | 4 + .../components/switchbot/humidifier.py | 85 +++++++++++++++++- homeassistant/components/switchbot/icons.json | 29 ++++++ homeassistant/components/switchbot/sensor.py | 7 ++ .../components/switchbot/strings.json | 25 ++++++ tests/components/switchbot/__init__.py | 24 +++++ tests/components/switchbot/test_humidifier.py | 88 ++++++++++++++++++- tests/components/switchbot/test_sensor.py | 60 +++++++++++++ 9 files changed, 322 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index af4001f0d9a..c10a0036b1c 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -92,6 +92,7 @@ PLATFORMS_BY_TYPE = { ], SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR], SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR], + SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -117,6 +118,7 @@ CLASS_BY_DEVICE = { SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock, SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier, + SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index f6536ca3ff3..981b7c75a28 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -48,6 +48,7 @@ class SupportedModels(StrEnum): LOCK_ULTRA = "lock_ultra" AIR_PURIFIER = "air_purifier" AIR_PURIFIER_TABLE = "air_purifier_table" + EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -75,6 +76,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA, SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE, + SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -103,6 +105,7 @@ ENCRYPTED_MODELS = { SwitchbotModel.LOCK_ULTRA, SwitchbotModel.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE, + SwitchbotModel.EVAPORATIVE_HUMIDIFIER, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -116,6 +119,7 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock, SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier, SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier, + SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py index c15cf7ac9c6..c162f4947ed 100644 --- a/homeassistant/components/switchbot/humidifier.py +++ b/homeassistant/components/switchbot/humidifier.py @@ -2,11 +2,16 @@ from __future__ import annotations +import logging +from typing import Any + import switchbot +from switchbot import HumidifierAction as SwitchbotHumidifierAction, HumidifierMode from homeassistant.components.humidifier import ( MODE_AUTO, MODE_NORMAL, + HumidifierAction, HumidifierDeviceClass, HumidifierEntity, HumidifierEntityFeature, @@ -17,7 +22,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import SwitchbotConfigEntry from .entity import SwitchbotSwitchedEntity, exception_handler +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +EVAPORATIVE_HUMIDIFIER_ACTION_MAP: dict[int, HumidifierAction] = { + SwitchbotHumidifierAction.OFF: HumidifierAction.OFF, + SwitchbotHumidifierAction.HUMIDIFYING: HumidifierAction.HUMIDIFYING, + SwitchbotHumidifierAction.DRYING: HumidifierAction.DRYING, +} async def async_setup_entry( @@ -26,7 +37,11 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Switchbot based on a config entry.""" - async_add_entities([SwitchBotHumidifier(entry.runtime_data)]) + coordinator = entry.runtime_data + if isinstance(coordinator.device, switchbot.SwitchbotEvaporativeHumidifier): + async_add_entities([SwitchBotEvaporativeHumidifier(coordinator)]) + else: + async_add_entities([SwitchBotHumidifier(coordinator)]) class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): @@ -69,3 +84,71 @@ class SwitchBotHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): else: self._last_run_success = await self._device.async_set_manual() self.async_write_ha_state() + + +class SwitchBotEvaporativeHumidifier(SwitchbotSwitchedEntity, HumidifierEntity): + """Representation of a Switchbot evaporative humidifier.""" + + _device: switchbot.SwitchbotEvaporativeHumidifier + _attr_device_class = HumidifierDeviceClass.HUMIDIFIER + _attr_supported_features = HumidifierEntityFeature.MODES + _attr_available_modes = HumidifierMode.get_modes() + _attr_min_humidity = 1 + _attr_max_humidity = 99 + _attr_translation_key = "evaporative_humidifier" + _attr_name = None + + @property + def is_on(self) -> bool | None: + """Return true if device is on.""" + return self._device.is_on() + + @property + def mode(self) -> str: + """Return the evaporative humidifier current mode.""" + return self._device.get_mode().name.lower() + + @property + def current_humidity(self) -> int | None: + """Return the current humidity.""" + return self._device.get_humidity() + + @property + def target_humidity(self) -> int | None: + """Return the humidity we try to reach.""" + return self._device.get_target_humidity() + + @property + def action(self) -> HumidifierAction | None: + """Return the current action.""" + return EVAPORATIVE_HUMIDIFIER_ACTION_MAP.get( + self._device.get_action(), HumidifierAction.IDLE + ) + + @exception_handler + async def async_set_humidity(self, humidity: int) -> None: + """Set new target humidity.""" + _LOGGER.debug("Setting target humidity to: %s %s", humidity, self._address) + await self._device.set_target_humidity(humidity) + self.async_write_ha_state() + + @exception_handler + async def async_set_mode(self, mode: str) -> None: + """Set new evaporative humidifier mode.""" + _LOGGER.debug("Setting mode to: %s %s", mode, self._address) + await self._device.set_mode(HumidifierMode[mode.upper()]) + self.async_write_ha_state() + + @exception_handler + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the humidifier.""" + _LOGGER.debug("Turning on the humidifier %s", self._address) + await self._device.turn_on() + self.async_write_ha_state() + + @exception_handler + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the humidifier.""" + _LOGGER.debug("Turning off the humidifier %s", self._address) + await self._device.turn_off() + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 9dd46e0717a..38e17ae6c56 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -1,5 +1,16 @@ { "entity": { + "sensor": { + "water_level": { + "default": "mdi:water-percent", + "state": { + "empty": "mdi:water-off", + "low": "mdi:water-outline", + "medium": "mdi:water", + "high": "mdi:water-check" + } + } + }, "fan": { "fan": { "state_attributes": { @@ -31,6 +42,24 @@ } } } + }, + "humidifier": { + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "mdi:water-plus", + "medium": "mdi:water", + "low": "mdi:water-outline", + "quiet": "mdi:volume-off", + "target_humidity": "mdi:target", + "sleep": "mdi:weather-night", + "auto": "mdi:autorenew", + "drying_filter": "mdi:water-remove" + } + } + } + } } } } diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 736297ca091..f6c5d526ab7 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from switchbot import HumidifierWaterLevel from switchbot.const.air_purifier import AirQualityLevel from homeassistant.components.bluetooth import async_last_service_info @@ -117,6 +118,12 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), + "water_level": SensorEntityDescription( + key="water_level", + translation_key="water_level", + device_class=SensorDeviceClass.ENUM, + options=HumidifierWaterLevel.get_levels(), + ), } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index c758ae645ae..9bce9614549 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -114,6 +114,15 @@ "moderate": "Moderate", "unhealthy": "Unhealthy" } + }, + "water_level": { + "name": "Water level", + "state": { + "empty": "Empty", + "low": "[%key:common::state::low%]", + "medium": "[%key:common::state::medium%]", + "high": "[%key:common::state::high%]" + } } }, "cover": { @@ -138,6 +147,22 @@ } } } + }, + "evaporative_humidifier": { + "state_attributes": { + "mode": { + "state": { + "high": "[%key:common::state::high%]", + "medium": "[%key:common::state::medium%]", + "low": "[%key:common::state::low%]", + "quiet": "Quiet", + "target_humidity": "Target humidity", + "sleep": "Sleep", + "auto": "[%key:common::state::auto%]", + "drying_filter": "Drying filter" + } + } + } } }, "lock": { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 5dca8167e05..6e0aaadacd4 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -859,3 +859,27 @@ AIR_PURIFIER_TABLE_VOC_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + +EVAPORATIVE_HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Evaporative Humidifier", + manufacturer_data={ + 2409: b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Evaporative Humidifier", + manufacturer_data={ + 2409: b"\xa0\xa3\xb3,\x9c\xe68\x86\x88\xb5\x99\x12\x10\x1b\x00\x85]", + }, + service_data={"0000fd3d-0000-1000-8000-00805f9b34fb": b"#\x00\x00\x15\x1c\x00"}, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Evaporative Humidifier"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_humidifier.py b/tests/components/switchbot/test_humidifier.py index fa9efac0bfd..6718fe763a8 100644 --- a/tests/components/switchbot/test_humidifier.py +++ b/tests/components/switchbot/test_humidifier.py @@ -21,7 +21,7 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import HUMIDIFIER_SERVICE_INFO +from . import EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUMIDIFIER_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -173,3 +173,89 @@ async def test_exception_handling_humidifier_service( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_TURN_ON, {}, "turn_on"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_target_humidity"), + (SERVICE_SET_MODE, {ATTR_MODE: "sleep"}, "set_mode"), + ], +) +async def test_evaporative_humidifier_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test evaporative humidifier services with proper parameters.""" + inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="evaporative_humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + mocked_instance = AsyncMock(return_value=True) + with patch.multiple( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier", + update=AsyncMock(return_value=None), + **{mock_method: mocked_instance}, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once() + + +@pytest.mark.parametrize( + ("service", "service_data", "mock_method"), + [ + (SERVICE_TURN_ON, {}, "turn_on"), + (SERVICE_TURN_OFF, {}, "turn_off"), + (SERVICE_SET_HUMIDITY, {ATTR_HUMIDITY: 60}, "set_target_humidity"), + (SERVICE_SET_MODE, {ATTR_MODE: "sleep"}, "set_mode"), + ], +) +async def test_evaporative_humidifier_services_with_exception( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, +) -> None: + """Test exception handling for evaporative humidifier services.""" + inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO) + + entry = mock_entry_encrypted_factory(sensor_type="evaporative_humidifier") + entry.add_to_hass(hass) + entity_id = "humidifier.test_name" + + patch_target = f"homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier.{mock_method}" + + with patch( + patch_target, + new=AsyncMock(side_effect=SwitchbotOperationError("Operation failed")), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="An error occurred while performing the action: Operation failed", + ): + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index db37f3f98dd..411d7282893 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.switchbot.const import ( DOMAIN, ) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, @@ -23,6 +24,7 @@ from homeassistant.setup import async_setup_component from . import ( CIRCULATOR_FAN_SERVICE_INFO, + EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, LEAK_SERVICE_INFO, @@ -484,3 +486,61 @@ async def test_hub3_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_evaporative_humidifier_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for evaporative humidifier.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "evaporative_humidifier", + CONF_KEY_ID: "ff", + CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.switchbot.humidifier.switchbot.SwitchbotEvaporativeHumidifier.update", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 4 + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "53" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "25.1" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + water_level_sensor = hass.states.get("sensor.test_name_water_level") + water_level_sensor_attrs = water_level_sensor.attributes + assert water_level_sensor.state == "medium" + assert water_level_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Water level" + assert water_level_sensor_attrs[ATTR_DEVICE_CLASS] == "enum" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() From f800248c10d379ecee77058e7a231e8c326d937a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 25 Jun 2025 10:33:13 +0300 Subject: [PATCH 0649/1664] Add more binary sensors to Alexa Devices (#146402) * Add more binary sensors to Amazon Devices * apply review comment * Add sensor platform to Amazon Devices * Revert "Add sensor platform to Amazon Devices" This reverts commit 25a9ca673e450634a17bdb79462b14aa855aca10. * clean * fix logic after latest changes * apply review comments --- .../components/alexa_devices/binary_sensor.py | 49 +++++++++++++++++-- .../components/alexa_devices/icons.json | 34 ++++++++++++- .../components/alexa_devices/strings.json | 15 ++++++ 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa_devices/binary_sensor.py b/homeassistant/components/alexa_devices/binary_sensor.py index 16cf73aee9f..231f144dd89 100644 --- a/homeassistant/components/alexa_devices/binary_sensor.py +++ b/homeassistant/components/alexa_devices/binary_sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from typing import Final from aioamazondevices.api import AmazonDevice +from aioamazondevices.const import SENSOR_STATE_OFF from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -28,7 +29,8 @@ PARALLEL_UPDATES = 0 class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription): """Alexa Devices binary sensor entity description.""" - is_on_fn: Callable[[AmazonDevice], bool] + is_on_fn: Callable[[AmazonDevice, str], bool] + is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True BINARY_SENSORS: Final = ( @@ -36,13 +38,49 @@ BINARY_SENSORS: Final = ( key="online", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, - is_on_fn=lambda _device: _device.online, + is_on_fn=lambda device, _: device.online, ), AmazonBinarySensorEntityDescription( key="bluetooth", entity_category=EntityCategory.DIAGNOSTIC, translation_key="bluetooth", - is_on_fn=lambda _device: _device.bluetooth_state, + is_on_fn=lambda device, _: device.bluetooth_state, + ), + AmazonBinarySensorEntityDescription( + key="babyCryDetectionState", + translation_key="baby_cry_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="beepingApplianceDetectionState", + translation_key="beeping_appliance_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="coughDetectionState", + translation_key="cough_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="dogBarkDetectionState", + translation_key="dog_bark_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="humanPresenceDetectionState", + device_class=BinarySensorDeviceClass.MOTION, + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, + ), + AmazonBinarySensorEntityDescription( + key="waterSoundsDetectionState", + translation_key="water_sounds_detection", + is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF), + is_supported=lambda device, key: device.sensors.get(key) is not None, ), ) @@ -60,6 +98,7 @@ async def async_setup_entry( AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc) for sensor_desc in BINARY_SENSORS for serial_num in coordinator.data + if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key) ) @@ -71,4 +110,6 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the binary sensor is on.""" - return self.entity_description.is_on_fn(self.device) + return self.entity_description.is_on_fn( + self.device, self.entity_description.key + ) diff --git a/homeassistant/components/alexa_devices/icons.json b/homeassistant/components/alexa_devices/icons.json index e3b20eb2c4a..492f89b8fe4 100644 --- a/homeassistant/components/alexa_devices/icons.json +++ b/homeassistant/components/alexa_devices/icons.json @@ -2,9 +2,39 @@ "entity": { "binary_sensor": { "bluetooth": { - "default": "mdi:bluetooth", + "default": "mdi:bluetooth-off", "state": { - "off": "mdi:bluetooth-off" + "on": "mdi:bluetooth" + } + }, + "baby_cry_detection": { + "default": "mdi:account-voice-off", + "state": { + "on": "mdi:account-voice" + } + }, + "beeping_appliance_detection": { + "default": "mdi:bell-off", + "state": { + "on": "mdi:bell-ring" + } + }, + "cough_detection": { + "default": "mdi:blur-off", + "state": { + "on": "mdi:blur" + } + }, + "dog_bark_detection": { + "default": "mdi:dog-side-off", + "state": { + "on": "mdi:dog-side" + } + }, + "water_sounds_detection": { + "default": "mdi:water-pump-off", + "state": { + "on": "mdi:water-pump" } } } diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 9d615b248ed..eb279e28d35 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -41,6 +41,21 @@ "binary_sensor": { "bluetooth": { "name": "Bluetooth" + }, + "baby_cry_detection": { + "name": "Baby crying" + }, + "beeping_appliance_detection": { + "name": "Beeping appliance" + }, + "cough_detection": { + "name": "Coughing" + }, + "dog_bark_detection": { + "name": "Dog barking" + }, + "water_sounds_detection": { + "name": "Water sounds" } }, "notify": { From f4b95ff5f16db7f9254429559c2dd340ad3c75f8 Mon Sep 17 00:00:00 2001 From: Simone Rescio Date: Wed, 25 Jun 2025 09:41:18 +0200 Subject: [PATCH 0650/1664] Ezviz battery camera work mode (#130478) * Add support for EzViz Battery Camera work mode * feat: address review comment, add 'battery' to work mode string * feat: optimize entity addition for Ezviz select component * refactor: streamline error handling in Ezviz select actions * Update library * update library * Bump api to pin mqtt to compatable version * fix after rebase * Update code owners * codeowners * Add support for EzViz Battery Camera work mode * feat: address review comment, add 'battery' to work mode string * feat: optimize entity addition for Ezviz select component * refactor: streamline error handling in Ezviz select actions * feat: address review item simplify Ezviz select actions by removing base class and moving methods * chore: fix ruff lint * feat: check for SupportExt before adding battery select * chore: cleanup logging * feat: restored battery work mode, separated defnitions for sound and battery selects, check SupportExt with type casting * Apply suggestions from code review --------- Co-authored-by: Pierre-Jean Buffard Co-authored-by: Renier Moorcroft <66512715+RenierM26@users.noreply.github.com> Co-authored-by: Erik Montnemery --- homeassistant/components/ezviz/select.py | 126 ++++++++++++++++---- homeassistant/components/ezviz/strings.json | 10 ++ 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py index 44f80ad6cd1..24842f45b68 100644 --- a/homeassistant/components/ezviz/select.py +++ b/homeassistant/components/ezviz/select.py @@ -2,9 +2,17 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pyezvizapi.constants import DeviceSwitchType, SoundMode +from pyezvizapi.constants import ( + BatteryCameraWorkMode, + DeviceCatagories, + DeviceSwitchType, + SoundMode, + SupportExt, +) from pyezvizapi.exceptions import HTTPError, PyEzvizError from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -24,17 +32,83 @@ class EzvizSelectEntityDescription(SelectEntityDescription): """Describe a EZVIZ Select entity.""" supported_switch: int + current_option: Callable[[EzvizSelect], str | None] + select_option: Callable[[EzvizSelect, str, str], None] -SELECT_TYPE = EzvizSelectEntityDescription( +def alarm_sound_mode_current_option(ezvizSelect: EzvizSelect) -> str | None: + """Return the selected entity option to represent the entity state.""" + sound_mode_value = getattr( + SoundMode, ezvizSelect.data[ezvizSelect.entity_description.key] + ).value + if sound_mode_value in [0, 1, 2]: + return ezvizSelect.options[sound_mode_value] + + return None + + +def alarm_sound_mode_select_option( + ezvizSelect: EzvizSelect, serial: str, option: str +) -> None: + """Change the selected option.""" + sound_mode_value = ezvizSelect.options.index(option) + ezvizSelect.coordinator.ezviz_client.alarm_sound(serial, sound_mode_value, 1) + + +ALARM_SOUND_MODE_SELECT_TYPE = EzvizSelectEntityDescription( key="alarm_sound_mod", translation_key="alarm_sound_mode", entity_category=EntityCategory.CONFIG, options=["soft", "intensive", "silent"], supported_switch=DeviceSwitchType.ALARM_TONE.value, + current_option=alarm_sound_mode_current_option, + select_option=alarm_sound_mode_select_option, ) +def battery_work_mode_current_option(ezvizSelect: EzvizSelect) -> str | None: + """Return the selected entity option to represent the entity state.""" + battery_work_mode = getattr( + BatteryCameraWorkMode, + ezvizSelect.data[ezvizSelect.entity_description.key], + BatteryCameraWorkMode.UNKNOWN, + ) + if battery_work_mode == BatteryCameraWorkMode.UNKNOWN: + return None + + return battery_work_mode.name.lower() + + +def battery_work_mode_select_option( + ezvizSelect: EzvizSelect, serial: str, option: str +) -> None: + """Change the selected option.""" + battery_work_mode = getattr(BatteryCameraWorkMode, option.upper()) + ezvizSelect.coordinator.ezviz_client.set_battery_camera_work_mode( + serial, battery_work_mode.value + ) + + +BATTERY_WORK_MODE_SELECT_TYPE = EzvizSelectEntityDescription( + key="battery_camera_work_mode", + translation_key="battery_camera_work_mode", + icon="mdi:battery-sync", + entity_category=EntityCategory.CONFIG, + options=[ + "plugged_in", + "high_performance", + "power_save", + "super_power_save", + "custom", + ], + supported_switch=-1, + current_option=battery_work_mode_current_option, + select_option=battery_work_mode_select_option, +) + +SELECT_TYPES = [ALARM_SOUND_MODE_SELECT_TYPE, BATTERY_WORK_MODE_SELECT_TYPE] + + async def async_setup_entry( hass: HomeAssistant, entry: EzvizConfigEntry, @@ -43,12 +117,26 @@ async def async_setup_entry( """Set up EZVIZ select entities based on a config entry.""" coordinator = entry.runtime_data - async_add_entities( - EzvizSelect(coordinator, camera) + entities = [ + EzvizSelect(coordinator, camera, ALARM_SOUND_MODE_SELECT_TYPE) for camera in coordinator.data for switch in coordinator.data[camera]["switches"] - if switch == SELECT_TYPE.supported_switch - ) + if switch == ALARM_SOUND_MODE_SELECT_TYPE.supported_switch + ] + + for camera in coordinator.data: + device_category = coordinator.data[camera].get("device_category") + supportExt = coordinator.data[camera].get("supportExt") + if ( + device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value + and supportExt + and str(SupportExt.SupportBatteryManage.value) in supportExt + ): + entities.append( + EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE) + ) + + async_add_entities(entities) class EzvizSelect(EzvizEntity, SelectEntity): @@ -58,31 +146,23 @@ class EzvizSelect(EzvizEntity, SelectEntity): self, coordinator: EzvizDataUpdateCoordinator, serial: str, + description: EzvizSelectEntityDescription, ) -> None: - """Initialize the sensor.""" + """Initialize the select entity.""" super().__init__(coordinator, serial) - self._attr_unique_id = f"{serial}_{SELECT_TYPE.key}" - self.entity_description = SELECT_TYPE + self._attr_unique_id = f"{serial}_{description.key}" + self.entity_description = description @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - sound_mode_value = getattr( - SoundMode, self.data[self.entity_description.key] - ).value - if sound_mode_value in [0, 1, 2]: - return self.options[sound_mode_value] - - return None + desc = cast(EzvizSelectEntityDescription, self.entity_description) + return desc.current_option(self) def select_option(self, option: str) -> None: """Change the selected option.""" - sound_mode_value = self.options.index(option) - + desc = cast(EzvizSelectEntityDescription, self.entity_description) try: - self.coordinator.ezviz_client.alarm_sound(self._serial, sound_mode_value, 1) - + return desc.select_option(self, self._serial, option) except (HTTPError, PyEzvizError) as err: - raise HomeAssistantError( - f"Cannot set Warning sound level for {self.entity_id}" - ) from err + raise HomeAssistantError(f"Cannot select option for {desc.key}") from err diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index cd8bbc9d199..b03a5dbc61a 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -68,6 +68,16 @@ "intensive": "Intensive", "silent": "Silent" } + }, + "battery_camera_work_mode": { + "name": "Battery work mode", + "state": { + "plugged_in": "Plugged in", + "high_performance": "High performance", + "power_save": "Power save", + "super_power_save": "Super power saving", + "custom": "Custom" + } } }, "image": { From 33bd35bff4c4aaad4c14a4e88ad5a878ab37008c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Jun 2025 10:36:58 +0200 Subject: [PATCH 0651/1664] Migrate Meater to use HassKey (#147485) * Migrate Meater to use HassKey * Update homeassistant/components/meater/sensor.py Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> * Migrate Meater to use HassKey * Migrate Meater to use HassKey --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/meater/__init__.py | 4 ++-- homeassistant/components/meater/const.py | 4 ++++ homeassistant/components/meater/sensor.py | 4 ++-- tests/components/meater/test_config_flow.py | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 0a9fa77f902..212e8a2a33a 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -3,7 +3,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import MEATER_DATA from .coordinator import MeaterConfigEntry, MeaterCoordinator PLATFORMS = [Platform.SENSOR] @@ -15,7 +15,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo coordinator = MeaterCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}).setdefault("known_probes", set()) + hass.data.setdefault(MEATER_DATA, set()) entry.runtime_data = coordinator diff --git a/homeassistant/components/meater/const.py b/homeassistant/components/meater/const.py index 6b40aa18d59..ac3a238856b 100644 --- a/homeassistant/components/meater/const.py +++ b/homeassistant/components/meater/const.py @@ -1,3 +1,7 @@ """Constants for the Meater Temperature Probe integration.""" +from homeassistant.util.hass_dict import HassKey + DOMAIN = "meater" + +MEATER_DATA: HassKey[set[str]] = HassKey(DOMAIN) diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py index 2369ab30b16..6f180386520 100644 --- a/homeassistant/components/meater/sensor.py +++ b/homeassistant/components/meater/sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from . import MeaterCoordinator -from .const import DOMAIN +from .const import DOMAIN, MEATER_DATA from .coordinator import MeaterConfigEntry COOK_STATES = { @@ -163,7 +163,7 @@ async def async_setup_entry( devices = coordinator.data entities = [] - known_probes: set = hass.data[DOMAIN]["known_probes"] + known_probes = hass.data[MEATER_DATA] # Add entities for temperature probes which we've not yet seen for device_id in devices: diff --git a/tests/components/meater/test_config_flow.py b/tests/components/meater/test_config_flow.py index c6704f2f3f7..9579ba3c1e9 100644 --- a/tests/components/meater/test_config_flow.py +++ b/tests/components/meater/test_config_flow.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock from meater import AuthenticationError, ServiceUnavailableError import pytest -from homeassistant.components.meater import DOMAIN +from homeassistant.components.meater.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant From 58e60fdfac8be81e527842e60e80a562b36b8554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 25 Jun 2025 10:15:09 +0100 Subject: [PATCH 0652/1664] Bump hass-nabucasa from 0.103.0 to 0.104.0 (#147488) --- 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 b5c73e08f3e..70cf6a2c072 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.103.0"], + "requirements": ["hass-nabucasa==0.104.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d4fd42df379..918b8a0f1fd 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==3.49.0 -hass-nabucasa==0.103.0 +hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250531.4 diff --git a/pyproject.toml b/pyproject.toml index 995308bbf0d..87dec7a8429 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.103.0", + "hass-nabucasa==0.104.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 687e5584355..1791d12268b 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.103.0 +hass-nabucasa==0.104.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 3fa6c315b15..23f039ebea2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1130,7 +1130,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.103.0 +hass-nabucasa==0.104.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4319f4e42a0..69141cce9bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.103.0 +hass-nabucasa==0.104.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 0a884c72537e1ef5ae0cfd2f863139709637154c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 11:24:30 +0200 Subject: [PATCH 0653/1664] Add subdevices support to ESPHome (#147343) --- homeassistant/components/esphome/entity.py | 103 +- .../components/esphome/entry_data.py | 24 +- homeassistant/components/esphome/manager.py | 66 +- tests/components/esphome/test_entity.py | 900 +++++++++++++++++- tests/components/esphome/test_manager.py | 288 ++++++ 5 files changed, 1361 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 37f8e738aee..501c773ba39 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine import functools +import logging import math from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast @@ -13,7 +14,6 @@ from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, - build_unique_id, ) import voluptuous as vol @@ -24,6 +24,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_platform, + entity_registry as er, ) from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -32,9 +33,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN # Import config flow so that it's added to the registry -from .entry_data import ESPHomeConfigEntry, RuntimeEntryData +from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id from .enum_mapper import EsphomeEnumMapper +_LOGGER = logging.getLogger(__name__) + _InfoT = TypeVar("_InfoT", bound=EntityInfo) _EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]") _StateT = TypeVar("_StateT", bound=EntityState) @@ -53,21 +56,74 @@ def async_static_info_updated( ) -> None: """Update entities of this platform when entities are listed.""" current_infos = entry_data.info[info_type] + device_info = entry_data.device_info + if TYPE_CHECKING: + assert device_info is not None new_infos: dict[int, EntityInfo] = {} add_entities: list[_EntityT] = [] + ent_reg = er.async_get(hass) + dev_reg = dr.async_get(hass) + for info in infos: - if not current_infos.pop(info.key, None): - # Create new entity + new_infos[info.key] = info + + # Create new entity if it doesn't exist + if not (old_info := current_infos.pop(info.key, None)): entity = entity_type(entry_data, platform.domain, info, state_type) add_entities.append(entity) - new_infos[info.key] = info + continue + + # Entity exists - check if device_id has changed + if old_info.device_id == info.device_id: + continue + + # Entity has switched devices, need to migrate unique_id + old_unique_id = build_device_unique_id(device_info.mac_address, old_info) + entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id) + + # If entity not found in registry, re-add it + # This happens when the device_id changed and the old device was deleted + if entity_id is None: + _LOGGER.info( + "Entity with old unique_id %s not found in registry after device_id " + "changed from %s to %s, re-adding entity", + old_unique_id, + old_info.device_id, + info.device_id, + ) + entity = entity_type(entry_data, platform.domain, info, state_type) + add_entities.append(entity) + continue + + updates: dict[str, Any] = {} + new_unique_id = build_device_unique_id(device_info.mac_address, info) + + # Update unique_id if it changed + if old_unique_id != new_unique_id: + updates["new_unique_id"] = new_unique_id + + # Update device assignment + if info.device_id: + # Entity now belongs to a sub device + new_device = dev_reg.async_get_device( + identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")} + ) + else: + # Entity now belongs to the main device + new_device = dev_reg.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + + if new_device: + updates["device_id"] = new_device.id + + # Apply all updates at once + if updates: + ent_reg.async_update_entity(entity_id, **updates) # Anything still in current_infos is now gone if current_infos: - device_info = entry_data.device_info - if TYPE_CHECKING: - assert device_info is not None entry_data.async_remove_entities( hass, current_infos.values(), device_info.mac_address ) @@ -244,11 +300,28 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): self._key = entity_info.key self._state_type = state_type self._on_static_info_update(entity_info) - self._attr_device_info = DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} - ) + + device_name = device_info.name + # Determine the device connection based on whether this entity belongs to a sub device + if entity_info.device_id: + # Entity belongs to a sub device + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}") + } + ) + # Use the pre-computed device_id_to_name mapping for O(1) lookup + device_name = entry_data.device_id_to_name.get( + entity_info.device_id, device_info.name + ) + else: + # Entity belongs to the main device + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)} + ) + if entity_info.name: - self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}" + self.entity_id = f"{domain}.{device_name}_{entity_info.object_id}" else: # https://github.com/home-assistant/core/issues/132532 # If name is not set, ESPHome will use the sanitized friendly name @@ -256,7 +329,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): # as the entity_id before it is sanitized since the sanitizer # is not utf-8 aware. In this case, its always going to be # an empty string so we drop the object_id. - self.entity_id = f"{domain}.{device_info.name}" + self.entity_id = f"{domain}.{device_name}" async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -290,7 +363,9 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): static_info = cast(_InfoT, static_info) assert device_info self._static_info = static_info - self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) + self._attr_unique_id = build_device_unique_id( + device_info.mac_address, static_info + ) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default # https://github.com/home-assistant/core/issues/132532 # If the name is "", we need to set it to None since otherwise diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 1e6375d8caf..71680873611 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -95,6 +95,22 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { } +def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str: + """Build unique ID for entity, appending @device_id if it belongs to a sub-device. + + This wrapper around build_unique_id ensures that entities belonging to sub-devices + have their device_id appended to the unique_id to handle proper migration when + entities move between devices. + """ + base_unique_id = build_unique_id(mac, entity_info) + + # If entity belongs to a sub-device, append @device_id + if entity_info.device_id: + return f"{base_unique_id}@{entity_info.device_id}" + + return base_unique_id + + class StoreData(TypedDict, total=False): """ESPHome storage data.""" @@ -160,6 +176,7 @@ class RuntimeEntryData: assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field( default_factory=list ) + device_id_to_name: dict[int, str] = field(default_factory=dict) @property def name(self) -> str: @@ -222,7 +239,9 @@ class RuntimeEntryData: ent_reg = er.async_get(hass) for info in static_infos: if entry := ent_reg.async_get_entity_id( - INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info) + INFO_TYPE_TO_PLATFORM[type(info)], + DOMAIN, + build_device_unique_id(mac, info), ): ent_reg.async_remove(entry) @@ -278,7 +297,8 @@ class RuntimeEntryData: if ( (old_unique_id := info.unique_id) and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) - and (new_unique_id := build_unique_id(mac, info)) != old_unique_id + and (new_unique_id := build_device_unique_id(mac, info)) + != old_unique_id and not registry_get_entity(platform, DOMAIN, new_unique_id) ): ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index b4af39586d4..6c2da31e48b 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -527,6 +527,11 @@ class ESPHomeManager: device_info.name, device_mac, ) + # Build device_id_to_name mapping for efficient lookup + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name or device_info.name + for sub_device in device_info.devices + } self.device_id = _async_setup_device_registry(hass, entry, entry_data) entry_data.async_update_device_state() @@ -751,6 +756,28 @@ def _async_setup_device_registry( device_info = entry_data.device_info if TYPE_CHECKING: assert device_info is not None + + device_registry = dr.async_get(hass) + # Build sets of valid device identifiers and connections + valid_connections = { + (dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address)) + } + valid_identifiers = { + (DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}") + for sub_device in device_info.devices + } + + # Remove devices that no longer exist + for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id): + # Skip devices we want to keep + if ( + device.connections & valid_connections + or device.identifiers & valid_identifiers + ): + continue + # Remove everything else + device_registry.async_remove_device(device.id) + sw_version = device_info.esphome_version if device_info.compilation_time: sw_version += f" ({device_info.compilation_time})" @@ -779,11 +806,14 @@ def _async_setup_device_registry( f"{device_info.project_version} (ESPHome {device_info.esphome_version})" ) - suggested_area = None - if device_info.suggested_area: + suggested_area: str | None = None + if device_info.area and device_info.area.name: + # Prefer device_info.area over suggested_area when area name is not empty + suggested_area = device_info.area.name + elif device_info.suggested_area: suggested_area = device_info.suggested_area - device_registry = dr.async_get(hass) + # Create/update main device device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, configuration_url=configuration_url, @@ -794,6 +824,36 @@ def _async_setup_device_registry( sw_version=sw_version, suggested_area=suggested_area, ) + + # Handle sub devices + # Find available areas from device_info + areas_by_id = {area.area_id: area for area in device_info.areas} + # Add the main device's area if it exists + if device_info.area: + areas_by_id[device_info.area.area_id] = device_info.area + # Create/update sub devices that should exist + for sub_device in device_info.devices: + # Determine the area for this sub device + sub_device_suggested_area: str | None = None + if sub_device.area_id is not None and sub_device.area_id in areas_by_id: + sub_device_suggested_area = areas_by_id[sub_device.area_id].name + + sub_device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")}, + name=sub_device.name or device_entry.name, + manufacturer=manufacturer, + model=model, + sw_version=sw_version, + suggested_area=sub_device_suggested_area, + ) + + # Update the sub device to set via_device_id + device_registry.async_update_device( + sub_device_entry.id, + via_device_id=device_entry.id, + ) + return device_entry.id diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 9dcfe73b898..8d597ffecb0 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -12,6 +12,7 @@ from aioesphomeapi import ( DeviceInfo, SensorInfo, SensorState, + SubDeviceInfo, build_unique_id, ) import pytest @@ -27,7 +28,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_state_change_event from .conftest import MockESPHomeDevice, MockESPHomeDeviceType @@ -699,3 +700,900 @@ async def test_deep_sleep_added_after_setup( state = hass.states.get(entity_id) assert state is not None assert state.state == STATE_ON + + +async def test_entity_assignment_to_sub_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entities are assigned to correct sub devices.""" + device_registry = dr.async_get(hass) + + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=0), + SubDeviceInfo(device_id=22222222, name="Door Sensor", area_id=0), + ] + + device_info = { + "devices": sub_devices, + } + + # Create entities that belong to different devices + entity_info = [ + # Entity for main device (device_id=0) + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + unique_id="main_sensor", + device_id=0, + ), + # Entity for sub device 1 + BinarySensorInfo( + object_id="motion", + key=2, + name="Motion", + unique_id="motion", + device_id=11111111, + ), + # Entity for sub device 2 + BinarySensorInfo( + object_id="door", + key=3, + name="Door", + unique_id="door", + device_id=22222222, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=False, missing_state=False), + BinarySensorState(key=3, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check main device + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + + # Check entities are assigned to correct devices + main_sensor = entity_registry.async_get("binary_sensor.test_main_sensor") + assert main_sensor is not None + assert main_sensor.device_id == main_device.id + + # Check sub device 1 entity + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + + motion_sensor = entity_registry.async_get("binary_sensor.motion_sensor_motion") + assert motion_sensor is not None + assert motion_sensor.device_id == sub_device_1.id + + # Check sub device 2 entity + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + + door_sensor = entity_registry.async_get("binary_sensor.door_sensor_door") + assert door_sensor is not None + assert door_sensor.device_id == sub_device_2.id + + # Check states + assert hass.states.get("binary_sensor.test_main_sensor").state == STATE_ON + assert hass.states.get("binary_sensor.motion_sensor_motion").state == STATE_OFF + assert hass.states.get("binary_sensor.door_sensor_door").state == STATE_ON + + # Check entity friendly names + # Main device entity should have: "{device_name} {entity_name}" + main_sensor_state = hass.states.get("binary_sensor.test_main_sensor") + assert main_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Test Main Sensor" + + # Sub device 1 entity should have: "Motion Sensor Motion" + motion_sensor_state = hass.states.get("binary_sensor.motion_sensor_motion") + assert motion_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Motion Sensor Motion" + + # Sub device 2 entity should have: "Door Sensor Door" + door_sensor_state = hass.states.get("binary_sensor.door_sensor_door") + assert door_sensor_state.attributes[ATTR_FRIENDLY_NAME] == "Door Sensor Door" + + +async def test_entity_friendly_names_with_empty_device_names( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entity friendly names when sub-devices have empty names.""" + # Define sub devices with different name scenarios + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + SubDeviceInfo( + device_id=22222222, name="Kitchen Light", area_id=0 + ), # Valid name + ] + + device_info = { + "devices": sub_devices, + "friendly_name": "Main Device", + } + + # Entity on sub-device with empty name + entity_info = [ + BinarySensorInfo( + object_id="motion", + key=1, + name="Motion Detected", + device_id=11111111, + ), + # Entity on sub-device with valid name + BinarySensorInfo( + object_id="status", + key=2, + name="Status", + device_id=22222222, + ), + # Entity with empty name on sub-device with valid name + BinarySensorInfo( + object_id="sensor", + key=3, + name="", # Empty entity name + device_id=22222222, + ), + # Entity on main device + BinarySensorInfo( + object_id="main_status", + key=4, + name="Main Status", + device_id=0, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=False, missing_state=False), + BinarySensorState(key=3, state=True, missing_state=False), + BinarySensorState(key=4, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check entity friendly name on sub-device with empty name + # Since sub device has empty name, it falls back to main device name "test" + state_1 = hass.states.get("binary_sensor.test_motion") + assert state_1 is not None + # With has_entity_name, friendly name is "{device_name} {entity_name}" + # Since sub-device falls back to main device name: "Main Device Motion Detected" + assert state_1.attributes[ATTR_FRIENDLY_NAME] == "Main Device Motion Detected" + + # Check entity friendly name on sub-device with valid name + state_2 = hass.states.get("binary_sensor.kitchen_light_status") + assert state_2 is not None + # Device has name "Kitchen Light", entity has name "Status" + assert state_2.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light Status" + + # Test entity with empty name on sub-device + state_3 = hass.states.get("binary_sensor.kitchen_light") + assert state_3 is not None + # Entity has empty name, so friendly name is just the device name + assert state_3.attributes[ATTR_FRIENDLY_NAME] == "Kitchen Light" + + # Test entity on main device + state_4 = hass.states.get("binary_sensor.test_main_status") + assert state_4 is not None + assert state_4.attributes[ATTR_FRIENDLY_NAME] == "Main Device Main Status" + + +async def test_entity_switches_between_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entities can switch between devices correctly.""" + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0), + SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0), + ] + + device_info = { + "devices": sub_devices, + } + + # Create initial entity assigned to main device (no device_id) + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + unique_id="sensor", + # device_id omitted - entity belongs to main device + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify entity is on main device + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + + sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == main_device.id + + # Test 1: Main device → Sub device 1 + updated_entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + unique_id="sensor", + device_id=11111111, # Now on sub device 1 + ), + ] + + # Update the entity info by changing what the mock returns + mock_client.list_entities_services = AsyncMock( + return_value=(updated_entity_info, []) + ) + # Trigger a reconnect to simulate the entity info update + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Verify entity is now on sub device 1 + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + + sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == sub_device_1.id + + # Test 2: Sub device 1 → Sub device 2 + updated_entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + unique_id="sensor", + device_id=22222222, # Now on sub device 2 + ), + ] + + mock_client.list_entities_services = AsyncMock( + return_value=(updated_entity_info, []) + ) + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Verify entity is now on sub device 2 + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + + sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == sub_device_2.id + + # Test 3: Sub device 2 → Main device + updated_entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Test Sensor", + unique_id="sensor", + # device_id omitted - back to main device + ), + ] + + mock_client.list_entities_services = AsyncMock( + return_value=(updated_entity_info, []) + ) + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Verify entity is back on main device + sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + assert sensor_entity is not None + assert sensor_entity.device_id == main_device.id + + +async def test_entity_id_uses_sub_device_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entity_id uses sub device name when entity belongs to sub device.""" + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="motion_sensor", area_id=0), + SubDeviceInfo(device_id=22222222, name="door_sensor", area_id=0), + ] + + device_info = { + "devices": sub_devices, + "name": "main_device", + } + + # Create entities that belong to different devices + entity_info = [ + # Entity for main device (device_id=0) + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + unique_id="main_sensor", + device_id=0, + ), + # Entity for sub device 1 + BinarySensorInfo( + object_id="motion", + key=2, + name="Motion", + unique_id="motion", + device_id=11111111, + ), + # Entity for sub device 2 + BinarySensorInfo( + object_id="door", + key=3, + name="Door", + unique_id="door", + device_id=22222222, + ), + # Entity without name on sub device + BinarySensorInfo( + object_id="sensor_no_name", + key=4, + name="", + unique_id="sensor_no_name", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=2, state=False, missing_state=False), + BinarySensorState(key=3, state=True, missing_state=False), + BinarySensorState(key=4, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check entity_id for main device entity + # Should be: binary_sensor.{main_device_name}_{object_id} + assert hass.states.get("binary_sensor.main_device_main_sensor") is not None + + # Check entity_id for sub device 1 entity + # Should be: binary_sensor.{sub_device_name}_{object_id} + assert hass.states.get("binary_sensor.motion_sensor_motion") is not None + + # Check entity_id for sub device 2 entity + # Should be: binary_sensor.{sub_device_name}_{object_id} + assert hass.states.get("binary_sensor.door_sensor_door") is not None + + # Check entity_id for entity without name on sub device + # Should be: binary_sensor.{sub_device_name} + assert hass.states.get("binary_sensor.motion_sensor") is not None + + +async def test_entity_id_with_empty_sub_device_name( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test entity_id when sub device has empty name (falls back to main device name).""" + # Define sub device with empty name + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + ] + + device_info = { + "devices": sub_devices, + "name": "main_device", + } + + # Create entity on sub device with empty name + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Sensor", + unique_id="sensor", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # When sub device has empty name, entity_id should use main device name + # Should be: binary_sensor.{main_device_name}_{object_id} + assert hass.states.get("binary_sensor.main_device_sensor") is not None + + +async def test_unique_id_migration_when_entity_moves_between_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that unique_id is migrated when entity moves between devices while entity_id stays the same.""" + # Initial setup: entity on main device + device_info = { + "name": "test", + "devices": [], # No sub-devices initially + } + + # Entity on main device + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + unique_id="unused", # This field is not used by the integration + device_id=0, # Main device + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check initial entity + state = hass.states.get("binary_sensor.test_temperature") + assert state is not None + + # Get the entity from registry + entity_entry = entity_registry.async_get("binary_sensor.test_temperature") + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Initial unique_id should not have @device_id suffix since it's on main device + assert "@" not in initial_unique_id + + # Add sub-device to device info + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + ] + + # Get the config entry from hass + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Build device_id_to_name mapping like manager.py does + entry_data = entry.runtime_data + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name for sub_device in sub_devices + } + + # Create a new DeviceInfo with sub-devices since it's frozen + # Get the current device info and convert to dict + current_device_info = mock_client.device_info.return_value + device_info_dict = asdict(current_device_info) + + # Update the devices list + device_info_dict["devices"] = sub_devices + + # Create new DeviceInfo with updated devices + new_device_info = DeviceInfo(**device_info_dict) + + # Update mock_client to return new device info + mock_client.device_info.return_value = new_device_info + + # Update entity info - same key and object_id but now on sub-device + new_entity_info = [ + BinarySensorInfo( + object_id="temperature", # Same object_id + key=1, # Same key - this is what identifies the entity + name="Temperature", + unique_id="unused", # This field is not used + device_id=22222222, # Now on sub-device + ), + ] + + # Update the entity info by changing what the mock returns + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + + # Trigger a reconnect to simulate the entity info update + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + + # Wait for entity to be updated + await hass.async_block_till_done() + + # The entity_id doesn't change when moving between devices + # Only the unique_id gets updated with @device_id suffix + state = hass.states.get("binary_sensor.test_temperature") + assert state is not None + + # Get updated entity from registry - entity_id should be the same + entity_entry = entity_registry.async_get("binary_sensor.test_temperature") + assert entity_entry is not None + + # Unique ID should have been migrated to include @device_id + # This is done by our build_device_unique_id wrapper + expected_unique_id = f"{initial_unique_id}@22222222" + assert entity_entry.unique_id == expected_unique_id + + # Entity should now be associated with the sub-device + sub_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device is not None + assert entity_entry.device_id == sub_device.id + + +async def test_unique_id_migration_sub_device_to_main_device( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that unique_id is migrated when entity moves from sub-device to main device.""" + # Initial setup: entity on sub-device + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on sub-device + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + unique_id="unused", + device_id=22222222, # On sub-device + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check initial entity + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get the entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Initial unique_id should have @device_id suffix since it's on sub-device + assert "@22222222" in initial_unique_id + + # Update entity info - move to main device + new_entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + unique_id="unused", + device_id=0, # Now on main device + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + + # Trigger a reconnect + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The entity_id should remain the same + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get updated entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + + # Unique ID should have been migrated to remove @device_id suffix + expected_unique_id = initial_unique_id.replace("@22222222", "") + assert entity_entry.unique_id == expected_unique_id + + # Entity should now be associated with the main device + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + assert entity_entry.device_id == main_device.id + + +async def test_unique_id_migration_between_sub_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that unique_id is migrated when entity moves between sub-devices.""" + # Initial setup: two sub-devices + sub_devices = [ + SubDeviceInfo(device_id=22222222, name="kitchen_controller", area_id=0), + SubDeviceInfo(device_id=33333333, name="bedroom_controller", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on first sub-device + entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + unique_id="unused", + device_id=22222222, # On kitchen_controller + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Check initial entity + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get the entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Initial unique_id should have @22222222 suffix + assert "@22222222" in initial_unique_id + + # Update entity info - move to second sub-device + new_entity_info = [ + BinarySensorInfo( + object_id="temperature", + key=1, + name="Temperature", + unique_id="unused", + device_id=33333333, # Now on bedroom_controller + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + + # Trigger a reconnect + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The entity_id should remain the same + state = hass.states.get("binary_sensor.kitchen_controller_temperature") + assert state is not None + + # Get updated entity from registry + entity_entry = entity_registry.async_get( + "binary_sensor.kitchen_controller_temperature" + ) + assert entity_entry is not None + + # Unique ID should have been migrated from @22222222 to @33333333 + expected_unique_id = initial_unique_id.replace("@22222222", "@33333333") + assert entity_entry.unique_id == expected_unique_id + + # Entity should now be associated with the second sub-device + bedroom_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + assert bedroom_device is not None + assert entity_entry.device_id == bedroom_device.id + + +async def test_entity_device_id_rename_in_yaml( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that entities are re-added as new when user renames device_id in YAML config.""" + # Initial setup: entity on sub-device with device_id 11111111 + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="old_device", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Entity on sub-device + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Sensor", + unique_id="unused", + device_id=11111111, + ), + ] + + states = [ + BinarySensorState(key=1, state=True, missing_state=False), + ] + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify initial entity setup + state = hass.states.get("binary_sensor.old_device_sensor") + assert state is not None + assert state.state == STATE_ON + + # Wait for entity to be registered + await hass.async_block_till_done() + + # Get the entity from registry + entity_entry = entity_registry.async_get("binary_sensor.old_device_sensor") + assert entity_entry is not None + initial_unique_id = entity_entry.unique_id + # Should have @11111111 suffix + assert "@11111111" in initial_unique_id + + # Simulate user renaming device_id in YAML config + # The device_id hash changes from 11111111 to 99999999 + # This is treated as a completely new device + renamed_sub_devices = [ + SubDeviceInfo(device_id=99999999, name="renamed_device", area_id=0), + ] + + # Get the config entry from hass + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Update device_id_to_name mapping + entry_data = entry.runtime_data + entry_data.device_id_to_name = { + sub_device.device_id: sub_device.name for sub_device in renamed_sub_devices + } + + # Create new DeviceInfo with renamed device + current_device_info = mock_client.device_info.return_value + device_info_dict = asdict(current_device_info) + device_info_dict["devices"] = renamed_sub_devices + new_device_info = DeviceInfo(**device_info_dict) + mock_client.device_info.return_value = new_device_info + + # Entity info now has the new device_id + new_entity_info = [ + BinarySensorInfo( + object_id="sensor", # Same object_id + key=1, # Same key + name="Sensor", + unique_id="unused", + device_id=99999999, # New device_id after rename + ), + ] + + # Update the entity info + mock_client.list_entities_services = AsyncMock(return_value=(new_entity_info, [])) + + # Trigger a reconnect to simulate the YAML config change + await device.mock_disconnect(expected_disconnect=False) + await device.mock_connect() + await hass.async_block_till_done() + + # The old entity should be gone (device was deleted) + state = hass.states.get("binary_sensor.old_device_sensor") + assert state is None + + # A new entity should exist with a new entity_id based on the new device name + # This is a completely new entity, not a migrated one + state = hass.states.get("binary_sensor.renamed_device_sensor") + assert state is not None + assert state.state == STATE_ON + + # Get the new entity from registry + entity_entry = entity_registry.async_get("binary_sensor.renamed_device_sensor") + assert entity_entry is not None + + # Unique ID should have the new device_id + base_unique_id = initial_unique_id.replace("@11111111", "") + expected_unique_id = f"{base_unique_id}@99999999" + assert entity_entry.unique_id == expected_unique_id + + # Entity should be associated with the new device + renamed_device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_99999999")} + ) + assert renamed_device is not None + assert entity_entry.device_id == renamed_device.id diff --git a/tests/components/esphome/test_manager.py b/tests/components/esphome/test_manager.py index dfadf6ad6d7..318ccde221f 100644 --- a/tests/components/esphome/test_manager.py +++ b/tests/components/esphome/test_manager.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, Mock, call from aioesphomeapi import ( APIClient, APIConnectionError, + AreaInfo, DeviceInfo, EncryptionPlaintextAPIError, HomeassistantServiceCall, @@ -14,6 +15,7 @@ from aioesphomeapi import ( InvalidEncryptionKeyAPIError, LogLevel, RequiresEncryptionAPIError, + SubDeviceInfo, UserService, UserServiceArg, UserServiceArgType, @@ -1179,6 +1181,29 @@ async def test_esphome_device_with_suggested_area( assert dev.suggested_area == "kitchen" +async def test_esphome_device_area_priority( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test that device_info.area takes priority over suggested_area.""" + device = await mock_esphome_device( + mock_client=mock_client, + device_info={ + "suggested_area": "kitchen", + "area": AreaInfo(area_id=0, name="Living Room"), + }, + ) + await hass.async_block_till_done() + entry = device.entry + dev = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} + ) + # Should use device_info.area.name instead of suggested_area + assert dev.suggested_area == "Living Room" + + async def test_esphome_device_with_project( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1500,3 +1525,266 @@ async def test_assist_in_progress_issue_deleted( ) is None ) + + +async def test_sub_device_creation( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices are created in device registry.""" + device_registry = dr.async_get(hass) + + # Define areas + areas = [ + AreaInfo(area_id=1, name="Living Room"), + AreaInfo(area_id=2, name="Bedroom"), + AreaInfo(area_id=3, name="Kitchen"), + ] + + # Define sub devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Motion Sensor", area_id=1), + SubDeviceInfo(device_id=22222222, name="Light Switch", area_id=1), + SubDeviceInfo(device_id=33333333, name="Temperature Sensor", area_id=2), + ] + + device_info = { + "areas": areas, + "devices": sub_devices, + "area": AreaInfo(area_id=0, name="Main Hub"), + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + + # Check main device is created + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + assert main_device.suggested_area == "Main Hub" + + # Check sub devices are created + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + assert sub_device_1.name == "Motion Sensor" + assert sub_device_1.suggested_area == "Living Room" + assert sub_device_1.via_device_id == main_device.id + + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + assert sub_device_2.name == "Light Switch" + assert sub_device_2.suggested_area == "Living Room" + assert sub_device_2.via_device_id == main_device.id + + sub_device_3 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + assert sub_device_3 is not None + assert sub_device_3.name == "Temperature Sensor" + assert sub_device_3.suggested_area == "Bedroom" + assert sub_device_3.via_device_id == main_device.id + + +async def test_sub_device_cleanup( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices are removed when they no longer exist.""" + device_registry = dr.async_get(hass) + + # Initial sub devices + sub_devices_initial = [ + SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0), + SubDeviceInfo(device_id=22222222, name="Device 2", area_id=0), + SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0), + ] + + device_info = { + "devices": sub_devices_initial, + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + + # Verify all sub devices exist + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + is not None + ) + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + is not None + ) + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + is not None + ) + + # Now update with fewer sub devices (device 2 removed) + sub_devices_updated = [ + SubDeviceInfo(device_id=11111111, name="Device 1", area_id=0), + SubDeviceInfo(device_id=33333333, name="Device 3", area_id=0), + ] + + # Update device info + device.device_info = DeviceInfo( + name="test", + friendly_name="Test", + esphome_version="1.0.0", + mac_address="11:22:33:44:55:AA", + devices=sub_devices_updated, + ) + + # Update the mock client to return the new device info + mock_client.device_info = AsyncMock(return_value=device.device_info) + + # Simulate reconnection which triggers device registry update + await device.mock_connect() + await hass.async_block_till_done() + + # Verify device 2 was removed + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + is not None + ) + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + is None + ) # Should be removed + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + is not None + ) + + +async def test_sub_device_with_empty_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices with empty names are handled correctly.""" + device_registry = dr.async_get(hass) + + # Define sub devices with empty names + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="", area_id=0), # Empty name + SubDeviceInfo(device_id=22222222, name="Valid Name", area_id=0), + ] + + device_info = { + "devices": sub_devices, + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + await hass.async_block_till_done() + + # Check sub device with empty name + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + # Empty sub-device names should fall back to main device name + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert sub_device_1.name == main_device.name + + # Check sub device with valid name + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + assert sub_device_2.name == "Valid Name" + + +async def test_sub_device_references_main_device_area( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test sub devices can reference the main device's area.""" + device_registry = dr.async_get(hass) + + # Define areas - note we don't include area_id=0 in the areas list + areas = [ + AreaInfo(area_id=1, name="Living Room"), + AreaInfo(area_id=2, name="Bedroom"), + ] + + # Define sub devices - one references the main device's area (area_id=0) + sub_devices = [ + SubDeviceInfo( + device_id=11111111, name="Motion Sensor", area_id=0 + ), # Main device area + SubDeviceInfo( + device_id=22222222, name="Light Switch", area_id=1 + ), # Living Room + SubDeviceInfo( + device_id=33333333, name="Temperature Sensor", area_id=2 + ), # Bedroom + ] + + device_info = { + "areas": areas, + "devices": sub_devices, + "area": AreaInfo(area_id=0, name="Main Hub Area"), + } + + device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + ) + + # Check main device has correct area + main_device = device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device.device_info.mac_address)} + ) + assert main_device is not None + assert main_device.suggested_area == "Main Hub Area" + + # Check sub device 1 uses main device's area + sub_device_1 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_11111111")} + ) + assert sub_device_1 is not None + assert sub_device_1.suggested_area == "Main Hub Area" + + # Check sub device 2 uses Living Room + sub_device_2 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_22222222")} + ) + assert sub_device_2 is not None + assert sub_device_2.suggested_area == "Living Room" + + # Check sub device 3 uses Bedroom + sub_device_3 = device_registry.async_get_device( + identifiers={(DOMAIN, f"{device.device_info.mac_address}_33333333")} + ) + assert sub_device_3 is not None + assert sub_device_3.suggested_area == "Bedroom" From 0bbb168862db5fe30ac0739f70537e7516ab6717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 25 Jun 2025 11:24:38 +0200 Subject: [PATCH 0654/1664] Add Home Connect DHCP information (#147494) * Add Home Connect DHCP information * Add tests --- .../components/home_connect/manifest.json | 2 +- homeassistant/generated/dhcp.py | 2 +- .../home_connect/test_config_flow.py | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 8ced21ecba5..2008e618f5e 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -14,7 +14,7 @@ "macaddress": "68A40E*" }, { - "hostname": "(siemens|neff)-*", + "hostname": "(bosch|neff|siemens)-*", "macaddress": "38B4D3*" } ], diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index b253c5a553d..47072d4c05d 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -288,7 +288,7 @@ DHCP: Final[list[dict[str, str | bool]]] = [ }, { "domain": "home_connect", - "hostname": "(siemens|neff)-*", + "hostname": "(bosch|neff|siemens)-*", "macaddress": "38B4D3*", }, { diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index ad35f890528..3245f439bef 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -36,6 +36,21 @@ DHCP_DISCOVERY = ( hostname="BOSCH-ABCDE1234-68A40E000000", macaddress="68:A4:0E:00:00:00", ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="BOSCH-ABCDE1234-68A40E000000", + macaddress="38:B4:D3:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="bosch-dishwasher-000000000000000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="bosch-dishwasher-000000000000000000", + macaddress="38:B4:D3:00:00:00", + ), DhcpServiceInfo( ip="1.1.1.1", hostname="SIEMENS-ABCDE1234-68A40E000000", @@ -56,6 +71,26 @@ DHCP_DISCOVERY = ( hostname="siemens-dishwasher-000000000000000000", macaddress="38:B4:D3:00:00:00", ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="NEFF-ABCDE1234-68A40E000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="NEFF-ABCDE1234-38B4D3000000", + macaddress="38:B4:D3:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="neff-dishwasher-000000000000000000", + macaddress="68:A4:0E:00:00:00", + ), + DhcpServiceInfo( + ip="1.1.1.1", + hostname="neff-dishwasher-000000000000000000", + macaddress="38:B4:D3:00:00:00", + ), ) From f897a728f18d4fc0c6746714dff5a1cf019eb8cf Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 02:25:01 -0700 Subject: [PATCH 0655/1664] Fix Google AI not using correct config options after subentries migration (#147493) --- .../__init__.py | 3 +- .../entity.py | 87 ++++++++++--------- 2 files changed, 46 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 3a7d160399d..40d441929a3 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -35,7 +35,6 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, @@ -190,7 +189,7 @@ async def async_setup_entry( client = await hass.async_add_executor_job(_init_client) await client.aio.models.get( - model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + model=RECOMMENDED_CHAT_MODEL, config={"http_options": {"timeout": TIMEOUT_MILLIS}}, ) except (APIError, Timeout) as err: diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index d4b0ec2bbd0..66acb6b158a 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -337,7 +337,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): tools = tools or [] tools.append(Tool(google_search=GoogleSearch())) - model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) # Avoid INVALID_ARGUMENT Developer instruction is not enabled for supports_system_instruction = ( "gemma" not in model_name @@ -389,47 +389,13 @@ class GoogleGenerativeAILLMBaseEntity(Entity): if tool_results: messages.append(_create_google_tool_response_content(tool_results)) - generateContentConfig = GenerateContentConfig( - temperature=self.entry.options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K), - top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - max_output_tokens=self.entry.options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - safety_settings=[ - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, - threshold=self.entry.options.get( - CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_HARASSMENT, - threshold=self.entry.options.get( - CONF_HARASSMENT_BLOCK_THRESHOLD, - RECOMMENDED_HARM_BLOCK_THRESHOLD, - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, - threshold=self.entry.options.get( - CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - SafetySetting( - category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, - threshold=self.entry.options.get( - CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD - ), - ), - ], - tools=tools or None, - system_instruction=prompt if supports_system_instruction else None, - automatic_function_calling=AutomaticFunctionCallingConfig( - disable=True, maximum_remote_calls=None - ), + generateContentConfig = self.create_generate_content_config() + generateContentConfig.tools = tools or None + generateContentConfig.system_instruction = ( + prompt if supports_system_instruction else None + ) + generateContentConfig.automatic_function_calling = ( + AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None) ) if not supports_system_instruction: @@ -472,3 +438,40 @@ class GoogleGenerativeAILLMBaseEntity(Entity): if not chat_log.unresponded_tool_results: break + + def create_generate_content_config(self) -> GenerateContentConfig: + """Create the GenerateContentConfig for the LLM.""" + options = self.subentry.data + return GenerateContentConfig( + temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K), + top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + safety_settings=[ + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=options.get( + CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=options.get( + CONF_HARASSMENT_BLOCK_THRESHOLD, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=options.get( + CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + SafetySetting( + category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=options.get( + CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD + ), + ), + ], + ) From c9e9575a3d10a04530a62a0cbb6a414bc79a430b Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 25 Jun 2025 05:38:51 -0400 Subject: [PATCH 0656/1664] Add tests for join and unjoin service calls in Sonos (#145602) * fix: add tests for join and unjoin * fix: update comments * fix: update comments * fix: refactor to common functions * fix: refactor to common functions * fix: add type def * fix: add return types * fix: add return types * fix: correct type annontation for uui_ds * fix: update comments * fix: merge issues * fix: merge issue * fix: raise homeassistanterror on timeout * fix: add comments * fix: simplify test * fix: simplify test * fix: simplify test --- homeassistant/components/sonos/speaker.py | 11 +- homeassistant/components/sonos/strings.json | 3 + tests/components/sonos/conftest.py | 63 ++++++- tests/components/sonos/test_services.py | 184 +++++++++++++++++--- tests/components/sonos/test_speaker.py | 39 +---- 5 files changed, 240 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index aee0a40c184..f5cfb84ec36 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -1172,8 +1172,15 @@ class SonosSpeaker: while not _test_groups(groups): await config_entry.runtime_data.topology_condition.wait() except TimeoutError: - _LOGGER.warning("Timeout waiting for target groups %s", groups) - + group_description = [ + f"{group[0].zone_name}: {', '.join(speaker.zone_name for speaker in group)}" + for group in groups + ] + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout_join", + translation_placeholders={"group_description": str(group_description)}, + ) from TimeoutError any_speaker = next(iter(config_entry.runtime_data.discovered.values())) any_speaker.soco.zone_group_state.clear_cache() diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 433bb3cc36a..c40f5ccd416 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -194,6 +194,9 @@ }, "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" + }, + "timeout_join": { + "message": "Timeout while waiting for Sonos player to join the group {group_description}" } } } diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d121d5a4a12..d3de2a889d5 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,5 +1,7 @@ """Configuration for Sonos tests.""" +from __future__ import annotations + import asyncio from collections.abc import Callable, Coroutine, Generator from copy import copy @@ -107,13 +109,31 @@ class SonosMockAlarmClock(SonosMockService): class SonosMockEvent: """Mock a sonos Event used in callbacks.""" - def __init__(self, soco, service, variables) -> None: - """Initialize the instance.""" + def __init__( + self, + soco: MockSoCo, + service: SonosMockService, + variables: dict[str, str], + zone_player_uui_ds_in_group: str | None = None, + ) -> None: + """Initialize the instance. + + Args: + soco: The mock SoCo device associated with this event. + service: The Sonos mock service that generated the event. + variables: A dictionary of event variables and their values. + zone_player_uui_ds_in_group: Optional comma-separated string of unique zone IDs in the group. + + """ self.sid = f"{soco.uid}_sub0000000001" self.seq = "0" self.timestamp = 1621000000.0 self.service = service self.variables = variables + # In Soco events of the same type may or may not have this attribute present. + # Only create the attribute if it should be present. + if zone_player_uui_ds_in_group: + self.zone_player_uui_ds_in_group = zone_player_uui_ds_in_group def increment_variable(self, var_name): """Increment the value of the var_name key in variables dict attribute. @@ -823,3 +843,42 @@ async def sonos_setup_two_speakers( ) await hass.async_block_till_done() return [soco_lr, soco_br] + + +def create_zgs_sonos_event( + fixture_file: str, + soco_1: MockSoCo, + soco_2: MockSoCo, + create_uui_ds_in_group: bool = True, +) -> SonosMockEvent: + """Create a Sonos Event for zone group state, with the option of creating the uui_ds_in_group.""" + zgs = load_fixture(fixture_file, DOMAIN) + variables = {} + variables["ZoneGroupState"] = zgs + # Sonos does not always send this variable with zgs events + if create_uui_ds_in_group: + variables["zone_player_uui_ds_in_group"] = f"{soco_1.uid},{soco_2.uid}" + zone_player_uui_ds_in_group = ( + f"{soco_1.uid},{soco_2.uid}" if create_uui_ds_in_group else None + ) + return SonosMockEvent( + soco_1, soco_1.zoneGroupTopology, variables, zone_player_uui_ds_in_group + ) + + +def group_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None: + """Generate events to group two speakers together.""" + event = create_zgs_sonos_event( + "zgs_group.xml", coordinator, group_member, create_uui_ds_in_group=True + ) + coordinator.zoneGroupTopology.subscribe.return_value._callback(event) + group_member.zoneGroupTopology.subscribe.return_value._callback(event) + + +def ungroup_speakers(coordinator: MockSoCo, group_member: MockSoCo) -> None: + """Generate events to ungroup two speakers.""" + event = create_zgs_sonos_event( + "zgs_two_single.xml", coordinator, group_member, create_uui_ds_in_group=False + ) + coordinator.zoneGroupTopology.subscribe.return_value._callback(event) + group_member.zoneGroupTopology.subscribe.return_value._callback(event) diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 8f83ce2f814..48e4cc139f3 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -1,53 +1,191 @@ """Tests for Sonos services.""" +import asyncio +from contextlib import asynccontextmanager +import logging +import re from unittest.mock import Mock, patch import pytest -from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, SERVICE_JOIN +from homeassistant.components.media_player import ( + DOMAIN as MP_DOMAIN, + SERVICE_JOIN, + SERVICE_UNJOIN, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from tests.common import MockConfigEntry +from .conftest import MockSoCo, group_speakers, ungroup_speakers async def test_media_player_join( - hass: HomeAssistant, async_autosetup_sonos, config_entry: MockConfigEntry + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, ) -> None: - """Test join service.""" - valid_entity_id = "media_player.zone_a" - mocked_entity_id = "media_player.mocked" + """Test joining two speakers together.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] - # Ensure an error is raised if the entity is unknown - with pytest.raises(HomeAssistantError): + # After dispatching the join to the speakers, the integration waits for the + # group to be updated before returning. To simulate this we will dispatch + # a ZGS event to group the speaker. This event is + # triggered by the firing of the join_complete_event in the join mock. + join_complete_event = asyncio.Event() + + def mock_join(*args, **kwargs) -> None: + hass.loop.call_soon_threadsafe(join_complete_event.set) + + soco_bedroom.join = Mock(side_effect=mock_join) + + with caplog.at_level(logging.WARNING): + caplog.clear() await hass.services.async_call( MP_DOMAIN, SERVICE_JOIN, - {"entity_id": valid_entity_id, "group_members": mocked_entity_id}, + { + "entity_id": "media_player.living_room", + "group_members": ["media_player.bedroom"], + }, + blocking=False, + ) + await join_complete_event.wait() + # Fire the ZGS event to update the speaker grouping as the join method is waiting + # for the speakers to be regrouped. + group_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + # Code logs warning messages if the join is not successful, so we check + # that no warning messages were logged. + assert len(caplog.records) == 0 + # The API joins the group members to the entity_id speaker. + assert soco_bedroom.join.call_count == 1 + assert soco_bedroom.join.call_args[0][0] == soco_living_room + assert soco_living_room.join.call_count == 0 + + +async def test_media_player_join_bad_entity( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], +) -> None: + """Test error handling of joining with a bad entity.""" + + # Ensure an error is raised if the entity is unknown + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_JOIN, + { + "entity_id": "media_player.living_room", + "group_members": "media_player.bad_entity", + }, blocking=True, ) + assert "media_player.bad_entity" in str(excinfo.value) - # Ensure SonosSpeaker.join_multi is called if entity is found - mocked_speaker = Mock() - mock_entity_id_mappings = {mocked_entity_id: mocked_speaker} +@asynccontextmanager +async def instant_timeout(*args, **kwargs) -> None: + """Mock a timeout error.""" + raise TimeoutError + # This is never reached, but is needed to satisfy the asynccontextmanager + yield # pylint: disable=unreachable + + +async def test_media_player_join_timeout( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test joining of two speakers with timeout error.""" + + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + expected = ( + "Timeout while waiting for Sonos player to join the " + "group ['Living Room: Living Room, Bedroom']" + ) with ( - patch.dict( - config_entry.runtime_data.entity_id_mappings, - mock_entity_id_mappings, - ), patch( - "homeassistant.components.sonos.speaker.SonosSpeaker.join_multi" - ) as mock_join_multi, + "homeassistant.components.sonos.speaker.asyncio.timeout", instant_timeout + ), + pytest.raises(HomeAssistantError, match=re.escape(expected)), ): await hass.services.async_call( MP_DOMAIN, SERVICE_JOIN, - {"entity_id": valid_entity_id, "group_members": mocked_entity_id}, + { + "entity_id": "media_player.living_room", + "group_members": ["media_player.bedroom"], + }, + blocking=True, + ) + assert soco_bedroom.join.call_count == 1 + assert soco_bedroom.join.call_args[0][0] == soco_living_room + assert soco_living_room.join.call_count == 0 + + +async def test_media_player_unjoin( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unjoing two speaker.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + # First group the speakers together + group_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + # Now that the speaker are joined, test unjoining + unjoin_complete_event = asyncio.Event() + + def mock_unjoin(*args, **kwargs): + hass.loop.call_soon_threadsafe(unjoin_complete_event.set) + + soco_bedroom.unjoin = Mock(side_effect=mock_unjoin) + + with caplog.at_level(logging.WARNING): + caplog.clear() + await hass.services.async_call( + MP_DOMAIN, + SERVICE_UNJOIN, + {"entity_id": "media_player.bedroom"}, + blocking=False, + ) + await unjoin_complete_event.wait() + # Fire the ZGS event to ungroup the speakers as the unjoin method is waiting + # for the speakers to be ungrouped. + ungroup_speakers(soco_living_room, soco_bedroom) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(caplog.records) == 0 + assert soco_bedroom.unjoin.call_count == 1 + assert soco_living_room.unjoin.call_count == 0 + + +async def test_media_player_unjoin_already_unjoined( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unjoining when already unjoined.""" + soco_living_room = sonos_setup_two_speakers[0] + soco_bedroom = sonos_setup_two_speakers[1] + + with caplog.at_level(logging.WARNING): + caplog.clear() + await hass.services.async_call( + MP_DOMAIN, + SERVICE_UNJOIN, + {"entity_id": "media_player.bedroom"}, blocking=True, ) - found_speaker = config_entry.runtime_data.entity_id_mappings[valid_entity_id] - mock_join_multi.assert_called_with( - hass, config_entry, found_speaker, [mocked_speaker] - ) + assert len(caplog.records) == 0 + # Should not have called unjoin, since the speakers are already unjoined. + assert soco_bedroom.unjoin.call_count == 0 + assert soco_living_room.unjoin.call_count == 0 diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 468b848dfb5..cdb7be15589 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -13,12 +13,11 @@ from homeassistant.components.sonos.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent +from .conftest import MockSoCo, SonosMockEvent, group_speakers, ungroup_speakers from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_fixture, load_json_value_fixture, ) @@ -81,22 +80,6 @@ async def test_subscription_creation_fails( assert speaker._subscriptions -def _create_zgs_sonos_event( - fixture_file: str, soco_1: MockSoCo, soco_2: MockSoCo, create_uui_ds: bool = True -) -> SonosMockEvent: - """Create a Sonos Event for zone group state, with the option of creating the uui_ds_in_group.""" - zgs = load_fixture(fixture_file, DOMAIN) - variables = {} - variables["ZoneGroupState"] = zgs - # Sonos does not always send this variable with zgs events - if create_uui_ds: - variables["zone_player_uui_ds_in_group"] = f"{soco_1.uid},{soco_2.uid}" - event = SonosMockEvent(soco_1, soco_1.zoneGroupTopology, variables) - if create_uui_ds: - event.zone_player_uui_ds_in_group = f"{soco_1.uid},{soco_2.uid}" - return event - - def _create_avtransport_sonos_event( fixture_file: str, soco: MockSoCo ) -> SonosMockEvent: @@ -142,11 +125,8 @@ async def test_zgs_event_group_speakers( soco_br.play.reset_mock() # Test 2 - Group the speakers, living room is the coordinator - event = _create_zgs_sonos_event( - "zgs_group.xml", soco_lr, soco_br, create_uui_ds=True - ) - soco_lr.zoneGroupTopology.subscribe.return_value._callback(event) - soco_br.zoneGroupTopology.subscribe.return_value._callback(event) + group_speakers(soco_lr, soco_br) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("media_player.living_room") assert state.attributes["group_members"] == [ @@ -168,11 +148,8 @@ async def test_zgs_event_group_speakers( soco_br.play.reset_mock() # Test 3 - Ungroup the speakers - event = _create_zgs_sonos_event( - "zgs_two_single.xml", soco_lr, soco_br, create_uui_ds=False - ) - soco_lr.zoneGroupTopology.subscribe.return_value._callback(event) - soco_br.zoneGroupTopology.subscribe.return_value._callback(event) + ungroup_speakers(soco_lr, soco_br) + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get("media_player.living_room") assert state.attributes["group_members"] == ["media_player.living_room"] @@ -206,11 +183,7 @@ async def test_zgs_avtransport_group_speakers( soco_br.play.reset_mock() # Test 2- Send a zgs event to return living room to its own coordinator - event = _create_zgs_sonos_event( - "zgs_two_single.xml", soco_lr, soco_br, create_uui_ds=False - ) - soco_lr.zoneGroupTopology.subscribe.return_value._callback(event) - soco_br.zoneGroupTopology.subscribe.return_value._callback(event) + ungroup_speakers(soco_lr, soco_br) await hass.async_block_till_done(wait_background_tasks=True) # Call should route to the living room await _media_play(hass, "media_player.living_room") From 1e4fbebf4994f11c4639fd7872756a813190cd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Wed, 25 Jun 2025 11:49:54 +0200 Subject: [PATCH 0657/1664] Improve Home Connect diagnostics exposing more data (#147492) --- .../components/home_connect/diagnostics.py | 22 +- .../snapshots/test_diagnostics.ambr | 444 ++++++++++++++---- 2 files changed, 363 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/home_connect/diagnostics.py b/homeassistant/components/home_connect/diagnostics.py index 59856999ec7..f5f4999fa2e 100644 --- a/homeassistant/components/home_connect/diagnostics.py +++ b/homeassistant/components/home_connect/diagnostics.py @@ -4,6 +4,8 @@ from __future__ import annotations from typing import Any +from aiohomeconnect.model import GetSetting, Status + from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry @@ -11,14 +13,30 @@ from .const import DOMAIN from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry +def _serialize_item(item: Status | GetSetting) -> dict[str, Any]: + """Serialize a status or setting item to a dictionary.""" + data = {"value": item.value} + if item.unit is not None: + data["unit"] = item.unit + if item.constraints is not None: + data["constraints"] = { + k: v for k, v in item.constraints.to_dict().items() if v is not None + } + return data + + async def _generate_appliance_diagnostics( appliance: HomeConnectApplianceData, ) -> dict[str, Any]: return { **appliance.info.to_dict(), - "status": {key.value: status.value for key, status in appliance.status.items()}, + "status": { + key.value: _serialize_item(status) + for key, status in appliance.status.items() + }, "settings": { - key.value: setting.value for key, setting in appliance.settings.items() + key.value: _serialize_item(setting) + for key, setting in appliance.settings.items() }, "programs": [program.raw_key for program in appliance.programs], } diff --git a/tests/components/home_connect/snapshots/test_diagnostics.ambr b/tests/components/home_connect/snapshots/test_diagnostics.ambr index e18489d5220..a57743dfc9e 100644 --- a/tests/components/home_connect/snapshots/test_diagnostics.ambr +++ b/tests/components/home_connect/snapshots/test_diagnostics.ambr @@ -12,11 +12,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'CookProcessor', 'vib': 'HCS000006', @@ -32,11 +42,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'DNE', 'vib': 'HCS000000', @@ -52,11 +72,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Hob', 'vib': 'HCS000005', @@ -74,11 +104,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'WasherDryer', 'vib': 'HCS000001', @@ -94,11 +134,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Refrigerator', 'vib': 'HCS000002', @@ -114,11 +164,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Freezer', 'vib': 'HCS000003', @@ -135,21 +195,57 @@ 'Cooking.Common.Program.Hood.DelayedShutOff', ]), 'settings': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': 70, - 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', - 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', - 'BSH.Common.Setting.AmbientLightEnabled': True, - 'Cooking.Common.Setting.Lighting': True, - 'Cooking.Common.Setting.LightingBrightness': 70, - 'Cooking.Hood.Setting.ColorTemperature': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', - 'Cooking.Hood.Setting.ColorTemperaturePercent': 70, + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'value': True, + }), + 'Cooking.Common.Setting.Lighting': dict({ + 'value': True, + }), + 'Cooking.Common.Setting.LightingBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'Cooking.Hood.Setting.ColorTemperature': dict({ + 'constraints': dict({ + 'allowed_values': list([ + 'Cooking.Hood.EnumType.ColorTemperature.warm', + 'Cooking.Hood.EnumType.ColorTemperature.neutral', + 'Cooking.Hood.EnumType.ColorTemperature.cold', + ]), + }), + 'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral', + }), + 'Cooking.Hood.Setting.ColorTemperaturePercent': dict({ + 'unit': '%', + 'value': 70, + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Hood', 'vib': 'HCS000004', @@ -166,15 +262,29 @@ 'Cooking.Oven.Program.HeatingMode.PizzaSetting', ]), 'settings': dict({ - 'BSH.Common.Setting.AlarmClock': 0, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.Setting.AlarmClock': dict({ + 'value': 0, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'value': 'BSH.Common.EnumType.PowerState.On', + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Oven', 'vib': 'HCS01OVN1', @@ -193,11 +303,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Dryer', 'vib': 'HCS04DYR1', @@ -219,11 +339,21 @@ 'settings': dict({ }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'CoffeeMaker', 'vib': 'HCS06COM1', @@ -242,19 +372,48 @@ 'Dishcare.Dishwasher.Program.Quick45', ]), 'settings': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': 70, - 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', - 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', - 'BSH.Common.Setting.AmbientLightEnabled': True, - 'BSH.Common.Setting.ChildLock': False, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'constraints': dict({ + 'allowed_values': list([ + 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.EnumType.PowerState.Off', + ]), + }), + 'value': 'BSH.Common.EnumType.PowerState.On', + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Dishwasher', 'vib': 'HCS02DWH1', @@ -273,16 +432,32 @@ 'LaundryCare.Washer.Program.Wool', ]), 'settings': dict({ - 'BSH.Common.Setting.ChildLock': False, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', - 'LaundryCare.Washer.Setting.IDos2BaseLevel': 0, + 'BSH.Common.Setting.ChildLock': dict({ + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'value': 'BSH.Common.EnumType.PowerState.On', + }), + 'LaundryCare.Washer.Setting.IDos2BaseLevel': dict({ + 'value': 0, + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Washer', 'vib': 'HCS03WCH1', @@ -296,19 +471,57 @@ 'programs': list([ ]), 'settings': dict({ - 'Refrigeration.Common.Setting.Dispenser.Enabled': False, - 'Refrigeration.Common.Setting.Light.External.Brightness': 70, - 'Refrigeration.Common.Setting.Light.External.Power': True, - 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8, - 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False, - 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False, + 'Refrigeration.Common.Setting.Dispenser.Enabled': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'value': False, + }), + 'Refrigeration.Common.Setting.Light.External.Brightness': dict({ + 'constraints': dict({ + 'access': 'readWrite', + 'max': 100, + 'min': 0, + }), + 'unit': '%', + 'value': 70, + }), + 'Refrigeration.Common.Setting.Light.External.Power': dict({ + 'value': True, + }), + 'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': dict({ + 'unit': '°C', + 'value': 8, + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'value': False, + }), + 'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({ + 'constraints': dict({ + 'access': 'readWrite', + }), + 'value': False, + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'FridgeFreezer', 'vib': 'HCS05FRF1', @@ -330,19 +543,48 @@ 'Dishcare.Dishwasher.Program.Quick45', ]), 'settings': dict({ - 'BSH.Common.Setting.AmbientLightBrightness': 70, - 'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43', - 'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8', - 'BSH.Common.Setting.AmbientLightEnabled': True, - 'BSH.Common.Setting.ChildLock': False, - 'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.Setting.AmbientLightBrightness': dict({ + 'unit': '%', + 'value': 70, + }), + 'BSH.Common.Setting.AmbientLightColor': dict({ + 'value': 'BSH.Common.EnumType.AmbientLightColor.Color43', + }), + 'BSH.Common.Setting.AmbientLightCustomColor': dict({ + 'value': '#4a88f8', + }), + 'BSH.Common.Setting.AmbientLightEnabled': dict({ + 'value': True, + }), + 'BSH.Common.Setting.ChildLock': dict({ + 'value': False, + }), + 'BSH.Common.Setting.PowerState': dict({ + 'constraints': dict({ + 'allowed_values': list([ + 'BSH.Common.EnumType.PowerState.On', + 'BSH.Common.EnumType.PowerState.Off', + ]), + }), + 'value': 'BSH.Common.EnumType.PowerState.On', + }), }), 'status': dict({ - 'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed', - 'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready', - 'BSH.Common.Status.RemoteControlActive': True, - 'BSH.Common.Status.RemoteControlStartAllowed': True, - 'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open', + 'BSH.Common.Status.DoorState': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Closed', + }), + 'BSH.Common.Status.OperationState': dict({ + 'value': 'BSH.Common.EnumType.OperationState.Ready', + }), + 'BSH.Common.Status.RemoteControlActive': dict({ + 'value': True, + }), + 'BSH.Common.Status.RemoteControlStartAllowed': dict({ + 'value': True, + }), + 'Refrigeration.Common.Status.Door.Refrigerator': dict({ + 'value': 'BSH.Common.EnumType.DoorState.Open', + }), }), 'type': 'Dishwasher', 'vib': 'HCS02DWH1', From bca7502611ed99479164c7198b04013b0e4d6ba3 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 25 Jun 2025 12:50:00 +0200 Subject: [PATCH 0658/1664] Add quality scale for LCN (#147367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/lcn/manifest.json | 1 + .../components/lcn/quality_scale.yaml | 77 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 78 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/lcn/quality_scale.yaml diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 9e300716d3e..97e1bbcd390 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -8,5 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], + "quality_scale": "bronze", "requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"] } diff --git a/homeassistant/components/lcn/quality_scale.yaml b/homeassistant/components/lcn/quality_scale.yaml new file mode 100644 index 00000000000..26be4d210ba --- /dev/null +++ b/homeassistant/components/lcn/quality_scale.yaml @@ -0,0 +1,77 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + 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: todo + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: Integration has no configuration parameters + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: | + Integration has no authentication. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: done + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: | + Device discovery has to be manually triggered in LCN. Manually adding devices is implemented. + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: + status: exempt + comment: | + Since all entities are configured manually, they are enabled by default. + entity-translations: + status: exempt + comment: | + Since all entities are configured manually, names are user-defined. + exception-translations: todo + icon-translations: todo + reconfiguration-flow: done + repair-issues: done + stale-devices: + status: exempt + comment: | + Device discovery has to be manually triggered in LCN. Manually removing devices is implemented. + # Platinum + async-dependency: done + inject-websession: + status: exempt + comment: | + Integration is not making any HTTP requests. + strict-typing: todo diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index ff6fbcad85e..46751bda4f8 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -566,7 +566,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "lastfm", "launch_library", "laundrify", - "lcn", "ld2410_ble", "leaone", "led_ble", @@ -1615,7 +1614,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "lametric", "launch_library", "laundrify", - "lcn", "ld2410_ble", "leaone", "led_ble", From b95af2d86b13ae96eccc7c15be0a5e2dac40dc6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Kiss?= <70820303+g-kiss@users.noreply.github.com> Date: Wed, 25 Jun 2025 13:19:55 +0200 Subject: [PATCH 0659/1664] Fix ESPHome entity_id generation if name contains unicode characters (#146796) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/entity.py | 2 +- .../esphome/test_alarm_control_panel.py | 22 +-- .../components/esphome/test_binary_sensor.py | 10 +- tests/components/esphome/test_button.py | 8 +- tests/components/esphome/test_camera.py | 36 ++-- tests/components/esphome/test_climate.py | 38 ++-- tests/components/esphome/test_cover.py | 24 +-- tests/components/esphome/test_date.py | 6 +- tests/components/esphome/test_datetime.py | 6 +- tests/components/esphome/test_entity.py | 181 ++++++++++++++---- tests/components/esphome/test_event.py | 6 +- tests/components/esphome/test_fan.py | 46 ++--- tests/components/esphome/test_light.py | 152 +++++++-------- tests/components/esphome/test_lock.py | 14 +- tests/components/esphome/test_media_player.py | 40 ++-- tests/components/esphome/test_number.py | 10 +- tests/components/esphome/test_repairs.py | 6 +- tests/components/esphome/test_select.py | 4 +- tests/components/esphome/test_sensor.py | 38 ++-- tests/components/esphome/test_switch.py | 6 +- tests/components/esphome/test_text.py | 8 +- tests/components/esphome/test_time.py | 6 +- tests/components/esphome/test_update.py | 2 +- tests/components/esphome/test_valve.py | 24 +-- 24 files changed, 396 insertions(+), 299 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 501c773ba39..74f73508d83 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -321,7 +321,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): ) if entity_info.name: - self.entity_id = f"{domain}.{device_name}_{entity_info.object_id}" + self.entity_id = f"{domain}.{device_name}_{entity_info.name}" else: # https://github.com/home-assistant/core/issues/132532 # If name is not set, ESPHome will use the sanitized friendly name diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index 5a90086eac0..62924404458 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -59,7 +59,7 @@ async def test_generic_alarm_control_panel_requires_code( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") assert state is not None assert state.state == AlarmControlPanelState.ARMED_AWAY @@ -67,7 +67,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_AWAY, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -81,7 +81,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -95,7 +95,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_HOME, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -109,7 +109,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_NIGHT, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -123,7 +123,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_ARM_VACATION, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -137,7 +137,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_TRIGGER, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -151,7 +151,7 @@ async def test_generic_alarm_control_panel_requires_code( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, { - ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel", + ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel", ATTR_CODE: 1234, }, blocking=True, @@ -192,14 +192,14 @@ async def test_generic_alarm_control_panel_no_code( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") assert state is not None assert state.state == AlarmControlPanelState.ARMED_AWAY await hass.services.async_call( ALARM_CONTROL_PANEL_DOMAIN, SERVICE_ALARM_DISARM, - {ATTR_ENTITY_ID: "alarm_control_panel.test_myalarm_control_panel"}, + {ATTR_ENTITY_ID: "alarm_control_panel.test_my_alarm_control_panel"}, blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( @@ -238,6 +238,6 @@ async def test_generic_alarm_control_panel_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("alarm_control_panel.test_myalarm_control_panel") + state = hass.states.get("alarm_control_panel.test_my_alarm_control_panel") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index fee285ea312..d2cab36c672 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -36,7 +36,7 @@ async def test_binary_sensor_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == hass_state @@ -64,7 +64,7 @@ async def test_status_binary_sensor( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON @@ -91,7 +91,7 @@ async def test_binary_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -118,12 +118,12 @@ async def test_binary_sensor_has_state_false( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNKNOWN mock_device.set_state(BinarySensorState(key=1, state=True, missing_state=False)) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index 8c120949caa..d3fec2a56d2 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -29,22 +29,22 @@ async def test_button_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("button.test_mybutton") + state = hass.states.get("button.test_my_button") assert state is not None assert state.state == STATE_UNKNOWN await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_mybutton"}, + {ATTR_ENTITY_ID: "button.test_my_button"}, blocking=True, ) mock_client.button_command.assert_has_calls([call(1)]) - state = hass.states.get("button.test_mybutton") + state = hass.states.get("button.test_my_button") assert state is not None assert state.state != STATE_UNKNOWN await mock_device.mock_disconnect(False) - state = hass.states.get("button.test_mybutton") + state = hass.states.get("button.test_my_button") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index b03d2bb7983..e29eed16d9f 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -41,7 +41,7 @@ async def test_camera_single_image( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -51,9 +51,9 @@ async def test_camera_single_image( mock_client.request_single_image = _mock_camera_image client = await hass_client() - resp = await client.get("/api/camera_proxy/camera.test_mycamera") + resp = await client.get("/api/camera_proxy/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -86,15 +86,15 @@ async def test_camera_single_image_unavailable_before_requested( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE await mock_device.mock_disconnect(False) client = await hass_client() - resp = await client.get("/api/camera_proxy/camera.test_mycamera") + resp = await client.get("/api/camera_proxy/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -124,7 +124,7 @@ async def test_camera_single_image_unavailable_during_request( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -134,9 +134,9 @@ async def test_camera_single_image_unavailable_during_request( mock_client.request_single_image = _mock_camera_image client = await hass_client() - resp = await client.get("/api/camera_proxy/camera.test_mycamera") + resp = await client.get("/api/camera_proxy/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -166,7 +166,7 @@ async def test_camera_stream( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE remaining_responses = 3 @@ -182,9 +182,9 @@ async def test_camera_stream( mock_client.request_single_image = _mock_camera_image client = await hass_client() - resp = await client.get("/api/camera_proxy_stream/camera.test_mycamera") + resp = await client.get("/api/camera_proxy_stream/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE @@ -223,16 +223,16 @@ async def test_camera_stream_unavailable( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE await mock_device.mock_disconnect(False) client = await hass_client() - await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await client.get("/api/camera_proxy_stream/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE @@ -260,7 +260,7 @@ async def test_camera_stream_with_disconnection( user_service=user_service, states=states, ) - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == CameraState.IDLE remaining_responses = 3 @@ -278,8 +278,8 @@ async def test_camera_stream_with_disconnection( mock_client.request_single_image = _mock_camera_image client = await hass_client() - await client.get("/api/camera_proxy_stream/camera.test_mycamera") + await client.get("/api/camera_proxy_stream/camera.test_my_camera") await hass.async_block_till_done() - state = hass.states.get("camera.test_mycamera") + state = hass.states.get("camera.test_my_camera") assert state is not None assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index dd42ee97029..3c529adf21f 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -83,14 +83,14 @@ async def test_climate_entity( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) @@ -137,7 +137,7 @@ async def test_climate_entity_with_step_and_two_point( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL @@ -145,7 +145,7 @@ async def test_climate_entity_with_step_and_two_point( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_TEMPERATURE: 25}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, blocking=True, ) @@ -153,7 +153,7 @@ async def test_climate_entity_with_step_and_two_point( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, @@ -217,7 +217,7 @@ async def test_climate_entity_with_step_and_target_temp( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL @@ -225,7 +225,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TEMPERATURE: 25, }, @@ -241,7 +241,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.AUTO, ATTR_TARGET_TEMP_LOW: 20, ATTR_TARGET_TEMP_HIGH: 30, @@ -253,7 +253,7 @@ async def test_climate_entity_with_step_and_target_temp( CLIMATE_DOMAIN, SERVICE_SET_HVAC_MODE, { - ATTR_ENTITY_ID: "climate.test_myclimate", + ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HVAC_MODE: HVACMode.HEAT, }, blocking=True, @@ -271,7 +271,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_PRESET_MODE: "away"}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "away"}, blocking=True, ) mock_client.climate_command.assert_has_calls( @@ -287,7 +287,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_PRESET_MODE: "preset1"}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "preset1"}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, custom_preset="preset1")]) @@ -296,7 +296,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_FAN_MODE: FAN_HIGH}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: FAN_HIGH}, blocking=True, ) mock_client.climate_command.assert_has_calls( @@ -307,7 +307,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_FAN_MODE: "fan2"}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: "fan2"}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, custom_fan_mode="fan2")]) @@ -316,7 +316,7 @@ async def test_climate_entity_with_step_and_target_temp( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_SWING_MODE, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_SWING_MODE: SWING_BOTH}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_SWING_MODE: SWING_BOTH}, blocking=True, ) mock_client.climate_command.assert_has_calls( @@ -368,7 +368,7 @@ async def test_climate_entity_with_humidity( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes @@ -380,7 +380,7 @@ async def test_climate_entity_with_humidity( await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_HUMIDITY, - {ATTR_ENTITY_ID: "climate.test_myclimate", ATTR_HUMIDITY: 23}, + {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HUMIDITY: 23}, blocking=True, ) mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) @@ -430,7 +430,7 @@ async def test_climate_entity_with_inf_value( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.AUTO attributes = state.attributes @@ -492,7 +492,7 @@ async def test_climate_entity_attributes( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.state == HVACMode.COOL assert state.attributes == snapshot(name="climate-entity-attributes") @@ -526,6 +526,6 @@ async def test_climate_entity_attribute_current_temperature_unsupported( user_service=user_service, states=states, ) - state = hass.states.get("climate.test_myclimate") + state = hass.states.get("climate.test_my_climate") assert state is not None assert state.attributes[ATTR_CURRENT_TEMPERATURE] is None diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 2ea789e9cc1..f6ec9f20d6b 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -62,7 +62,7 @@ async def test_cover_entity( user_service=user_service, states=states, ) - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.OPENING assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -71,7 +71,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, position=0.0)]) @@ -80,7 +80,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, position=1.0)]) @@ -89,7 +89,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_POSITION, - {ATTR_ENTITY_ID: "cover.test_mycover", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_POSITION: 50}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, position=0.5)]) @@ -98,7 +98,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, stop=True)]) @@ -107,7 +107,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0)]) @@ -116,7 +116,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: "cover.test_mycover"}, + {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0)]) @@ -125,7 +125,7 @@ async def test_cover_entity( await hass.services.async_call( COVER_DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: "cover.test_mycover", ATTR_TILT_POSITION: 50}, + {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_TILT_POSITION: 50}, blocking=True, ) mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5)]) @@ -135,7 +135,7 @@ async def test_cover_entity( ESPHomeCoverState(key=1, position=0.0, current_operation=CoverOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.CLOSED @@ -145,7 +145,7 @@ async def test_cover_entity( ) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.CLOSING @@ -153,7 +153,7 @@ async def test_cover_entity( ESPHomeCoverState(key=1, position=1.0, current_operation=CoverOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.OPEN @@ -190,7 +190,7 @@ async def test_cover_entity_without_position( user_service=user_service, states=states, ) - state = hass.states.get("cover.test_mycover") + state = hass.states.get("cover.test_my_cover") assert state is not None assert state.state == CoverState.OPENING assert ATTR_CURRENT_TILT_POSITION not in state.attributes diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 4bf291c50f5..331c3d50bd4 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -37,14 +37,14 @@ async def test_generic_date_entity( user_service=user_service, states=states, ) - state = hass.states.get("date.test_mydate") + state = hass.states.get("date.test_my_date") assert state is not None assert state.state == "2024-12-31" await hass.services.async_call( DATE_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "date.test_mydate", ATTR_DATE: "1999-01-01"}, + {ATTR_ENTITY_ID: "date.test_my_date", ATTR_DATE: "1999-01-01"}, blocking=True, ) mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1)]) @@ -73,6 +73,6 @@ async def test_generic_date_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("date.test_mydate") + state = hass.states.get("date.test_my_date") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 1ccb101f581..63ca02360fd 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -37,7 +37,7 @@ async def test_generic_datetime_entity( user_service=user_service, states=states, ) - state = hass.states.get("datetime.test_mydatetime") + state = hass.states.get("datetime.test_my_datetime") assert state is not None assert state.state == "2024-04-16T12:34:56+00:00" @@ -45,7 +45,7 @@ async def test_generic_datetime_entity( DATETIME_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: "datetime.test_mydatetime", + ATTR_ENTITY_ID: "datetime.test_my_datetime", ATTR_DATETIME: "2000-01-01T01:23:45+00:00", }, blocking=True, @@ -76,6 +76,6 @@ async def test_generic_datetime_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("datetime.test_mydatetime") + state = hass.states.get("datetime.test_my_datetime") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index 8d597ffecb0..c97965a1ba3 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -31,7 +31,11 @@ from homeassistant.core import Event, EventStateChangedData, HomeAssistant, call from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_state_change_event -from .conftest import MockESPHomeDevice, MockESPHomeDeviceType +from .conftest import ( + MockESPHomeDevice, + MockESPHomeDeviceType, + MockGenericDeviceEntryType, +) async def test_entities_removed( @@ -68,10 +72,10 @@ async def test_entities_removed( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON @@ -80,13 +84,13 @@ async def test_entities_removed( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.attributes[ATTR_RESTORED] is True - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True @@ -109,13 +113,13 @@ async def test_entities_removed( entry=entry, ) assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) @@ -157,15 +161,15 @@ async def test_entities_removed_after_reload( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None @@ -174,15 +178,15 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.attributes[ATTR_RESTORED] is True - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.attributes[ATTR_RESTORED] is True reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None @@ -191,14 +195,14 @@ async def test_entities_removed_after_reload( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 2 - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert ATTR_RESTORED not in state.attributes - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert ATTR_RESTORED not in state.attributes reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None @@ -226,23 +230,23 @@ async def test_entities_removed_after_reload( on_future.set_result(None) async_track_state_change_event( - hass, ["binary_sensor.test_mybinary_sensor"], _async_wait_for_on + hass, ["binary_sensor.test_my_binary_sensor"], _async_wait_for_on ) await hass.async_block_till_done() async with asyncio.timeout(2): await on_future assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is None await hass.async_block_till_done() reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is None assert await hass.config_entries.async_unload(entry.entry_id) @@ -277,7 +281,7 @@ async def test_entities_for_entire_platform_removed( entry = mock_device.entry entry_id = entry.entry_id storage_key = f"esphome.{entry_id}" - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None assert state.state == STATE_ON @@ -286,10 +290,10 @@ async def test_entities_for_entire_platform_removed( assert len(hass_storage[storage_key]["data"]["binary_sensor"]) == 1 - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is not None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is not None assert state.attributes[ATTR_RESTORED] is True @@ -299,10 +303,10 @@ async def test_entities_for_entire_platform_removed( entry=entry, ) assert mock_device.entry.entry_id == entry_id - state = hass.states.get("binary_sensor.test_mybinary_sensor_to_be_removed") + state = hass.states.get("binary_sensor.test_my_binary_sensor_to_be_removed") assert state is None reg_entry = entity_registry.async_get( - "binary_sensor.test_mybinary_sensor_to_be_removed" + "binary_sensor.test_my_binary_sensor_to_be_removed" ) assert reg_entry is None await hass.config_entries.async_unload(entry.entry_id) @@ -330,7 +334,7 @@ async def test_entity_info_object_ids( entity_info=entity_info, states=states, ) - state = hass.states.get("binary_sensor.test_object_id_is_used") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None @@ -366,7 +370,7 @@ async def test_deep_sleep_device( states=states, device_info={"has_deep_sleep": True}, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") @@ -375,7 +379,7 @@ async def test_deep_sleep_device( await mock_device.mock_disconnect(False) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.test_my_sensor") @@ -385,7 +389,7 @@ async def test_deep_sleep_device( await mock_device.mock_connect() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") @@ -399,7 +403,7 @@ async def test_deep_sleep_device( mock_device.set_state(BinarySensorState(key=1, state=False, missing_state=False)) mock_device.set_state(SensorState(key=3, state=56, missing_state=False)) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_OFF state = hass.states.get("sensor.test_my_sensor") @@ -408,7 +412,7 @@ async def test_deep_sleep_device( await mock_device.mock_disconnect(True) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_OFF state = hass.states.get("sensor.test_my_sensor") @@ -419,7 +423,7 @@ async def test_deep_sleep_device( await hass.async_block_till_done() await mock_device.mock_disconnect(False) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_UNAVAILABLE state = hass.states.get("sensor.test_my_sensor") @@ -428,14 +432,14 @@ async def test_deep_sleep_device( await mock_device.mock_connect() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() # Verify we do not dispatch any more state updates or # availability updates after the stop event is fired - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON @@ -465,7 +469,7 @@ async def test_esphome_device_without_friendly_name( states=states, device_info={"friendly_name": None}, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON @@ -880,7 +884,7 @@ async def test_entity_friendly_names_with_empty_device_names( # Check entity friendly name on sub-device with empty name # Since sub device has empty name, it falls back to main device name "test" - state_1 = hass.states.get("binary_sensor.test_motion") + state_1 = hass.states.get("binary_sensor.test_motion_detected") assert state_1 is not None # With has_entity_name, friendly name is "{device_name} {entity_name}" # Since sub-device falls back to main device name: "Main Device Motion Detected" @@ -950,7 +954,7 @@ async def test_entity_switches_between_devices( ) assert main_device is not None - sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") assert sensor_entity is not None assert sensor_entity.device_id == main_device.id @@ -979,7 +983,7 @@ async def test_entity_switches_between_devices( ) assert sub_device_1 is not None - sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") assert sensor_entity is not None assert sensor_entity.device_id == sub_device_1.id @@ -1006,7 +1010,7 @@ async def test_entity_switches_between_devices( ) assert sub_device_2 is not None - sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") assert sensor_entity is not None assert sensor_entity.device_id == sub_device_2.id @@ -1028,7 +1032,7 @@ async def test_entity_switches_between_devices( await device.mock_connect() # Verify entity is back on main device - sensor_entity = entity_registry.async_get("binary_sensor.test_sensor") + sensor_entity = entity_registry.async_get("binary_sensor.test_test_sensor") assert sensor_entity is not None assert sensor_entity.device_id == main_device.id @@ -1597,3 +1601,96 @@ async def test_entity_device_id_rename_in_yaml( ) assert renamed_device is not None assert entity_entry.device_id == renamed_device.id + + +@pytest.mark.parametrize( + ("unicode_name", "expected_entity_id"), + [ + ("Árvíztűrő tükörfúrógép", "binary_sensor.test_arvizturo_tukorfurogep"), + ("Teplota venku °C", "binary_sensor.test_teplota_venku_degc"), + ("Влажность %", "binary_sensor.test_vlazhnost"), + ("中文传感器", "binary_sensor.test_zhong_wen_chuan_gan_qi"), + ("Sensor à côté", "binary_sensor.test_sensor_a_cote"), + ("τιμή αισθητήρα", "binary_sensor.test_time_aisthetera"), + ], +) +async def test_entity_with_unicode_name( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, + unicode_name: str, + expected_entity_id: str, +) -> None: + """Test that entities with Unicode names get proper entity IDs. + + This verifies the fix for Unicode entity names where ESPHome's C++ code + sanitizes Unicode characters to underscores (not UTF-8 aware), but the + entity_id should use the original name from entity_info.name rather than + the sanitized object_id to preserve Unicode characters properly. + """ + # Simulate what ESPHome would send - a heavily sanitized object_id + # but with the original Unicode name preserved + sanitized_object_id = "_".join("_" * len(word) for word in unicode_name.split()) + + entity_info = [ + BinarySensorInfo( + object_id=sanitized_object_id, # ESPHome sends the sanitized version + key=1, + name=unicode_name, # But also sends the original Unicode name + unique_id="unicode_sensor", + ) + ] + states = [BinarySensorState(key=1, state=True)] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # The entity_id should be based on the Unicode name, properly transliterated + state = hass.states.get(expected_entity_id) + assert state is not None, f"Entity with ID {expected_entity_id} should exist" + assert state.state == STATE_ON + + # The friendly name should preserve the original Unicode characters + assert state.attributes["friendly_name"] == f"Test {unicode_name}" + + # Verify that using the sanitized object_id would NOT find the entity + # This confirms we're not using the object_id for entity_id generation + wrong_entity_id = f"binary_sensor.test_{sanitized_object_id}" + wrong_state = hass.states.get(wrong_entity_id) + assert wrong_state is None, f"Entity should NOT be found at {wrong_entity_id}" + + +async def test_entity_without_name_uses_device_name_only( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry: MockGenericDeviceEntryType, +) -> None: + """Test that entities without a name fall back to using device name only. + + When entity_info.name is empty, the entity_id should just be domain.device_name + without the object_id appended, as noted in the comment in entity.py. + """ + entity_info = [ + BinarySensorInfo( + object_id="some_sanitized_id", + key=1, + name="", # Empty name + unique_id="no_name_sensor", + ) + ] + states = [BinarySensorState(key=1, state=True)] + + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + # With empty name, entity_id should just be domain.device_name + expected_entity_id = "binary_sensor.test" + state = hass.states.get(expected_entity_id) + assert state is not None, f"Entity {expected_entity_id} should exist" + assert state.state == STATE_ON diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index d4688e8ab4e..2756aa6d251 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -36,7 +36,7 @@ async def test_generic_event_entity( await hass.async_block_till_done() # Test initial state - state = hass.states.get("event.test_myevent") + state = hass.states.get("event.test_my_event") assert state is not None assert state.state == "2024-04-24T00:00:00.000+00:00" assert state.attributes["event_type"] == "type1" @@ -44,7 +44,7 @@ async def test_generic_event_entity( # Test device becomes unavailable await device.mock_disconnect(True) await hass.async_block_till_done() - state = hass.states.get("event.test_myevent") + state = hass.states.get("event.test_my_event") assert state.state == STATE_UNAVAILABLE # Test device becomes available again @@ -52,6 +52,6 @@ async def test_generic_event_entity( await hass.async_block_till_done() # Event entity should be available immediately without waiting for data - state = hass.states.get("event.test_myevent") + state = hass.states.get("event.test_my_event") assert state.state == "2024-04-24T00:00:00.000+00:00" assert state.attributes["event_type"] == "type1" diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 05a95fe0e00..558acb281b5 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -66,14 +66,14 @@ async def test_fan_entity_with_all_features_old_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_myfan") + state = hass.states.get("fan.test_my_fan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 20}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -84,7 +84,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 50}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -95,7 +95,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_DECREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -106,7 +106,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_INCREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -117,7 +117,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) @@ -126,7 +126,7 @@ async def test_fan_entity_with_all_features_old_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 100}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -172,14 +172,14 @@ async def test_fan_entity_with_all_features_new_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_myfan") + state = hass.states.get("fan.test_my_fan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 20}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=1, state=True)]) @@ -188,7 +188,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 50}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) @@ -197,7 +197,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_DECREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) @@ -206,7 +206,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_INCREASE_SPEED, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) @@ -215,7 +215,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) @@ -224,7 +224,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 100}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) @@ -233,7 +233,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PERCENTAGE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PERCENTAGE: 0}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) @@ -242,7 +242,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_OSCILLATING: True}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: True}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, oscillating=True)]) @@ -251,7 +251,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_OSCILLATE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_OSCILLATING: False}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: False}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, oscillating=False)]) @@ -260,7 +260,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_DIRECTION: "forward"}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "forward"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -271,7 +271,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_DIRECTION, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_DIRECTION: "reverse"}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_DIRECTION: "reverse"}, blocking=True, ) mock_client.fan_command.assert_has_calls( @@ -282,7 +282,7 @@ async def test_fan_entity_with_all_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "fan.test_myfan", ATTR_PRESET_MODE: "Preset1"}, + {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PRESET_MODE: "Preset1"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) @@ -316,14 +316,14 @@ async def test_fan_entity_with_no_features_new_api( user_service=user_service, states=states, ) - state = hass.states.get("fan.test_myfan") + state = hass.states.get("fan.test_my_fan") assert state is not None assert state.state == STATE_ON await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=True)]) @@ -332,7 +332,7 @@ async def test_fan_entity_with_no_features_new_api( await hass.services.async_call( FAN_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "fan.test_myfan"}, + {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 0cf3e10f11e..34ada36a4f8 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -70,14 +70,14 @@ async def test_light_on_off( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -112,14 +112,14 @@ async def test_light_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -130,7 +130,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -148,7 +148,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_TRANSITION: 2}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_TRANSITION: 2}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -159,7 +159,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_FLASH: FLASH_LONG}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_FLASH: FLASH_LONG}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -170,7 +170,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_TRANSITION: 2}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_TRANSITION: 2}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -188,7 +188,7 @@ async def test_light_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_FLASH: FLASH_SHORT}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_FLASH: FLASH_SHORT}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -234,7 +234,7 @@ async def test_light_legacy_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -244,7 +244,7 @@ async def test_light_legacy_brightness( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -283,7 +283,7 @@ async def test_light_brightness_on_off( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -294,7 +294,7 @@ async def test_light_brightness_on_off( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -311,7 +311,7 @@ async def test_light_brightness_on_off( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -357,14 +357,14 @@ async def test_light_legacy_white_converted_to_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -417,7 +417,7 @@ async def test_light_legacy_white_with_rgb( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -428,7 +428,7 @@ async def test_light_legacy_white_with_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_WHITE: 60}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_WHITE: 60}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -480,14 +480,14 @@ async def test_light_brightness_on_off_with_unknown_color_mode( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -504,7 +504,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -549,14 +549,14 @@ async def test_light_on_and_brightness( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -602,14 +602,14 @@ async def test_rgb_color_temp_light( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -626,7 +626,7 @@ async def test_rgb_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -644,7 +644,7 @@ async def test_rgb_color_temp_light( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -688,14 +688,14 @@ async def test_light_rgb( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -714,7 +714,7 @@ async def test_light_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -735,7 +735,7 @@ async def test_light_rgb( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -760,7 +760,7 @@ async def test_light_rgb( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -822,7 +822,7 @@ async def test_light_rgbw( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBW] @@ -831,7 +831,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -851,7 +851,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -873,7 +873,7 @@ async def test_light_rgbw( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -900,7 +900,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -923,7 +923,7 @@ async def test_light_rgbw( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -992,7 +992,7 @@ async def test_light_rgbww_with_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ @@ -1008,7 +1008,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1025,7 +1025,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1044,7 +1044,7 @@ async def test_light_rgbww_with_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -1067,7 +1067,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1086,7 +1086,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1107,7 +1107,7 @@ async def test_light_rgbww_with_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBWW_COLOR: (255, 255, 255, 255, 255), }, blocking=True, @@ -1130,7 +1130,7 @@ async def test_light_rgbww_with_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1194,7 +1194,7 @@ async def test_light_rgbww_without_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.RGBWW] @@ -1204,7 +1204,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1225,7 +1225,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1248,7 +1248,7 @@ async def test_light_rgbww_without_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127, ATTR_HS_COLOR: (100, 100), }, @@ -1277,7 +1277,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1302,7 +1302,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBW_COLOR: (255, 255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1328,7 +1328,7 @@ async def test_light_rgbww_without_cold_warm_white_support( LIGHT_DOMAIN, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.test_mylight", + ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGBWW_COLOR: (255, 255, 255, 255, 255), }, blocking=True, @@ -1355,7 +1355,7 @@ async def test_light_rgbww_without_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1416,7 +1416,7 @@ async def test_light_color_temp( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1426,7 +1426,7 @@ async def test_light_color_temp( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1445,7 +1445,7 @@ async def test_light_color_temp( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1490,7 +1490,7 @@ async def test_light_color_temp_no_mireds_set( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1500,7 +1500,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1519,7 +1519,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 6000}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 6000}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1539,7 +1539,7 @@ async def test_light_color_temp_no_mireds_set( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1591,7 +1591,7 @@ async def test_light_color_temp_legacy( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1604,7 +1604,7 @@ async def test_light_color_temp_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1623,7 +1623,7 @@ async def test_light_color_temp_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1677,7 +1677,7 @@ async def test_light_rgb_legacy( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON attributes = state.attributes @@ -1687,7 +1687,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1703,7 +1703,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=False)]) @@ -1712,7 +1712,7 @@ async def test_light_rgb_legacy( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_RGB_COLOR: (255, 255, 255)}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_RGB_COLOR: (255, 255, 255)}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1762,7 +1762,7 @@ async def test_light_effects( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_EFFECT_LIST] == ["effect1", "effect2"] @@ -1770,7 +1770,7 @@ async def test_light_effects( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_EFFECT: "effect1"}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_EFFECT: "effect1"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1830,7 +1830,7 @@ async def test_only_cold_warm_white_support( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] @@ -1839,7 +1839,7 @@ async def test_only_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1850,7 +1850,7 @@ async def test_only_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_BRIGHTNESS: 127}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_BRIGHTNESS: 127}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1868,7 +1868,7 @@ async def test_only_cold_warm_white_support( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight", ATTR_COLOR_TEMP_KELVIN: 2500}, + {ATTR_ENTITY_ID: "light.test_my_light", ATTR_COLOR_TEMP_KELVIN: 2500}, blocking=True, ) mock_client.light_command.assert_has_calls( @@ -1911,7 +1911,7 @@ async def test_light_no_color_modes( user_service=user_service, states=states, ) - state = hass.states.get("light.test_mylight") + state = hass.states.get("light.test_my_light") assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] @@ -1919,7 +1919,7 @@ async def test_light_no_color_modes( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.test_mylight"}, + {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index 96c91b1d79f..ab16311fc68 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -47,14 +47,14 @@ async def test_lock_entity_no_open( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_mylock") + state = hass.states.get("lock.test_my_lock") assert state is not None assert state.state == LockState.UNLOCKING await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) @@ -83,7 +83,7 @@ async def test_lock_entity_start_locked( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_mylock") + state = hass.states.get("lock.test_my_lock") assert state is not None assert state.state == LockState.LOCKED @@ -112,14 +112,14 @@ async def test_lock_entity_supports_open( user_service=user_service, states=states, ) - state = hass.states.get("lock.test_mylock") + state = hass.states.get("lock.test_my_lock") assert state is not None assert state.state == LockState.LOCKING await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) @@ -128,7 +128,7 @@ async def test_lock_entity_supports_open( await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.UNLOCK, None)]) @@ -137,7 +137,7 @@ async def test_lock_entity_supports_open( await hass.services.async_call( LOCK_DOMAIN, SERVICE_OPEN, - {ATTR_ENTITY_ID: "lock.test_mylock"}, + {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN)]) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index ccc3ed3e70a..ecd0ec4cb8b 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -71,7 +71,7 @@ async def test_media_player_entity( user_service=user_service, states=states, ) - state = hass.states.get("media_player.test_mymedia_player") + state = hass.states.get("media_player.test_my_media_player") assert state is not None assert state.state == "paused" @@ -79,7 +79,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_VOLUME_MUTED: True, }, blocking=True, @@ -93,7 +93,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_VOLUME_MUTED: True, }, blocking=True, @@ -107,7 +107,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_VOLUME_LEVEL: 0.5, }, blocking=True, @@ -119,7 +119,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", }, blocking=True, ) @@ -132,7 +132,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", }, blocking=True, ) @@ -145,7 +145,7 @@ async def test_media_player_entity( MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", }, blocking=True, ) @@ -216,7 +216,7 @@ async def test_media_player_entity_with_source( user_service=user_service, states=states, ) - state = hass.states.get("media_player.test_mymedia_player") + state = hass.states.get("media_player.test_my_media_player") assert state is not None assert state.state == "playing" @@ -225,7 +225,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: "media-source://local/xz", }, @@ -249,7 +249,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: "audio/mp3", ATTR_MEDIA_CONTENT_ID: "media-source://local/xy", }, @@ -265,7 +265,7 @@ async def test_media_player_entity_with_source( { "id": 1, "type": "media_player/browse_media", - "entity_id": "media_player.test_mymedia_player", + "entity_id": "media_player.test_my_media_player", } ) response = await client.receive_json() @@ -275,7 +275,7 @@ async def test_media_player_entity_with_source( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.URL, ATTR_MEDIA_CONTENT_ID: "media-source://tts?message=hello", ATTR_MEDIA_ANNOUNCE: True, @@ -339,7 +339,7 @@ async def test_media_player_proxy( connections={(dr.CONNECTION_NETWORK_MAC, mock_device.entry.unique_id)} ) assert dev is not None - state = hass.states.get("media_player.test_mymedia_player") + state = hass.states.get("media_player.test_my_media_player") assert state is not None assert state.state == "paused" @@ -356,7 +356,7 @@ async def test_media_player_proxy( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, }, @@ -387,7 +387,7 @@ async def test_media_player_proxy( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, ATTR_MEDIA_ANNOUNCE: True, @@ -417,7 +417,7 @@ async def test_media_player_proxy( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_mymedia_player", + ATTR_ENTITY_ID: "media_player.test_my_media_player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, ATTR_MEDIA_EXTRA: { @@ -475,7 +475,7 @@ async def test_media_player_formats_reload_preserves_data( await hass.async_block_till_done() # Verify entity was created - state = hass.states.get("media_player.test_test_media_player") + state = hass.states.get("media_player.test_Test_Media_Player") assert state is not None assert state.state == "idle" @@ -486,7 +486,7 @@ async def test_media_player_formats_reload_preserves_data( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_ENTITY_ID: "media_player.test_Test_Media_Player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, }, @@ -507,7 +507,7 @@ async def test_media_player_formats_reload_preserves_data( await hass.async_block_till_done() # Verify entity still exists after reload - state = hass.states.get("media_player.test_test_media_player") + state = hass.states.get("media_player.test_Test_Media_Player") assert state is not None # Test that play_media still works after reload with announcement @@ -515,7 +515,7 @@ async def test_media_player_formats_reload_preserves_data( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, { - ATTR_ENTITY_ID: "media_player.test_test_media_player", + ATTR_ENTITY_ID: "media_player.test_Test_Media_Player", ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, ATTR_MEDIA_CONTENT_ID: media_url, ATTR_MEDIA_ANNOUNCE: True, diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 9a711f2766e..932d86c70e3 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -50,14 +50,14 @@ async def test_generic_number_entity( user_service=user_service, states=states, ) - state = hass.states.get("number.test_mynumber") + state = hass.states.get("number.test_my_number") assert state is not None assert state.state == "50" await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_mynumber", ATTR_VALUE: 50}, + {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 50}, blocking=True, ) mock_client.number_command.assert_has_calls([call(1, 50)]) @@ -91,7 +91,7 @@ async def test_generic_number_nan( user_service=user_service, states=states, ) - state = hass.states.get("number.test_mynumber") + state = hass.states.get("number.test_my_number") assert state is not None assert state.state == STATE_UNKNOWN @@ -123,7 +123,7 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( user_service=user_service, states=states, ) - state = hass.states.get("number.test_mynumber") + state = hass.states.get("number.test_my_number") assert state is not None assert state.state == "42" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes @@ -162,7 +162,7 @@ async def test_generic_number_entity_set_when_disconnected( await hass.services.async_call( NUMBER_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "number.test_mynumber", ATTR_VALUE: 20}, + {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 20}, blocking=True, ) mock_client.number_command.reset_mock() diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index 692a7dd9cc9..fed76ac580a 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -145,12 +145,12 @@ async def test_device_conflict_migration( user_service=user_service, states=states, ) - state = hass.states.get("binary_sensor.test_mybinary_sensor") + state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON mock_config_entry = device.entry - ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry assert ent_reg_entry.unique_id == "11:22:33:44:55:AA-binary_sensor-mybinary_sensor" entries = er.async_entries_for_config_entry( @@ -222,7 +222,7 @@ async def test_device_conflict_migration( assert issue_registry.async_get_issue(DOMAIN, issue_id) is None assert mock_config_entry.unique_id == "11:22:33:44:55:ab" - ent_reg_entry = entity_registry.async_get("binary_sensor.test_mybinary_sensor") + ent_reg_entry = entity_registry.async_get("binary_sensor.test_my_binary_sensor") assert ent_reg_entry assert ent_reg_entry.unique_id == "11:22:33:44:55:AB-binary_sensor-mybinary_sensor" diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 1dc37ca3cad..a30075b5833 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -79,14 +79,14 @@ async def test_select_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("select.test_myselect") + state = hass.states.get("select.test_my_select") assert state is not None assert state.state == "a" await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: "select.test_myselect", ATTR_OPTION: "b"}, + {ATTR_ENTITY_ID: "select.test_my_select", ATTR_OPTION: "b"}, blocking=True, ) mock_client.select_command.assert_has_calls([call(1, "b")]) diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 6763d2ab9a9..55e228b72be 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -55,35 +55,35 @@ async def test_generic_numeric_sensor( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" # Test updating state mock_device.set_state(SensorState(key=1, state=60)) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "60" # Test sending the same state again mock_device.set_state(SensorState(key=1, state=60)) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "60" # Test we can still update after the same state mock_device.set_state(SensorState(key=1, state=70)) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "70" # Test invalid data from the underlying api does not crash us mock_device.set_state(SensorState(key=1, state=object())) await hass.async_block_till_done() - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "70" @@ -113,11 +113,11 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_ICON] == "mdi:leaf" - entry = entity_registry.async_get("sensor.test_mysensor") + entry = entity_registry.async_get("sensor.test_my_sensor") assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) @@ -151,11 +151,11 @@ async def test_generic_numeric_sensor_state_class_measurement( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - entry = entity_registry.async_get("sensor.test_mysensor") + entry = entity_registry.async_get("sensor.test_my_sensor") assert entry is not None # Note that ESPHome includes the EntityInfo type in the unique id # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) @@ -186,7 +186,7 @@ async def test_generic_numeric_sensor_device_class_timestamp( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "2023-06-22T18:43:52+00:00" @@ -215,7 +215,7 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "50" assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING @@ -243,7 +243,7 @@ async def test_generic_numeric_sensor_no_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -270,7 +270,7 @@ async def test_generic_numeric_sensor_nan_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -297,7 +297,7 @@ async def test_generic_numeric_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -324,7 +324,7 @@ async def test_generic_text_sensor( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "i am a teapot" @@ -351,7 +351,7 @@ async def test_generic_text_sensor_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == STATE_UNKNOWN @@ -379,7 +379,7 @@ async def test_generic_text_sensor_device_class_timestamp( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "2023-06-22T18:43:52+00:00" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TIMESTAMP @@ -408,7 +408,7 @@ async def test_generic_text_sensor_device_class_date( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "2023-06-22" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DATE @@ -437,7 +437,7 @@ async def test_generic_numeric_sensor_empty_string_uom( user_service=user_service, states=states, ) - state = hass.states.get("sensor.test_mysensor") + state = hass.states.get("sensor.test_my_sensor") assert state is not None assert state.state == "123" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index b3c13ee2fe5..0efb3d86256 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -37,14 +37,14 @@ async def test_switch_generic_entity( user_service=user_service, states=states, ) - state = hass.states.get("switch.test_myswitch") + state = hass.states.get("switch.test_my_switch") assert state is not None assert state.state == STATE_ON await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.test_myswitch"}, + {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) mock_client.switch_command.assert_has_calls([call(1, True)]) @@ -52,7 +52,7 @@ async def test_switch_generic_entity( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.test_myswitch"}, + {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) mock_client.switch_command.assert_has_calls([call(1, False)]) diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index 899b4a732ca..c8a7b2b9b45 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -41,14 +41,14 @@ async def test_generic_text_entity( user_service=user_service, states=states, ) - state = hass.states.get("text.test_mytext") + state = hass.states.get("text.test_my_text") assert state is not None assert state.state == "hello world" await hass.services.async_call( TEXT_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "text.test_mytext", ATTR_VALUE: "goodbye"}, + {ATTR_ENTITY_ID: "text.test_my_text", ATTR_VALUE: "goodbye"}, blocking=True, ) mock_client.text_command.assert_has_calls([call(1, "goodbye")]) @@ -81,7 +81,7 @@ async def test_generic_text_entity_no_state( user_service=user_service, states=states, ) - state = hass.states.get("text.test_mytext") + state = hass.states.get("text.test_my_text") assert state is not None assert state.state == STATE_UNKNOWN @@ -112,6 +112,6 @@ async def test_generic_text_entity_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("text.test_mytext") + state = hass.states.get("text.test_my_text") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 543a903f0a9..9342bd16055 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -37,14 +37,14 @@ async def test_generic_time_entity( user_service=user_service, states=states, ) - state = hass.states.get("time.test_mytime") + state = hass.states.get("time.test_my_time") assert state is not None assert state.state == "12:34:56" await hass.services.async_call( TIME_DOMAIN, SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: "time.test_mytime", ATTR_TIME: "01:23:45"}, + {ATTR_ENTITY_ID: "time.test_my_time", ATTR_TIME: "01:23:45"}, blocking=True, ) mock_client.time_command.assert_has_calls([call(1, 1, 23, 45)]) @@ -73,6 +73,6 @@ async def test_generic_time_missing_state( user_service=user_service, states=states, ) - state = hass.states.get("time.test_mytime") + state = hass.states.get("time.test_my_time") assert state is not None assert state.state == STATE_UNKNOWN diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 960cc016efc..fd852949e65 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -35,7 +35,7 @@ from tests.typing import WebSocketGenerator RELEASE_SUMMARY = "This is a release summary" RELEASE_URL = "https://esphome.io/changelog" -ENTITY_ID = "update.test_myupdate" +ENTITY_ID = "update.test_my_update" @pytest.fixture(autouse=True) diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index bc5c77a62d6..d31e2bfb09e 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -55,7 +55,7 @@ async def test_valve_entity( user_service=user_service, states=states, ) - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.OPENING assert state.attributes[ATTR_CURRENT_POSITION] == 50 @@ -63,7 +63,7 @@ async def test_valve_entity( await hass.services.async_call( VALVE_DOMAIN, SERVICE_CLOSE_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) @@ -72,7 +72,7 @@ async def test_valve_entity( await hass.services.async_call( VALVE_DOMAIN, SERVICE_OPEN_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) @@ -81,7 +81,7 @@ async def test_valve_entity( await hass.services.async_call( VALVE_DOMAIN, SERVICE_SET_VALVE_POSITION, - {ATTR_ENTITY_ID: "valve.test_myvalve", ATTR_POSITION: 50}, + {ATTR_ENTITY_ID: "valve.test_my_valve", ATTR_POSITION: 50}, blocking=True, ) mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) @@ -90,7 +90,7 @@ async def test_valve_entity( await hass.services.async_call( VALVE_DOMAIN, SERVICE_STOP_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) @@ -100,7 +100,7 @@ async def test_valve_entity( ESPHomeValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.CLOSED @@ -110,7 +110,7 @@ async def test_valve_entity( ) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.CLOSING @@ -118,7 +118,7 @@ async def test_valve_entity( ESPHomeValveState(key=1, position=1.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.OPEN @@ -153,7 +153,7 @@ async def test_valve_entity_without_position( user_service=user_service, states=states, ) - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.OPENING assert ATTR_CURRENT_POSITION not in state.attributes @@ -161,7 +161,7 @@ async def test_valve_entity_without_position( await hass.services.async_call( VALVE_DOMAIN, SERVICE_CLOSE_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) @@ -170,7 +170,7 @@ async def test_valve_entity_without_position( await hass.services.async_call( VALVE_DOMAIN, SERVICE_OPEN_VALVE, - {ATTR_ENTITY_ID: "valve.test_myvalve"}, + {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) @@ -180,6 +180,6 @@ async def test_valve_entity_without_position( ESPHomeValveState(key=1, position=0.0, current_operation=ValveOperation.IDLE) ) await hass.async_block_till_done() - state = hass.states.get("valve.test_myvalve") + state = hass.states.get("valve.test_my_valve") assert state is not None assert state.state == ValveState.CLOSED From 716ec1eef2ba52cbe5968fca1f725647ba8de738 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 25 Jun 2025 07:27:57 -0400 Subject: [PATCH 0660/1664] Bump ZHA to 0.0.61 (#147472) Co-authored-by: TheJulianJES --- homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 50 ++++++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 2ba35d1b1ad..4fb5f57320f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.60"], + "requirements": ["zha==0.0.61"], "usb": [ { "vid": "10C4", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 1327a78b0b3..9694388e784 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1155,6 +1155,21 @@ }, "update_frequency": { "name": "Update frequency" + }, + "sound_volume": { + "name": "Sound volume" + }, + "lift_drive_up_time": { + "name": "Lift drive up time" + }, + "lift_drive_down_time": { + "name": "Lift drive down time" + }, + "tilt_open_close_and_step_time": { + "name": "Tilt open close and step time" + }, + "tilt_position_percentage_after_move_to_level": { + "name": "Tilt position percentage after move to level" } }, "select": { @@ -1388,6 +1403,12 @@ }, "external_switch_type": { "name": "External switch type" + }, + "switch_indication": { + "name": "Switch indication" + }, + "switch_actions": { + "name": "Switch actions" } }, "sensor": { @@ -1741,6 +1762,32 @@ }, "lifetime": { "name": "Lifetime" + }, + "last_action_source": { + "name": "Last action source", + "state": { + "zigbee": "Zigbee", + "keypad": "Keypad", + "fingerprint": "Fingerprint", + "rfid": "RFID", + "self": "Self" + } + }, + "last_action": { + "name": "Last action", + "state": { + "lock": "[%key:common::state::locked%]", + "unlock": "[%key:common::state::unlocked%]" + } + }, + "last_action_user": { + "name": "Last action user" + }, + "last_pin_code": { + "name": "Last PIN code" + }, + "opening": { + "name": "Opening" } }, "switch": { @@ -1956,6 +2003,9 @@ }, "external_temperature_sensor": { "name": "External temperature sensor" + }, + "auto_relock": { + "name": "Auto relock" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 23f039ebea2..cb9065a5ba1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3193,7 +3193,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.60 +zha==0.0.61 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69141cce9bd..32cfef23f87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2634,7 +2634,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.60 +zha==0.0.61 # homeassistant.components.zwave_js zwave-js-server-python==0.64.0 From 7587fc985f830c740a74b74b194d7c73922ca483 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Jun 2025 13:31:43 +0200 Subject: [PATCH 0661/1664] Bump py-dormakaba-dkey to 1.0.6 (#147499) --- homeassistant/components/dormakaba_dkey/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dormakaba_dkey/manifest.json b/homeassistant/components/dormakaba_dkey/manifest.json index 52e68b7521c..96fe9b9bd5f 100644 --- a/homeassistant/components/dormakaba_dkey/manifest.json +++ b/homeassistant/components/dormakaba_dkey/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-dormakaba-dkey==1.0.5"] + "requirements": ["py-dormakaba-dkey==1.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index cb9065a5ba1..3861bfacdbb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1757,7 +1757,7 @@ py-cpuinfo==9.0.0 py-dactyl==2.0.4 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.5 +py-dormakaba-dkey==1.0.6 # homeassistant.components.improv_ble py-improv-ble-client==1.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32cfef23f87..ef634f46a8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1480,7 +1480,7 @@ py-cpuinfo==9.0.0 py-dactyl==2.0.4 # homeassistant.components.dormakaba_dkey -py-dormakaba-dkey==1.0.5 +py-dormakaba-dkey==1.0.6 # homeassistant.components.improv_ble py-improv-ble-client==1.0.3 From 47811e13a68733b75370f37d742d8a70c36869ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Jun 2025 13:58:39 +0200 Subject: [PATCH 0662/1664] Bump PySwitchbot to 0.67.0 (#147503) changelog: https://github.com/sblibs/pySwitchbot/compare/0.66.0...0.67.0 --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 78cd5276134..8e727425a2a 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.66.0"] + "requirements": ["PySwitchbot==0.67.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3861bfacdbb..9f775b13102 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.66.0 +PySwitchbot==0.67.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef634f46a8c..9cbc154c9bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.66.0 +PySwitchbot==0.67.0 # homeassistant.components.syncthru PySyncThru==0.8.0 From 12812049eaae38ba3e15eb97fc5f060294a42354 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 25 Jun 2025 14:14:33 +0200 Subject: [PATCH 0663/1664] Split setup tests in devolo Home Network (#147498) --- .../snapshots/test_init.ambr | 6 +-- .../devolo_home_network/test_init.py | 38 +++++++++++-------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr index 5753fd82817..27ffd981b1e 100644 --- a/tests/components/devolo_home_network/snapshots/test_init.ambr +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_setup_entry[mock_device] +# name: test_device[mock_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -36,7 +36,7 @@ 'via_device_id': None, }) # --- -# name: test_setup_entry[mock_ipv6_device] +# name: test_device[mock_ipv6_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -73,7 +73,7 @@ 'via_device_id': None, }) # --- -# name: test_setup_entry[mock_repeater_device] +# name: test_device[mock_repeater_device] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index c25aff7e9ad..9c609334718 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -25,28 +25,14 @@ from .const import IP from .mock import MockDevice -@pytest.mark.parametrize( - "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] -) -async def test_setup_entry( - hass: HomeAssistant, - device: str, - device_registry: dr.DeviceRegistry, - snapshot: SnapshotAssertion, - request: pytest.FixtureRequest, -) -> None: +@pytest.mark.usefixtures("mock_device") +async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup entry.""" - mock_device: MockDevice = request.getfixturevalue(device) entry = configure_integration(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state is ConfigEntryState.LOADED - device_info = device_registry.async_get_device( - {(DOMAIN, mock_device.serial_number)} - ) - assert device_info == snapshot - async def test_setup_device_not_found(hass: HomeAssistant) -> None: """Test setup entry.""" @@ -79,6 +65,26 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: mock_device.async_disconnect.assert_called_once() +@pytest.mark.parametrize( + "device", ["mock_device", "mock_repeater_device", "mock_ipv6_device"] +) +async def test_device( + hass: HomeAssistant, + device: str, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + request: pytest.FixtureRequest, +) -> None: + """Test device setup.""" + mock_device: MockDevice = request.getfixturevalue(device) + entry = configure_integration(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + device_info = device_registry.async_get_device( + {(DOMAIN, mock_device.serial_number)} + ) + assert device_info == snapshot + + @pytest.mark.parametrize( ("device", "expected_platforms"), [ From c447729ce4a5746845ca63284922493df37aa364 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:33:02 +0200 Subject: [PATCH 0664/1664] Add sensor platform to PlayStation Network (#147469) --- .../playstation_network/__init__.py | 2 +- .../components/playstation_network/helpers.py | 7 +- .../components/playstation_network/icons.json | 23 ++ .../playstation_network/media_player.py | 5 +- .../components/playstation_network/sensor.py | 168 +++++++++ .../playstation_network/strings.json | 29 ++ .../playstation_network/conftest.py | 29 ++ .../snapshots/test_sensor.ambr | 343 ++++++++++++++++++ .../playstation_network/test_sensor.py | 42 +++ 9 files changed, 645 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/playstation_network/sensor.py create mode 100644 tests/components/playstation_network/snapshots/test_sensor.ambr create mode 100644 tests/components/playstation_network/test_sensor.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index c111cf8c960..72ce0b9cfc2 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -9,7 +9,7 @@ from .const import CONF_NPSSO from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator from .helpers import PlaystationNetwork -PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER] +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SENSOR] async def async_setup_entry( diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index a106ef1d8f4..267dc77ff06 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -8,7 +8,7 @@ from typing import Any from psnawp_api import PSNAWP from psnawp_api.models.client import Client -from psnawp_api.models.trophies import PlatformType +from psnawp_api.models.trophies import PlatformType, TrophySummary from psnawp_api.models.user import User from pyrate_limiter import Duration, Rate @@ -41,6 +41,8 @@ class PlaystationNetworkData: available: bool = False active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict) registered_platforms: set[PlatformType] = field(default_factory=set) + trophy_summary: TrophySummary | None = None + profile: dict[str, Any] = field(default_factory=dict) class PlaystationNetwork: @@ -76,6 +78,9 @@ class PlaystationNetwork: data.presence = self.user.get_presence() + data.trophy_summary = self.client.trophy_summary() + data.profile = self.user.profile() + # check legacy platforms if owned if LEGACY_PLATFORMS & data.registered_platforms: self.legacy_profile = self.client.get_profile_legacy() diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2ff18bf6e59..a05170f78d3 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -4,6 +4,29 @@ "playstation": { "default": "mdi:sony-playstation" } + }, + "sensor": { + "trophy_level": { + "default": "mdi:trophy-award" + }, + "trophy_level_progress": { + "default": "mdi:trending-up" + }, + "earned_trophies_platinum": { + "default": "mdi:trophy" + }, + "earned_trophies_gold": { + "default": "mdi:trophy-variant" + }, + "earned_trophies_silver": { + "default": "mdi:trophy-variant" + }, + "earned_trophies_bronze": { + "default": "mdi:trophy-variant" + }, + "online_id": { + "default": "mdi:account" + } } } } diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 08840fbbabd..c1320e9b280 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -1,6 +1,7 @@ """Media player entity for the PlayStation Network Integration.""" import logging +from typing import TYPE_CHECKING from psnawp_api.models.trophies import PlatformType @@ -89,7 +90,8 @@ class PsnMediaPlayerEntity( ) -> None: """Initialize PSN MediaPlayer.""" super().__init__(coordinator) - + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{platform.value}" self.key = platform self._attr_device_info = DeviceInfo( @@ -97,6 +99,7 @@ class PsnMediaPlayerEntity( name=PLATFORM_MAP[platform], manufacturer="Sony Interactive Entertainment", model=PLATFORM_MAP[platform], + via_device=(DOMAIN, coordinator.config_entry.unique_id), ) @property diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py new file mode 100644 index 00000000000..b4563b00f25 --- /dev/null +++ b/homeassistant/components/playstation_network/sensor.py @@ -0,0 +1,168 @@ +"""Sensor platform for PlayStation Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import TYPE_CHECKING + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.const import PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkCoordinator, + PlaystationNetworkData, +) + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkSensorEntityDescription(SensorEntityDescription): + """PlayStation Network sensor description.""" + + value_fn: Callable[[PlaystationNetworkData], StateType] + entity_picture: str | None = None + + +class PlaystationNetworkSensor(StrEnum): + """PlayStation Network sensors.""" + + TROPHY_LEVEL = "trophy_level" + TROPHY_LEVEL_PROGRESS = "trophy_level_progress" + EARNED_TROPHIES_PLATINUM = "earned_trophies_platinum" + EARNED_TROPHIES_GOLD = "earned_trophies_gold" + EARNED_TROPHIES_SILVER = "earned_trophies_silver" + EARNED_TROPHIES_BRONZE = "earned_trophies_bronze" + ONLINE_ID = "online_id" + + +SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.TROPHY_LEVEL, + translation_key=PlaystationNetworkSensor.TROPHY_LEVEL, + value_fn=( + lambda psn: psn.trophy_summary.trophy_level if psn.trophy_summary else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.TROPHY_LEVEL_PROGRESS, + translation_key=PlaystationNetworkSensor.TROPHY_LEVEL_PROGRESS, + value_fn=( + lambda psn: psn.trophy_summary.progress if psn.trophy_summary else None + ), + native_unit_of_measurement=PERCENTAGE, + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_PLATINUM, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_PLATINUM, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.platinum + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_GOLD, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_GOLD, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.gold + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_SILVER, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_SILVER, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.silver + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.EARNED_TROPHIES_BRONZE, + translation_key=PlaystationNetworkSensor.EARNED_TROPHIES_BRONZE, + value_fn=( + lambda psn: psn.trophy_summary.earned_trophies.bronze + if psn.trophy_summary + else None + ), + ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.ONLINE_ID, + translation_key=PlaystationNetworkSensor.ONLINE_ID, + value_fn=lambda psn: psn.username, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + PlaystationNetworkSensorEntity(coordinator, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PlaystationNetworkSensorEntity( + CoordinatorEntity[PlaystationNetworkCoordinator], SensorEntity +): + """Representation of a PlayStation Network sensor entity.""" + + entity_description: PlaystationNetworkSensorEntityDescription + coordinator: PlaystationNetworkCoordinator + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PlaystationNetworkCoordinator, + description: PlaystationNetworkSensorEntityDescription, + ) -> None: + """Initialize a sensor entity.""" + super().__init__(coordinator) + self.entity_description = description + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id + self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + name=coordinator.data.username, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Sony Interactive Entertainment", + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data) + + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if self.entity_description.key is PlaystationNetworkSensor.ONLINE_ID and ( + profile_pictures := self.coordinator.data.profile["personalDetail"].get( + "profilePictures" + ) + ): + return next( + (pic.get("url") for pic in profile_pictures if pic.get("size") == "xl"), + None, + ) + + return super().entity_picture diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 19d61859f97..5d8333e785f 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -40,5 +40,34 @@ "update_failed": { "message": "Data retrieval failed when trying to access the PlayStation Network." } + }, + "entity": { + "sensor": { + "trophy_level": { + "name": "Trophy level" + }, + "trophy_level_progress": { + "name": "Next level" + }, + "earned_trophies_platinum": { + "name": "Platinum trophies", + "unit_of_measurement": "trophies" + }, + "earned_trophies_gold": { + "name": "Gold trophies", + "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" + }, + "earned_trophies_silver": { + "name": "Silver trophies", + "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" + }, + "earned_trophies_bronze": { + "name": "Bronze trophies", + "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" + }, + "online_id": { + "name": "Online-ID" + } + } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index f03b3e6f1cf..821025dbb9c 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -3,6 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, MagicMock, patch +from psnawp_api.models.trophies import TrophySet, TrophySummary import pytest from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN @@ -89,6 +90,34 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: "accountDeviceVector": "abcdefghijklmnopqrstuv", } ] + client.me.return_value.trophy_summary.return_value = TrophySummary( + PSN_ID, 1079, 19, 10, TrophySet(14450, 8722, 11754, 1398) + ) + client.user.return_value.profile.return_value = { + "onlineId": "testuser", + "personalDetail": { + "firstName": "Rick", + "lastName": "Astley", + "profilePictures": [ + { + "size": "xl", + "url": "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png", + } + ], + }, + "aboutMe": "Never Gonna Give You Up", + "avatars": [ + { + "size": "xl", + "url": "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png", + } + ], + "languages": ["de-DE"], + "isPlus": True, + "isOfficiallyVerified": False, + "isMe": True, + } + yield client diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..61030ee0a39 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -0,0 +1,343 @@ +# serializer version: 1 +# name: test_sensors[sensor.testuser_bronze_trophies-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.testuser_bronze_trophies', + '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': 'Bronze trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_bronze', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_bronze_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Bronze trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_bronze_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '14450', + }) +# --- +# name: test_sensors[sensor.testuser_gold_trophies-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.testuser_gold_trophies', + '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': 'Gold trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_gold', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_gold_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Gold trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_gold_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11754', + }) +# --- +# name: test_sensors[sensor.testuser_next_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.testuser_next_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': 'Next level', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_trophy_level_progress', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[sensor.testuser_next_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Next level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.testuser_next_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19', + }) +# --- +# name: test_sensors[sensor.testuser_online_id-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.testuser_online_id', + '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': 'Online-ID', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_online_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_id-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png', + 'friendly_name': 'testuser Online-ID', + }), + 'context': , + 'entity_id': 'sensor.testuser_online_id', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'testuser', + }) +# --- +# name: test_sensors[sensor.testuser_platinum_trophies-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.testuser_platinum_trophies', + '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': 'Platinum trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_platinum', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_platinum_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Platinum trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_platinum_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1398', + }) +# --- +# name: test_sensors[sensor.testuser_silver_trophies-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.testuser_silver_trophies', + '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': 'Silver trophies', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_earned_trophies_silver', + 'unit_of_measurement': 'trophies', + }) +# --- +# name: test_sensors[sensor.testuser_silver_trophies-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Silver trophies', + 'unit_of_measurement': 'trophies', + }), + 'context': , + 'entity_id': 'sensor.testuser_silver_trophies', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8722', + }) +# --- +# name: test_sensors[sensor.testuser_trophy_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.testuser_trophy_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': 'Trophy level', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_trophy_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_trophy_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Trophy level', + }), + 'context': , + 'entity_id': 'sensor.testuser_trophy_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1079', + }) +# --- diff --git a/tests/components/playstation_network/test_sensor.py b/tests/components/playstation_network/test_sensor.py new file mode 100644 index 00000000000..c39f121c912 --- /dev/null +++ b/tests/components/playstation_network/test_sensor.py @@ -0,0 +1,42 @@ +"""Test the Playstation Network sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the PlayStation Network sensor 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) From 8918b0d7a946c90a3618e2a5d8927fc1da1883e2 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 25 Jun 2025 14:33:37 +0200 Subject: [PATCH 0665/1664] Add missing reauth_confirm strings to devolo Home Control (#147496) --- .../components/devolo_home_control/strings.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/devolo_home_control/strings.json b/homeassistant/components/devolo_home_control/strings.json index a5a8086ba47..4ec1a35ece2 100644 --- a/homeassistant/components/devolo_home_control/strings.json +++ b/homeassistant/components/devolo_home_control/strings.json @@ -19,6 +19,16 @@ "password": "Password of your mydevolo account." } }, + "reauth_confirm": { + "data": { + "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]", + "password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]" + } + }, "zeroconf_confirm": { "data": { "username": "[%key:component::devolo_home_control::config::step::user::data::username%]", From 8393f17bb305d8f11bb18b2688c85edec79f8dec Mon Sep 17 00:00:00 2001 From: Pavel Skuratovich Date: Wed, 25 Jun 2025 15:34:11 +0300 Subject: [PATCH 0666/1664] Fix sensor state class for fuel sensor in StarLine integration (#146769) --- homeassistant/components/starline/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 916d0a9f26b..d87c2eed304 100644 --- a/homeassistant/components/starline/sensor.py +++ b/homeassistant/components/starline/sensor.py @@ -62,7 +62,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="fuel", translation_key="fuel", device_class=SensorDeviceClass.VOLUME, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="errors", From c5f8acfe9317edf0d0fa62388e8dc59885ed6469 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Wed, 25 Jun 2025 20:45:07 +0800 Subject: [PATCH 0667/1664] Add effect mode support for switchbot light (#147326) * add support for strip light3 and floor lamp * clear the color mode * add led unit test * use property for effect * fix color mode issue * remove new products * fix adv data * adjust log level * add translation and icon --- homeassistant/components/switchbot/icons.json | 29 ++ homeassistant/components/switchbot/light.py | 92 +++-- .../components/switchbot/strings.json | 29 ++ tests/components/switchbot/__init__.py | 58 +++ tests/components/switchbot/test_light.py | 356 ++++++++++++------ 5 files changed, 420 insertions(+), 144 deletions(-) diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index 38e17ae6c56..2aef019aab4 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -60,6 +60,35 @@ } } } + }, + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "christmas": "mdi:string-lights", + "halloween": "mdi:halloween", + "sunset": "mdi:weather-sunset", + "vitality": "mdi:parachute", + "flashing": "mdi:flash", + "strobe": "mdi:led-strip-variant", + "fade": "mdi:water-opacity", + "smooth": "mdi:led-strip-variant", + "forest": "mdi:forest", + "ocean": "mdi:waves", + "autumn": "mdi:leaf-maple", + "cool": "mdi:emoticon-cool-outline", + "flow": "mdi:pulse", + "relax": "mdi:coffee", + "modern": "mdi:school-outline", + "rose": "mdi:flower", + "colorful": "mdi:looks", + "flickering": "mdi:led-strip-variant", + "breathing": "mdi:heart-pulse" + } + } + } + } } } } diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py index ad37f3ebec0..e9a3518498d 100644 --- a/homeassistant/components/switchbot/light.py +++ b/homeassistant/components/switchbot/light.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any, cast import switchbot @@ -10,14 +11,16 @@ from switchbot import ColorMode as SwitchBotColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityFeature, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator +from .coordinator import SwitchbotConfigEntry from .entity import SwitchbotEntity, exception_handler SWITCHBOT_COLOR_MODE_TO_HASS = { @@ -25,6 +28,7 @@ SWITCHBOT_COLOR_MODE_TO_HASS = { SwitchBotColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP, } +_LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -42,34 +46,69 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): _device: switchbot.SwitchbotBaseLight _attr_name = None + _attr_translation_key = "light" - def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None: - """Initialize the Switchbot light.""" - super().__init__(coordinator) - device = self._device - self._attr_max_color_temp_kelvin = device.max_temp - self._attr_min_color_temp_kelvin = device.min_temp - self._attr_supported_color_modes = { - SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in device.color_modes - } - self._async_update_attrs() + @property + def max_color_temp_kelvin(self) -> int: + """Return the max color temperature.""" + return self._device.max_temp - @callback - def _async_update_attrs(self) -> None: - """Handle updating _attr values.""" - device = self._device - self._attr_is_on = self._device.on - self._attr_brightness = max(0, min(255, round(device.brightness * 2.55))) - if device.color_mode == SwitchBotColorMode.COLOR_TEMP: - self._attr_color_temp_kelvin = device.color_temp - self._attr_color_mode = ColorMode.COLOR_TEMP - return - self._attr_rgb_color = device.rgb - self._attr_color_mode = ColorMode.RGB + @property + def min_color_temp_kelvin(self) -> int: + """Return the min color temperature.""" + return self._device.min_temp + + @property + def supported_color_modes(self) -> set[ColorMode]: + """Return the supported color modes.""" + return {SWITCHBOT_COLOR_MODE_TO_HASS[mode] for mode in self._device.color_modes} + + @property + def supported_features(self) -> LightEntityFeature: + """Return the supported features.""" + return LightEntityFeature.EFFECT if self.effect_list else LightEntityFeature(0) + + @property + def brightness(self) -> int | None: + """Return the brightness of the light.""" + return max(0, min(255, round(self._device.brightness * 2.55))) + + @property + def color_mode(self) -> ColorMode | None: + """Return the color mode of the light.""" + return SWITCHBOT_COLOR_MODE_TO_HASS.get( + self._device.color_mode, ColorMode.UNKNOWN + ) + + @property + def effect_list(self) -> list[str] | None: + """Return the list of effects supported by the light.""" + return self._device.get_effect_list + + @property + def effect(self) -> str | None: + """Return the current effect of the light.""" + return self._device.get_effect() + + @property + def rgb_color(self) -> tuple[int, int, int] | None: + """Return the RGB color of the light.""" + return self._device.rgb + + @property + def color_temp_kelvin(self) -> int | None: + """Return the color temperature of the light.""" + return self._device.color_temp + + @property + def is_on(self) -> bool: + """Return true if the light is on.""" + return self._device.on @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" + _LOGGER.debug("Turning on light %s, address %s", kwargs, self._address) brightness = round( cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100 ) @@ -82,6 +121,10 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): kelvin = max(2700, min(6500, kwargs[ATTR_COLOR_TEMP_KELVIN])) await self._device.set_color_temp(brightness, kelvin) return + if ATTR_EFFECT in kwargs: + effect = kwargs[ATTR_EFFECT] + await self._device.set_effect(effect) + return if ATTR_RGB_COLOR in kwargs: rgb = kwargs[ATTR_RGB_COLOR] await self._device.set_rgb(brightness, rgb[0], rgb[1], rgb[2]) @@ -94,4 +137,5 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity): @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Instruct the light to turn off.""" + _LOGGER.debug("Turning off light %s, address %s", kwargs, self._address) await self._device.turn_off() diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 9bce9614549..dbbf98c3945 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -246,6 +246,35 @@ } } } + }, + "light": { + "light": { + "state_attributes": { + "effect": { + "state": { + "christmas": "Christmas", + "halloween": "Halloween", + "sunset": "Sunset", + "vitality": "Vitality", + "flashing": "Flashing", + "strobe": "Strobe", + "fade": "Fade", + "smooth": "Smooth", + "forest": "Forest", + "ocean": "Ocean", + "autumn": "Autumn", + "cool": "Cool", + "flow": "Flow", + "relax": "Relax", + "modern": "Modern", + "rose": "Rose", + "colorful": "Colorful", + "flickering": "Flickering", + "breathing": "Breathing" + } + } + } + } } }, "exceptions": { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 6e0aaadacd4..98e576e4fe5 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -883,3 +883,61 @@ EVAPORATIVE_HUMIDIFIER_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +BULB_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Bulb", + manufacturer_data={ + 2409: b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Bulb", + manufacturer_data={ + 2409: b"@L\xca\xa7_\x12\x02\x81\x12\x00\x00", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"u\x00d", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Bulb"), + time=0, + connectable=True, + tx_power=-127, +) + + +CEILING_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Ceiling Light", + manufacturer_data={ + 2409: b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3\xa4", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"q\x00", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Ceiling Light", + manufacturer_data={ + 2409: b"\xef\xfe\xfb\x9d\x10\xfe\n\x01\x18\xf3$", + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"q\x00", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Ceiling Light"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index 957d56411da..6629de0150e 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -5,12 +5,12 @@ from typing import Any from unittest.mock import AsyncMock, patch import pytest -from switchbot import ColorMode as switchbotColorMode from switchbot.devices.device import SwitchbotOperationError from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, + ATTR_EFFECT, ATTR_RGB_COLOR, DOMAIN as LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -20,89 +20,111 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import WOSTRIP_SERVICE_INFO +from . import BULB_SERVICE_INFO, CEILING_LIGHT_SERVICE_INFO, WOSTRIP_SERVICE_INFO from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info - -@pytest.mark.parametrize( - ( - "service", - "service_data", - "mock_method", - "expected_args", - "color_modes", - "color_mode", - ), +COMMON_PARAMETERS = ( + "service", + "service_data", + "mock_method", + "expected_args", +) +TURN_ON_PARAMETERS = ( + SERVICE_TURN_ON, + {}, + "turn_on", + {}, +) +TURN_OFF_PARAMETERS = ( + SERVICE_TURN_OFF, + {}, + "turn_off", + {}, +) +SET_BRIGHTNESS_PARAMETERS = ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128}, + "set_brightness", + (round(128 / 255 * 100),), +) +SET_RGB_PARAMETERS = ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128, ATTR_RGB_COLOR: (255, 0, 0)}, + "set_rgb", + (round(128 / 255 * 100), 255, 0, 0), +) +SET_COLOR_TEMP_PARAMETERS = ( + SERVICE_TURN_ON, + {ATTR_BRIGHTNESS: 128, ATTR_COLOR_TEMP_KELVIN: 4000}, + "set_color_temp", + (round(128 / 255 * 100), 4000), +) +BULB_PARAMETERS = ( + COMMON_PARAMETERS, [ - ( - SERVICE_TURN_OFF, - {}, - "turn_off", - (), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, ( SERVICE_TURN_ON, - {}, - "turn_on", - (), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_BRIGHTNESS: 128}, - "set_brightness", - (round(128 / 255 * 100),), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_RGB_COLOR: (255, 0, 0)}, - "set_rgb", - (round(255 / 255 * 100), 255, 0, 0), - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_COLOR_TEMP_KELVIN: 4000}, - "set_color_temp", - (100, 4000), - {switchbotColorMode.COLOR_TEMP}, - switchbotColorMode.COLOR_TEMP, + {ATTR_EFFECT: "Breathing"}, + "set_effect", + ("Breathing",), ), ], ) -async def test_light_strip_services( +CEILING_LIGHT_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, + ], +) +STRIP_LIGHT_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "Halloween"}, + "set_effect", + ("Halloween",), + ), + ], +) + + +@pytest.mark.parametrize(*BULB_PARAMETERS) +async def test_bulb_services( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry], service: str, service_data: dict, mock_method: str, expected_args: Any, - color_modes: set | None, - color_mode: switchbotColorMode | None, ) -> None: - """Test all SwitchBot light strip services with proper parameters.""" - inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + """Test all SwitchBot bulb services.""" + inject_bluetooth_service_info(hass, BULB_SERVICE_INFO) - entry = mock_entry_factory(sensor_type="light_strip") + entry = mock_entry_factory(sensor_type="bulb") entry.add_to_hass(hass) entity_id = "light.test_name" mocked_instance = AsyncMock(return_value=True) with patch.multiple( - "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", - color_modes=color_modes, - color_mode=color_mode, - update=AsyncMock(return_value=None), + "homeassistant.components.switchbot.light.switchbot.SwitchbotBulb", **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -117,79 +139,173 @@ async def test_light_strip_services( mocked_instance.assert_awaited_once_with(*expected_args) -@pytest.mark.parametrize( - ("exception", "error_message"), - [ - ( - SwitchbotOperationError("Operation failed"), - "An error occurred while performing the action: Operation failed", - ), - ], -) -@pytest.mark.parametrize( - ("service", "service_data", "mock_method", "color_modes", "color_mode"), - [ - ( - SERVICE_TURN_ON, - {}, - "turn_on", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_OFF, - {}, - "turn_off", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_BRIGHTNESS: 128}, - "set_brightness", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_RGB_COLOR: (255, 0, 0)}, - "set_rgb", - {switchbotColorMode.RGB}, - switchbotColorMode.RGB, - ), - ( - SERVICE_TURN_ON, - {ATTR_COLOR_TEMP_KELVIN: 4000}, - "set_color_temp", - {switchbotColorMode.COLOR_TEMP}, - switchbotColorMode.COLOR_TEMP, - ), - ], -) -async def test_exception_handling_light_service( +@pytest.mark.parametrize(*BULB_PARAMETERS) +async def test_bulb_services_exception( hass: HomeAssistant, mock_entry_factory: Callable[[str], MockConfigEntry], service: str, service_data: dict, mock_method: str, - color_modes: set | None, - color_mode: switchbotColorMode | None, - exception: Exception, - error_message: str, + expected_args: Any, ) -> None: - """Test exception handling for light service with exception.""" - inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + """Test all SwitchBot bulb services with exception.""" + inject_bluetooth_service_info(hass, BULB_SERVICE_INFO) - entry = mock_entry_factory(sensor_type="light_strip") + entry = mock_entry_factory(sensor_type="bulb") entry.add_to_hass(hass) entity_id = "light.test_name" + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + with patch.multiple( - "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", - color_modes=color_modes, - color_mode=color_mode, - update=AsyncMock(return_value=None), + "homeassistant.components.switchbot.light.switchbot.SwitchbotBulb", **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize(*CEILING_LIGHT_PARAMETERS) +async def test_ceiling_light_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot ceiling light services.""" + inject_bluetooth_service_info(hass, CEILING_LIGHT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="ceiling_light") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotCeilingLight", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize(*CEILING_LIGHT_PARAMETERS) +async def test_ceiling_light_services_exception( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot ceiling light services with exception.""" + inject_bluetooth_service_info(hass, CEILING_LIGHT_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="ceiling_light") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotCeilingLight", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + +@pytest.mark.parametrize(*STRIP_LIGHT_PARAMETERS) +async def test_strip_light_services( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot strip light services.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize(*STRIP_LIGHT_PARAMETERS) +async def test_strip_light_services_exception( + hass: HomeAssistant, + mock_entry_factory: Callable[[str], MockConfigEntry], + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot strip light services with exception.""" + inject_bluetooth_service_info(hass, WOSTRIP_SERVICE_INFO) + + entry = mock_entry_factory(sensor_type="light_strip") + entry.add_to_hass(hass) + entity_id = "light.test_name" + + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotLightStrip", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), ): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() From c54ce7eabda6096e136c16c5ad3799c217ee6295 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:50:07 +0200 Subject: [PATCH 0668/1664] Split models and helpers from coordinator module in AVM Fritz!Box tools (#147412) * split models from coordinator * split helpers from coordinator --- .../components/fritz/binary_sensor.py | 3 +- homeassistant/components/fritz/button.py | 11 +- homeassistant/components/fritz/coordinator.py | 223 ++---------------- .../components/fritz/device_tracker.py | 11 +- homeassistant/components/fritz/entity.py | 3 +- homeassistant/components/fritz/helpers.py | 39 +++ homeassistant/components/fritz/models.py | 182 ++++++++++++++ homeassistant/components/fritz/sensor.py | 3 +- homeassistant/components/fritz/switch.py | 12 +- 9 files changed, 252 insertions(+), 235 deletions(-) create mode 100644 homeassistant/components/fritz/helpers.py create mode 100644 homeassistant/components/fritz/models.py diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py index 2a4eb8c82b5..0bc772db5a4 100644 --- a/homeassistant/components/fritz/binary_sensor.py +++ b/homeassistant/components/fritz/binary_sensor.py @@ -15,8 +15,9 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ConnectionInfo, FritzConfigEntry +from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription +from .models import ConnectionInfo _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py index 926e233d159..7fd158f3224 100644 --- a/homeassistant/components/fritz/button.py +++ b/homeassistant/components/fritz/button.py @@ -19,15 +19,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles -from .coordinator import ( - FRITZ_DATA_KEY, - AvmWrapper, - FritzConfigEntry, - FritzData, - FritzDevice, - _is_tracked, -) +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzDeviceBase +from .helpers import _is_tracked +from .models import FritzDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py index e22a66d254f..d8d3bbd7a53 100644 --- a/homeassistant/components/fritz/coordinator.py +++ b/homeassistant/components/fritz/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Callable, Mapping, ValuesView +from collections.abc import Callable, Mapping from dataclasses import dataclass, field from datetime import datetime, timedelta from functools import partial @@ -34,7 +34,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from homeassistant.util.hass_dict import HassKey from .const import ( @@ -48,6 +47,15 @@ from .const import ( FRITZ_EXCEPTIONS, MeshRoles, ) +from .helpers import _ha_is_stopping +from .models import ( + ConnectionInfo, + Device, + FritzDevice, + HostAttributes, + HostInfo, + Interface, +) _LOGGER = logging.getLogger(__name__) @@ -56,33 +64,13 @@ FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN) type FritzConfigEntry = ConfigEntry[AvmWrapper] -def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: - """Check if device is already tracked.""" - return any(mac in tracked for tracked in current_devices) +@dataclass +class FritzData: + """Storage class for platform global data.""" - -def device_filter_out_from_trackers( - mac: str, - device: FritzDevice, - current_devices: ValuesView[set[str]], -) -> bool: - """Check if device should be filtered out from trackers.""" - reason: str | None = None - if device.ip_address == "": - reason = "Missing IP" - elif _is_tracked(mac, current_devices): - reason = "Already tracked" - - if reason: - _LOGGER.debug( - "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason - ) - return bool(reason) - - -def _ha_is_stopping(activity: str) -> None: - """Inform that HA is stopping.""" - _LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity) + tracked: dict[str, set[str]] = field(default_factory=dict) + profile_switches: dict[str, set[str]] = field(default_factory=dict) + wol_buttons: dict[str, set[str]] = field(default_factory=dict) class ClassSetupMissing(Exception): @@ -93,68 +81,6 @@ class ClassSetupMissing(Exception): super().__init__("Function called before Class setup") -@dataclass -class Device: - """FRITZ!Box device class.""" - - connected: bool - connected_to: str - connection_type: str - ip_address: str - name: str - ssid: str | None - wan_access: bool | None = None - - -class Interface(TypedDict): - """Interface details.""" - - device: str - mac: str - op_mode: str - ssid: str | None - type: str - - -HostAttributes = TypedDict( - "HostAttributes", - { - "Index": int, - "IPAddress": str, - "MACAddress": str, - "Active": bool, - "HostName": str, - "InterfaceType": str, - "X_AVM-DE_Port": int, - "X_AVM-DE_Speed": int, - "X_AVM-DE_UpdateAvailable": bool, - "X_AVM-DE_UpdateSuccessful": str, - "X_AVM-DE_InfoURL": str | None, - "X_AVM-DE_MACAddressList": str | None, - "X_AVM-DE_Model": str | None, - "X_AVM-DE_URL": str | None, - "X_AVM-DE_Guest": bool, - "X_AVM-DE_RequestClient": str, - "X_AVM-DE_VPN": bool, - "X_AVM-DE_WANAccess": str, - "X_AVM-DE_Disallow": bool, - "X_AVM-DE_IsMeshable": str, - "X_AVM-DE_Priority": str, - "X_AVM-DE_FriendlyName": str, - "X_AVM-DE_FriendlyNameIsWriteable": str, - }, -) - - -class HostInfo(TypedDict): - """FRITZ!Box host info class.""" - - mac: str - name: str - ip: str - status: bool - - class UpdateCoordinatorDataType(TypedDict): """Update coordinator data type.""" @@ -898,120 +824,3 @@ class AvmWrapper(FritzBoxTools): "X_AVM-DE_WakeOnLANByMACAddress", NewMACAddress=mac_address, ) - - -@dataclass -class FritzData: - """Storage class for platform global data.""" - - tracked: dict[str, set[str]] = field(default_factory=dict) - profile_switches: dict[str, set[str]] = field(default_factory=dict) - wol_buttons: dict[str, set[str]] = field(default_factory=dict) - - -class FritzDevice: - """Representation of a device connected to the FRITZ!Box.""" - - def __init__(self, mac: str, name: str) -> None: - """Initialize device info.""" - self._connected = False - self._connected_to: str | None = None - self._connection_type: str | None = None - self._ip_address: str | None = None - self._last_activity: datetime | None = None - self._mac = mac - self._name = name - self._ssid: str | None = None - self._wan_access: bool | None = False - - def update(self, dev_info: Device, consider_home: float) -> None: - """Update device info.""" - utc_point_in_time = dt_util.utcnow() - - if self._last_activity: - consider_home_evaluated = ( - utc_point_in_time - self._last_activity - ).total_seconds() < consider_home - else: - consider_home_evaluated = dev_info.connected - - if not self._name: - self._name = dev_info.name or self._mac.replace(":", "_") - - self._connected = dev_info.connected or consider_home_evaluated - - if dev_info.connected: - self._last_activity = utc_point_in_time - - self._connected_to = dev_info.connected_to - self._connection_type = dev_info.connection_type - self._ip_address = dev_info.ip_address - self._ssid = dev_info.ssid - self._wan_access = dev_info.wan_access - - @property - def connected_to(self) -> str | None: - """Return connected status.""" - return self._connected_to - - @property - def connection_type(self) -> str | None: - """Return connected status.""" - return self._connection_type - - @property - def is_connected(self) -> bool: - """Return connected status.""" - return self._connected - - @property - def mac_address(self) -> str: - """Get MAC address.""" - return self._mac - - @property - def hostname(self) -> str: - """Get Name.""" - return self._name - - @property - def ip_address(self) -> str | None: - """Get IP address.""" - return self._ip_address - - @property - def last_activity(self) -> datetime | None: - """Return device last activity.""" - return self._last_activity - - @property - def ssid(self) -> str | None: - """Return device connected SSID.""" - return self._ssid - - @property - def wan_access(self) -> bool | None: - """Return device wan access.""" - return self._wan_access - - -class SwitchInfo(TypedDict): - """FRITZ!Box switch info class.""" - - description: str - friendly_name: str - icon: str - type: str - callback_update: Callable - callback_switch: Callable - init_state: bool - - -@dataclass -class ConnectionInfo: - """Fritz sensor connection information class.""" - - connection: str - mesh_role: MeshRoles - wan_enabled: bool - ipv6_active: bool diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py index 618214a1c55..a658f5d19cb 100644 --- a/homeassistant/components/fritz/device_tracker.py +++ b/homeassistant/components/fritz/device_tracker.py @@ -10,15 +10,10 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .coordinator import ( - FRITZ_DATA_KEY, - AvmWrapper, - FritzConfigEntry, - FritzData, - FritzDevice, - device_filter_out_from_trackers, -) +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzDeviceBase +from .helpers import device_filter_out_from_trackers +from .models import FritzDevice _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py index e8b5c49fd43..49dc73bba26 100644 --- a/homeassistant/components/fritz/entity.py +++ b/homeassistant/components/fritz/entity.py @@ -14,7 +14,8 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_DEVICE_NAME, DOMAIN -from .coordinator import AvmWrapper, FritzDevice +from .coordinator import AvmWrapper +from .models import FritzDevice class FritzDeviceBase(CoordinatorEntity[AvmWrapper]): diff --git a/homeassistant/components/fritz/helpers.py b/homeassistant/components/fritz/helpers.py new file mode 100644 index 00000000000..af75b97e59a --- /dev/null +++ b/homeassistant/components/fritz/helpers.py @@ -0,0 +1,39 @@ +"""Helpers for AVM FRITZ!Box.""" + +from __future__ import annotations + +from collections.abc import ValuesView +import logging + +from .models import FritzDevice + +_LOGGER = logging.getLogger(__name__) + + +def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool: + """Check if device is already tracked.""" + return any(mac in tracked for tracked in current_devices) + + +def device_filter_out_from_trackers( + mac: str, + device: FritzDevice, + current_devices: ValuesView[set[str]], +) -> bool: + """Check if device should be filtered out from trackers.""" + reason: str | None = None + if device.ip_address == "": + reason = "Missing IP" + elif _is_tracked(mac, current_devices): + reason = "Already tracked" + + if reason: + _LOGGER.debug( + "Skip adding device %s [%s], reason: %s", device.hostname, mac, reason + ) + return bool(reason) + + +def _ha_is_stopping(activity: str) -> None: + """Inform that HA is stopping.""" + _LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity) diff --git a/homeassistant/components/fritz/models.py b/homeassistant/components/fritz/models.py new file mode 100644 index 00000000000..f66c1d338b9 --- /dev/null +++ b/homeassistant/components/fritz/models.py @@ -0,0 +1,182 @@ +"""Models for AVM FRITZ!Box.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + +from homeassistant.util import dt as dt_util + +from .const import MeshRoles + + +@dataclass +class Device: + """FRITZ!Box device class.""" + + connected: bool + connected_to: str + connection_type: str + ip_address: str + name: str + ssid: str | None + wan_access: bool | None = None + + +class Interface(TypedDict): + """Interface details.""" + + device: str + mac: str + op_mode: str + ssid: str | None + type: str + + +HostAttributes = TypedDict( + "HostAttributes", + { + "Index": int, + "IPAddress": str, + "MACAddress": str, + "Active": bool, + "HostName": str, + "InterfaceType": str, + "X_AVM-DE_Port": int, + "X_AVM-DE_Speed": int, + "X_AVM-DE_UpdateAvailable": bool, + "X_AVM-DE_UpdateSuccessful": str, + "X_AVM-DE_InfoURL": str | None, + "X_AVM-DE_MACAddressList": str | None, + "X_AVM-DE_Model": str | None, + "X_AVM-DE_URL": str | None, + "X_AVM-DE_Guest": bool, + "X_AVM-DE_RequestClient": str, + "X_AVM-DE_VPN": bool, + "X_AVM-DE_WANAccess": str, + "X_AVM-DE_Disallow": bool, + "X_AVM-DE_IsMeshable": str, + "X_AVM-DE_Priority": str, + "X_AVM-DE_FriendlyName": str, + "X_AVM-DE_FriendlyNameIsWriteable": str, + }, +) + + +class HostInfo(TypedDict): + """FRITZ!Box host info class.""" + + mac: str + name: str + ip: str + status: bool + + +class FritzDevice: + """Representation of a device connected to the FRITZ!Box.""" + + def __init__(self, mac: str, name: str) -> None: + """Initialize device info.""" + self._connected = False + self._connected_to: str | None = None + self._connection_type: str | None = None + self._ip_address: str | None = None + self._last_activity: datetime | None = None + self._mac = mac + self._name = name + self._ssid: str | None = None + self._wan_access: bool | None = False + + def update(self, dev_info: Device, consider_home: float) -> None: + """Update device info.""" + utc_point_in_time = dt_util.utcnow() + + if self._last_activity: + consider_home_evaluated = ( + utc_point_in_time - self._last_activity + ).total_seconds() < consider_home + else: + consider_home_evaluated = dev_info.connected + + if not self._name: + self._name = dev_info.name or self._mac.replace(":", "_") + + self._connected = dev_info.connected or consider_home_evaluated + + if dev_info.connected: + self._last_activity = utc_point_in_time + + self._connected_to = dev_info.connected_to + self._connection_type = dev_info.connection_type + self._ip_address = dev_info.ip_address + self._ssid = dev_info.ssid + self._wan_access = dev_info.wan_access + + @property + def connected_to(self) -> str | None: + """Return connected status.""" + return self._connected_to + + @property + def connection_type(self) -> str | None: + """Return connected status.""" + return self._connection_type + + @property + def is_connected(self) -> bool: + """Return connected status.""" + return self._connected + + @property + def mac_address(self) -> str: + """Get MAC address.""" + return self._mac + + @property + def hostname(self) -> str: + """Get Name.""" + return self._name + + @property + def ip_address(self) -> str | None: + """Get IP address.""" + return self._ip_address + + @property + def last_activity(self) -> datetime | None: + """Return device last activity.""" + return self._last_activity + + @property + def ssid(self) -> str | None: + """Return device connected SSID.""" + return self._ssid + + @property + def wan_access(self) -> bool | None: + """Return device wan access.""" + return self._wan_access + + +class SwitchInfo(TypedDict): + """FRITZ!Box switch info class.""" + + description: str + friendly_name: str + icon: str + type: str + callback_update: Callable + callback_switch: Callable + init_state: bool + + +@dataclass +class ConnectionInfo: + """Fritz sensor connection information class.""" + + connection: str + mesh_role: MeshRoles + wan_enabled: bool + ipv6_active: bool diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py index 65a776b9ad5..e2df5dc6e8b 100644 --- a/homeassistant/components/fritz/sensor.py +++ b/homeassistant/components/fritz/sensor.py @@ -27,8 +27,9 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utcnow from .const import DSL_CONNECTION, UPTIME_DEVIATION -from .coordinator import ConnectionInfo, FritzConfigEntry +from .coordinator import FritzConfigEntry from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription +from .models import ConnectionInfo _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index a033e45fcec..f1c34682cff 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -25,16 +25,10 @@ from .const import ( WIFI_STANDARD, MeshRoles, ) -from .coordinator import ( - FRITZ_DATA_KEY, - AvmWrapper, - FritzConfigEntry, - FritzData, - FritzDevice, - SwitchInfo, - device_filter_out_from_trackers, -) +from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData from .entity import FritzBoxBaseEntity, FritzDeviceBase +from .helpers import device_filter_out_from_trackers +from .models import FritzDevice, SwitchInfo _LOGGER = logging.getLogger(__name__) From 977e8adbfba5c49a6b6f10d1ca53d609477aa212 Mon Sep 17 00:00:00 2001 From: ocrease <18347739+ocrease@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:23:38 +0100 Subject: [PATCH 0669/1664] Fix operational state and vacuum state for matter vacuum (#147466) --- homeassistant/components/matter/sensor.py | 24 +++++++++++++----- homeassistant/components/matter/vacuum.py | 19 +++++++------- .../matter/snapshots/test_sensor.ambr | 4 +-- tests/components/matter/test_vacuum.py | 25 +++++++++++++++---- 4 files changed, 49 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 0b4d3cc3330..f744ec8885a 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from typing import TYPE_CHECKING, cast @@ -74,6 +74,11 @@ OPERATIONAL_STATE_MAP = { clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", +} + +RVC_OPERATIONAL_STATE_MAP = { + # enum with known Operation state values which we can translate + **OPERATIONAL_STATE_MAP, clusters.RvcOperationalState.Enums.OperationalStateEnum.kSeekingCharger: "seeking_charger", clusters.RvcOperationalState.Enums.OperationalStateEnum.kCharging: "charging", clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked", @@ -171,6 +176,10 @@ class MatterOperationalStateSensorEntityDescription(MatterSensorEntityDescriptio state_list_attribute: type[ClusterAttributeDescriptor] = ( clusters.OperationalState.Attributes.OperationalStateList ) + state_attribute: type[ClusterAttributeDescriptor] = ( + clusters.OperationalState.Attributes.OperationalState + ) + state_map: dict[int, str] = field(default_factory=lambda: OPERATIONAL_STATE_MAP) class MatterSensor(MatterEntity, SensorEntity): @@ -245,15 +254,15 @@ class MatterOperationalStateSensor(MatterSensor): for state in operational_state_list: # prefer translateable (known) state from mapping, # fallback to the raw state label as given by the device/manufacturer - states_map[state.operationalStateID] = OPERATIONAL_STATE_MAP.get( - state.operationalStateID, slugify(state.operationalStateLabel) + states_map[state.operationalStateID] = ( + self.entity_description.state_map.get( + state.operationalStateID, slugify(state.operationalStateLabel) + ) ) self.states_map = states_map self._attr_options = list(states_map.values()) self._attr_native_value = states_map.get( - self.get_matter_attribute_value( - clusters.OperationalState.Attributes.OperationalState - ) + self.get_matter_attribute_value(self.entity_description.state_attribute) ) @@ -999,6 +1008,8 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, translation_key="operational_state", state_list_attribute=clusters.RvcOperationalState.Attributes.OperationalStateList, + state_attribute=clusters.RvcOperationalState.Attributes.OperationalState, + state_map=RVC_OPERATIONAL_STATE_MAP, ), entity_class=MatterOperationalStateSensor, required_attributes=( @@ -1016,6 +1027,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, translation_key="operational_state", state_list_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalStateList, + state_attribute=clusters.OvenCavityOperationalState.Attributes.OperationalState, ), entity_class=MatterOperationalStateSensor, required_attributes=( diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 5ea1716a37d..96c6ba212de 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -30,10 +30,10 @@ class OperationalState(IntEnum): Combination of generic OperationalState and RvcOperationalState. """ - NO_ERROR = 0x00 - UNABLE_TO_START_OR_RESUME = 0x01 - UNABLE_TO_COMPLETE_OPERATION = 0x02 - COMMAND_INVALID_IN_STATE = 0x03 + STOPPED = 0x00 + RUNNING = 0x01 + PAUSED = 0x02 + ERROR = 0x03 SEEKING_CHARGER = 0x40 CHARGING = 0x41 DOCKED = 0x42 @@ -95,7 +95,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): async def async_pause(self) -> None: """Pause the cleaning task.""" - await self.send_device_command(clusters.OperationalState.Commands.Pause()) + await self.send_device_command(clusters.RvcOperationalState.Commands.Pause()) @callback def _update_from_device(self) -> None: @@ -120,11 +120,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): state = VacuumActivity.DOCKED elif operational_state == OperationalState.SEEKING_CHARGER: state = VacuumActivity.RETURNING - elif operational_state in ( - OperationalState.UNABLE_TO_COMPLETE_OPERATION, - OperationalState.UNABLE_TO_START_OR_RESUME, - ): + elif operational_state == OperationalState.ERROR: state = VacuumActivity.ERROR + elif operational_state == OperationalState.PAUSED: + state = VacuumActivity.PAUSED elif (run_mode := self._supported_run_modes.get(run_mode_raw)) is not None: tags = {x.value for x in run_mode.modeTags} if ModeTag.CLEANING in tags: @@ -201,7 +200,7 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterVacuum, required_attributes=( clusters.RvcRunMode.Attributes.CurrentMode, - clusters.RvcOperationalState.Attributes.CurrentPhase, + clusters.RvcOperationalState.Attributes.OperationalState, ), optional_attributes=( clusters.RvcCleanMode.Attributes.CurrentMode, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 17841121445..8e459c0f573 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3775,7 +3775,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'running', }) # --- # name: test_sensors[oven][sensor.mock_oven_temperature_2-entry] @@ -6433,7 +6433,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'stopped', }) # --- # name: test_sensors[window_covering_full][sensor.mock_full_window_covering_target_opening_position-entry] diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 5bd90ee1109..2642ff39ef8 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -93,7 +93,7 @@ async def test_vacuum_actions( assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.OperationalState.Commands.Pause(), + command=clusters.RvcOperationalState.Commands.Pause(), ) matter_client.send_device_command.reset_mock() @@ -168,19 +168,26 @@ async def test_vacuum_updates( assert state assert state.state == "returning" - # confirm state is 'error' by setting the operational state to 0x01 + # confirm state is 'idle' by setting the operational state to 0x01 (running) but mode is idle set_node_attribute(matter_node, 1, 97, 4, 0x01) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.state == "error" + assert state.state == "idle" - # confirm state is 'error' by setting the operational state to 0x02 + # confirm state is 'idle' by setting the operational state to 0x01 (running) but mode is cleaning + set_node_attribute(matter_node, 1, 97, 4, 0x01) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "idle" + + # confirm state is 'paused' by setting the operational state to 0x02 set_node_attribute(matter_node, 1, 97, 4, 0x02) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state - assert state.state == "error" + assert state.state == "paused" # confirm state is 'cleaning' by setting; # - the operational state to 0x00 @@ -211,3 +218,11 @@ async def test_vacuum_updates( state = hass.states.get(entity_id) assert state assert state.state == "unknown" + + # confirm state is 'error' by setting; + # - the operational state to 0x03 + set_node_attribute(matter_node, 1, 97, 4, 3) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "error" From 809aced9cccea1b15e5fdb38972573f6e585edd1 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 25 Jun 2025 15:38:43 +0200 Subject: [PATCH 0670/1664] Add cover platform to Qbus integration (#147420) * Add scene platform * Add cover platform * Refactor receiving state * Fix wrong auto-merged code --- homeassistant/components/qbus/climate.py | 15 +- homeassistant/components/qbus/const.py | 1 + homeassistant/components/qbus/cover.py | 193 +++++++++++ homeassistant/components/qbus/entity.py | 18 +- homeassistant/components/qbus/light.py | 25 +- homeassistant/components/qbus/scene.py | 3 +- homeassistant/components/qbus/switch.py | 13 +- tests/components/qbus/conftest.py | 12 + .../qbus/fixtures/payload_config.json | 71 +++++ tests/components/qbus/test_cover.py | 301 ++++++++++++++++++ 10 files changed, 609 insertions(+), 43 deletions(-) create mode 100644 homeassistant/components/qbus/cover.py create mode 100644 tests/components/qbus/test_cover.py diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py index c6f234a14b7..caaec2f95d7 100644 --- a/homeassistant/components/qbus/climate.py +++ b/homeassistant/components/qbus/climate.py @@ -13,7 +13,7 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.components.mqtt import ReceiveMessage, client as mqtt +from homeassistant.components.mqtt import client as mqtt from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -57,6 +57,8 @@ async def async_setup_entry( class QbusClimate(QbusEntity, ClimateEntity): """Representation of a Qbus climate entity.""" + _state_cls = QbusMqttThermoState + _attr_name = None _attr_hvac_modes = [HVACMode.HEAT] _attr_supported_features = ( @@ -128,14 +130,7 @@ class QbusClimate(QbusEntity, ClimateEntity): await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: - state = self._message_factory.parse_output_state( - QbusMqttThermoState, msg.payload - ) - - if state is None: - return - + async def _handle_state_received(self, state: QbusMqttThermoState) -> None: if preset_mode := state.read_regime(): self._attr_preset_mode = preset_mode @@ -155,8 +150,6 @@ class QbusClimate(QbusEntity, ClimateEntity): assert self._request_state_debouncer is not None await self._request_state_debouncer.async_call() - self.async_schedule_update_ha_state() - def _set_hvac_action(self) -> None: if self.target_temperature is None or self.current_temperature is None: self._attr_hvac_action = HVACAction.IDLE diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py index e679c4b9927..73819d2a11b 100644 --- a/homeassistant/components/qbus/const.py +++ b/homeassistant/components/qbus/const.py @@ -7,6 +7,7 @@ from homeassistant.const import Platform DOMAIN: Final = "qbus" PLATFORMS: list[Platform] = [ Platform.CLIMATE, + Platform.COVER, Platform.LIGHT, Platform.SCENE, Platform.SWITCH, diff --git a/homeassistant/components/qbus/cover.py b/homeassistant/components/qbus/cover.py new file mode 100644 index 00000000000..2adb8253551 --- /dev/null +++ b/homeassistant/components/qbus/cover.py @@ -0,0 +1,193 @@ +"""Support for Qbus cover.""" + +from typing import Any + +from qbusmqttapi.const import ( + KEY_PROPERTIES_SHUTTER_POSITION, + KEY_PROPERTIES_SLAT_POSITION, +) +from qbusmqttapi.discovery import QbusMqttOutput +from qbusmqttapi.state import QbusMqttShutterState, StateType + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + CoverDeviceClass, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import QbusConfigEntry +from .entity import QbusEntity, add_new_outputs + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: QbusConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up cover entities.""" + + coordinator = entry.runtime_data + added_outputs: list[QbusMqttOutput] = [] + + def _check_outputs() -> None: + add_new_outputs( + coordinator, + added_outputs, + lambda output: output.type == "shutter", + QbusCover, + async_add_entities, + ) + + _check_outputs() + entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) + + +class QbusCover(QbusEntity, CoverEntity): + """Representation of a Qbus cover entity.""" + + _state_cls = QbusMqttShutterState + + _attr_name = None + _attr_supported_features: CoverEntityFeature + _attr_device_class = CoverDeviceClass.BLIND + + def __init__(self, mqtt_output: QbusMqttOutput) -> None: + """Initialize cover entity.""" + + super().__init__(mqtt_output) + + self._attr_assumed_state = False + self._attr_current_cover_position = 0 + self._attr_current_cover_tilt_position = 0 + self._attr_is_closed = True + + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + ) + + if "shutterStop" in mqtt_output.actions: + self._attr_supported_features |= CoverEntityFeature.STOP + self._attr_assumed_state = True + + if KEY_PROPERTIES_SHUTTER_POSITION in mqtt_output.properties: + self._attr_supported_features |= CoverEntityFeature.SET_POSITION + + if KEY_PROPERTIES_SLAT_POSITION in mqtt_output.properties: + self._attr_supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features |= CoverEntityFeature.OPEN_TILT + self._attr_supported_features |= CoverEntityFeature.CLOSE_TILT + + self._target_shutter_position: int | None = None + self._target_slat_position: int | None = None + self._target_state: str | None = None + self._previous_state: str | None = None + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + + if self._attr_supported_features & CoverEntityFeature.SET_POSITION: + state.write_position(100) + else: + state.write_state("up") + + await self._async_publish_output_state(state) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + + if self._attr_supported_features & CoverEntityFeature.SET_POSITION: + state.write_position(0) + + if self._attr_supported_features & CoverEntityFeature.SET_TILT_POSITION: + state.write_slat_position(0) + else: + state.write_state("down") + + await self._async_publish_output_state(state) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_state("stop") + await self._async_publish_output_state(state) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_position(int(kwargs[ATTR_POSITION])) + await self._async_publish_output_state(state) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_slat_position(50) + await self._async_publish_output_state(state) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_slat_position(0) + await self._async_publish_output_state(state) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + state = QbusMqttShutterState(id=self._mqtt_output.id, type=StateType.STATE) + state.write_slat_position(int(kwargs[ATTR_TILT_POSITION])) + await self._async_publish_output_state(state) + + async def _handle_state_received(self, state: QbusMqttShutterState) -> None: + output_state = state.read_state() + shutter_position = state.read_position() + slat_position = state.read_slat_position() + + if output_state is not None: + self._previous_state = self._target_state + self._target_state = output_state + + if shutter_position is not None: + self._target_shutter_position = shutter_position + + if slat_position is not None: + self._target_slat_position = slat_position + + self._update_is_closed() + self._update_cover_position() + self._update_tilt_position() + + def _update_is_closed(self) -> None: + if self._attr_supported_features & CoverEntityFeature.SET_POSITION: + if self._attr_supported_features & CoverEntityFeature.SET_TILT_POSITION: + self._attr_is_closed = ( + self._target_shutter_position == 0 + and self._target_slat_position in (0, 100) + ) + else: + self._attr_is_closed = self._target_shutter_position == 0 + else: + self._attr_is_closed = ( + self._previous_state == "down" and self._target_state == "stop" + ) + + def _update_cover_position(self) -> None: + self._attr_current_cover_position = ( + self._target_shutter_position + if self._attr_supported_features & CoverEntityFeature.SET_POSITION + else None + ) + + def _update_tilt_position(self) -> None: + self._attr_current_cover_tilt_position = ( + self._target_slat_position + if self._attr_supported_features & CoverEntityFeature.SET_TILT_POSITION + else None + ) diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index 70d469f9c93..ec800c15afa 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -5,6 +5,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable import re +from typing import Generic, TypeVar, cast from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.factory import QbusMqttMessageFactory, QbusMqttTopicFactory @@ -20,6 +21,8 @@ from .coordinator import QbusControllerCoordinator _REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$") +StateT = TypeVar("StateT", bound=QbusMqttState) + def add_new_outputs( coordinator: QbusControllerCoordinator, @@ -59,9 +62,11 @@ def create_main_device_identifier(mqtt_output: QbusMqttOutput) -> tuple[str, str return (DOMAIN, format_mac(mqtt_output.device.mac)) -class QbusEntity(Entity, ABC): +class QbusEntity(Entity, Generic[StateT], ABC): """Representation of a Qbus entity.""" + _state_cls: type[StateT] = cast(type[StateT], QbusMqttState) + _attr_has_entity_name = True _attr_should_poll = False @@ -97,9 +102,16 @@ class QbusEntity(Entity, ABC): ) ) - @abstractmethod async def _state_received(self, msg: ReceiveMessage) -> None: - pass + state = self._message_factory.parse_output_state(self._state_cls, msg.payload) + + if isinstance(state, self._state_cls): + await self._handle_state_received(state) + self.async_schedule_update_ha_state() + + @abstractmethod + async def _handle_state_received(self, state: StateT) -> None: + raise NotImplementedError async def _async_publish_output_state(self, state: QbusMqttState) -> None: request = self._message_factory.create_set_output_state_request( diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py index 654aab80ac7..4385cfe60f0 100644 --- a/homeassistant/components/qbus/light.py +++ b/homeassistant/components/qbus/light.py @@ -6,7 +6,6 @@ from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttAnalogState, StateType from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity -from homeassistant.components.mqtt import ReceiveMessage from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.color import brightness_to_value, value_to_brightness @@ -43,6 +42,8 @@ async def async_setup_entry( class QbusLight(QbusEntity, LightEntity): """Representation of a Qbus light entity.""" + _state_cls = QbusMqttAnalogState + _attr_name = None _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_color_mode = ColorMode.BRIGHTNESS @@ -57,17 +58,11 @@ class QbusLight(QbusEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" brightness = kwargs.get(ATTR_BRIGHTNESS) - - percentage: int | None = None - on: bool | None = None - state = QbusMqttAnalogState(id=self._mqtt_output.id) if brightness is None: - on = True - state.type = StateType.ACTION - state.write_on_off(on) + state.write_on_off(on=True) else: percentage = round(brightness_to_value((1, 100), brightness)) @@ -83,16 +78,10 @@ class QbusLight(QbusEntity, LightEntity): await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: - output = self._message_factory.parse_output_state( - QbusMqttAnalogState, msg.payload - ) + async def _handle_state_received(self, state: QbusMqttAnalogState) -> None: + percentage = round(state.read_percentage()) + self._set_state(percentage) - if output is not None: - percentage = round(output.read_percentage()) - self._set_state(percentage) - self.async_schedule_update_ha_state() - - def _set_state(self, percentage: int = 0) -> None: + def _set_state(self, percentage: int) -> None: self._attr_is_on = percentage > 0 self._attr_brightness = value_to_brightness((1, 100), percentage) diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py index 9a9a1e2df83..8d18feb26d3 100644 --- a/homeassistant/components/qbus/scene.py +++ b/homeassistant/components/qbus/scene.py @@ -5,7 +5,6 @@ from typing import Any from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttState, StateAction, StateType -from homeassistant.components.mqtt import ReceiveMessage from homeassistant.components.scene import Scene from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo @@ -61,6 +60,6 @@ class QbusScene(QbusEntity, Scene): ) await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: + async def _handle_state_received(self, state: QbusMqttState) -> None: # Nothing to do pass diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py index c0e2b112bc5..05283a44cfc 100644 --- a/homeassistant/components/qbus/switch.py +++ b/homeassistant/components/qbus/switch.py @@ -5,7 +5,6 @@ from typing import Any from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttOnOffState, StateType -from homeassistant.components.mqtt import ReceiveMessage from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -42,6 +41,8 @@ async def async_setup_entry( class QbusSwitch(QbusEntity, SwitchEntity): """Representation of a Qbus switch entity.""" + _state_cls = QbusMqttOnOffState + _attr_name = None _attr_device_class = SwitchDeviceClass.SWITCH @@ -66,11 +67,5 @@ class QbusSwitch(QbusEntity, SwitchEntity): await self._async_publish_output_state(state) - async def _state_received(self, msg: ReceiveMessage) -> None: - output = self._message_factory.parse_output_state( - QbusMqttOnOffState, msg.payload - ) - - if output is not None: - self._attr_is_on = output.read_value() - self.async_schedule_update_ha_state() + async def _handle_state_received(self, state: QbusMqttOnOffState) -> None: + self._attr_is_on = state.read_value() diff --git a/tests/components/qbus/conftest.py b/tests/components/qbus/conftest.py index f1fd96c321b..9b42a6a3de8 100644 --- a/tests/components/qbus/conftest.py +++ b/tests/components/qbus/conftest.py @@ -1,10 +1,13 @@ """Test fixtures for qbus.""" +from collections.abc import Generator import json +from unittest.mock import AsyncMock, patch import pytest from homeassistant.components.qbus.const import CONF_SERIAL_NUMBER, DOMAIN +from homeassistant.components.qbus.entity import QbusEntity from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant from homeassistant.util.json import JsonObjectType @@ -16,6 +19,7 @@ from tests.common import ( async_fire_mqtt_message, load_json_object_fixture, ) +from tests.typing import MqttMockHAClient @pytest.fixture @@ -39,9 +43,17 @@ def payload_config() -> JsonObjectType: return load_json_object_fixture(FIXTURE_PAYLOAD_CONFIG, DOMAIN) +@pytest.fixture +def mock_publish_state() -> Generator[AsyncMock]: + """Return a mocked publish state call.""" + with patch.object(QbusEntity, "_async_publish_output_state") as mock: + yield mock + + @pytest.fixture async def setup_integration( hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, mock_config_entry: MockConfigEntry, payload_config: JsonObjectType, ) -> None: diff --git a/tests/components/qbus/fixtures/payload_config.json b/tests/components/qbus/fixtures/payload_config.json index 3a9e845bc26..2cad6c623db 100644 --- a/tests/components/qbus/fixtures/payload_config.json +++ b/tests/components/qbus/fixtures/payload_config.json @@ -112,6 +112,77 @@ "active": null }, "properties": {} + }, + { + "id": "UL30", + "location": "Guest bedroom", + "locationId": 0, + "name": "CURTAINS", + "originalName": "CURTAINS", + "refId": "000001/108", + "type": "shutter", + "actions": { + "shutterDown": null, + "shutterStop": null, + "shutterUp": null + }, + "properties": { + "state": { + "enumValues": ["up", "stop", "down"], + "read": true, + "type": "enumString", + "write": false + } + } + }, + { + "actions": { + "shutterDown": null, + "shutterUp": null, + "slatDown": null, + "slatUp": null + }, + "id": "UL31", + "location": "Living", + "locationId": 8, + "name": "SLATS", + "originalName": "SLATS", + "properties": { + "shutterPosition": { + "read": true, + "step": 0.10000000000000001, + "type": "percent", + "write": true + }, + "slatPosition": { + "read": true, + "step": 0.10000000000000001, + "type": "percent", + "write": true + } + }, + "refId": "000001/8", + "type": "shutter" + }, + { + "actions": { + "shutterDown": null, + "shutterUp": null + }, + "id": "UL32", + "location": "Kitchen", + "locationId": 8, + "name": "BLINDS", + "originalName": "BLINDS", + "properties": { + "shutterPosition": { + "read": true, + "type": "percent", + "write": true + } + }, + "refId": "000001/4", + "type": "shutter" } ] } diff --git a/tests/components/qbus/test_cover.py b/tests/components/qbus/test_cover.py new file mode 100644 index 00000000000..724be5cb280 --- /dev/null +++ b/tests/components/qbus/test_cover.py @@ -0,0 +1,301 @@ +"""Test Qbus cover entities.""" + +from unittest.mock import AsyncMock + +from qbusmqttapi.state import QbusMqttShutterState + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, + CoverState, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, +) +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message + +_PAYLOAD_UDS_STATE_CLOSED = '{"id":"UL30","properties":{"state":"down"},"type":"state"}' +_PAYLOAD_UDS_STATE_OPENED = '{"id":"UL30","properties":{"state":"up"},"type":"state"}' +_PAYLOAD_UDS_STATE_STOPPED = ( + '{"id":"UL30","properties":{"state":"stop"},"type":"state"}' +) + +_PAYLOAD_POS_STATE_CLOSED = ( + '{"id":"UL32","properties":{"shutterPosition":0},"type":"event"}' +) +_PAYLOAD_POS_STATE_OPENED = ( + '{"id":"UL32","properties":{"shutterPosition":100},"type":"event"}' +) +_PAYLOAD_POS_STATE_POSITION = ( + '{"id":"UL32","properties":{"shutterPosition":50},"type":"event"}' +) + +_PAYLOAD_SLAT_STATE_CLOSED = ( + '{"id":"UL31","properties":{"slatPosition":0},"type":"event"}' +) +_PAYLOAD_SLAT_STATE_FULLY_CLOSED = ( + '{"id":"UL31","properties":{"slatPosition":0,"shutterPosition":0},"type":"event"}' +) +_PAYLOAD_SLAT_STATE_OPENED = ( + '{"id":"UL31","properties":{"slatPosition":50},"type":"event"}' +) +_PAYLOAD_SLAT_STATE_POSITION = ( + '{"id":"UL31","properties":{"slatPosition":75},"type":"event"}' +) + +_TOPIC_UDS_STATE = "cloudapp/QBUSMQTTGW/UL1/UL30/state" +_TOPIC_POS_STATE = "cloudapp/QBUSMQTTGW/UL1/UL32/state" +_TOPIC_SLAT_STATE = "cloudapp/QBUSMQTTGW/UL1/UL31/state" + +_ENTITY_ID_UDS = "cover.curtains" +_ENTITY_ID_POS = "cover.blinds" +_ENTITY_ID_SLAT = "cover.slats" + + +async def test_cover_up_down_stop( + hass: HomeAssistant, setup_integration: None, mock_publish_state: AsyncMock +) -> None: + """Test cover up, down and stop.""" + + attributes = hass.states.get(_ENTITY_ID_UDS).attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + + # Cover open + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_UDS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_state() == "up" + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_UDS_STATE, _PAYLOAD_UDS_STATE_OPENED) + await hass.async_block_till_done() + + assert hass.states.get(_ENTITY_ID_UDS).state == CoverState.OPEN + + # Cover close + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_UDS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_state() == "down" + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_UDS_STATE, _PAYLOAD_UDS_STATE_CLOSED) + await hass.async_block_till_done() + + assert hass.states.get(_ENTITY_ID_UDS).state == CoverState.OPEN + + # Cover stop + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_UDS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_state() == "stop" + + # Simulate response + async_fire_mqtt_message(hass, _TOPIC_UDS_STATE, _PAYLOAD_UDS_STATE_STOPPED) + await hass.async_block_till_done() + + assert hass.states.get(_ENTITY_ID_UDS).state == CoverState.CLOSED + + +async def test_cover_position( + hass: HomeAssistant, setup_integration: None, mock_publish_state: AsyncMock +) -> None: + """Test cover positions.""" + + attributes = hass.states.get(_ENTITY_ID_POS).attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + # Cover open + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_POS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 100 + + async_fire_mqtt_message(hass, _TOPIC_POS_STATE, _PAYLOAD_POS_STATE_OPENED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_POS) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 100 + + # Cover position + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: _ENTITY_ID_POS, ATTR_POSITION: 50}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 50 + + async_fire_mqtt_message(hass, _TOPIC_POS_STATE, _PAYLOAD_POS_STATE_POSITION) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_POS) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 50 + + # Cover close + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_POS}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 0 + + async_fire_mqtt_message(hass, _TOPIC_POS_STATE, _PAYLOAD_POS_STATE_CLOSED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_POS) + assert entity_state.state == CoverState.CLOSED + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 0 + + +async def test_cover_slats( + hass: HomeAssistant, setup_integration: None, mock_publish_state: AsyncMock +) -> None: + """Test cover slats.""" + + attributes = hass.states.get(_ENTITY_ID_SLAT).attributes + assert attributes.get("supported_features") == ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + + # Start with a fully closed cover + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_position() == 0 + assert publish_state.read_slat_position() == 0 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_FULLY_CLOSED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.CLOSED + assert entity_state.attributes[ATTR_CURRENT_POSITION] == 0 + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Slat open + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_slat_position() == 50 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_OPENED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 50 + + # SLat position + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT, ATTR_TILT_POSITION: 75}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_slat_position() == 75 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_POSITION) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.OPEN + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 75 + + # Slat close + mock_publish_state.reset_mock() + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: _ENTITY_ID_SLAT}, + blocking=True, + ) + + publish_state = _get_publish_state(mock_publish_state) + assert publish_state.read_slat_position() == 0 + + async_fire_mqtt_message(hass, _TOPIC_SLAT_STATE, _PAYLOAD_SLAT_STATE_CLOSED) + await hass.async_block_till_done() + + entity_state = hass.states.get(_ENTITY_ID_SLAT) + assert entity_state.state == CoverState.CLOSED + assert entity_state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + +def _get_publish_state(mock_publish_state: AsyncMock) -> QbusMqttShutterState: + assert mock_publish_state.call_count == 1 + state = mock_publish_state.call_args.args[0] + assert isinstance(state, QbusMqttShutterState) + return state From e210681751249c1b92fb74adfdaa72baf9a4ccc8 Mon Sep 17 00:00:00 2001 From: Nathan Larsen Date: Wed, 25 Jun 2025 09:58:21 -0500 Subject: [PATCH 0671/1664] Fix API POST endpoints json parsing error-handling (#134326) * Fix API POST endpoints json parsing error-handling * Add tests * Fix mypy and ruff errors * Fix coverage by removing non-needed error handling * Correct error handling and improve tests --------- Co-authored-by: Robert Resch Co-authored-by: Erik --- homeassistant/components/api/__init__.py | 23 ++++++++++-- tests/components/api/test_init.py | 47 ++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index d183d46a717..242c21eb524 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -260,11 +260,18 @@ class APIEntityStateView(HomeAssistantView): if not user.is_admin: raise Unauthorized(entity_id=entity_id) hass = request.app[KEY_HASS] + + body = await request.text() + try: - data = await request.json() + data: Any = json_loads(body) if body else None except ValueError: return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) + if not isinstance(data, dict): + return self.json_message( + "State data should be a JSON object.", HTTPStatus.BAD_REQUEST + ) if (new_state := data.get("state")) is None: return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST) @@ -477,9 +484,19 @@ class APITemplateView(HomeAssistantView): @require_admin async def post(self, request: web.Request) -> web.Response: """Render a template.""" + body = await request.text() + + try: + data: Any = json_loads(body) if body else None + except ValueError: + return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST) + + if not isinstance(data, dict): + return self.json_message( + "Template data should be a JSON object.", HTTPStatus.BAD_REQUEST + ) + tpl = _cached_template(data["template"], request.app[KEY_HASS]) try: - data = await request.json() - tpl = _cached_template(data["template"], request.app[KEY_HASS]) return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return] except (ValueError, TemplateError) as ex: return self.json_message( diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 26a3d7c7a8c..bc484a1632a 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -129,6 +129,28 @@ async def test_api_state_change_with_bad_data( assert resp.status == HTTPStatus.BAD_REQUEST +async def test_api_state_change_with_invalid_json( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if send invalid json data.""" + resp = await mock_api_client.post("/api/states/test.test", data="{,}") + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "Invalid JSON specified."} + + +async def test_api_state_change_with_string_body( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we send a string instead of a JSON object.""" + resp = await mock_api_client.post( + "/api/states/bad.entity.id", json='"{"state": "new_state"}"' + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "State data should be a JSON object."} + + async def test_api_state_change_to_zero_value( hass: HomeAssistant, mock_api_client: TestClient ) -> None: @@ -529,6 +551,31 @@ async def test_api_template_error( assert resp.status == HTTPStatus.BAD_REQUEST +async def test_api_template_with_invalid_json( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if send invalid json data.""" + resp = await mock_api_client.post(const.URL_API_TEMPLATE, data="{,}") + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "Invalid JSON specified."} + + +async def test_api_template_error_with_string_body( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test that the API returns an appropriate error when a string is sent in the body.""" + hass.states.async_set("sensor.temperature", 10) + + resp = await mock_api_client.post( + const.URL_API_TEMPLATE, + json='"{"template": "{{ states.sensor.temperature.state"}"', + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + assert await resp.json() == {"message": "Template data should be a JSON object."} + + async def test_stream(hass: HomeAssistant, mock_api_client: TestClient) -> None: """Test the stream.""" listen_count = _listen_count(hass) From c05d8aab1c718090dd566f668a20dea26696a65d Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:01:10 +0800 Subject: [PATCH 0672/1664] Add floor lamp and strip light 3 for switchbot integration (#147517) --- .../components/switchbot/__init__.py | 4 + homeassistant/components/switchbot/const.py | 8 ++ tests/components/switchbot/__init__.py | 58 +++++++++ tests/components/switchbot/test_light.py | 121 +++++++++++++++++- 4 files changed, 186 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index c10a0036b1c..acf37fe916b 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -93,6 +93,8 @@ PLATFORMS_BY_TYPE = { SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR], SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR], SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], + SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -119,6 +121,8 @@ CLASS_BY_DEVICE = { SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier, SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier, SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, + SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 981b7c75a28..c57b8d467cc 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -49,6 +49,8 @@ class SupportedModels(StrEnum): AIR_PURIFIER = "air_purifier" AIR_PURIFIER_TABLE = "air_purifier_table" EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier" + FLOOR_LAMP = "floor_lamp" + STRIP_LIGHT_3 = "strip_light_3" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -77,6 +79,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = { SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE, SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER, + SwitchbotModel.FLOOR_LAMP: SupportedModels.FLOOR_LAMP, + SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -106,6 +110,8 @@ ENCRYPTED_MODELS = { SwitchbotModel.AIR_PURIFIER, SwitchbotModel.AIR_PURIFIER_TABLE, SwitchbotModel.EVAPORATIVE_HUMIDIFIER, + SwitchbotModel.FLOOR_LAMP, + SwitchbotModel.STRIP_LIGHT_3, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -120,6 +126,8 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier, SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier, SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, + SwitchbotModel.FLOOR_LAMP: switchbot.SwitchbotStripLight3, + SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 98e576e4fe5..d64ee2d7a73 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -941,3 +941,61 @@ CEILING_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak( connectable=True, tx_power=-127, ) + + +STRIP_LIGHT_3_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Strip Light 3", + manufacturer_data={ + 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb1" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Strip Light 3", + manufacturer_data={ + 2409: b'\xc0N0\xe0U\x9a\x85\x9e"\xd0\x00\x00\x00\x00\x00\x00\x12\x91\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb1" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Strip Light 3"), + time=0, + connectable=True, + tx_power=-127, +) + + +FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Floor Lamp", + manufacturer_data={ + 2409: b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb0" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Floor Lamp", + manufacturer_data={ + 2409: b'\xa0\x85\xe3e,\x06P\xaa"\xd4\x00\x00\x00\x00\x00\x00\r\x93\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb0" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Floor Lamp"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index 6629de0150e..718d7aecf96 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch import pytest from switchbot.devices.device import SwitchbotOperationError +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP_KELVIN, @@ -20,7 +21,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import BULB_SERVICE_INFO, CEILING_LIGHT_SERVICE_INFO, WOSTRIP_SERVICE_INFO +from . import ( + BULB_SERVICE_INFO, + CEILING_LIGHT_SERVICE_INFO, + FLOOR_LAMP_SERVICE_INFO, + STRIP_LIGHT_3_SERVICE_INFO, + WOSTRIP_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info @@ -71,9 +78,9 @@ BULB_PARAMETERS = ( SET_COLOR_TEMP_PARAMETERS, ( SERVICE_TURN_ON, - {ATTR_EFFECT: "Breathing"}, + {ATTR_EFFECT: "breathing"}, "set_effect", - ("Breathing",), + ("breathing",), ), ], ) @@ -95,9 +102,25 @@ STRIP_LIGHT_PARAMETERS = ( SET_RGB_PARAMETERS, ( SERVICE_TURN_ON, - {ATTR_EFFECT: "Halloween"}, + {ATTR_EFFECT: "halloween"}, "set_effect", - ("Halloween",), + ("halloween",), + ), + ], +) +FLOOR_LAMP_PARAMETERS = ( + COMMON_PARAMETERS, + [ + TURN_ON_PARAMETERS, + TURN_OFF_PARAMETERS, + SET_BRIGHTNESS_PARAMETERS, + SET_RGB_PARAMETERS, + SET_COLOR_TEMP_PARAMETERS, + ( + SERVICE_TURN_ON, + {ATTR_EFFECT: "halloween"}, + "set_effect", + ("halloween",), ), ], ) @@ -317,3 +340,91 @@ async def test_strip_light_services_exception( {**service_data, ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ], +) +@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) +async def test_floor_lamp_services( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot floor lamp services.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "light.test_name" + + mocked_instance = AsyncMock(return_value=True) + + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + **{mock_method: mocked_instance}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + mocked_instance.assert_awaited_once_with(*expected_args) + + +@pytest.mark.parametrize( + ("sensor_type", "service_info"), + [ + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ], +) +@pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) +async def test_floor_lamp_services_exception( + hass: HomeAssistant, + mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], + sensor_type: str, + service_info: BluetoothServiceInfoBleak, + service: str, + service_data: dict, + mock_method: str, + expected_args: Any, +) -> None: + """Test all SwitchBot floor lamp services with exception.""" + inject_bluetooth_service_info(hass, service_info) + + entry = mock_entry_encrypted_factory(sensor_type=sensor_type) + entry.add_to_hass(hass) + entity_id = "light.test_name" + exception = SwitchbotOperationError("Operation failed") + error_message = "An error occurred while performing the action: Operation failed" + with patch.multiple( + "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + **{mock_method: AsyncMock(side_effect=exception)}, + update=AsyncMock(return_value=None), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError, match=error_message): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {**service_data, ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) From d8258924f776973063c40afda6dcd1b8eea97fed Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 25 Jun 2025 12:29:23 -0400 Subject: [PATCH 0673/1664] Remove mapping of entity_ids to speakers in Sonos (#147506) * fix * fix: change entity_id mappings * fix: translate errors * fix:merge issues * fix: translate error messages * fix: improve test coverage * fix: remove unneeded strings --- homeassistant/components/sonos/diagnostics.py | 19 +++++++++-- homeassistant/components/sonos/entity.py | 8 +++-- homeassistant/components/sonos/helpers.py | 3 +- .../components/sonos/media_player.py | 32 +++++++++++++++---- homeassistant/components/sonos/strings.json | 6 ++++ tests/components/sonos/test_services.py | 26 +++++++++++++++ 6 files changed, 82 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 35d81edbea0..fafa142273a 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -6,6 +6,7 @@ import time from typing import Any from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN @@ -132,11 +133,23 @@ async def async_generate_speaker_info( value = getattr(speaker, attrib) payload[attrib] = get_contents(value) + entity_registry = er.async_get(hass) payload["enabled_entities"] = sorted( - entity_id - for entity_id, s in config_entry.runtime_data.entity_id_mappings.items() - if s is speaker + registry_entry.entity_id + for registry_entry in entity_registry.entities.get_entries_for_config_entry_id( + config_entry.entry_id + ) + if ( + ( + entity_speaker + := config_entry.runtime_data.unique_id_speaker_mappings.get( + registry_entry.unique_id + ) + ) + and speaker.uid == entity_speaker.uid + ) ) + payload["media"] = await async_generate_media_info(hass, speaker) payload["activity_stats"] = speaker.activity_stats.report() payload["event_stats"] = speaker.event_stats.report() diff --git a/homeassistant/components/sonos/entity.py b/homeassistant/components/sonos/entity.py index 58108f9974c..5f7a2fb2d70 100644 --- a/homeassistant/components/sonos/entity.py +++ b/homeassistant/components/sonos/entity.py @@ -34,7 +34,10 @@ class SonosEntity(Entity): async def async_added_to_hass(self) -> None: """Handle common setup when added to hass.""" - self.config_entry.runtime_data.entity_id_mappings[self.entity_id] = self.speaker + assert self.unique_id + self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] = ( + self.speaker + ) self.async_on_remove( async_dispatcher_connect( self.hass, @@ -52,7 +55,8 @@ class SonosEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Clean up when entity is removed.""" - del self.config_entry.runtime_data.entity_id_mappings[self.entity_id] + assert self.unique_id + del self.config_entry.runtime_data.unique_id_speaker_mappings[self.unique_id] async def async_fallback_poll(self, now: datetime.datetime) -> None: """Poll the entity if subscriptions fail.""" diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index 3350df430f8..1fb3bb3d5e7 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -149,7 +149,8 @@ class SonosData: discovery_known: set[str] = field(default_factory=set) boot_counts: dict[str, int] = field(default_factory=dict) mdns_names: dict[str, str] = field(default_factory=dict) - entity_id_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) + # Maps the entity unique id to the associated SonosSpeaker instance. + unique_id_speaker_mappings: dict[str, SonosSpeaker] = field(default_factory=dict) unjoin_data: dict[str, UnjoinData] = field(default_factory=dict) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 96e4d34ddc4..6fb7bf00589 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -43,7 +43,12 @@ from homeassistant.components.plex.services import process_plex_payload from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError -from homeassistant.helpers import config_validation as cv, entity_platform, service +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, + service, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -880,13 +885,28 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_join_players(self, group_members: list[str]) -> None: """Join `group_members` as a player group with the current player.""" speakers = [] + + entity_registry = er.async_get(self.hass) for entity_id in group_members: - if speaker := self.config_entry.runtime_data.entity_id_mappings.get( - entity_id + if not (entity_reg_entry := entity_registry.async_get(entity_id)): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + if not ( + speaker + := self.config_entry.runtime_data.unique_id_speaker_mappings.get( + entity_reg_entry.unique_id + ) ): - speakers.append(speaker) - else: - raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="speaker_not_found", + translation_placeholders={"entity_id": entity_id}, + ) + + speakers.append(speaker) await SonosSpeaker.join_multi( self.hass, self.config_entry, self.speaker, speakers diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index c40f5ccd416..4fb8037ab64 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -195,6 +195,12 @@ "announce_media_error": { "message": "Announcing clip {media_id} failed {response}" }, + "entity_not_found": { + "message": "Entity {entity_id} not found." + }, + "speaker_not_found": { + "message": "{entity_id} is not a known Sonos speaker." + }, "timeout_join": { "message": "Timeout while waiting for Sonos player to join the group {group_description}" } diff --git a/tests/components/sonos/test_services.py b/tests/components/sonos/test_services.py index 48e4cc139f3..a94a03b95a0 100644 --- a/tests/components/sonos/test_services.py +++ b/tests/components/sonos/test_services.py @@ -15,6 +15,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import MockSoCo, group_speakers, ungroup_speakers @@ -85,6 +86,31 @@ async def test_media_player_join_bad_entity( assert "media_player.bad_entity" in str(excinfo.value) +async def test_media_player_join_entity_no_speaker( + hass: HomeAssistant, + sonos_setup_two_speakers: list[MockSoCo], + entity_registry: er.EntityRegistry, +) -> None: + """Test error handling of joining with no associated speaker.""" + + bad_media_player = entity_registry.async_get_or_create( + "media_player", "demo", "1234" + ) + + # Ensure an error is raised if the entity does not have a speaker + with pytest.raises(HomeAssistantError) as excinfo: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_JOIN, + { + "entity_id": "media_player.living_room", + "group_members": bad_media_player.entity_id, + }, + blocking=True, + ) + assert bad_media_player.entity_id in str(excinfo.value) + + @asynccontextmanager async def instant_timeout(*args, **kwargs) -> None: """Mock a timeout error.""" From 1fb587bf03fc0ecd68660cab83c1774c44f4a90b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Jun 2025 18:35:15 +0200 Subject: [PATCH 0674/1664] Allow core integrations to describe their triggers (#147075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/bootstrap.py | 2 + homeassistant/components/mqtt/icons.json | 5 + homeassistant/components/mqtt/strings.json | 17 ++ homeassistant/components/mqtt/triggers.yaml | 14 ++ .../components/websocket_api/commands.py | 65 ++++- .../components/websocket_api/messages.py | 13 + homeassistant/helpers/trigger.py | 214 +++++++++++++++- homeassistant/loader.py | 11 +- script/hassfest/__main__.py | 2 + script/hassfest/icons.py | 11 + script/hassfest/translations.py | 16 ++ script/hassfest/triggers.py | 238 ++++++++++++++++++ tests/common.py | 2 + .../components/websocket_api/test_commands.py | 90 ++++++- tests/helpers/test_trigger.py | 221 +++++++++++++++- 15 files changed, 908 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/mqtt/triggers.yaml create mode 100644 script/hassfest/triggers.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 810c1f1e8d2..afe8ea6f356 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -89,6 +89,7 @@ from .helpers import ( restore_state, template, translation, + trigger, ) from .helpers.dispatcher import async_dispatcher_send_internal from .helpers.storage import get_internal_store_manager @@ -452,6 +453,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: create_eager_task(restore_state.async_load(hass)), create_eager_task(hass.config_entries.async_initialize()), create_eager_task(async_get_system_info(hass)), + create_eager_task(trigger.async_setup(hass)), ) diff --git a/homeassistant/components/mqtt/icons.json b/homeassistant/components/mqtt/icons.json index 73cbf22b629..46a588a5667 100644 --- a/homeassistant/components/mqtt/icons.json +++ b/homeassistant/components/mqtt/icons.json @@ -9,5 +9,10 @@ "reload": { "service": "mdi:reload" } + }, + "triggers": { + "mqtt": { + "trigger": "mdi:swap-horizontal" + } } } diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index ed7da6fc112..9c7a2fcea96 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -992,6 +992,23 @@ "description": "Reloads MQTT entities from the YAML-configuration." } }, + "triggers": { + "mqtt": { + "name": "MQTT", + "description": "When a specific message is received on a given MQTT topic.", + "description_configured": "When an MQTT message has been received", + "fields": { + "payload": { + "name": "Payload", + "description": "The payload to trigger on." + }, + "topic": { + "name": "Topic", + "description": "MQTT topic to listen to." + } + } + } + }, "exceptions": { "addon_start_failed": { "message": "Failed to correctly start {addon} add-on." diff --git a/homeassistant/components/mqtt/triggers.yaml b/homeassistant/components/mqtt/triggers.yaml new file mode 100644 index 00000000000..d3998674d58 --- /dev/null +++ b/homeassistant/components/mqtt/triggers.yaml @@ -0,0 +1,14 @@ +# Describes the format for MQTT triggers + +mqtt: + fields: + payload: + example: "on" + required: false + selector: + text: + topic: + example: "living_room/switch/ac" + required: true + selector: + text: diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 498a986e806..701a9a659b1 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -52,7 +52,13 @@ from homeassistant.helpers.json import ( json_bytes, json_fragment, ) -from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.service import ( + async_get_all_descriptions as async_get_all_service_descriptions, +) +from homeassistant.helpers.trigger import ( + async_get_all_descriptions as async_get_all_trigger_descriptions, + async_subscribe_platform_events as async_subscribe_trigger_platform_events, +) from homeassistant.loader import ( IntegrationNotFound, async_get_integration, @@ -68,9 +74,10 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection -from .messages import construct_result_message +from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json" _LOGGER = logging.getLogger(__name__) @@ -96,6 +103,7 @@ def async_register_commands( async_reg(hass, handle_subscribe_bootstrap_integrations) async_reg(hass, handle_subscribe_events) async_reg(hass, handle_subscribe_trigger) + async_reg(hass, handle_subscribe_trigger_platforms) async_reg(hass, handle_test_condition) async_reg(hass, handle_unsubscribe_events) async_reg(hass, handle_validate_config) @@ -493,9 +501,9 @@ def _send_handle_entities_init_response( ) -async def _async_get_all_descriptions_json(hass: HomeAssistant) -> bytes: +async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes: """Return JSON of descriptions (i.e. user documentation) for all service calls.""" - descriptions = await async_get_all_descriptions(hass) + descriptions = await async_get_all_service_descriptions(hass) if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: cached_descriptions, cached_json_payload = hass.data[ ALL_SERVICE_DESCRIPTIONS_JSON_CACHE @@ -514,10 +522,57 @@ async def handle_get_services( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get services command.""" - payload = await _async_get_all_descriptions_json(hass) + payload = await _async_get_all_service_descriptions_json(hass) connection.send_message(construct_result_message(msg["id"], payload)) +async def _async_get_all_trigger_descriptions_json(hass: HomeAssistant) -> bytes: + """Return JSON of descriptions (i.e. user documentation) for all triggers.""" + descriptions = await async_get_all_trigger_descriptions(hass) + if ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(bytes, cached_json_payload) + json_payload = json_bytes( + { + trigger: description + for trigger, description in descriptions.items() + if description is not None + } + ) + hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload + + +@decorators.websocket_command({vol.Required("type"): "trigger_platforms/subscribe"}) +@decorators.async_response +async def handle_subscribe_trigger_platforms( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe triggers command.""" + + async def on_new_triggers(new_triggers: set[str]) -> None: + """Forward new triggers to websocket.""" + descriptions = await async_get_all_trigger_descriptions(hass) + new_trigger_descriptions = {} + for trigger in new_triggers: + if (description := descriptions[trigger]) is not None: + new_trigger_descriptions[trigger] = description + if not new_trigger_descriptions: + return + connection.send_event(msg["id"], new_trigger_descriptions) + + connection.subscriptions[msg["id"]] = async_subscribe_trigger_platform_events( + hass, on_new_triggers + ) + connection.send_result(msg["id"]) + triggers_json = await _async_get_all_trigger_descriptions_json(hass) + connection.send_message(construct_event_message(msg["id"], triggers_json)) + + @callback @decorators.websocket_command({vol.Required("type"): "get_config"}) def handle_get_config( diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 6ae7de2c4b7..88d29f243d5 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -109,6 +109,19 @@ def event_message(iden: int, event: Any) -> dict[str, Any]: return {"id": iden, "type": "event", "event": event} +def construct_event_message(iden: int, event: bytes) -> bytes: + """Construct an event message JSON.""" + return b"".join( + ( + b'{"id":', + str(iden).encode(), + b',"type":"event","event":', + event, + b"}", + ) + ) + + def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: """Return an event message. diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 853b5aaf812..66d1560ac70 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -5,11 +5,11 @@ from __future__ import annotations import abc import asyncio from collections import defaultdict -from collections.abc import Callable, Coroutine +from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import functools import logging -from typing import Any, Protocol, TypedDict, cast +from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast import voluptuous as vol @@ -29,13 +29,24 @@ from homeassistant.core import ( is_callback, ) from homeassistant.exceptions import HomeAssistantError, TemplateError -from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integration, + async_get_integrations, +) from homeassistant.util.async_ import create_eager_task from homeassistant.util.hass_dict import HassKey +from homeassistant.util.yaml import load_yaml_dict +from homeassistant.util.yaml.loader import JSON_TYPE +from . import config_validation as cv +from .integration_platform import async_process_integration_platforms from .template import Template from .typing import ConfigType, TemplateVarsType +_LOGGER = logging.getLogger(__name__) + _PLATFORM_ALIASES = { "device": "device_automation", "event": "homeassistant", @@ -49,6 +60,99 @@ DATA_PLUGGABLE_ACTIONS: HassKey[defaultdict[tuple, PluggableActionsEntry]] = Has "pluggable_actions" ) +TRIGGER_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( + "trigger_description_cache" +) +TRIGGER_PLATFORM_SUBSCRIPTIONS: HassKey[ + list[Callable[[set[str]], Coroutine[Any, Any, None]]] +] = HassKey("trigger_platform_subscriptions") +TRIGGERS: HassKey[dict[str, str]] = HassKey("triggers") + + +# Basic schemas to sanity check the trigger descriptions, +# full validation is done by hassfest.triggers +_FIELD_SCHEMA = vol.Schema( + {}, + extra=vol.ALLOW_EXTRA, +) + +_TRIGGER_SCHEMA = vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise vol.Invalid("Key does not start with .") + return key + + +_TRIGGERS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, starts_with_dot)): object, + cv.slug: vol.Any(None, _TRIGGER_SCHEMA), + } +) + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the trigger helper.""" + hass.data[TRIGGER_DESCRIPTION_CACHE] = {} + hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] = [] + hass.data[TRIGGERS] = {} + await async_process_integration_platforms( + hass, "trigger", _register_trigger_platform, wait_for_platforms=True + ) + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[set[str]], Coroutine[Any, Any, None]], +) -> Callable[[], None]: + """Subscribe to trigger platform events.""" + trigger_platform_event_subscriptions = hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] + + def remove_subscription() -> None: + trigger_platform_event_subscriptions.remove(on_event) + + trigger_platform_event_subscriptions.append(on_event) + return remove_subscription + + +async def _register_trigger_platform( + hass: HomeAssistant, integration_domain: str, platform: TriggerProtocol +) -> None: + """Register a trigger platform.""" + + new_triggers: set[str] = set() + + if hasattr(platform, "async_get_triggers"): + for trigger_key in await platform.async_get_triggers(hass): + hass.data[TRIGGERS][trigger_key] = integration_domain + new_triggers.add(trigger_key) + elif hasattr(platform, "async_validate_trigger_config") or hasattr( + platform, "TRIGGER_SCHEMA" + ): + hass.data[TRIGGERS][integration_domain] = integration_domain + new_triggers.add(integration_domain) + else: + _LOGGER.debug( + "Integration %s does not provide trigger support, skipping", + integration_domain, + ) + return + + tasks: list[asyncio.Task[None]] = [ + create_eager_task(listener(new_triggers)) + for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] + ] + await asyncio.gather(*tasks) + class Trigger(abc.ABC): """Trigger class.""" @@ -409,3 +513,107 @@ async def async_initialize_triggers( remove() return remove_triggers + + +def _load_triggers_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + """Load triggers file for an integration.""" + try: + return cast( + JSON_TYPE, + _TRIGGERS_SCHEMA( + load_yaml_dict(str(integration.file_path / "triggers.yaml")) + ), + ) + except FileNotFoundError: + _LOGGER.warning( + "Unable to find triggers.yaml for the %s integration", integration.domain + ) + return {} + except (HomeAssistantError, vol.Invalid) as ex: + _LOGGER.warning( + "Unable to parse triggers.yaml for the %s integration: %s", + integration.domain, + ex, + ) + return {} + + +def _load_triggers_files( + hass: HomeAssistant, integrations: Iterable[Integration] +) -> dict[str, JSON_TYPE]: + """Load trigger files for multiple integrations.""" + return { + integration.domain: _load_triggers_file(hass, integration) + for integration in integrations + } + + +async def async_get_all_descriptions( + hass: HomeAssistant, +) -> dict[str, dict[str, Any] | None]: + """Return descriptions (i.e. user documentation) for all triggers.""" + descriptions_cache = hass.data[TRIGGER_DESCRIPTION_CACHE] + + triggers = hass.data[TRIGGERS] + # See if there are new triggers not seen before. + # Any trigger that we saw before already has an entry in description_cache. + all_triggers = set(triggers) + previous_all_triggers = set(descriptions_cache) + # If the triggers are the same, we can return the cache + if previous_all_triggers == all_triggers: + return descriptions_cache + + # Files we loaded for missing descriptions + new_triggers_descriptions: dict[str, JSON_TYPE] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new triggers get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + triggers = triggers.copy() + + if missing_triggers := all_triggers.difference(descriptions_cache): + domains_with_missing_triggers = { + triggers[missing_trigger] for missing_trigger in missing_triggers + } + ints_or_excs = await async_get_integrations(hass, domains_with_missing_triggers) + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration and int_or_exc.has_triggers: + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.debug( + "Failed to load triggers.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) + + if integrations: + new_triggers_descriptions = await hass.async_add_executor_job( + _load_triggers_files, hass, integrations + ) + + # Make a copy of the old cache and add missing descriptions to it + new_descriptions_cache = descriptions_cache.copy() + for missing_trigger in missing_triggers: + domain = triggers[missing_trigger] + + if ( + yaml_description := new_triggers_descriptions.get(domain, {}).get( # type: ignore[union-attr] + missing_trigger + ) + ) is None: + _LOGGER.debug( + "No trigger descriptions found for trigger %s, skipping", + missing_trigger, + ) + new_descriptions_cache[missing_trigger] = None + continue + + description = {"fields": yaml_description.get("fields", {})} + + new_descriptions_cache[missing_trigger] = description + + hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache + return new_descriptions_cache diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6a3061b0d2a..ae3709e383b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -857,15 +857,20 @@ class Integration: # True. return self.manifest.get("import_executor", True) + @cached_property + def has_services(self) -> bool: + """Return if the integration has services.""" + return "services.yaml" in self._top_level_files + @cached_property def has_translations(self) -> bool: """Return if the integration has translations.""" return "translations" in self._top_level_files @cached_property - def has_services(self) -> bool: - """Return if the integration has services.""" - return "services.yaml" in self._top_level_files + def has_triggers(self) -> bool: + """Return if the integration has triggers.""" + return "triggers.yaml" in self._top_level_files @property def mqtt(self) -> list[str] | None: diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 277696c669b..05c0d455af6 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -28,6 +28,7 @@ from . import ( services, ssdp, translations, + triggers, usb, zeroconf, ) @@ -49,6 +50,7 @@ INTEGRATION_PLUGINS = [ services, ssdp, translations, + triggers, usb, zeroconf, config_flow, # This needs to run last, after translations are processed diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 563fe0edb93..6abe338e45b 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -120,6 +120,16 @@ CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys( ) +TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( + vol.Schema( + { + vol.Optional("trigger"): icon_value_validator, + } + ), + slug_validator=translation_key_validator, +) + + def icon_schema( core_integration: bool, integration_type: str, no_entity_platform: bool ) -> vol.Schema: @@ -164,6 +174,7 @@ def icon_schema( vol.Optional("services"): CORE_SERVICE_ICONS_SCHEMA if core_integration else CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA, + vol.Optional("triggers"): TRIGGER_ICONS_SCHEMA, } ) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 34c06abb451..913f7df2e7a 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -416,6 +416,22 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=translation_key_validator, ), + vol.Optional("triggers"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Required("description_configured"): translation_value_validator, + vol.Optional("fields"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required("description"): translation_value_validator, + vol.Optional("example"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=translation_key_validator, + ), vol.Optional("conversation"): { vol.Required("agent"): { vol.Required("done"): translation_value_validator, diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py new file mode 100644 index 00000000000..ff6654f2789 --- /dev/null +++ b/script/hassfest/triggers.py @@ -0,0 +1,238 @@ +"""Validate triggers.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib +import re +from typing import Any + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import CONF_SELECTOR +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv, selector, trigger +from homeassistant.util.yaml import load_yaml_dict + +from .model import Config, Integration + + +def exists(value: Any) -> Any: + """Check if value exists.""" + if value is None: + raise vol.Invalid("Value cannot be None") + return value + + +FIELD_SCHEMA = vol.Schema( + { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, + } +) + +TRIGGER_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + } + ), + None, +) + +TRIGGERS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, trigger.starts_with_dot)): object, + cv.slug: TRIGGER_SCHEMA, + } +) + +NON_MIGRATED_INTEGRATIONS = { + "calendar", + "conversation", + "device_automation", + "geo_location", + "homeassistant", + "knx", + "lg_netcast", + "litejet", + "persistent_notification", + "samsungtv", + "sun", + "tag", + "template", + "webhook", + "webostv", + "zone", + "zwave_js", +} + + +def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: + """Recursively go through a dir and it's children and find the regex.""" + pattern = re.compile(search_pattern) + + for fil in path.glob(glob_pattern): + if not fil.is_file(): + continue + + if pattern.search(fil.read_text()): + return True + + return False + + +def validate_triggers(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate triggers.""" + try: + data = load_yaml_dict(str(integration.path / "triggers.yaml")) + except FileNotFoundError: + # Find if integration uses triggers + has_triggers = grep_dir( + integration.path, + "**/trigger.py", + r"async_attach_trigger|async_get_triggers", + ) + + if has_triggers and integration.domain not in NON_MIGRATED_INTEGRATIONS: + integration.add_error( + "triggers", "Registers triggers but has no triggers.yaml" + ) + return + except HomeAssistantError: + integration.add_error("triggers", "Invalid triggers.yaml") + return + + try: + triggers = TRIGGERS_SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + "triggers", f"Invalid triggers.yaml: {humanize_error(data, err)}" + ) + return + + icons_file = integration.path / "icons.json" + icons = {} + if icons_file.is_file(): + with contextlib.suppress(ValueError): + icons = json.loads(icons_file.read_text()) + trigger_icons = icons.get("triggers", {}) + + # Try loading translation strings + if integration.core: + strings_file = integration.path / "strings.json" + else: + # For custom integrations, use the en.json file + strings_file = integration.path / "translations/en.json" + + strings = {} + if strings_file.is_file(): + with contextlib.suppress(ValueError): + strings = json.loads(strings_file.read_text()) + + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + + # For each trigger in the integration: + # 1. Check if the trigger description is set, if not, + # check if it's in the strings file else add an error. + # 2. Check if the trigger has an icon set in icons.json. + # raise an error if not., + for trigger_name, trigger_schema in triggers.items(): + if integration.core and trigger_name not in trigger_icons: + # This is enforced for Core integrations only + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no icon in icons.json.", + ) + if trigger_schema is None: + continue + if "name" not in trigger_schema and integration.core: + try: + strings["triggers"][trigger_name]["name"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no name {error_msg_suffix}", + ) + + if "description" not in trigger_schema and integration.core: + try: + strings["triggers"][trigger_name]["description"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has no description {error_msg_suffix}", + ) + + # The same check is done for the description in each of the fields of the + # trigger schema. + for field_name, field_schema in trigger_schema.get("fields", {}).items(): + if "fields" in field_schema: + # This is a section + continue + if "name" not in field_schema and integration.core: + try: + strings["triggers"][trigger_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "triggers", + ( + f"Trigger {trigger_name} has a field {field_name} with no " + f"name {error_msg_suffix}" + ), + ) + + if "description" not in field_schema and integration.core: + try: + strings["triggers"][trigger_name]["fields"][field_name][ + "description" + ] + except KeyError: + integration.add_error( + "triggers", + ( + f"Trigger {trigger_name} has a field {field_name} with no " + f"description {error_msg_suffix}" + ), + ) + + if "selector" in field_schema: + with contextlib.suppress(KeyError): + translation_key = field_schema["selector"]["select"][ + "translation_key" + ] + try: + strings["selector"][translation_key] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", + ) + + # The same check is done for the description in each of the sections of the + # trigger schema. + for section_name, section_schema in trigger_schema.get("fields", {}).items(): + if "fields" not in section_schema: + # This is not a section + continue + if "name" not in section_schema and integration.core: + try: + strings["triggers"][trigger_name]["sections"][section_name]["name"] + except KeyError: + integration.add_error( + "triggers", + f"Trigger {trigger_name} has a section {section_name} with no name {error_msg_suffix}", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle dependencies for integrations.""" + # check triggers.yaml is valid + for integration in integrations.values(): + validate_triggers(config, integration) diff --git a/tests/common.py b/tests/common.py index 40d6e4d79d3..ff64dcb33a7 100644 --- a/tests/common.py +++ b/tests/common.py @@ -87,6 +87,7 @@ from homeassistant.helpers import ( restore_state as rs, storage, translation, + trigger, ) from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -295,6 +296,7 @@ async def async_test_home_assistant( # Load the registries entity.async_setup(hass) loader.async_setup(hass) + await trigger.async_setup(hass) # setup translation cache instead of calling translation.async_setup(hass) hass.data[translation.TRANSLATION_FLATTEN_CACHE] = translation._TranslationCache( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 6e4fa34ed26..bfb8c917f71 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy +import io import logging from typing import Any from unittest.mock import ANY, AsyncMock, Mock, patch @@ -19,6 +20,7 @@ from homeassistant.components.websocket_api.auth import ( ) from homeassistant.components.websocket_api.commands import ( ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, + ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE, ) from homeassistant.components.websocket_api.const import FEATURE_COALESCE_MESSAGES, URL from homeassistant.config_entries import ConfigEntryState @@ -28,9 +30,10 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.loader import async_get_integration +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component from homeassistant.util.json import json_loads +from homeassistant.util.yaml.loader import parse_yaml from tests.common import ( MockConfigEntry, @@ -707,6 +710,91 @@ async def test_get_services( assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_triggers", return_value=True) +async def test_subscribe_triggers( + mock_has_triggers: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test trigger_platforms/subscribe command.""" + sun_trigger_descriptions = """ + sun: {} + """ + tag_trigger_descriptions = """ + tag: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + elif fname.endswith("tag/triggers.yaml"): + trigger_descriptions = tag_trigger_descriptions + else: + raise FileNotFoundError + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + assert await async_setup_component(hass, "sun", {}) + assert await async_setup_component(hass, "system_health", {}) + await hass.async_block_till_done() + + assert ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE not in hass.data + + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + + # Test start subscription with initial event + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"} + + old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] + + # Test we receive an event when a new platform is loaded, if it has descriptions + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "tag", {}) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg == { + "event": {"tag": {"fields": {}}}, + "id": 1, + "type": "event", + } + + # Initiate a second subscription to check the cache is updated because of the new + # trigger + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"sun": {"fields": {}}, "tag": {"fields": {}}}, + "id": 2, + "type": "event", + } + + assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Initiate a third subscription to check the cache is not updated because no new + # trigger was added + old_cache = hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "trigger_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 3, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"sun": {"fields": {}}, "tag": {"fields": {}}}, + "id": 3, + "type": "event", + } + + assert hass.data[ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE] is old_cache + + async def test_get_config( hass: HomeAssistant, websocket_client: MockHAClientWebSocket ) -> None: diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index f5a2b549f89..27cde92d14f 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -1,10 +1,15 @@ """The tests for the trigger helper.""" +import io from unittest.mock import ANY, AsyncMock, MagicMock, Mock, call, patch import pytest +from pytest_unordered import unordered import voluptuous as vol +from homeassistant.components.sun import DOMAIN as DOMAIN_SUN +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH +from homeassistant.components.tag import DOMAIN as DOMAIN_TAG from homeassistant.core import ( CALLBACK_TYPE, Context, @@ -12,6 +17,8 @@ from homeassistant.core import ( ServiceCall, callback, ) +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import trigger from homeassistant.helpers.trigger import ( DATA_PLUGGABLE_ACTIONS, PluggableAction, @@ -23,9 +30,11 @@ from homeassistant.helpers.trigger import ( async_validate_trigger_config, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component +from homeassistant.util.yaml.loader import parse_yaml -from tests.common import MockModule, mock_integration, mock_platform +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform async def test_bad_trigger_platform(hass: HomeAssistant) -> None: @@ -519,3 +528,213 @@ async def test_platform_multiple_triggers(hass: HomeAssistant) -> None: with pytest.raises(KeyError): await async_initialize_triggers(hass, config_3, cb_action, "test", "", log_cb) + + +@pytest.mark.parametrize( + "sun_trigger_descriptions", + [ + """ + sun: + fields: + event: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + offset: + selector: + time: null + """, + """ + .anchor: &anchor + - sunrise + - sunset + sun: + fields: + event: + example: sunrise + selector: + select: + options: *anchor + offset: + selector: + time: null + """, + ], +) +async def test_async_get_all_descriptions( + hass: HomeAssistant, sun_trigger_descriptions: str +) -> None: + """Test async_get_all_descriptions.""" + tag_trigger_descriptions = """ + tag: {} + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + elif fname.endswith("tag/triggers.yaml"): + trigger_descriptions = tag_trigger_descriptions + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.trigger._load_triggers_files", + side_effect=trigger._load_triggers_files, + ) as proxy_load_triggers_files, + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + # Test we only load triggers.yaml for integrations with triggers, + # system_health has no triggers + assert proxy_load_triggers_files.mock_calls[0][1][1] == unordered( + [ + await async_get_integration(hass, DOMAIN_SUN), + ] + ) + + # system_health does not have triggers and should not be in descriptions + assert descriptions == { + DOMAIN_SUN: { + "fields": { + "event": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "offset": {"selector": {"time": None}}, + } + } + } + + # Verify the cache returns the same object + assert await trigger.async_get_all_descriptions(hass) is descriptions + + # Load the tag integration and check a new cache object is created + assert await async_setup_component(hass, DOMAIN_TAG, {}) + await hass.async_block_till_done() + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + new_descriptions = await trigger.async_get_all_descriptions(hass) + assert new_descriptions is not descriptions + assert new_descriptions == { + DOMAIN_SUN: { + "fields": { + "event": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "offset": {"selector": {"time": None}}, + } + }, + DOMAIN_TAG: { + "fields": {}, + }, + } + + # Verify the cache returns the same object + assert await trigger.async_get_all_descriptions(hass) is new_descriptions + + +@pytest.mark.parametrize( + ("yaml_error", "expected_message"), + [ + ( + FileNotFoundError("Blah"), + "Unable to find triggers.yaml for the sun integration", + ), + ( + HomeAssistantError("Test error"), + "Unable to parse triggers.yaml for the sun integration: Test error", + ), + ], +) +async def test_async_get_all_descriptions_with_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + yaml_error: Exception, + expected_message: str, +) -> None: + """Test async_get_all_descriptions.""" + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml_dict(fname, secrets=None): + raise yaml_error + + with ( + patch( + "homeassistant.helpers.trigger.load_yaml_dict", + side_effect=_load_yaml_dict, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert expected_message in caplog.text + + +async def test_async_get_all_descriptions_with_bad_description( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_get_all_descriptions.""" + sun_service_descriptions = """ + sun: + fields: not_a_dict + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + with io.StringIO(sun_service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_triggers", return_value=True), + ): + descriptions = await trigger.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert ( + "Unable to parse triggers.yaml for the sun integration: " + "expected a dictionary for dictionary value @ data['sun']['fields']" + ) in caplog.text + + +async def test_invalid_trigger_platform( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid trigger platform.""" + mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True))) + mock_platform(hass, "test.trigger", MockPlatform()) + + await async_setup_component(hass, "test", {}) + + assert "Integration test does not provide trigger support, skipping" in caplog.text From f34f17bc24e232f57a2082269a3c96d97c757491 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:35:48 +0200 Subject: [PATCH 0675/1664] Update codeowners of PlayStation Network integration (#147510) Add myself as codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/playstation_network/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 419347d08a7..4e224f8802b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1169,8 +1169,8 @@ build.json @home-assistant/supervisor /tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan -/homeassistant/components/playstation_network/ @jackjpowell -/tests/components/playstation_network/ @jackjpowell +/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r +/tests/components/playstation_network/ @jackjpowell @tr4nt0r /homeassistant/components/plex/ @jjlawren /tests/components/plex/ @jjlawren /homeassistant/components/plugwise/ @CoMPaTech @bouwew diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index bdcb77f92c3..bb7fc7c27ff 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -1,7 +1,7 @@ { "domain": "playstation_network", "name": "PlayStation Network", - "codeowners": ["@jackjpowell"], + "codeowners": ["@jackjpowell", "@tr4nt0r"], "config_flow": true, "dhcp": [ { From 02c3cdd5d4636b103b75f0dc6627008f3287b5a2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 25 Jun 2025 18:44:46 +0200 Subject: [PATCH 0676/1664] Update frontend to 20250625.0 (#147521) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d996963cb9c..0028bda57be 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250531.4"] + "requirements": ["home-assistant-frontend==20250625.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 918b8a0f1fd..725033f814e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250625.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9f775b13102..6a253355141 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250625.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9cbc154c9bb..49fcf03a831 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250531.4 +home-assistant-frontend==20250625.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 3268b9ee18460897160fb2456c2e6f2cf38ba90b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 25 Jun 2025 18:45:09 +0200 Subject: [PATCH 0677/1664] Fix typo's in MQTT translation strings (#147489) --- homeassistant/components/mqtt/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9c7a2fcea96..592ea8686e1 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -222,8 +222,8 @@ "unit_of_measurement": "Unit of measurement" }, "data_description": { - "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)", - "entity_category": "Allow marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configiuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", + "device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)", + "entity_category": "Allows marking an entity as device configuration or diagnostics. An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect action calls to devices or areas. Sensor entities cannot be assigned a device configuration class. [Learn more.](https://developers.home-assistant.io/docs/core/entity/#registry-properties)", "fan_feature_speed": "The fan supports multiple speeds.", "fan_feature_preset_modes": "The fan supports preset modes.", "fan_feature_oscillation": "The fan supports oscillation.", From 2800921a5dd2554d6b982cf52fd1d75c98e69362 Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 26 Jun 2025 00:45:37 +0800 Subject: [PATCH 0678/1664] Remove force latch mode for locklite in switchbot integration (#147474) --- homeassistant/components/switchbot/config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 82e6e43130b..b207440d796 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -367,8 +367,12 @@ class SwitchbotOptionsFlowHandler(OptionsFlow): ), ): int } - if self.config_entry.data.get(CONF_SENSOR_TYPE, "").startswith( - SupportedModels.LOCK + if CONF_SENSOR_TYPE in self.config_entry.data and self.config_entry.data[ + CONF_SENSOR_TYPE + ] in ( + SupportedModels.LOCK, + SupportedModels.LOCK_PRO, + SupportedModels.LOCK_ULTRA, ): options.update( { From 99079d298044270539c771a7ef7b041dbcbdba99 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 25 Jun 2025 19:47:09 +0300 Subject: [PATCH 0679/1664] Bump aioamazondevices to 3.1.19 (#147462) --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index a2bb423860b..e82cd471ac7 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": "bronze", - "requirements": ["aioamazondevices==3.1.14"] + "requirements": ["aioamazondevices==3.1.19"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6a253355141..47ebaabdf31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.14 +aioamazondevices==3.1.19 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 49fcf03a831..6bd32ea2f57 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.14 +aioamazondevices==3.1.19 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index 0576a5b9b6a..a2115ae5591 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -81,6 +81,7 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # - reasonX should be the name of the invalid dependency "adax": {"adax": {"async-timeout"}, "adax-local": {"async-timeout"}}, "airthings": {"airthings-cloud": {"async-timeout"}}, + "alexa_devices": {"marisa-trie": {"setuptools"}}, "ampio": {"asmog": {"async-timeout"}}, "apache_kafka": {"aiokafka": {"async-timeout"}}, "apple_tv": {"pyatv": {"async-timeout"}}, From 2b5f5f641d7dff3508ed903fb529e44a0a1b497e Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Wed, 25 Jun 2025 18:48:38 +0200 Subject: [PATCH 0680/1664] Bump plugwise to v1.7.6 (#147508) --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 264afd79ed2..0cf50326df1 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.4"], + "requirements": ["plugwise==1.7.6"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 47ebaabdf31..76ef5d07d10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1692,7 +1692,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.4 +plugwise==1.7.6 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6bd32ea2f57..9557e405e98 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1427,7 +1427,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.4 +plugwise==1.7.6 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 From 26e3caea9a973df3a2790b98e124afc4e4390c9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Jun 2025 19:10:30 +0200 Subject: [PATCH 0681/1664] Add support for condition platforms to provide multiple conditions (#147376) --- .../components/device_automation/condition.py | 49 ++++++++---- homeassistant/components/sun/condition.py | 55 ++++++++----- homeassistant/helpers/condition.py | 69 ++++++++++------ tests/helpers/test_condition.py | 80 +++++++++++++++++-- 4 files changed, 189 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 92901f8e857..5e2146a533c 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -10,6 +10,7 @@ from homeassistant.const import CONF_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( + Condition, ConditionCheckerType, trace_condition_function, ) @@ -51,20 +52,38 @@ class DeviceAutomationConditionProtocol(Protocol): """List conditions.""" -async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate device condition config.""" - return await async_validate_device_automation_config( - hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION - ) +class DeviceCondition(Condition): + """Device condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + self._hass = hass + + @classmethod + async def async_validate_condition_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate device condition config.""" + return await async_validate_device_automation_config( + hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION + ) + + async def async_condition_from_config(self) -> condition.ConditionCheckerType: + """Test a device condition.""" + platform = await async_get_device_automation_platform( + self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION + ) + return trace_condition_function( + platform.async_condition_from_config(self._hass, self._config) + ) -async def async_condition_from_config( - hass: HomeAssistant, config: ConfigType -) -> condition.ConditionCheckerType: - """Test a device condition.""" - platform = await async_get_device_automation_platform( - hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION - ) - return trace_condition_function(platform.async_condition_from_config(hass, config)) +CONDITIONS: dict[str, type[Condition]] = { + "device": DeviceCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/sun/condition.py b/homeassistant/components/sun/condition.py index 205f1bb8b5c..f48505b4993 100644 --- a/homeassistant/components/sun/condition.py +++ b/homeassistant/components/sun/condition.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_CONDITION, SUN_EVENT_SUNRISE, SUN_EVENT_SUN from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.condition import ( + Condition, ConditionCheckerType, condition_trace_set_result, condition_trace_update_result, @@ -37,13 +38,6 @@ _CONDITION_SCHEMA = vol.All( ) -async def async_validate_condition_config( - hass: HomeAssistant, config: ConfigType -) -> ConfigType: - """Validate config.""" - return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] - - def sun( hass: HomeAssistant, before: str | None = None, @@ -128,16 +122,41 @@ def sun( return True -def async_condition_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with sun based condition.""" - before = config.get("before") - after = config.get("after") - before_offset = config.get("before_offset") - after_offset = config.get("after_offset") +class SunCondition(Condition): + """Sun condition.""" - @trace_condition_function - def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Validate time based if-condition.""" - return sun(hass, before, after, before_offset, after_offset) + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + self._hass = hass - return sun_if + @classmethod + async def async_validate_condition_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + async def async_condition_from_config(self) -> ConditionCheckerType: + """Wrap action method with sun based condition.""" + before = self._config.get("before") + after = self._config.get("after") + before_offset = self._config.get("before_offset") + after_offset = self._config.get("after_offset") + + @trace_condition_function + def sun_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Validate time based if-condition.""" + return sun(hass, before, after, before_offset, after_offset) + + return sun_if + + +CONDITIONS: dict[str, type[Condition]] = { + "sun": SunCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index fbdf2dce7b1..86b8a1002f1 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -2,6 +2,7 @@ from __future__ import annotations +import abc import asyncio from collections import deque from collections.abc import Callable, Container, Generator @@ -75,7 +76,7 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" -_PLATFORM_ALIASES = { +_PLATFORM_ALIASES: dict[str | None, str | None] = { "and": None, "device": "device_automation", "not": None, @@ -93,20 +94,33 @@ INPUT_ENTITY_ID = re.compile( ) -class ConditionProtocol(Protocol): - """Define the format of condition modules.""" +class Condition(abc.ABC): + """Condition class.""" + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + + @classmethod + @abc.abstractmethod async def async_validate_condition_config( - self, hass: HomeAssistant, config: ConfigType + cls, hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - def async_condition_from_config( - self, hass: HomeAssistant, config: ConfigType - ) -> ConditionCheckerType: + @abc.abstractmethod + async def async_condition_from_config(self) -> ConditionCheckerType: """Evaluate state based on configuration.""" +class ConditionProtocol(Protocol): + """Define the format of condition modules.""" + + async def async_get_conditions( + self, hass: HomeAssistant + ) -> dict[str, type[Condition]]: + """Return the conditions provided by this integration.""" + + type ConditionCheckerType = Callable[[HomeAssistant, TemplateVarsType], bool | None] @@ -179,7 +193,9 @@ def trace_condition_function(condition: ConditionCheckerType) -> ConditionChecke async def _async_get_condition_platform( hass: HomeAssistant, config: ConfigType ) -> ConditionProtocol | None: - platform = config[CONF_CONDITION] + condition_key: str = config[CONF_CONDITION] + platform_and_sub_type = condition_key.partition(".") + platform: str | None = platform_and_sub_type[0] platform = _PLATFORM_ALIASES.get(platform, platform) if platform is None: return None @@ -187,7 +203,7 @@ async def _async_get_condition_platform( integration = await async_get_integration(hass, platform) except IntegrationNotFound: raise HomeAssistantError( - f'Invalid condition "{platform}" specified {config}' + f'Invalid condition "{condition_key}" specified {config}' ) from None try: return await integration.async_get_platform("condition") @@ -205,19 +221,6 @@ async def async_from_config( Should be run on the event loop. """ - factory: Any = None - platform = await _async_get_condition_platform(hass, config) - - if platform is None: - condition = config.get(CONF_CONDITION) - for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): - factory = getattr(sys.modules[__name__], fmt.format(condition), None) - - if factory: - break - else: - factory = platform.async_condition_from_config - # Check if condition is not enabled if CONF_ENABLED in config: enabled = config[CONF_ENABLED] @@ -239,6 +242,21 @@ async def async_from_config( return disabled_condition + condition: str = config[CONF_CONDITION] + factory: Any = None + platform = await _async_get_condition_platform(hass, config) + + if platform is not None: + condition_descriptors = await platform.async_get_conditions(hass) + condition_instance = condition_descriptors[condition](hass, config) + return await condition_instance.async_condition_from_config() + + for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT): + factory = getattr(sys.modules[__name__], fmt.format(condition), None) + + if factory: + break + # Check for partials to properly determine if coroutine function check_factory = factory while isinstance(check_factory, ft.partial): @@ -936,7 +954,7 @@ async def async_validate_condition_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - condition = config[CONF_CONDITION] + condition: str = config[CONF_CONDITION] if condition in ("and", "not", "or"): conditions = [] for sub_cond in config["conditions"]: @@ -947,7 +965,10 @@ async def async_validate_condition_config( platform = await _async_get_condition_platform(hass, config) if platform is not None: - return await platform.async_validate_condition_config(hass, config) + condition_descriptors = await platform.async_get_conditions(hass) + if not (condition_class := condition_descriptors.get(condition)): + raise vol.Invalid(f"Invalid condition '{condition}' specified") + return await condition_class.async_validate_condition_config(hass, config) if platform is None and condition in ("numeric_state", "state"): validator = cast( Callable[[HomeAssistant, ConfigType], ConfigType], diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 7285301f12b..246afcb3022 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -2,7 +2,7 @@ from datetime import timedelta from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time import pytest @@ -26,9 +26,12 @@ from homeassistant.helpers import ( trace, ) from homeassistant.helpers.template import Template +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from tests.common import MockModule, mock_integration, mock_platform + def assert_element(trace_element, expected_element, path): """Assert a trace element is as expected. @@ -2251,15 +2254,78 @@ async def test_trigger(hass: HomeAssistant) -> None: assert test(hass, {"trigger": {"id": "123456"}}) -async def test_platform_async_validate_condition_config(hass: HomeAssistant) -> None: - """Test platform.async_validate_condition_config will be called if it exists.""" +async def test_platform_async_get_conditions(hass: HomeAssistant) -> None: + """Test platform.async_get_conditions will be called if it exists.""" config = {CONF_DEVICE_ID: "test", CONF_DOMAIN: "test", CONF_CONDITION: "device"} with patch( - "homeassistant.components.device_automation.condition.async_validate_condition_config", - AsyncMock(), - ) as device_automation_validate_condition_mock: + "homeassistant.components.device_automation.condition.async_get_conditions", + AsyncMock(return_value={"device": AsyncMock()}), + ) as device_automation_async_get_conditions_mock: await condition.async_validate_condition_config(hass, config) - device_automation_validate_condition_mock.assert_awaited() + device_automation_async_get_conditions_mock.assert_awaited() + + +async def test_platform_multiple_conditions(hass: HomeAssistant) -> None: + """Test a condition platform with multiple conditions.""" + + class MockCondition(condition.Condition): + """Mock condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + + @classmethod + async def async_validate_condition_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return config + + class MockCondition1(MockCondition): + """Mock condition 1.""" + + async def async_condition_from_config(self) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + return lambda hass, vars: True + + class MockCondition2(MockCondition): + """Mock condition 2.""" + + async def async_condition_from_config(self) -> condition.ConditionCheckerType: + """Evaluate state based on configuration.""" + return lambda hass, vars: False + + async def async_get_conditions( + hass: HomeAssistant, + ) -> dict[str, type[condition.Condition]]: + return { + "test": MockCondition1, + "test.cond_2": MockCondition2, + } + + mock_integration(hass, MockModule("test")) + mock_platform( + hass, "test.condition", Mock(async_get_conditions=async_get_conditions) + ) + + config_1 = {CONF_CONDITION: "test"} + config_2 = {CONF_CONDITION: "test.cond_2"} + config_3 = {CONF_CONDITION: "test.unknown_cond"} + assert await condition.async_validate_condition_config(hass, config_1) == config_1 + assert await condition.async_validate_condition_config(hass, config_2) == config_2 + with pytest.raises( + vol.Invalid, match="Invalid condition 'test.unknown_cond' specified" + ): + await condition.async_validate_condition_config(hass, config_3) + + cond_func = await condition.async_from_config(hass, config_1) + assert cond_func(hass, {}) is True + + cond_func = await condition.async_from_config(hass, config_2) + assert cond_func(hass, {}) is False + + with pytest.raises(KeyError): + await condition.async_from_config(hass, config_3) @pytest.mark.parametrize("enabled_value", [True, "{{ 1 == 1 }}"]) From cff3d3d6acc0feb2ef58d0d3178c42e56906193b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Jun 2025 18:51:19 +0000 Subject: [PATCH 0682/1664] Bump version to 2025.7.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 0abdcd59b77..02631a8f365 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 87dec7a8429..24e290faed2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0.dev0" +version = "2025.7.0b0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 1286b5d9d8126436095f951539c241b98e9bb99e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Jun 2025 21:38:35 +0200 Subject: [PATCH 0683/1664] Bump version to 2025.8.0dev0 (#147531) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19cc8bd3af7..f727d258d1e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,7 +40,7 @@ env: CACHE_VERSION: 3 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2025.7" + HA_SHORT_VERSION: "2025.8" DEFAULT_PYTHON: "3.13" ALL_PYTHON_VERSIONS: "['3.13']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 0abdcd59b77..e6da8ba4a69 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 -MINOR_VERSION: Final = 7 +MINOR_VERSION: Final = 8 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index 87dec7a8429..d97bf3e1890 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0.dev0" +version = "2025.8.0.dev0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." @@ -450,7 +450,7 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" filterwarnings = [ "error::sqlalchemy.exc.SAWarning", - "error:usefixtures\\(\\) in .* without arguments has no effect:UserWarning", # pytest + "error:usefixtures\\(\\) in .* without arguments has no effect:UserWarning", # pytest # -- HomeAssistant - aiohttp # Overwrite web.Application to pass a custom default argument to _make_request From 345ec97dd526a6bfa3cf07c00bf9bd3ef8716dab Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 25 Jun 2025 17:49:06 -0400 Subject: [PATCH 0684/1664] Add enum sensor for Sonos Power Source (#147449) * feat: add power source sensor * fix: translations * fix:cleanup * fix: simpify * fix: improve coverage * fix: improve coverage * fix: add missing test * fix: call it charging_base * fix: disable entity by default * update snapshots * Update homeassistant/components/sonos/strings.json Co-authored-by: Joost Lekkerkerker * fix: update test --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/sonos/sensor.py | 68 +++++++++++++++- homeassistant/components/sonos/strings.json | 8 ++ tests/components/sonos/test_sensor.py | 88 ++++++++++++++++++++- 3 files changed, 158 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py index 6b507ec910a..fcb04a10e98 100644 --- a/homeassistant/components/sonos/sensor.py +++ b/homeassistant/components/sonos/sensor.py @@ -24,6 +24,20 @@ from .speaker import SonosSpeaker _LOGGER = logging.getLogger(__name__) +SONOS_POWER_SOURCE_BATTERY = "BATTERY" +SONOS_POWER_SOURCE_CHARGING_RING = "SONOS_CHARGING_RING" +SONOS_POWER_SOURCE_USB = "USB_POWER" + +HA_POWER_SOURCE_BATTERY = "battery" +HA_POWER_SOURCE_CHARGING_BASE = "charging_base" +HA_POWER_SOURCE_USB = "usb" + +power_source_map = { + SONOS_POWER_SOURCE_BATTERY: HA_POWER_SOURCE_BATTERY, + SONOS_POWER_SOURCE_CHARGING_RING: HA_POWER_SOURCE_CHARGING_BASE, + SONOS_POWER_SOURCE_USB: HA_POWER_SOURCE_USB, +} + async def async_setup_entry( hass: HomeAssistant, @@ -42,9 +56,15 @@ async def async_setup_entry( @callback def _async_create_battery_sensor(speaker: SonosSpeaker) -> None: - _LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name) - entity = SonosBatteryEntity(speaker, config_entry) - async_add_entities([entity]) + _LOGGER.debug( + "Creating battery level and power source sensor on %s", speaker.zone_name + ) + async_add_entities( + [ + SonosBatteryEntity(speaker, config_entry), + SonosPowerSourceEntity(speaker, config_entry), + ] + ) @callback def _async_create_favorites_sensor(favorites: SonosFavorites) -> None: @@ -101,6 +121,48 @@ class SonosBatteryEntity(SonosEntity, SensorEntity): return self.speaker.available and self.speaker.power_source is not None +class SonosPowerSourceEntity(SonosEntity, SensorEntity): + """Representation of a Sonos Power Source entity.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_options = [ + HA_POWER_SOURCE_BATTERY, + HA_POWER_SOURCE_CHARGING_BASE, + HA_POWER_SOURCE_USB, + ] + _attr_translation_key = "power_source" + + def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None: + """Initialize the power source sensor.""" + super().__init__(speaker, config_entry) + self._attr_unique_id = f"{self.soco.uid}-power_source" + + async def _async_fallback_poll(self) -> None: + """Poll the device for the current state.""" + await self.speaker.async_poll_battery() + + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if not (power_source := self.speaker.power_source): + return None + if not (value := power_source_map.get(power_source)): + _LOGGER.warning( + "Unknown power source '%s' for speaker %s", + power_source, + self.speaker.zone_name, + ) + return None + return value + + @property + def available(self) -> bool: + """Return whether this entity is available.""" + return self.speaker.available and self.speaker.power_source is not None + + class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity): """Representation of a Sonos audio import format sensor entity.""" diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 4fb8037ab64..b2f20449beb 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -53,6 +53,14 @@ "sensor": { "audio_input_format": { "name": "Audio input format" + }, + "power_source": { + "name": "Power source", + "state": { + "battery": "Battery", + "charging_base": "Charging base", + "usb": "USB" + } } }, "switch": { diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 45068c01bc0..f98fd9a4fed 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -1,20 +1,35 @@ """Tests for the Sonos battery sensor platform.""" +from collections.abc import Callable, Coroutine from datetime import timedelta +from typing import Any from unittest.mock import PropertyMock, patch import pytest from soco.exceptions import NotSupportedException from homeassistant.components.sensor import SCAN_INTERVAL +from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos.binary_sensor import ATTR_BATTERY_POWER_SOURCE +from homeassistant.components.sonos.sensor import ( + HA_POWER_SOURCE_BATTERY, + HA_POWER_SOURCE_CHARGING_BASE, + HA_POWER_SOURCE_USB, + SensorDeviceClass, +) from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, translation from homeassistant.util import dt as dt_util -from .conftest import SonosMockEvent +from .conftest import MockSoCo, SonosMockEvent from tests.common import async_fire_time_changed @@ -42,8 +57,10 @@ async def test_entity_registry_supported( assert "media_player.zone_a" in entity_registry.entities assert "sensor.zone_a_battery" in entity_registry.entities assert "binary_sensor.zone_a_charging" in entity_registry.entities + assert "sensor.zone_a_power_source" in entity_registry.entities +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_battery_attributes( hass: HomeAssistant, async_autosetup_sonos, soco, entity_registry: er.EntityRegistry ) -> None: @@ -60,6 +77,71 @@ async def test_battery_attributes( power_state.attributes.get(ATTR_BATTERY_POWER_SOURCE) == "SONOS_CHARGING_RING" ) + power_source = entity_registry.entities["sensor.zone_a_power_source"] + power_source_state = hass.states.get(power_source.entity_id) + assert power_source_state.state == HA_POWER_SOURCE_CHARGING_BASE + assert power_source_state.attributes.get("device_class") == SensorDeviceClass.ENUM + assert power_source_state.attributes.get("options") == [ + HA_POWER_SOURCE_BATTERY, + HA_POWER_SOURCE_CHARGING_BASE, + HA_POWER_SOURCE_USB, + ] + result = translation.async_translate_state( + hass, + power_source_state.state, + Platform.SENSOR, + DOMAIN, + power_source.translation_key, + None, + ) + assert result == "Charging base" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_power_source_unknown_state( + hass: HomeAssistant, + async_setup_sonos: Callable[[], Coroutine[Any, Any, None]], + soco: MockSoCo, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test bad value for power source.""" + soco.get_battery_info.return_value = { + "Level": 100, + "PowerSource": "BAD_POWER_SOURCE", + } + + with caplog.at_level("WARNING"): + await async_setup_sonos() + assert "Unknown power source" in caplog.text + assert "BAD_POWER_SOURCE" in caplog.text + assert "Zone A" in caplog.text + + power_source = entity_registry.entities["sensor.zone_a_power_source"] + power_source_state = hass.states.get(power_source.entity_id) + assert power_source_state.state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_power_source_none( + hass: HomeAssistant, + async_setup_sonos: Callable[[], Coroutine[Any, Any, None]], + soco: MockSoCo, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test none value for power source.""" + soco.get_battery_info.return_value = { + "Level": 100, + "PowerSource": None, + } + + await async_setup_sonos() + + power_source = entity_registry.entities["sensor.zone_a_power_source"] + power_source_state = hass.states.get(power_source.entity_id) + assert power_source_state.state == STATE_UNAVAILABLE + async def test_battery_on_s1( hass: HomeAssistant, From f0a78aadbe1ed91862f40c87da69b37962c1f0d7 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 15:12:23 -0700 Subject: [PATCH 0685/1664] Fixes in Google AI TTS (#147501) * Fix Google AI not using correct config options after subentries migration * Fixes in Google AI TTS * Fix tests by @IvanLH * Change type name. --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- .../__init__.py | 13 + .../config_flow.py | 105 +++-- .../const.py | 16 +- .../strings.json | 29 +- .../google_generative_ai_conversation/tts.py | 92 ++--- homeassistant/config_entries.py | 5 + .../conftest.py | 9 +- .../test_config_flow.py | 65 ++- .../test_init.py | 56 ++- .../test_tts.py | 385 ++++++------------ 10 files changed, 412 insertions(+), 363 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 40d441929a3..1802073f760 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import mimetypes from pathlib import Path +from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError @@ -36,10 +37,12 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, LOGGER, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) @@ -242,6 +245,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) + if use_existing: + hass.config_entries.async_add_subentry( + parent_entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) conversation_entity = entity_registry.async_get_entity_id( "conversation", DOMAIN, diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 4b7c7a0dd47..bb526f95a21 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -47,13 +47,17 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -66,12 +70,6 @@ STEP_API_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -123,10 +121,16 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -172,10 +176,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": LLMSubentryFlowHandler, + "tts": LLMSubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): +class LLMSubentryFlowHandler(ConfigSubentryFlow): """Flow for managing conversation subentries.""" last_rendered_recommended = False @@ -202,7 +209,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input is None: if self._is_new: - options = RECOMMENDED_OPTIONS.copy() + options: dict[str, Any] + if self._subentry_type == "tts": + options = RECOMMENDED_TTS_OPTIONS.copy() + else: + options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: # If this is a reconfiguration, we need to copy the existing options # so that we can show the current values in the form. @@ -216,7 +227,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) - # Don't allow to save options that enable the Google Seearch tool with an Assist API + # Don't allow to save options that enable the Google Search tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True @@ -240,7 +251,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): options = user_input schema = await google_generative_ai_config_option_schema( - self.hass, self._is_new, options, self._genai_client + self.hass, self._is_new, self._subentry_type, options, self._genai_client ) return self.async_show_form( step_id="set_options", data_schema=vol.Schema(schema), errors=errors @@ -253,6 +264,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, is_new: bool, + subentry_type: str, options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: @@ -270,26 +282,39 @@ async def google_generative_ai_config_option_schema( suggested_llm_apis = [suggested_llm_apis] if is_new: + if CONF_NAME in options: + default_name = options[CONF_NAME] + elif subentry_type == "tts": + default_name = DEFAULT_TTS_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { - vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + vol.Required(CONF_NAME, default=default_name): str, } else: schema = {} + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ) schema.update( { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -310,7 +335,7 @@ async def google_generative_ai_config_option_schema( if ( api_model.display_name and api_model.name - and "tts" not in api_model.name + and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions and "generateContent" in api_model.supported_actions @@ -341,12 +366,17 @@ async def google_generative_ai_config_option_schema( ) ) + if subentry_type == "tts": + default_model = RECOMMENDED_TTS_MODEL + else: + default_model = RECOMMENDED_CHAT_MODEL + schema.update( { vol.Optional( CONF_CHAT_MODEL, description={"suggested_value": options.get(CONF_CHAT_MODEL)}, - default=RECOMMENDED_CHAT_MODEL, + default=default_model, ): SelectSelector( SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models) ), @@ -396,13 +426,18 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, - vol.Optional( - CONF_USE_GOOGLE_SEARCH_TOOL, - description={ - "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), - }, - default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, - ): bool, } ) + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, + } + ) return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 0735e9015c2..9f4132a1e3e 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -2,17 +2,20 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_TTS_NAME = "Google AI TTS" -ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" -RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" +RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" @@ -31,3 +34,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_RECOMMENDED: True, +} + +RECOMMENDED_TTS_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index e523aecbaec..eef595ad05d 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -29,7 +29,6 @@ "reconfigure": "Reconfigure conversation agent" }, "entry_type": "Conversation agent", - "step": { "set_options": { "data": { @@ -61,6 +60,34 @@ "error": { "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } + }, + "tts": { + "initiate_flow": { + "user": "Add Text-to-Speech service", + "reconfigure": "Reconfigure Text-to-Speech service" + }, + "entry_type": "Text-to-Speech", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } } }, "services": { diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 50baec67db2..174f0a50dc3 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress import io -import logging from typing import Any import wave from google.genai import types +from google.genai.errors import APIError, ClientError +from propcache.api import cached_property from homeassistant.components.tts import ( ATTR_VOICE, @@ -19,12 +21,10 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -32,15 +32,23 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up TTS entity.""" - tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) - async_add_entities([tts_entity]) + """Set up TTS entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "tts": + continue + + async_add_entities( + [GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) -class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): +class GoogleGenerativeAITextToSpeechEntity( + TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity +): """Google Generative AI text-to-speech entity.""" - _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages _attr_supported_languages = [ "ar-EG", @@ -68,6 +76,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): "uk-UA", "vi-VN", ] + # Unused, but required by base class. + # The Gemini TTS models detect the input language automatically. _attr_default_language = "en-US" # See https://ai.google.dev/gemini-api/docs/speech-generation#voices _supported_voices = [ @@ -106,53 +116,41 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): ) ] - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Google Generative AI Conversation speech entity.""" - self.entry = entry - self._attr_name = "Google Generative AI TTS" - self._attr_unique_id = f"{entry.entry_id}_tts" - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) - self._genai_client = entry.runtime_data - self._default_voice_id = self._supported_voices[0].voice_id - @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: + def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" return self._supported_voices + @cached_property + def default_options(self) -> Mapping[str, Any]: + """Return a mapping with the default options.""" + return { + ATTR_VOICE: self._supported_voices[0].voice_id, + } + async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine.""" - try: - response = self._genai_client.models.generate_content( - model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), - contents=message, - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig( - voice_name=options.get( - ATTR_VOICE, self._default_voice_id - ) - ) - ) - ), - ), + config = self.create_generate_content_config() + config.response_modalities = ["AUDIO"] + config.speech_config = types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options[ATTR_VOICE] + ) + ) + ) + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=config, ) - data = response.candidates[0].content.parts[0].inline_data.data mime_type = response.candidates[0].content.parts[0].inline_data.mime_type - except Exception as exc: - _LOGGER.warning( - "Error during processing of TTS request %s", exc, exc_info=True - ) + except (APIError, ClientError, ValueError) as exc: + LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc return "wav", self._convert_to_wav(data, mime_type) @@ -192,7 +190,7 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): """ if not mime_type.startswith("audio/L"): - _LOGGER.warning("Received unexpected MIME type %s", mime_type) + LOGGER.warning("Received unexpected MIME type %s", mime_type) raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") bits_per_sample = 16 diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c2481ae3fa3..ca3a78f8046 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3420,6 +3420,11 @@ class ConfigSubentryFlow( """Return config entry id.""" return self.handler[0] + @property + def _subentry_type(self) -> str: + """Return type of subentry we are editing/creating.""" + return self.handler[1] + @callback def _get_entry(self) -> ConfigEntry: """Return the config entry linked to the current context.""" diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 36d99cd2764..afea41bbb26 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -34,7 +35,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "data": {}, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ], ) entry.runtime_data = Mock() diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index e02d85e41c4..b43c8a42275 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -6,9 +6,6 @@ import pytest from requests.exceptions import Timeout from homeassistant import config_entries -from homeassistant.components.google_generative_ai_conversation.config_flow import ( - RECOMMENDED_OPTIONS, -) from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, CONF_DANGEROUS_BLOCK_THRESHOLD, @@ -23,12 +20,15 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME @@ -115,10 +115,16 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -172,19 +178,64 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, + {CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" - processed_options = RECOMMENDED_OPTIONS.copy() + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() assert result2["data"] == processed_options +async def test_creating_tts_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a TTS subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "tts"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "set_options" + assert not result["errors"] + + old_subentries = set(mock_config_entry.subentries) + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock TTS" + assert result2["data"] == RECOMMENDED_TTS_OPTIONS + + assert len(mock_config_entry.subentries) == 3 + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == "tts" + assert new_subentry.data == RECOMMENDED_TTS_OPTIONS + assert new_subentry.title == "Mock TTS" + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component: None, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 8de678213c2..a8a1e2840e3 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -7,7 +7,11 @@ import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion -from homeassistant.components.google_generative_ai_conversation.const import DOMAIN +from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_TTS_NAME, + DOMAIN, + RECOMMENDED_TTS_OPTIONS, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -469,13 +473,27 @@ async def test_migration_from_v1_to_v2( entry = entries[0] assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -493,7 +511,7 @@ async def test_migration_from_v1_to_v2( assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" @@ -591,11 +609,15 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 1 + assert len(entry.subentries) == 2 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + subentry = list(entry.subentries.values())[1] + assert subentry.subentry_type == "tts" + assert subentry.data == RECOMMENDED_TTS_OPTIONS + assert subentry.title == DEFAULT_TTS_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -680,13 +702,27 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -704,7 +740,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 4f197f0535f..108ac82947c 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -9,30 +9,37 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from google.genai import types +from google.genai.errors import APIError import pytest from homeassistant.components import tts -from homeassistant.components.google_generative_ai_conversation.tts import ( - ATTR_MODEL, +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, DOMAIN, - RECOMMENDED_TTS_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, ) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from . import API_ERROR_500 - from tests.common import MockConfigEntry, async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +API_ERROR_500 = APIError("test", response=MagicMock()) +TEST_CHAT_MODEL = "models/some-tts-model" + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: @@ -63,20 +70,22 @@ def mock_genai_client() -> Generator[AsyncMock]: """Mock genai_client.""" client = Mock() client.aio.models.get = AsyncMock() - client.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) ) - ) - ), + ), + ) ) ) with patch( @@ -90,17 +99,29 @@ def mock_genai_client() -> Generator[AsyncMock]: async def setup_fixture( hass: HomeAssistant, config: dict[str, Any], - request: pytest.FixtureRequest, mock_genai_client: AsyncMock, ) -> None: """Set up the test environment.""" - if request.param == "mock_setup": - await mock_setup(hass, config) - if request.param == "mock_config_entry_setup": - await mock_config_entry_setup(hass, config) - else: - raise RuntimeError("Invalid setup fixture") + config_entry = MockConfigEntry(domain=DOMAIN, data=config, version=2) + config_entry.add_to_hass(hass) + sub_entry = ConfigSubentry( + data={ + tts.CONF_LANG: "en-US", + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + }, + subentry_type="tts", + title="Google AI TTS", + subentry_id="test_subentry_tts_id", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() @@ -112,105 +133,38 @@ def config_fixture() -> dict[str, Any]: } -async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock setup.""" - assert await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} - ) - - -async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock config entry setup.""" - default_config = {tts.CONF_LANG: "en-US"} - config_entry = MockConfigEntry( - domain=DOMAIN, data=default_config | config, version=2 - ) - - client_mock = Mock() - client_mock.models.get = None - client_mock.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), - ) - ) - ), - ) - ) - config_entry.runtime_data = client_mock - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - - @pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), + "service_data", [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, - }, - ), + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, ], - indirect=["setup"], ) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, service_data: dict[str, Any], ) -> None: """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.reset_mock() await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -221,10 +175,9 @@ async def test_tts_service_speak( == HTTPStatus.OK ) voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") - model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=model_id, + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], @@ -233,109 +186,52 @@ async def test_tts_service_speak( prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "de-DE", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "it-IT", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_lang_config( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with languages in the config.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak_error( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], ) -> None: """Test service call with HTTP response 500.""" + service_data = { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + } tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + tts_entity._genai_client.aio.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.side_effect = API_ERROR_500 await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -346,70 +242,39 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE) + + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], speech_config=types.SpeechConfig( voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_without_options( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with HTTP response 200.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) From 6290facffb5719f578836d13ba37d52dd91a7ed8 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 02:55:58 +0300 Subject: [PATCH 0686/1664] Fix unload for Alexa Devices (#147548) --- homeassistant/components/alexa_devices/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index aff4c1bb391..fe623c10b33 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Unload a config entry.""" - await entry.runtime_data.api.close() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + coordinator = entry.runtime_data + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await coordinator.api.close() + + return unload_ok From 0f95fe566cbbbff6b7dd60c5c145059a43ae9cc9 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 19:30:41 -0700 Subject: [PATCH 0687/1664] Use default title for migrated Google Generative AI entries (#147551) --- .../components/google_generative_ai_conversation/__init__.py | 2 ++ .../google_generative_ai_conversation/config_flow.py | 3 ++- .../components/google_generative_ai_conversation/const.py | 1 + .../components/google_generative_ai_conversation/test_init.py | 4 ++++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1802073f760..7890af59f88 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, @@ -289,6 +290,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_TITLE, options={}, version=2, ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index bb526f95a21..ad90cbcf553 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -47,6 +47,7 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, @@ -116,7 +117,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) return self.async_create_entry( - title="Google Generative AI", + title=DEFAULT_TITLE, data=user_input, subentries=[ { diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 9f4132a1e3e..72665cd3437 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm DOMAIN = "google_generative_ai_conversation" +DEFAULT_TITLE = "Google Generative AI" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index a8a1e2840e3..46a2d634b81 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -8,6 +8,7 @@ from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_TTS_OPTIONS, @@ -473,6 +474,7 @@ async def test_migration_from_v1_to_v2( entry = entries[0] assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 conversation_subentries = [ subentry @@ -609,6 +611,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 2 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" @@ -702,6 +705,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 conversation_subentries = [ subentry From 3b64db5f767aa2e632a53d8b33a72f6f08c89de0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Jun 2025 08:20:26 +0200 Subject: [PATCH 0688/1664] Set end date for when allowing unique id collisions in config entries (#147516) * Set end date for when allowing unique id collisions in config entries * Update test --- homeassistant/config_entries.py | 1 + tests/test_config_entries.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ca3a78f8046..e76b7ae099f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1646,6 +1646,7 @@ class ConfigEntriesFlowManager( report_usage( "creates a config entry when another entry with the same unique ID " "exists", + breaks_in_ha_version="2026.3", core_behavior=ReportBehavior.LOG, core_integration_behavior=ReportBehavior.LOG, custom_integration_behavior=ReportBehavior.LOG, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 45bb956b7a1..dc893e4c5fd 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8823,7 +8823,7 @@ async def test_create_entry_existing_unique_id( log_text = ( f"Detected that integration '{domain}' creates a config entry " - "when another entry with the same unique ID exists. Please " - "create a bug report at https:" + "when another entry with the same unique ID exists. This will stop " + "working in Home Assistant 2026.3, please create a bug report at https:" ) assert (log_text in caplog.text) == expected_log From 651b33d49b53c92862ec2cf1787842210969f0dd Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 10:11:25 +0300 Subject: [PATCH 0689/1664] Bump zwave-js-server-python to 0.65.0 (#147561) * Bump zwave-js-server-python to 0.65.0 * update tests --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 082a3dd9f95..93d585d72a2 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.64.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.65.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 76ef5d07d10..eb8de18f20c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3205,7 +3205,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.65.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9557e405e98..f059b073f0f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2637,7 +2637,7 @@ zeversolar==0.3.2 zha==0.0.61 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.65.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 3f1f9b737bd..d6aed0b6d22 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5649,8 +5649,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) assert entry.unique_id == "1234" @@ -5684,8 +5685,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) assert ( "Failed to get server version, cannot update config entry" @@ -5738,8 +5740,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) client.async_send_command.reset_mock() From 38669ce96c7d8a5b74a8af29ebfe2f417bfc6c06 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 26 Jun 2025 10:47:24 +0200 Subject: [PATCH 0690/1664] Fix sending commands to Matter vacuum (#147567) --- homeassistant/components/matter/vacuum.py | 64 +++++++++++-------- .../matter/snapshots/test_vacuum.ambr | 4 +- tests/components/matter/test_vacuum.py | 58 ++++++++++------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 96c6ba212de..141400c384b 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -62,14 +62,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): _last_accepted_commands: list[int] | None = None _supported_run_modes: ( - dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None + dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None entity_description: StateVacuumEntityDescription _platform_translation_key = "vacuum" async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self.send_device_command(clusters.OperationalState.Commands.Stop()) + # We simply set the RvcRunMode to the first runmode + # that has the idle tag to stop the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for tag in mode.modeTags: + if tag.value == ModeTag.IDLE: + # stop the vacuum by changing the run mode to idle + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + return async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" @@ -83,15 +94,30 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): """Start or resume the cleaning task.""" if TYPE_CHECKING: assert self._last_accepted_commands is not None + + accepted_operational_commands = self._last_accepted_commands if ( clusters.RvcOperationalState.Commands.Resume.command_id - in self._last_accepted_commands + in accepted_operational_commands + and self.state == VacuumActivity.PAUSED ): + # vacuum is paused and supports resume command await self.send_device_command( clusters.RvcOperationalState.Commands.Resume() ) - else: - await self.send_device_command(clusters.OperationalState.Commands.Start()) + return + + # We simply set the RvcRunMode to the first runmode + # that has the cleaning tag to start the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for tag in mode.modeTags: + if tag.value == ModeTag.CLEANING: + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + return async def async_pause(self) -> None: """Pause the cleaning task.""" @@ -130,6 +156,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): state = VacuumActivity.CLEANING elif ModeTag.IDLE in tags: state = VacuumActivity.IDLE + elif ModeTag.MAPPING in tags: + state = VacuumActivity.CLEANING self._attr_activity = state @callback @@ -143,7 +171,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): return self._last_accepted_commands = accepted_operational_commands supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + supported_features |= VacuumEntityFeature.START supported_features |= VacuumEntityFeature.STATE + supported_features |= VacuumEntityFeature.STOP + # optional battery attribute = battery feature if self.get_matter_attribute_value( clusters.PowerSource.Attributes.BatPercentRemaining @@ -153,7 +184,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): supported_features |= VacuumEntityFeature.LOCATE # create a map of supported run modes - run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = ( + run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = ( self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.SupportedModes ) @@ -165,22 +196,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): in accepted_operational_commands ): supported_features |= VacuumEntityFeature.PAUSE - if ( - clusters.OperationalState.Commands.Stop.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.STOP - if ( - clusters.OperationalState.Commands.Start.command_id - in accepted_operational_commands - ): - # note that start has been replaced by resume in rev2 of the spec - supported_features |= VacuumEntityFeature.START - if ( - clusters.RvcOperationalState.Commands.Resume.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.START if ( clusters.RvcOperationalState.Commands.GoHome.command_id in accepted_operational_commands @@ -202,10 +217,7 @@ DISCOVERY_SCHEMAS = [ clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), - optional_attributes=( - clusters.RvcCleanMode.Attributes.CurrentMode, - clusters.PowerSource.Attributes.BatPercentRemaining, - ), + optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index cb859147d75..71e0f75614d 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -28,7 +28,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Vacuum', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.mock_vacuum', diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 2642ff39ef8..b464e9f1cd3 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -9,7 +9,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -61,7 +60,29 @@ async def test_vacuum_actions( ) matter_client.send_device_command.reset_mock() - # test start/resume action + # test start action (from idle state) + await hass.services.async_call( + "vacuum", + "start", + { + "entity_id": entity_id, + }, + 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.RvcRunMode.Commands.ChangeToMode(newMode=1), + ) + matter_client.send_device_command.reset_mock() + + # test resume action (from paused state) + # first set the operational state to paused + set_node_attribute(matter_node, 1, 97, 4, 0x02) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( "vacuum", "start", @@ -98,25 +119,6 @@ async def test_vacuum_actions( matter_client.send_device_command.reset_mock() # test stop action - # stop command is not supported by the vacuum fixture - with pytest.raises( - ServiceNotSupported, - match="Entity vacuum.mock_vacuum does not support action vacuum.stop", - ): - await hass.services.async_call( - "vacuum", - "stop", - { - "entity_id": entity_id, - }, - blocking=True, - ) - - # update accepted command list to add support for stop command - set_node_attribute( - matter_node, 1, 97, 65529, [clusters.OperationalState.Commands.Stop.command_id] - ) - await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( "vacuum", "stop", @@ -129,7 +131,7 @@ async def test_vacuum_actions( assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.OperationalState.Commands.Stop(), + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=0), ) matter_client.send_device_command.reset_mock() @@ -209,11 +211,21 @@ async def test_vacuum_updates( assert state assert state.state == "idle" + # confirm state is 'cleaning' by setting; + # - the operational state to 0x00 + # - the run mode is set to a mode which has mapping tag + set_node_attribute(matter_node, 1, 97, 4, 0) + set_node_attribute(matter_node, 1, 84, 1, 2) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "cleaning" + # confirm state is 'unknown' by setting; # - the operational state to 0x00 # - the run mode is set to a mode which has neither cleaning or idle tag set_node_attribute(matter_node, 1, 97, 4, 0) - set_node_attribute(matter_node, 1, 84, 1, 2) + set_node_attribute(matter_node, 1, 84, 1, 5) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state From fb133664e4b3e0bfd32e3b231e084a8c30a81288 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Jun 2025 01:50:47 -0700 Subject: [PATCH 0691/1664] Include subentries in Google Generative AI diagnostics (#147558) --- .../diagnostics.py | 1 + .../conftest.py | 2 + .../snapshots/test_diagnostics.ambr | 40 ++++++++++++++----- .../test_diagnostics.py | 6 +-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/diagnostics.py b/homeassistant/components/google_generative_ai_conversation/diagnostics.py index 13643da7e00..34b9f762355 100644 --- a/homeassistant/components/google_generative_ai_conversation/diagnostics.py +++ b/homeassistant/components/google_generative_ai_conversation/diagnostics.py @@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": entry.data, "options": entry.options, + "subentries": dict(entry.subentries), }, TO_REDACT, ) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index afea41bbb26..331afc723ae 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -34,12 +34,14 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "data": {}, "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, + "subentry_id": "ulid-conversation", "unique_id": None, }, { "data": {}, "subentry_type": "tts", "title": DEFAULT_TTS_NAME, + "subentry_id": "ulid-tts", "unique_id": None, }, ], diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index a31827c7acc..48091d83a00 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,17 +5,35 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-2.5-flash', - 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 1500, - 'prompt': 'Speak like a pirate', - 'recommended': False, - 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, + }), + 'subentries': dict({ + 'ulid-conversation': dict({ + 'data': dict({ + 'chat_model': 'models/gemini-2.5-flash', + 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'max_tokens': 1500, + 'prompt': 'Speak like a pirate', + 'recommended': False, + 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'subentry_id': 'ulid-conversation', + 'subentry_type': 'conversation', + 'title': 'Google AI Conversation', + 'unique_id': None, + }), + 'ulid-tts': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-tts', + 'subentry_type': 'tts', + 'title': 'Google AI TTS', + 'unique_id': None, + }), }), 'title': 'Google Generative AI Conversation', }) diff --git a/tests/components/google_generative_ai_conversation/test_diagnostics.py b/tests/components/google_generative_ai_conversation/test_diagnostics.py index ebc1b5e52a5..0f193238669 100644 --- a/tests/components/google_generative_ai_conversation/test_diagnostics.py +++ b/tests/components/google_generative_ai_conversation/test_diagnostics.py @@ -35,10 +35,10 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - mock_config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE, From 79df38eff23b38831a8034d709e6f6bb54ef43b9 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 11:52:14 +0300 Subject: [PATCH 0692/1664] Improve config flow strings for Alexa Devices (#147523) --- homeassistant/components/alexa_devices/strings.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index eb279e28d35..b3bb699d003 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -1,8 +1,7 @@ { "common": { - "data_country": "Country code", "data_code": "One-time password (OTP code)", - "data_description_country": "The country of your Amazon account.", + "data_description_country": "The country where your Amazon account is registered.", "data_description_username": "The email address of your Amazon account.", "data_description_password": "The password of your Amazon account.", "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." @@ -12,10 +11,10 @@ "step": { "user": { "data": { - "country": "[%key:component::alexa_devices::common::data_country%]", + "country": "[%key:common::config_flow::data::country%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "code": "[%key:component::alexa_devices::common::data_description_code%]" + "code": "[%key:component::alexa_devices::common::data_code%]" }, "data_description": { "country": "[%key:component::alexa_devices::common::data_description_country%]", From 4b9b08ece554cf9149c4b17b6b15d87bedb17ea1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 10:55:31 +0200 Subject: [PATCH 0693/1664] Show current Lametric version if there is no newer version (#147538) --- homeassistant/components/lametric/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/update.py b/homeassistant/components/lametric/update.py index d486d9d27ba..3d93f919c58 100644 --- a/homeassistant/components/lametric/update.py +++ b/homeassistant/components/lametric/update.py @@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity): def latest_version(self) -> str | None: """Return the latest version of the entity.""" if not self.coordinator.data.update: - return None + return self.coordinator.data.os_version return self.coordinator.data.update.version From 13ce27c94c6c7b58dc6f93197f74b43feb9dd74f Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 12:06:36 +0300 Subject: [PATCH 0694/1664] Remove obsolete routing info when migrating a Z-Wave network (#147568) --- homeassistant/components/zwave_js/api.py | 2 +- homeassistant/components/zwave_js/config_flow.py | 4 +++- tests/components/zwave_js/test_api.py | 6 +++--- tests/components/zwave_js/test_config_flow.py | 10 +++++----- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 168df5edcaa..a17f13e0d07 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -3105,7 +3105,7 @@ async def websocket_restore_nvm( driver.once("driver ready", set_driver_ready), ] - await controller.async_restore_nvm_base64(msg["data"]) + await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False}) with suppress(TimeoutError): async with asyncio.timeout(DRIVER_READY_TIMEOUT): diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 5e8e7022839..35b54aa2e49 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1400,7 +1400,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): driver.once("driver ready", set_driver_ready), ] try: - await controller.async_restore_nvm(self.backup_data) + await controller.async_restore_nvm( + self.backup_data, {"preserveRoutes": False} + ) except FailedCommand as err: raise AbortFlow(f"Failed to restore network: {err}") from err else: diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index d6aed0b6d22..bac0162ba74 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5649,7 +5649,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) @@ -5685,7 +5685,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) @@ -5740,7 +5740,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index dd8838e0775..a7bb02d5920 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -900,7 +900,7 @@ async def test_usb_discovery_migration( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -1031,7 +1031,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3501,7 +3501,7 @@ async def test_reconfigure_migrate_with_addon( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3686,7 +3686,7 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3835,7 +3835,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, From 076248c4551c72edc8f4efb55fedc5d6e36a6aa5 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 26 Jun 2025 11:07:07 +0200 Subject: [PATCH 0695/1664] Fix wind direction state class sensor for AEMET (#147535) --- homeassistant/components/aemet/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index a3aeab9deb9..2e7e977cf3d 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( keys=[AOD_WEATHER, AOD_WIND_DIRECTION], name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( From d55ecd885eba92f4fcd0fd8ebc1cbf6ed2c26538 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 26 Jun 2025 11:49:06 +0200 Subject: [PATCH 0696/1664] Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) --- .../components/music_assistant/button.py | 6 --- .../snapshots/test_button.ambr | 2 +- .../components/music_assistant/test_button.py | 42 ++++++++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py index 7969954e443..445ef2c3e98 100644 --- a/homeassistant/components/music_assistant/button.py +++ b/homeassistant/components/music_assistant/button.py @@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): translation_key="favorite_now_playing", ) - @property - def available(self) -> bool: - """Return availability of entity.""" - # mark the button as unavailable if the player has no current media item - return super().available and self.player.current_media is not None - @catch_musicassistant_error async def async_press(self) -> None: """Handle the button press command.""" diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr index ac9e4c660f6..d064916e044 100644 --- a/tests/components/music_assistant/snapshots/test_button.ambr +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -140,6 +140,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 8a1a4b0e241..5a326b1d8ea 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -2,14 +2,20 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import EventType +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) async def test_button_entities( @@ -46,3 +52,35 @@ async def test_button_press_action( "music/favorites/add_item", item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", ) + + # test again without current_media + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].current_media = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + # test again without active source + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].active_source = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="Player has no active source"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) From be492965474b7c4ab888837a223cb9357777ceea Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 26 Jun 2025 11:54:52 +0200 Subject: [PATCH 0697/1664] Deduplicate shared logic in Matter vacuum commands (#147578) Get the run mode by tag in a single place to avoid code duplication. Also raise an error if the run mode (unexpectedly) is not found. --- homeassistant/components/matter/vacuum.py | 47 ++++++++++++-------- tests/components/matter/test_vacuum.py | 53 +++++++++++++++++++++++ 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 141400c384b..6ab687e060a 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -17,6 +17,7 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity @@ -67,20 +68,31 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): entity_description: StateVacuumEntityDescription _platform_translation_key = "vacuum" + def _get_run_mode_by_tag( + self, tag: ModeTag + ) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None: + """Get the run mode by tag.""" + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for t in mode.modeTags: + if t.value == tag.value: + return mode + return None + async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" # We simply set the RvcRunMode to the first runmode # that has the idle tag to stop the vacuum cleaner. # this is compatible with both Matter 1.2 and 1.3+ devices. - supported_run_modes = self._supported_run_modes or {} - for mode in supported_run_modes.values(): - for tag in mode.modeTags: - if tag.value == ModeTag.IDLE: - # stop the vacuum by changing the run mode to idle - await self.send_device_command( - clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) - ) - return + mode = self._get_run_mode_by_tag(ModeTag.IDLE) + if mode is None: + raise HomeAssistantError( + "No supported run mode found to stop the vacuum cleaner." + ) + + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" @@ -110,14 +122,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): # We simply set the RvcRunMode to the first runmode # that has the cleaning tag to start the vacuum cleaner. # this is compatible with both Matter 1.2 and 1.3+ devices. - supported_run_modes = self._supported_run_modes or {} - for mode in supported_run_modes.values(): - for tag in mode.modeTags: - if tag.value == ModeTag.CLEANING: - await self.send_device_command( - clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) - ) - return + mode = self._get_run_mode_by_tag(ModeTag.CLEANING) + if mode is None: + raise HomeAssistantError( + "No supported run mode found to start the vacuum cleaner." + ) + + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) async def async_pause(self) -> None: """Pause the cleaning task.""" diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index b464e9f1cd3..cba4b9b59eb 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -9,6 +9,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -238,3 +239,55 @@ async def test_vacuum_updates( state = hass.states.get(entity_id) assert state assert state.state == "error" + + +@pytest.mark.parametrize("node_fixture", ["vacuum_cleaner"]) +async def test_vacuum_actions_no_supported_run_modes( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test vacuum entity actions when no supported run modes are available.""" + # Fetch translations + await async_setup_component(hass, "homeassistant", {}) + entity_id = "vacuum.mock_vacuum" + state = hass.states.get(entity_id) + assert state + + # Set empty supported modes to simulate no available run modes + # RvcRunMode cluster ID is 84, SupportedModes attribute ID is 0 + set_node_attribute(matter_node, 1, 84, 0, []) + # RvcOperationalState cluster ID is 97, AcceptedCommandList attribute ID is 65529 + set_node_attribute(matter_node, 1, 97, 65529, []) + await trigger_subscription_callback(hass, matter_client) + + # test start action fails when no supported run modes + with pytest.raises( + HomeAssistantError, + match="No supported run mode found to start the vacuum cleaner", + ): + await hass.services.async_call( + "vacuum", + "start", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + # test stop action fails when no supported run modes + with pytest.raises( + HomeAssistantError, + match="No supported run mode found to stop the vacuum cleaner", + ): + await hass.services.async_call( + "vacuum", + "stop", + { + "entity_id": entity_id, + }, + blocking=True, + ) + + # Ensure no commands were sent to the device + assert matter_client.send_device_command.call_count == 0 From a73dafe0978af375c55874714d7477955a78a289 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 13:15:02 +0300 Subject: [PATCH 0698/1664] Hide unnamed paths when selecting a USB Z-Wave adapter (#147571) * Hide unnamed paths when selecting a USB Z-Wave adapter * remove pointless sorting --- .../components/zwave_js/config_flow.py | 16 +-- tests/components/zwave_js/test_config_flow.py | 102 +++++++++++++++++- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 35b54aa2e49..2c37ee4b554 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -138,13 +138,15 @@ def get_usb_ports() -> dict[str, str]: ) port_descriptions[dev_path] = human_name - # Sort the dictionary by description, putting "n/a" last - return dict( - sorted( - port_descriptions.items(), - key=lambda x: x[1].lower().startswith("n/a"), - ) - ) + # Filter out "n/a" descriptions only if there are other ports available + non_na_ports = { + path: desc + for path, desc in port_descriptions.items() + if not desc.lower().startswith("n/a") + } + + # If we have non-"n/a" ports, return only those; otherwise return all ports as-is + return non_na_ports if non_na_ports else port_descriptions async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a7bb02d5920..2e41a176a9c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -4435,8 +4435,8 @@ async def test_configure_addon_usb_ports_failure( assert result["reason"] == "usb_ports_failed" -async def test_get_usb_ports_sorting() -> None: - """Test that get_usb_ports sorts ports with 'n/a' descriptions last.""" +async def test_get_usb_ports_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions when other ports are available.""" mock_ports = [ ListPortInfo("/dev/ttyUSB0"), ListPortInfo("/dev/ttyUSB1"), @@ -4453,13 +4453,105 @@ async def test_get_usb_ports_sorting() -> None: descriptions = list(result.values()) - # Verify that descriptions containing "n/a" are at the end - + # Verify that only non-"n/a" descriptions are returned assert descriptions == [ "Device A - /dev/ttyUSB1, s/n: n/a", "Device B - /dev/ttyUSB3, s/n: n/a", + ] + + +async def test_get_usb_ports_all_na() -> None: + """Test that get_usb_ports returns all ports as-is when only 'n/a' descriptions exist.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "N/A" + mock_ports[2].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that all ports are returned since they all have "n/a" descriptions + assert len(descriptions) == 3 + # Verify that all descriptions contain "n/a" (case-insensitive) + assert all("n/a" in desc.lower() for desc in descriptions) + # Verify that all expected device paths are present + device_paths = [desc.split(" - ")[1].split(",")[0] for desc in descriptions] + assert "/dev/ttyUSB0" in device_paths + assert "/dev/ttyUSB1" in device_paths + assert "/dev/ttyUSB2" in device_paths + + +async def test_get_usb_ports_mixed_case_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions with different case variations.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ListPortInfo("/dev/ttyUSB3"), + ListPortInfo("/dev/ttyUSB4"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "Device A" + mock_ports[2].description = "N/A" + mock_ports[3].description = "n/A" + mock_ports[4].description = "Device B" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that only non-"n/a" descriptions are returned (case-insensitive filtering) + assert descriptions == [ + "Device A - /dev/ttyUSB1, s/n: n/a", + "Device B - /dev/ttyUSB4, s/n: n/a", + ] + + +async def test_get_usb_ports_empty_list() -> None: + """Test that get_usb_ports handles empty port list.""" + with patch("serial.tools.list_ports.comports", return_value=[]): + result = get_usb_ports() + + # Verify that empty dict is returned + assert result == {} + + +async def test_get_usb_ports_single_na_port() -> None: + """Test that get_usb_ports returns single 'n/a' port when it's the only one available.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single "n/a" port is returned + assert descriptions == [ "n/a - /dev/ttyUSB0, s/n: n/a", - "N/A - /dev/ttyUSB2, s/n: n/a", + ] + + +async def test_get_usb_ports_single_valid_port() -> None: + """Test that get_usb_ports returns single valid port.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "Device A" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single valid port is returned + assert descriptions == [ + "Device A - /dev/ttyUSB0, s/n: n/a", ] From 4244d2f66fa3908f5623d1bf9b39c88e2fbba80c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 12:49:33 +0200 Subject: [PATCH 0699/1664] Set right model in OpenAI conversation (#147575) --- .../openai_conversation/conversation.py | 2 +- .../openai_conversation/conftest.py | 24 +++++--- .../snapshots/test_init.ambr | 55 +++++++++++++++++++ .../openai_conversation/test_init.py | 23 +++++++- 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e63bbf32c35..e590a72cadb 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -247,7 +247,7 @@ class OpenAIConversationEntity( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="OpenAI", - model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), entry_type=dr.DeviceEntryType.SERVICE, ) if self.subentry.data.get(CONF_LLM_HASS_API): diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index aa17c333a79..b8944d837be 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,10 +1,12 @@ """Tests helpers.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components.openai_conversation.const import DEFAULT_CONVERSATION_NAME +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -14,7 +16,15 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_subentry_data() -> dict[str, Any]: + """Mock subentry data.""" + return {} + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, mock_subentry_data: dict[str, Any] +) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( title="OpenAI", @@ -24,12 +34,12 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, version=2, subentries_data=[ - { - "data": {}, - "subentry_type": "conversation", - "title": DEFAULT_CONVERSATION_NAME, - "unique_id": None, - } + ConfigSubentryData( + data=mock_subentry_data, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ) ], ) entry.add_to_hass(hass) diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 00000000000..8648e47474e --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_devices[mock_subentry_data0] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-4o-mini', + 'model_id': None, + 'name': 'OpenAI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[mock_subentry_data1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-1o', + 'model_id': None, + 'name': 'OpenAI Conversation', + '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/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d209554e8d3..b7f2a5434eb 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -13,8 +13,10 @@ from openai.types.image import Image from openai.types.images_response import ImagesResponse from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.openai_conversation import CONF_FILENAMES +from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES from homeassistant.components.openai_conversation.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -806,3 +808,22 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + + +@pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert exception when invalid config entry is provided.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 1 + device = devices[0] + assert device == snapshot(exclude=props("identifiers")) + subentry = next(iter(mock_config_entry.subentries.values())) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} From 6f4615f012c3339542a9cc1e4dc24b4a0a99a744 Mon Sep 17 00:00:00 2001 From: Anders Peter Fugmann Date: Thu, 26 Jun 2025 12:56:46 +0200 Subject: [PATCH 0700/1664] Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) --- homeassistant/components/dlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json index 8afc44a082e..00867e98511 100644 --- a/homeassistant/components/dlink/manifest.json +++ b/homeassistant/components/dlink/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyW215"], - "requirements": ["pyW215==0.7.0"] + "requirements": ["pyW215==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index eb8de18f20c..abb3b15be3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1817,7 +1817,7 @@ pySDCP==1 pyTibber==0.31.2 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.w800rf32 pyW800rf32==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f059b073f0f..d6f5cc7ee06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1525,7 +1525,7 @@ pyRFXtrx==0.31.1 pyTibber==0.31.2 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 From bc46894b743a85fc246b868cb71863ab94752426 Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Thu, 26 Jun 2025 15:30:03 +0200 Subject: [PATCH 0701/1664] Fixed issue when tests (should) fail in Smarla (#146102) * Fixed issue when tests (should) fail * Use usefixture decorator * Throw ConfigEntryError instead of AuthFailed --- homeassistant/components/smarla/__init__.py | 4 ++-- tests/components/smarla/test_config_flow.py | 20 ++++++++++---------- tests/components/smarla/test_init.py | 3 +++ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index 2de3fcfa242..533acb3375b 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryError from .const import HOST, PLATFORMS @@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - # Check if token still has access if not await connection.refresh_token(): - raise ConfigEntryAuthFailed("Invalid authentication") + raise ConfigEntryError("Invalid authentication") federwiege = Federwiege(hass.loop, connection) federwiege.register() diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py index a2bd5b36fc0..beccf6e4b95 100644 --- a/tests/components/smarla/test_config_flow.py +++ b/tests/components/smarla/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant.components.smarla.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -12,9 +14,8 @@ from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_config_flow( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_config_flow(hass: HomeAssistant) -> None: """Test creating a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -35,9 +36,8 @@ async def test_config_flow( assert result["result"].unique_id == MOCK_SERIAL_NUMBER -async def test_malformed_token( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_malformed_token(hass: HomeAssistant) -> None: """Test we show user form on malformed token input.""" with patch( "homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError @@ -60,9 +60,8 @@ async def test_malformed_token( assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_invalid_auth( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_invalid_auth(hass: HomeAssistant, mock_connection: MagicMock) -> None: """Test we show user form on invalid auth.""" with patch.object( mock_connection, "refresh_token", new=AsyncMock(return_value=False) @@ -85,8 +84,9 @@ async def test_invalid_auth( assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") async def test_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test we abort config flow if Smarla device already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/smarla/test_init.py b/tests/components/smarla/test_init.py index b9d291f582d..9523772d914 100644 --- a/tests/components/smarla/test_init.py +++ b/tests/components/smarla/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,6 +12,7 @@ from . import setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_federwiege") async def test_init_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock ) -> None: From 40f553a0070d0ad1af405e0caa889f0a0eab11ba Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 26 Jun 2025 15:33:34 +0200 Subject: [PATCH 0702/1664] Migrate device connections to a normalized form (#140383) * Normalize device connections migration * Update version * Slightly improve tests * Update homeassistant/helpers/device_registry.py * Add validators * Fix validator * Move format mac function too * Add validator test --------- Co-authored-by: Erik Montnemery --- homeassistant/helpers/device_registry.py | 93 +++++++++------ tests/helpers/test_device_registry.py | 141 +++++++++++++++++++++++ 2 files changed, 201 insertions(+), 33 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index a6313381492..bad772abaff 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event ) STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 10 +STORAGE_VERSION_MINOR = 11 CLEANUP_DELAY = 10 @@ -266,6 +266,48 @@ def _validate_configuration_url(value: Any) -> str | None: return url_as_str +@lru_cache(maxsize=512) +def format_mac(mac: str) -> str: + """Format the mac address string for entry into dev reg.""" + to_test = mac + + if len(to_test) == 17 and to_test.count(":") == 5: + return to_test.lower() + + if len(to_test) == 17 and to_test.count("-") == 5: + to_test = to_test.replace("-", "") + elif len(to_test) == 14 and to_test.count(".") == 2: + to_test = to_test.replace(".", "") + + if len(to_test) == 12: + # no : included + return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2)) + + # Not sure how formatted, return original + return mac + + +def _normalize_connections( + connections: Iterable[tuple[str, str]], +) -> set[tuple[str, str]]: + """Normalize connections to ensure we can match mac addresses.""" + return { + (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) + for key, value in connections + } + + +def _normalize_connections_validator( + instance: Any, + attribute: Any, + connections: Iterable[tuple[str, str]], +) -> None: + """Check connections normalization used as attrs validator.""" + for key, value in connections: + if key == CONNECTION_NETWORK_MAC and format_mac(value) != value: + raise ValueError(f"Invalid mac address format: {value}") + + @attr.s(frozen=True, slots=True) class DeviceEntry: """Device Registry Entry.""" @@ -274,7 +316,9 @@ class DeviceEntry: config_entries: set[str] = attr.ib(converter=set, factory=set) config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict) configuration_url: str | None = attr.ib(default=None) - connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set) + connections: set[tuple[str, str]] = attr.ib( + converter=set, factory=set, validator=_normalize_connections_validator + ) created_at: datetime = attr.ib(factory=utcnow) disabled_by: DeviceEntryDisabler | None = attr.ib(default=None) entry_type: DeviceEntryType | None = attr.ib(default=None) @@ -397,7 +441,9 @@ class DeletedDeviceEntry: area_id: str | None = attr.ib() config_entries: set[str] = attr.ib() config_entries_subentries: dict[str, set[str | None]] = attr.ib() - connections: set[tuple[str, str]] = attr.ib() + connections: set[tuple[str, str]] = attr.ib( + validator=_normalize_connections_validator + ) created_at: datetime = attr.ib() disabled_by: DeviceEntryDisabler | None = attr.ib() id: str = attr.ib() @@ -459,31 +505,10 @@ class DeletedDeviceEntry: ) -@lru_cache(maxsize=512) -def format_mac(mac: str) -> str: - """Format the mac address string for entry into dev reg.""" - to_test = mac - - if len(to_test) == 17 and to_test.count(":") == 5: - return to_test.lower() - - if len(to_test) == 17 and to_test.count("-") == 5: - to_test = to_test.replace("-", "") - elif len(to_test) == 14 and to_test.count(".") == 2: - to_test = to_test.replace(".", "") - - if len(to_test) == 12: - # no : included - return ":".join(to_test.lower()[i : i + 2] for i in range(0, 12, 2)) - - # Not sure how formatted, return original - return mac - - class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): """Store entity registry data.""" - async def _async_migrate_func( + async def _async_migrate_func( # noqa: C901 self, old_major_version: int, old_minor_version: int, @@ -559,6 +584,16 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): device["disabled_by"] = None device["labels"] = [] device["name_by_user"] = None + if old_minor_version < 11: + # Normalization of stored CONNECTION_NETWORK_MAC, introduced in 2025.8 + for device in old_data["devices"]: + device["connections"] = _normalize_connections( + device["connections"] + ) + for device in old_data["deleted_devices"]: + device["connections"] = _normalize_connections( + device["connections"] + ) if old_major_version > 2: raise NotImplementedError @@ -1696,11 +1731,3 @@ def async_setup_cleanup(hass: HomeAssistant, dev_reg: DeviceRegistry) -> None: debounced_cleanup.async_cancel() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _on_homeassistant_stop) - - -def _normalize_connections(connections: set[tuple[str, str]]) -> set[tuple[str, str]]: - """Normalize connections to ensure we can match mac addresses.""" - return { - (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC else (key, value) - for key, value in connections - } diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index c8ec83934ac..58933ca4314 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1432,6 +1432,141 @@ async def test_migration_from_1_7( } +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_10( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.10.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "123456ABCDEF"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "123456ABCDAB"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.id == "abcdefghijklm" + deleted_entry = registry.deleted_devices.get_entry( + connections=set(), + identifiers={("serial", "123456ABCDAB")}, + ) + assert deleted_entry.id == "abcdefghijklm2" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[dr.STORAGE_KEY] == { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "12:34:56:ab:cd:ab"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + async def test_removing_config_entries( hass: HomeAssistant, device_registry: dr.DeviceRegistry ) -> None: @@ -4753,3 +4888,9 @@ async def test_update_device_no_connections_or_identifiers( device_registry.async_update_device( device.id, new_connections=set(), new_identifiers=set() ) + + +async def test_connections_validator() -> None: + """Test checking connections validator.""" + with pytest.raises(ValueError, match="Invalid mac address format"): + dr.DeviceEntry(connections={(dr.CONNECTION_NETWORK_MAC, "123456ABCDEF")}) From 68924d23ab640623bcb627157956155e86a719f1 Mon Sep 17 00:00:00 2001 From: hanwg Date: Thu, 26 Jun 2025 22:43:09 +0800 Subject: [PATCH 0703/1664] Fix Telegram bot default target when sending messages (#147470) * handle targets * updated error message * validate chat id for single target * add validation for chat id * handle empty target * handle empty target --- .../components/telegram_bot/__init__.py | 24 +++++-- homeassistant/components/telegram_bot/bot.py | 62 ++++++++++--------- .../components/telegram_bot/strings.json | 6 ++ .../telegram_bot/test_telegram_bot.py | 47 ++++++++++++-- 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 5bdc670d69c..cab147162aa 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -29,6 +29,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + HomeAssistantError, ServiceValidationError, ) from homeassistant.helpers import config_validation as cv @@ -390,9 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif msgtype == SERVICE_DELETE_MESSAGE: await notify_service.delete_message(context=service.context, **kwargs) elif msgtype == SERVICE_LEAVE_CHAT: - messages = await notify_service.leave_chat( - context=service.context, **kwargs - ) + await notify_service.leave_chat(context=service.context, **kwargs) elif msgtype == SERVICE_SET_MESSAGE_REACTION: await notify_service.set_message_reaction(context=service.context, **kwargs) else: @@ -400,12 +399,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) - if service.return_response and messages: + if service.return_response and messages is not None: + target: list[int] | None = service.data.get(ATTR_TARGET) + if not target: + target = notify_service.get_target_chat_ids(None) + + failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages] + if failed_chat_ids: + raise HomeAssistantError( + f"Failed targets: {failed_chat_ids}", + translation_domain=DOMAIN, + translation_key="failed_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join([str(i) for i in failed_chat_ids]), + "bot_name": config_entry.title, + }, + ) + return { "chats": [ {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() ] } + return None # Register notification services diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 4a00aff8d3f..a3feb120460 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -287,24 +287,32 @@ class TelegramNotificationService: inline_message_id = msg_data["inline_message_id"] return message_id, inline_message_id - def _get_target_chat_ids(self, target: Any) -> list[int]: + def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]: """Validate chat_id targets or return default target (first). :param target: optional list of integers ([12234, -12345]) :return list of chat_id targets (integers) """ allowed_chat_ids: list[int] = self._get_allowed_chat_ids() - default_user: int = allowed_chat_ids[0] - if target is not None: - if isinstance(target, int): - target = [target] - chat_ids = [t for t in target if t in allowed_chat_ids] - if chat_ids: - return chat_ids - _LOGGER.warning( - "Disallowed targets: %s, using default: %s", target, default_user + + if target is None: + return [allowed_chat_ids[0]] + + chat_ids = [target] if isinstance(target, int) else target + valid_chat_ids = [ + chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids + ] + if not valid_chat_ids: + raise ServiceValidationError( + "Invalid chat IDs", + translation_domain=DOMAIN, + translation_key="invalid_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), + "bot_name": self.config.title, + }, ) - return [default_user] + return valid_chat_ids def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" @@ -414,9 +422,9 @@ class TelegramNotificationService: """Send one message.""" try: out = await func_send(*args_msg, **kwargs_msg) - if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): + if isinstance(out, Message): chat_id = out.chat_id - message_id = out[ATTR_MESSAGEID] + message_id = out.message_id self._last_message_id[chat_id] = message_id _LOGGER.debug( "Last message ID: %s (from chat_id %s)", @@ -424,7 +432,7 @@ class TelegramNotificationService: chat_id, ) - event_data = { + event_data: dict[str, Any] = { ATTR_CHAT_ID: chat_id, ATTR_MESSAGEID: message_id, } @@ -437,10 +445,6 @@ class TelegramNotificationService: self.hass.bus.async_fire( EVENT_TELEGRAM_SENT, event_data, context=context ) - elif not isinstance(out, bool): - _LOGGER.warning( - "Update last message: out_type:%s, out=%s", type(out), out - ) except TelegramError as exc: _LOGGER.error( "%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg @@ -460,7 +464,7 @@ class TelegramNotificationService: text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) msg = await self._send_msg( self.bot.send_message, @@ -488,7 +492,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> bool: """Delete a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) deleted: bool = await self._send_msg( @@ -513,7 +517,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> Any: """Edit a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -620,7 +624,7 @@ class TelegramNotificationService: msg_ids = {} if file_content: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: @@ -738,7 +742,7 @@ class TelegramNotificationService: msg_ids = {} if stickerid: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", @@ -769,7 +773,7 @@ class TelegramNotificationService: longitude = float(longitude) params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) @@ -803,7 +807,7 @@ class TelegramNotificationService: params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) msg = await self._send_msg( self.bot.send_poll, @@ -826,12 +830,12 @@ class TelegramNotificationService: async def leave_chat( self, - chat_id: Any = None, + chat_id: int | None = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Remove bot from chat.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) return await self._send_msg( self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context @@ -839,14 +843,14 @@ class TelegramNotificationService: async def set_message_reaction( self, - chat_id: int, reaction: str, + chat_id: int | None = None, is_big: bool = False, context: Context | None = None, **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index e932d010894..a51d4a371f1 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -895,6 +895,12 @@ "missing_allowed_chat_ids": { "message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}." }, + "invalid_chat_ids": { + "message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}." + }, + "failed_chat_ids": { + "message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured." + }, "missing_input": { "message": "{field} is required." }, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index fd313867561..190fed07ae3 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -677,13 +677,35 @@ async def test_send_message_with_config_entry( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: send message to invalid chat id + + with pytest.raises(HomeAssistantError) as err: + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + { + CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, + ATTR_MESSAGE: "mock message", + ATTR_TARGET: [123456, 1], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "failed_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: send message to valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, { CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, ATTR_MESSAGE: "mock message", - ATTR_TARGET: 1, + ATTR_TARGET: 123456, }, blocking=True, return_response=True, @@ -767,6 +789,23 @@ async def test_delete_message( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: delete message with invalid chat id + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MESSAGE, + {ATTR_CHAT_ID: 1, ATTR_MESSAGEID: "last"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "invalid_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: delete message with valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, @@ -808,7 +847,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_MESSAGE, - {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -822,7 +861,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_CAPTION, - {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -836,7 +875,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_REPLYMARKUP, - {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) From 01205f8a14211a9459845cfd1c38754b14de30fd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:05:26 +0200 Subject: [PATCH 0704/1664] Add default title to migrated Ollama entry (#147599) --- homeassistant/components/ollama/__init__.py | 2 ++ homeassistant/components/ollama/const.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 90d2012766d..f174c709b65 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN, ) @@ -138,6 +139,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_NAME, options={}, version=2, ) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index ebace6404b2..3175525c70d 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -2,6 +2,8 @@ DOMAIN = "ollama" +DEFAULT_NAME = "Ollama" + CONF_MODEL = "model" CONF_PROMPT = "prompt" CONF_THINK = "think" From 69f0b6244a12fdb346d491ee3e8d73ab9f5fe8e9 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 26 Jun 2025 17:05:59 +0200 Subject: [PATCH 0705/1664] Remove default icon for wind direction sensor for Buienradar (#147603) * Fix wind direction state class sensor * Remove default icon for wind direction sensor --- homeassistant/components/buienradar/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 586543de129..b32e630ef5c 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="windazimuth", translation_key="windazimuth", native_unit_of_measurement=DEGREE, - icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, state_class=SensorStateClass.MEASUREMENT_ANGLE, ), From e7cc03c1d92e0d2b700478d450464e318f3a64e7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:11:13 +0200 Subject: [PATCH 0706/1664] Add default title to migrated Claude entry (#147598) --- homeassistant/components/anthropic/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c13c82f0020..c537a000c14 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -17,7 +17,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL +from .const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -123,6 +129,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_CONVERSATION_NAME, options={}, version=2, ) From 7b80c1c6931ab77df4806ad2a4595c0a303d9662 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:11:48 +0200 Subject: [PATCH 0707/1664] Add default conversation name for OpenAI integration (#147597) --- homeassistant/components/openai_conversation/__init__.py | 2 ++ homeassistant/components/openai_conversation/const.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index a5b13ded375..e14a8aabc1b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -49,6 +49,7 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_NAME, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -351,6 +352,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_NAME, options={}, version=2, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f90c05eed79..3f1c0dc7429 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -6,12 +6,12 @@ DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" -CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" From 1a92d4530ea90f98bc3c8b7fd102f7d5ecd71818 Mon Sep 17 00:00:00 2001 From: Fabio Natanael Kepler Date: Thu, 26 Jun 2025 16:12:15 +0100 Subject: [PATCH 0708/1664] Fix playing TTS and local media source over DLNA (#134903) Co-authored-by: Erik Montnemery --- homeassistant/components/http/auth.py | 2 +- homeassistant/components/image/__init__.py | 37 +++++++++++++++++-- .../components/media_source/local_source.py | 25 +++++++++++-- homeassistant/components/tts/__init__.py | 15 ++++++++ tests/components/http/test_auth.py | 8 +++- tests/components/image/test_init.py | 21 +++++++++++ .../media_source/test_local_source.py | 12 ++++++ tests/components/tts/test_init.py | 23 ++++++++++++ 8 files changed, 134 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7e00cc70eaa..227ee074439 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -223,7 +223,7 @@ async def async_setup_auth( # We first start with a string check to avoid parsing query params # for every request. elif ( - request.method == "GET" + request.method in ["GET", "HEAD"] and SIGN_QUERY_PARAM in request.query_string and async_validate_signed_request(request) ): diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 644d335bbca..0a3b9bf9af7 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -288,8 +288,10 @@ class ImageView(HomeAssistantView): """Initialize an image view.""" self.component = component - async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: - """Start a GET request.""" + async def _authenticate_request( + self, request: web.Request, entity_id: str + ) -> ImageEntity: + """Authenticate request and return image entity.""" if (image_entity := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound @@ -306,6 +308,31 @@ class ImageView(HomeAssistantView): # Invalid sigAuth or image entity access token raise web.HTTPForbidden + return image_entity + + async def head(self, request: web.Request, entity_id: str) -> web.Response: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + """ + image_entity = await self._authenticate_request(request, entity_id) + + # Don't use `handle` as we don't care about the stream case, we only want + # to verify that the image exists. + try: + image = await _async_get_image(image_entity, IMAGE_TIMEOUT) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError from ex + + return web.Response( + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) + + async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: + """Start a GET request.""" + image_entity = await self._authenticate_request(request, entity_id) return await self.handle(request, image_entity) async def handle( @@ -317,7 +344,11 @@ class ImageView(HomeAssistantView): except (HomeAssistantError, ValueError) as ex: raise web.HTTPInternalServerError from ex - return web.Response(body=image.content, content_type=image.content_type) + return web.Response( + body=image.content, + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) async def async_get_still_stream( diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 7916f72c6b9..4e3d6ff59db 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView): self.hass = hass self.source = source - async def get( - self, request: web.Request, source_dir_id: str, location: str - ) -> web.FileResponse: - """Start a GET request.""" + async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: + """Validate media path and return it if valid.""" try: raise_if_invalid_path(location) except ValueError as err: @@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView): if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: raise web.HTTPNotFound + return media_path + + async def head( + self, request: web.Request, source_dir_id: str, location: str + ) -> None: + """Handle a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the location exists or not. + """ + await self._validate_media_path(source_dir_id, location) + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.FileResponse: + """Handle a GET request.""" + media_path = await self._validate_media_path(source_dir_id, location) return web.FileResponse(media_path) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8292df07ef8..c8e6e0f67fb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1185,6 +1185,21 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager + async def head(self, request: web.Request, token: str) -> web.StreamResponse: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the token (file) exists and return its content type. + """ + stream = self.manager.token_to_stream.get(token) + + if stream is None: + return web.Response(status=HTTPStatus.NOT_FOUND) + + return web.Response(content_type=stream.content_type) + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 8bf2e66a286..ca66b8fef4b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -305,16 +305,22 @@ async def test_auth_access_signed_path_with_refresh_token( hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id ) + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id # Use signature on other path + req = await client.head(f"/another_path?{signed_path.split('?')[1]}") + assert req.status == HTTPStatus.UNAUTHORIZED + req = await client.get(f"/another_path?{signed_path.split('?')[1]}") assert req.status == HTTPStatus.UNAUTHORIZED - # We only allow GET + # We only allow GET and HEAD req = await client.post(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 3bcf0df52e3..bb8762f17e2 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -174,10 +174,22 @@ async def test_fetch_image_authenticated( """Test fetching an image with an authenticated client.""" client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 + + resp = await client.head("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 resp = await client.get("/api/image_proxy/image.unknown") assert resp.status == HTTPStatus.NOT_FOUND @@ -260,10 +272,19 @@ async def test_fetch_image_url_success( client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 @respx.mock diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index d3ae95736a5..1823165d906 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -105,6 +105,9 @@ async def test_media_view( client = await hass_client() # Protects against non-existent files + resp = await client.head("/media/local/invalid.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/invalid.txt") assert resp.status == HTTPStatus.NOT_FOUND @@ -112,14 +115,23 @@ async def test_media_view( assert resp.status == HTTPStatus.NOT_FOUND # Protects against non-media files + resp = await client.head("/media/local/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Protects against unknown local media sources + resp = await client.head("/media/unknown_source/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/unknown_source/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Fetch available media + resp = await client.head("/media/local/test.mp3") + assert resp.status == HTTPStatus.OK + resp = await client.get("/media/local/test.mp3") assert resp.status == HTTPStatus.OK diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index ccb62959eba..22fb10209b0 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -916,6 +916,29 @@ async def test_web_view_wrong_file( assert req.status == HTTPStatus.NOT_FOUND +@pytest.mark.parametrize( + ("setup", "expected_url_suffix"), + [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], + indirect=["setup"], +) +async def test_web_view_wrong_file_with_head_request( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + expected_url_suffix: str, +) -> None: + """Set up a TTS platform and receive wrong file from web.""" + client = await hass_client() + + url = ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ) + + req = await client.head(url) + assert req.status == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize( ("setup", "expected_url_suffix"), [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], From b5821ef499212772588d75c05a988ba460d6579f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 26 Jun 2025 17:46:45 +0200 Subject: [PATCH 0709/1664] Update frontend to 20250626.0 (#147601) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0028bda57be..8e4ea47da5b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250625.0"] + "requirements": ["home-assistant-frontend==20250626.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 725033f814e..5839a3ae014 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index abb3b15be3d..9bc728320a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6f5cc7ee06..8a5f97014e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From b4dd912bee6f77d084a12b800b2bcb70916d6547 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Jun 2025 08:53:16 -0700 Subject: [PATCH 0710/1664] Refactor in Google AI TTS in preparation for STT (#147562) --- .../helpers.py | 73 +++++++++++++++++++ .../google_generative_ai_conversation/tts.py | 64 +--------------- 2 files changed, 75 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/helpers.py diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py new file mode 100644 index 00000000000..3d053aa9f1a --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/helpers.py @@ -0,0 +1,73 @@ +"""Helper classes for Google Generative AI integration.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import wave + +from homeassistant.exceptions import HomeAssistantError + +from .const import LOGGER + + +def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = _parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + +# Below code is from https://aistudio.google.com/app/generate-speech +# when you select "Get SDK code to generate speech". +def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 174f0a50dc3..9bd7d547100 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -3,10 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from contextlib import suppress -import io from typing import Any -import wave from google.genai import types from google.genai.errors import APIError, ClientError @@ -25,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav async def async_setup_entry( @@ -152,62 +150,4 @@ class GoogleGenerativeAITextToSpeechEntity( except (APIError, ClientError, ValueError) as exc: LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc - return "wav", self._convert_to_wav(data, mime_type) - - def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: - """Generate a WAV file header for the given audio data and parameters. - - Args: - audio_data: The raw audio data as a bytes object. - mime_type: Mime type of the audio data. - - Returns: - A bytes object representing the WAV file header. - - """ - parameters = self._parse_audio_mime_type(mime_type) - - wav_buffer = io.BytesIO() - with wave.open(wav_buffer, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(parameters["bits_per_sample"] // 8) - wf.setframerate(parameters["rate"]) - wf.writeframes(audio_data) - - return wav_buffer.getvalue() - - def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: - """Parse bits per sample and rate from an audio MIME type string. - - Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". - - Args: - mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). - - Returns: - A dictionary with "bits_per_sample" and "rate" keys. Values will be - integers if found, otherwise None. - - """ - if not mime_type.startswith("audio/L"): - LOGGER.warning("Received unexpected MIME type %s", mime_type) - raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") - - bits_per_sample = 16 - rate = 24000 - - # Extract rate from parameters - parts = mime_type.split(";") - for param in parts: # Skip the main type part - param = param.strip() - if param.lower().startswith("rate="): - # Handle cases like "rate=" with no value or non-integer value and keep rate as default - with suppress(ValueError, IndexError): - rate_str = param.split("=", 1)[1] - rate = int(rate_str) - elif param.startswith("audio/L"): - # Keep bits_per_sample as default if conversion fails - with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) - - return {"bits_per_sample": bits_per_sample, "rate": rate} + return "wav", convert_to_wav(data, mime_type) From 69af74a593050b0d3df69d1584de2a896fab4f0c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 18:21:56 +0200 Subject: [PATCH 0711/1664] Improve explanation on how to get API token in Telegram (#147605) --- homeassistant/components/telegram_bot/config_flow.py | 6 +++++- homeassistant/components/telegram_bot/strings.json | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index d9b334a4ac1..67981cbd704 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -293,10 +293,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow to create a new config entry for a Telegram bot.""" + description_placeholders: dict[str, str] = { + "botfather_username": "@BotFather", + "botfather_url": "https://t.me/botfather", + } if not user_input: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders=description_placeholders, ) # prevent duplicates @@ -305,7 +310,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} bot_name = await self._validate_bot( user_input, errors, description_placeholders ) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index a51d4a371f1..17b2e6f24d6 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -2,11 +2,10 @@ "config": { "step": { "user": { - "title": "Telegram bot setup", - "description": "Create a new Telegram bot", + "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_key%]", + "api_key": "[%key:common::config_flow::data::api_token%]", "proxy_url": "Proxy URL" }, "data_description": { From 35478e316296f6efd640a42ff5abfcb85edc6342 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:44:15 +0200 Subject: [PATCH 0712/1664] Set Google AI model as device model (#147582) * Set Google AI model as device model * fix --- .../entity.py | 9 ++- .../google_generative_ai_conversation/tts.py | 6 +- .../snapshots/test_init.ambr | 66 +++++++++++++++++++ .../test_init.py | 14 ++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 66acb6b158a..dea875212ef 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -301,7 +301,12 @@ async def _transform_stream( class GoogleGenerativeAILLMBaseEntity(Entity): """Google Generative AI base entity.""" - def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + def __init__( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + default_model: str = RECOMMENDED_CHAT_MODEL, + ) -> None: """Initialize the agent.""" self.entry = entry self.subentry = subentry @@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Google", - model="Generative AI", + model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1], entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 9bd7d547100..9bc5b0c6cb6 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -15,7 +15,7 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -114,6 +114,10 @@ class GoogleGenerativeAITextToSpeechEntity( ) ] + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the TTS entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL) + @callback def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f89871ff131..5722713bc56 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,4 +1,70 @@ # serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-conversation', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-tts', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash-preview-tts', + 'model_id': None, + 'name': 'Google AI TTS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- # name: test_generate_content_file_processing_succeeds list([ tuple( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 46a2d634b81..85d6c70b658 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -762,3 +762,17 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + + +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert that devices are created correctly.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot From bf88fcd5bfee1589ddd2ebecd6d120eb8d50e09e Mon Sep 17 00:00:00 2001 From: Maximilian Arzberger Date: Thu, 26 Jun 2025 19:50:27 +0200 Subject: [PATCH 0713/1664] Add Manual Charge Switch for Installers for Kostal Plenticore (#146932) * Add Manual Charge Switch for Installers * Update stale docstring * Installer config fixture * fix ruff --- .../components/kostal_plenticore/switch.py | 21 +++++- .../components/kostal_plenticore/conftest.py | 15 ++++ .../kostal_plenticore/test_switch.py | 69 +++++++++++++++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 tests/components/kostal_plenticore/test_switch.py diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 44eced7ca4a..feeb4bc5bb5 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .const import CONF_SERVICE_CODE from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -29,6 +30,7 @@ class PlenticoreSwitchEntityDescription(SwitchEntityDescription): on_label: str off_value: str off_label: str + installer_required: bool = False SWITCH_SETTINGS_DATA = [ @@ -42,6 +44,17 @@ SWITCH_SETTINGS_DATA = [ off_value="2", off_label="Automatic economical", ), + PlenticoreSwitchEntityDescription( + module_id="devices:local", + key="Battery:ManualCharge", + name="Battery Manual Charge", + is_on="1", + on_value="1", + on_label="On", + off_value="0", + off_label="Off", + installer_required=True, + ), ] @@ -73,7 +86,13 @@ async def async_setup_entry( description.key, ) continue - + if entry.data.get(CONF_SERVICE_CODE) is None and description.installer_required: + _LOGGER.debug( + "Skipping installer required setting data %s/%s", + description.module_id, + description.key, + ) + continue entities.append( PlenticoreDataSwitch( settings_data_update_coordinator, diff --git a/tests/components/kostal_plenticore/conftest.py b/tests/components/kostal_plenticore/conftest.py index acce8ebed7a..bedcea4ddc2 100644 --- a/tests/components/kostal_plenticore/conftest.py +++ b/tests/components/kostal_plenticore/conftest.py @@ -26,6 +26,21 @@ def mock_config_entry() -> MockConfigEntry: ) +@pytest.fixture +def mock_installer_config_entry() -> MockConfigEntry: + """Return a mocked ConfigEntry for testing with installer login.""" + return MockConfigEntry( + entry_id="2ab8dd92a62787ddfe213a67e09406bd", + title="scb", + domain="kostal_plenticore", + data={ + "host": "192.168.1.2", + "password": "secret_password", + "service_code": "12345", + }, + ) + + @pytest.fixture def mock_plenticore() -> Generator[Plenticore]: """Set up a Plenticore mock with some default values.""" diff --git a/tests/components/kostal_plenticore/test_switch.py b/tests/components/kostal_plenticore/test_switch.py new file mode 100644 index 00000000000..0dd4c958fd5 --- /dev/null +++ b/tests/components/kostal_plenticore/test_switch.py @@ -0,0 +1,69 @@ +"""Test the Kostal Plenticore Solar Inverter switch platform.""" + +from pykoplenti import SettingsData + +from homeassistant.components.kostal_plenticore.coordinator import Plenticore +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_installer_setting_not_available( + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the manual charge setting is not available when not using the installer login.""" + + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not entity_registry.async_is_registered("switch.scb_battery_manual_charge") + + +async def test_installer_setting_available( + hass: HomeAssistant, + mock_plenticore: Plenticore, + mock_installer_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that the manual charge setting is available when using the installer login.""" + + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData( + min=None, + max=None, + default=None, + access="readwrite", + unit=None, + id="Battery:ManualCharge", + type="bool", + ) + ] + } + + mock_installer_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_installer_config_entry.entry_id) + await hass.async_block_till_done() + + assert entity_registry.async_is_registered("switch.scb_battery_manual_charge") From af7b1a76bcf3220252bf6e36e8ebadc937dacc3b Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Thu, 26 Jun 2025 18:51:31 +0100 Subject: [PATCH 0714/1664] Add description placeholders to `SchemaFlowFormStep` (#147544) * test description placeholders * Update test_schema_config_entry_flow.py * fix copy and paste indentation * Apply suggestions from code review --------- Co-authored-by: Erik Montnemery --- .../helpers/schema_config_entry_flow.py | 11 ++++++ .../helpers/test_schema_config_entry_flow.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 93d9a3d06f1..8bc773d85f7 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -95,6 +95,12 @@ class SchemaFlowFormStep(SchemaFlowStep): preview: str | None = None """Optional preview component.""" + description_placeholders: ( + Callable[[SchemaCommonFlowHandler], Coroutine[Any, Any, dict[str, str]]] + | UndefinedType + ) = UNDEFINED + """Optional property to populate description placeholders.""" + @dataclass(slots=True) class SchemaFlowMenuStep(SchemaFlowStep): @@ -257,6 +263,10 @@ class SchemaCommonFlowHandler: if (data_schema := await self._get_schema(form_step)) is None: return await self._show_next_step_or_create_entry(form_step) + description_placeholders: dict[str, str] | None = None + if form_step.description_placeholders is not UNDEFINED: + description_placeholders = await form_step.description_placeholders(self) + suggested_values: dict[str, Any] = {} if form_step.suggested_values is UNDEFINED: suggested_values = self._options @@ -285,6 +295,7 @@ class SchemaCommonFlowHandler: return self._handler.async_show_form( step_id=next_step_id, data_schema=data_schema, + description_placeholders=description_placeholders, errors=errors, last_step=last_step, preview=form_step.preview, diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index e67525253bc..e76faf9ee52 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -591,6 +591,45 @@ async def test_suggested_values( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY +async def test_description_placeholders( + hass: HomeAssistant, manager: data_entry_flow.FlowManager +) -> None: + """Test description_placeholders handling in SchemaFlowFormStep.""" + manager.hass = hass + + OPTIONS_SCHEMA = vol.Schema( + {vol.Optional("option1", default="a very reasonable default"): str} + ) + + async def _get_description_placeholders( + _: SchemaCommonFlowHandler, + ) -> dict[str, Any]: + return {"option1": "a dynamic string"} + + OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep( + OPTIONS_SCHEMA, + next_step="step_1", + description_placeholders=_get_description_placeholders, + ), + } + + class TestFlow(MockSchemaConfigFlowHandler, domain="test"): + config_flow = {} + options_flow = OPTIONS_FLOW + + mock_integration(hass, MockModule("test")) + mock_platform(hass, "test.config_flow", None) + config_entry = MockConfigEntry(data={}, domain="test") + config_entry.add_to_hass(hass) + + # Start flow and check the description_placeholders is populated + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["description_placeholders"] == {"option1": "a dynamic string"} + + async def test_options_flow_state(hass: HomeAssistant) -> None: """Test flow_state handling in SchemaFlowFormStep.""" From 1416f0f1e02f58934314191f2d47c45cfca342c2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:52:29 +0200 Subject: [PATCH 0715/1664] Fix meaters not being added after a reload (#147614) --- homeassistant/components/meater/__init__.py | 6 ++- .../components/meater/coordinator.py | 6 ++- tests/components/meater/test_init.py | 44 ++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 212e8a2a33a..9f35d941b65 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[MEATER_DATA] = ( + hass.data[MEATER_DATA] - entry.runtime_data.found_probes + ) + return unload_ok diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py index 042a3c87b0c..9a9910f6e1a 100644 --- a/homeassistant/components/meater/coordinator.py +++ b/homeassistant/components/meater/coordinator.py @@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): ) session = async_get_clientsession(hass) self.client = MeaterApi(session) + self.found_probes: set[str] = set() async def _async_setup(self) -> None: """Set up the Meater Coordinator.""" @@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): raise UpdateFailed( "Too many requests have been made to the API, rate limiting is in place" ) from err - - return {device.id: device for device in devices} + res = {device.id: device for device in devices} + self.found_probes.update(set(res.keys())) + return res diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py index 52f6b29d488..8f4e4e75a86 100644 --- a/tests/components/meater/test_init.py +++ b/tests/components/meater/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion from homeassistant.components.meater.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .const import PROBE_ID @@ -26,3 +28,43 @@ async def test_device_info( device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) assert device_entry is not None assert device_entry == snapshot + + +async def test_load_unload( + hass: HomeAssistant, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unload of Meater integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) From aef08091f86519c1a2d21721f05acd62f07d5116 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:52:58 +0200 Subject: [PATCH 0716/1664] Fix asset url in Habitica integration (#147612) --- homeassistant/components/habitica/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index f9874c711f0..d7cede1db03 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" SITE_DATA_URL = "https://habitica.com/user/settings/siteData" FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password" SIGN_UP_URL = "https://habitica.com/register" -HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" +HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png" DOMAIN = "habitica" From 61a32466b67eeb348f2bde861f0ee6149d1e0b88 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:55:38 +0200 Subject: [PATCH 0717/1664] Hide Telegram bot proxy URL behind section (#147613) Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> --- .../components/telegram_bot/config_flow.py | 53 +++++++++++++++---- .../components/telegram_bot/const.py | 2 +- .../components/telegram_bot/strings.json | 34 +++++++++--- .../telegram_bot/test_config_flow.py | 26 ++++++--- .../telegram_bot/test_telegram_bot.py | 2 + 5 files changed, 91 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 67981cbd704..1a77a5b9a81 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, section from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.network import NoURLAvailableError, get_url @@ -58,6 +58,7 @@ from .const import ( PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) @@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema( autocomplete="current-password", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema( translation_key="platforms", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -197,6 +212,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): import_data[CONF_TRUSTED_NETWORKS] = ",".join( import_data[CONF_TRUSTED_NETWORKS] ) + import_data[SECTION_ADVANCED_SETTINGS] = { + CONF_PROXY_URL: import_data.get(CONF_PROXY_URL) + } try: config_flow_result: ConfigFlowResult = await self.async_step_user( import_data @@ -332,7 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), }, options={ # this value may come from yaml import @@ -444,7 +462,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: self._step_user_data[CONF_PLATFORM], CONF_API_KEY: self._step_user_data[CONF_API_KEY], - CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL), + CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), CONF_URL: user_input.get(CONF_URL), CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS], }, @@ -509,9 +529,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - self._get_reconfigure_entry().data, + { + **self._get_reconfigure_entry().data, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: self._get_reconfigure_entry().data.get( + CONF_PROXY_URL + ), + }, + }, ), ) + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -527,7 +557,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - user_input, + { + **user_input, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + }, + }, ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index d6da96d9a28..0f1d5193e2c 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -7,7 +7,7 @@ DOMAIN = "telegram_bot" PLATFORM_BROADCAST = "broadcast" PLATFORM_POLLING = "polling" PLATFORM_WEBHOOKS = "webhooks" - +SECTION_ADVANCED_SETTINGS = "advanced_settings" SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_BOT_COUNT = "bot_count" diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 17b2e6f24d6..4187b6311d9 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -5,13 +5,22 @@ "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_token%]", - "proxy_url": "Proxy URL" + "api_key": "[%key:common::config_flow::data::api_token%]" }, "data_description": { "platform": "Telegram bot implementation", - "api_key": "The API token of your bot.", - "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + "api_key": "The API token of your bot." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "proxy_url": "Proxy URL" + }, + "data_description": { + "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + } + } } }, "webhooks": { @@ -29,12 +38,21 @@ "title": "Telegram bot setup", "description": "Reconfigure Telegram bot", "data": { - "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]" }, "data_description": { - "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]" + }, + "sections": { + "advanced_settings": { + "name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]", + "data": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]" + }, + "data_description": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]" + } + } } }, "reauth_confirm": { diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 0287ccc5dfa..e13fab8f28b 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.telegram_bot.const import ( PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigSubentry @@ -89,7 +90,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -104,7 +107,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -131,7 +136,9 @@ async def test_reconfigure_flow_webhooks( result["flow_id"], { CONF_PLATFORM: PLATFORM_WEBHOOKS, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -197,9 +204,7 @@ async def test_reconfigure_flow_webhooks( ] -async def test_create_entry( - hass: HomeAssistant, -) -> None: +async def test_create_entry(hass: HomeAssistant) -> None: """Test user flow.""" # test: no input @@ -225,7 +230,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -245,7 +252,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "https://proxy", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://proxy", + }, }, ) await hass.async_block_till_done() @@ -535,6 +544,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch( diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 190fed07ae3..6590bbed1cf 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -51,6 +51,7 @@ from homeassistant.components.telegram_bot.const import ( CONF_CONFIG_ENTRY_ID, DOMAIN, PLATFORM_BROADCAST, + SECTION_ADVANCED_SETTINGS, SERVICE_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE, SERVICE_EDIT_CAPTION, @@ -722,6 +723,7 @@ async def test_send_message_no_chat_id_error( data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch("homeassistant.components.telegram_bot.config_flow.Bot.get_me"): From c2f1e86a4e38150ed40d808b8d0d8d42b86165b0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 20:59:02 +0300 Subject: [PATCH 0718/1664] Add action exceptions to Alexa Devices (#147546) --- .../components/alexa_devices/notify.py | 2 + .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/strings.json | 8 +++ .../components/alexa_devices/switch.py | 2 + .../components/alexa_devices/utils.py | 40 +++++++++++++ tests/components/alexa_devices/test_utils.py | 56 +++++++++++++++++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/alexa_devices/utils.py create mode 100644 tests/components/alexa_devices/test_utils.py diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 46db294377a..08f2e214f38 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity): entity_description: AmazonNotifyEntityDescription + @alexa_api_call async def async_send_message( self, message: str, title: str | None = None, **kwargs: Any ) -> None: diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 881a02bc6d3..afd12ca1df2 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -26,7 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index b3bb699d003..d092cfaa2ae 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -70,5 +70,13 @@ "name": "Do not disturb" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Error connecting: {error}" + }, + "cannot_retrieve_data": { + "message": "Error retrieving data: {error}" + } } } diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index b8f78134feb..e53ea40965a 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): entity_description: AmazonSwitchEntityDescription + @alexa_api_call async def _switch_set_state(self, state: bool) -> None: """Set desired switch state.""" method = getattr(self.coordinator.api, self.entity_description.method) diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py new file mode 100644 index 00000000000..4d1365d1d41 --- /dev/null +++ b/homeassistant/components/alexa_devices/utils.py @@ -0,0 +1,40 @@ +"""Utils for Alexa Devices.""" + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import AmazonEntity + + +def alexa_api_call[_T: AmazonEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch Alexa API call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except CannotConnect as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data", + translation_placeholders={"error": repr(err)}, + ) from err + + return cmd_wrapper diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py new file mode 100644 index 00000000000..12009719a2f --- /dev/null +++ b/tests/components/alexa_devices/test_utils.py @@ -0,0 +1,56 @@ +"""Tests for Alexa Devices utils.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +import pytest + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +ENTITY_ID = "switch.echo_test_do_not_disturb" + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_connect", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"), + ], +) +async def test_alexa_api_call_exceptions( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test alexa_api_call decorator for exceptions.""" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + mock_amazon_devices_client.set_do_not_disturb.side_effect = side_effect + + # Call API + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} From 17cd39748bd028a9cce0f524cb452d2d7e833918 Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Thu, 26 Jun 2025 19:59:49 +0200 Subject: [PATCH 0719/1664] Create a new client session for air-Q to fix cookie polution (#147027) --- homeassistant/components/airq/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 743d12d40e5..3ab41978b05 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator): name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - session = async_get_clientsession(hass) + session = async_create_clientsession(hass) self.airq = AirQ( entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session ) From a296324c30b151e873528a81ce0124da637dea21 Mon Sep 17 00:00:00 2001 From: Fabio Natanael Kepler Date: Thu, 26 Jun 2025 16:12:15 +0100 Subject: [PATCH 0720/1664] Fix playing TTS and local media source over DLNA (#134903) Co-authored-by: Erik Montnemery --- homeassistant/components/http/auth.py | 2 +- homeassistant/components/image/__init__.py | 37 +++++++++++++++++-- .../components/media_source/local_source.py | 25 +++++++++++-- homeassistant/components/tts/__init__.py | 15 ++++++++ tests/components/http/test_auth.py | 8 +++- tests/components/image/test_init.py | 21 +++++++++++ .../media_source/test_local_source.py | 12 ++++++ tests/components/tts/test_init.py | 23 ++++++++++++ 8 files changed, 134 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 7e00cc70eaa..227ee074439 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -223,7 +223,7 @@ async def async_setup_auth( # We first start with a string check to avoid parsing query params # for every request. elif ( - request.method == "GET" + request.method in ["GET", "HEAD"] and SIGN_QUERY_PARAM in request.query_string and async_validate_signed_request(request) ): diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index 644d335bbca..0a3b9bf9af7 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -288,8 +288,10 @@ class ImageView(HomeAssistantView): """Initialize an image view.""" self.component = component - async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: - """Start a GET request.""" + async def _authenticate_request( + self, request: web.Request, entity_id: str + ) -> ImageEntity: + """Authenticate request and return image entity.""" if (image_entity := self.component.get_entity(entity_id)) is None: raise web.HTTPNotFound @@ -306,6 +308,31 @@ class ImageView(HomeAssistantView): # Invalid sigAuth or image entity access token raise web.HTTPForbidden + return image_entity + + async def head(self, request: web.Request, entity_id: str) -> web.Response: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + """ + image_entity = await self._authenticate_request(request, entity_id) + + # Don't use `handle` as we don't care about the stream case, we only want + # to verify that the image exists. + try: + image = await _async_get_image(image_entity, IMAGE_TIMEOUT) + except (HomeAssistantError, ValueError) as ex: + raise web.HTTPInternalServerError from ex + + return web.Response( + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) + + async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse: + """Start a GET request.""" + image_entity = await self._authenticate_request(request, entity_id) return await self.handle(request, image_entity) async def handle( @@ -317,7 +344,11 @@ class ImageView(HomeAssistantView): except (HomeAssistantError, ValueError) as ex: raise web.HTTPInternalServerError from ex - return web.Response(body=image.content, content_type=image.content_type) + return web.Response( + body=image.content, + content_type=image.content_type, + headers={"Content-Length": str(len(image.content))}, + ) async def async_get_still_stream( diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 7916f72c6b9..4e3d6ff59db 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView): self.hass = hass self.source = source - async def get( - self, request: web.Request, source_dir_id: str, location: str - ) -> web.FileResponse: - """Start a GET request.""" + async def _validate_media_path(self, source_dir_id: str, location: str) -> Path: + """Validate media path and return it if valid.""" try: raise_if_invalid_path(location) except ValueError as err: @@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView): if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES: raise web.HTTPNotFound + return media_path + + async def head( + self, request: web.Request, source_dir_id: str, location: str + ) -> None: + """Handle a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the location exists or not. + """ + await self._validate_media_path(source_dir_id, location) + + async def get( + self, request: web.Request, source_dir_id: str, location: str + ) -> web.FileResponse: + """Handle a GET request.""" + media_path = await self._validate_media_path(source_dir_id, location) return web.FileResponse(media_path) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 8292df07ef8..c8e6e0f67fb 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -1185,6 +1185,21 @@ class TextToSpeechView(HomeAssistantView): """Initialize a tts view.""" self.manager = manager + async def head(self, request: web.Request, token: str) -> web.StreamResponse: + """Start a HEAD request. + + This is sent by some DLNA renderers, like Samsung ones, prior to sending + the GET request. + + Check whether the token (file) exists and return its content type. + """ + stream = self.manager.token_to_stream.get(token) + + if stream is None: + return web.Response(status=HTTPStatus.NOT_FOUND) + + return web.Response(content_type=stream.content_type) + async def get(self, request: web.Request, token: str) -> web.StreamResponse: """Start a get request.""" stream = self.manager.token_to_stream.get(token) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 8bf2e66a286..ca66b8fef4b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -305,16 +305,22 @@ async def test_auth_access_signed_path_with_refresh_token( hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id ) + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id # Use signature on other path + req = await client.head(f"/another_path?{signed_path.split('?')[1]}") + assert req.status == HTTPStatus.UNAUTHORIZED + req = await client.get(f"/another_path?{signed_path.split('?')[1]}") assert req.status == HTTPStatus.UNAUTHORIZED - # We only allow GET + # We only allow GET and HEAD req = await client.post(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py index 3bcf0df52e3..bb8762f17e2 100644 --- a/tests/components/image/test_init.py +++ b/tests/components/image/test_init.py @@ -174,10 +174,22 @@ async def test_fetch_image_authenticated( """Test fetching an image with an authenticated client.""" client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 + + resp = await client.head("/api/image_proxy/image.unknown") + assert resp.status == HTTPStatus.NOT_FOUND + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/jpeg" + assert resp.content_length == 4 resp = await client.get("/api/image_proxy/image.unknown") assert resp.status == HTTPStatus.NOT_FOUND @@ -260,10 +272,19 @@ async def test_fetch_image_url_success( client = await hass_client() + # Using HEAD + resp = await client.head("/api/image_proxy/image.test") + assert resp.status == HTTPStatus.OK + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + # Using GET resp = await client.get("/api/image_proxy/image.test") assert resp.status == HTTPStatus.OK body = await resp.read() assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 @respx.mock diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index d3ae95736a5..1823165d906 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -105,6 +105,9 @@ async def test_media_view( client = await hass_client() # Protects against non-existent files + resp = await client.head("/media/local/invalid.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/invalid.txt") assert resp.status == HTTPStatus.NOT_FOUND @@ -112,14 +115,23 @@ async def test_media_view( assert resp.status == HTTPStatus.NOT_FOUND # Protects against non-media files + resp = await client.head("/media/local/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/local/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Protects against unknown local media sources + resp = await client.head("/media/unknown_source/not_media.txt") + assert resp.status == HTTPStatus.NOT_FOUND + resp = await client.get("/media/unknown_source/not_media.txt") assert resp.status == HTTPStatus.NOT_FOUND # Fetch available media + resp = await client.head("/media/local/test.mp3") + assert resp.status == HTTPStatus.OK + resp = await client.get("/media/local/test.mp3") assert resp.status == HTTPStatus.OK diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index ccb62959eba..22fb10209b0 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -916,6 +916,29 @@ async def test_web_view_wrong_file( assert req.status == HTTPStatus.NOT_FOUND +@pytest.mark.parametrize( + ("setup", "expected_url_suffix"), + [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], + indirect=["setup"], +) +async def test_web_view_wrong_file_with_head_request( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup: str, + expected_url_suffix: str, +) -> None: + """Set up a TTS platform and receive wrong file from web.""" + client = await hass_client() + + url = ( + "/api/tts_proxy/42f18378fd4393d18c8dd11d03fa9563c1e54491" + f"_en-us_-_{expected_url_suffix}.mp3" + ) + + req = await client.head(url) + assert req.status == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize( ("setup", "expected_url_suffix"), [("mock_setup", "test"), ("mock_config_entry_setup", "tts.test")], From d4b548b16969b7b2728d91aafd9ac5c9cdf246a8 Mon Sep 17 00:00:00 2001 From: Robin Lintermann Date: Thu, 26 Jun 2025 15:30:03 +0200 Subject: [PATCH 0721/1664] Fixed issue when tests (should) fail in Smarla (#146102) * Fixed issue when tests (should) fail * Use usefixture decorator * Throw ConfigEntryError instead of AuthFailed --- homeassistant/components/smarla/__init__.py | 4 ++-- tests/components/smarla/test_config_flow.py | 20 ++++++++++---------- tests/components/smarla/test_init.py | 3 +++ 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/smarla/__init__.py b/homeassistant/components/smarla/__init__.py index 2de3fcfa242..533acb3375b 100644 --- a/homeassistant/components/smarla/__init__.py +++ b/homeassistant/components/smarla/__init__.py @@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryError from .const import HOST, PLATFORMS @@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) - # Check if token still has access if not await connection.refresh_token(): - raise ConfigEntryAuthFailed("Invalid authentication") + raise ConfigEntryError("Invalid authentication") federwiege = Federwiege(hass.loop, connection) federwiege.register() diff --git a/tests/components/smarla/test_config_flow.py b/tests/components/smarla/test_config_flow.py index a2bd5b36fc0..beccf6e4b95 100644 --- a/tests/components/smarla/test_config_flow.py +++ b/tests/components/smarla/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch +import pytest + from homeassistant.components.smarla.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant @@ -12,9 +14,8 @@ from .const import MOCK_SERIAL_NUMBER, MOCK_USER_INPUT from tests.common import MockConfigEntry -async def test_config_flow( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_config_flow(hass: HomeAssistant) -> None: """Test creating a config entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -35,9 +36,8 @@ async def test_config_flow( assert result["result"].unique_id == MOCK_SERIAL_NUMBER -async def test_malformed_token( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") +async def test_malformed_token(hass: HomeAssistant) -> None: """Test we show user form on malformed token input.""" with patch( "homeassistant.components.smarla.config_flow.Connection", side_effect=ValueError @@ -60,9 +60,8 @@ async def test_malformed_token( assert result["type"] is FlowResultType.CREATE_ENTRY -async def test_invalid_auth( - hass: HomeAssistant, mock_setup_entry, mock_connection: MagicMock -) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_invalid_auth(hass: HomeAssistant, mock_connection: MagicMock) -> None: """Test we show user form on invalid auth.""" with patch.object( mock_connection, "refresh_token", new=AsyncMock(return_value=False) @@ -85,8 +84,9 @@ async def test_invalid_auth( assert result["type"] is FlowResultType.CREATE_ENTRY +@pytest.mark.usefixtures("mock_setup_entry", "mock_connection") async def test_device_exists_abort( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test we abort config flow if Smarla device already configured.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/smarla/test_init.py b/tests/components/smarla/test_init.py index b9d291f582d..9523772d914 100644 --- a/tests/components/smarla/test_init.py +++ b/tests/components/smarla/test_init.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,6 +12,7 @@ from . import setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_federwiege") async def test_init_invalid_auth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_connection: MagicMock ) -> None: From aec812a475c9e1499ba42a2fe5a936f08e84447e Mon Sep 17 00:00:00 2001 From: Renat Sibgatulin Date: Thu, 26 Jun 2025 19:59:49 +0200 Subject: [PATCH 0722/1664] Create a new client session for air-Q to fix cookie polution (#147027) --- homeassistant/components/airq/coordinator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py index 743d12d40e5..3ab41978b05 100644 --- a/homeassistant/components/airq/coordinator.py +++ b/homeassistant/components/airq/coordinator.py @@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator): name=DOMAIN, update_interval=timedelta(seconds=UPDATE_INTERVAL), ) - session = async_get_clientsession(hass) + session = async_create_clientsession(hass) self.airq = AirQ( entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session ) From 71f281cc140dc132593fc12b8846b99fef1c426d Mon Sep 17 00:00:00 2001 From: hanwg Date: Thu, 26 Jun 2025 22:43:09 +0800 Subject: [PATCH 0723/1664] Fix Telegram bot default target when sending messages (#147470) * handle targets * updated error message * validate chat id for single target * add validation for chat id * handle empty target * handle empty target --- .../components/telegram_bot/__init__.py | 24 +++++-- homeassistant/components/telegram_bot/bot.py | 62 ++++++++++--------- .../components/telegram_bot/strings.json | 6 ++ .../telegram_bot/test_telegram_bot.py | 47 ++++++++++++-- 4 files changed, 102 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 5bdc670d69c..cab147162aa 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -29,6 +29,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, + HomeAssistantError, ServiceValidationError, ) from homeassistant.helpers import config_validation as cv @@ -390,9 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: elif msgtype == SERVICE_DELETE_MESSAGE: await notify_service.delete_message(context=service.context, **kwargs) elif msgtype == SERVICE_LEAVE_CHAT: - messages = await notify_service.leave_chat( - context=service.context, **kwargs - ) + await notify_service.leave_chat(context=service.context, **kwargs) elif msgtype == SERVICE_SET_MESSAGE_REACTION: await notify_service.set_message_reaction(context=service.context, **kwargs) else: @@ -400,12 +399,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: msgtype, context=service.context, **kwargs ) - if service.return_response and messages: + if service.return_response and messages is not None: + target: list[int] | None = service.data.get(ATTR_TARGET) + if not target: + target = notify_service.get_target_chat_ids(None) + + failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages] + if failed_chat_ids: + raise HomeAssistantError( + f"Failed targets: {failed_chat_ids}", + translation_domain=DOMAIN, + translation_key="failed_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join([str(i) for i in failed_chat_ids]), + "bot_name": config_entry.title, + }, + ) + return { "chats": [ {"chat_id": cid, "message_id": mid} for cid, mid in messages.items() ] } + return None # Register notification services diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index 4a00aff8d3f..a3feb120460 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -287,24 +287,32 @@ class TelegramNotificationService: inline_message_id = msg_data["inline_message_id"] return message_id, inline_message_id - def _get_target_chat_ids(self, target: Any) -> list[int]: + def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]: """Validate chat_id targets or return default target (first). :param target: optional list of integers ([12234, -12345]) :return list of chat_id targets (integers) """ allowed_chat_ids: list[int] = self._get_allowed_chat_ids() - default_user: int = allowed_chat_ids[0] - if target is not None: - if isinstance(target, int): - target = [target] - chat_ids = [t for t in target if t in allowed_chat_ids] - if chat_ids: - return chat_ids - _LOGGER.warning( - "Disallowed targets: %s, using default: %s", target, default_user + + if target is None: + return [allowed_chat_ids[0]] + + chat_ids = [target] if isinstance(target, int) else target + valid_chat_ids = [ + chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids + ] + if not valid_chat_ids: + raise ServiceValidationError( + "Invalid chat IDs", + translation_domain=DOMAIN, + translation_key="invalid_chat_ids", + translation_placeholders={ + "chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids), + "bot_name": self.config.title, + }, ) - return [default_user] + return valid_chat_ids def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]: """Get parameters in message data kwargs.""" @@ -414,9 +422,9 @@ class TelegramNotificationService: """Send one message.""" try: out = await func_send(*args_msg, **kwargs_msg) - if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID): + if isinstance(out, Message): chat_id = out.chat_id - message_id = out[ATTR_MESSAGEID] + message_id = out.message_id self._last_message_id[chat_id] = message_id _LOGGER.debug( "Last message ID: %s (from chat_id %s)", @@ -424,7 +432,7 @@ class TelegramNotificationService: chat_id, ) - event_data = { + event_data: dict[str, Any] = { ATTR_CHAT_ID: chat_id, ATTR_MESSAGEID: message_id, } @@ -437,10 +445,6 @@ class TelegramNotificationService: self.hass.bus.async_fire( EVENT_TELEGRAM_SENT, event_data, context=context ) - elif not isinstance(out, bool): - _LOGGER.warning( - "Update last message: out_type:%s, out=%s", type(out), out - ) except TelegramError as exc: _LOGGER.error( "%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg @@ -460,7 +464,7 @@ class TelegramNotificationService: text = f"{title}\n{message}" if title else message params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params) msg = await self._send_msg( self.bot.send_message, @@ -488,7 +492,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> bool: """Delete a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id) deleted: bool = await self._send_msg( @@ -513,7 +517,7 @@ class TelegramNotificationService: **kwargs: dict[str, Any], ) -> Any: """Edit a previously sent message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) _LOGGER.debug( @@ -620,7 +624,7 @@ class TelegramNotificationService: msg_ids = {} if file_content: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Sending file to chat ID %s", chat_id) if file_type == SERVICE_SEND_PHOTO: @@ -738,7 +742,7 @@ class TelegramNotificationService: msg_ids = {} if stickerid: - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): msg = await self._send_msg( self.bot.send_sticker, "Error sending sticker", @@ -769,7 +773,7 @@ class TelegramNotificationService: longitude = float(longitude) params = self._get_msg_kwargs(kwargs) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug( "Send location %s/%s to chat ID %s", latitude, longitude, chat_id ) @@ -803,7 +807,7 @@ class TelegramNotificationService: params = self._get_msg_kwargs(kwargs) openperiod = kwargs.get(ATTR_OPEN_PERIOD) msg_ids = {} - for chat_id in self._get_target_chat_ids(target): + for chat_id in self.get_target_chat_ids(target): _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) msg = await self._send_msg( self.bot.send_poll, @@ -826,12 +830,12 @@ class TelegramNotificationService: async def leave_chat( self, - chat_id: Any = None, + chat_id: int | None = None, context: Context | None = None, **kwargs: dict[str, Any], ) -> Any: """Remove bot from chat.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] _LOGGER.debug("Leave from chat ID %s", chat_id) return await self._send_msg( self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context @@ -839,14 +843,14 @@ class TelegramNotificationService: async def set_message_reaction( self, - chat_id: int, reaction: str, + chat_id: int | None = None, is_big: bool = False, context: Context | None = None, **kwargs: dict[str, Any], ) -> None: """Set the bot's reaction for a given message.""" - chat_id = self._get_target_chat_ids(chat_id)[0] + chat_id = self.get_target_chat_ids(chat_id)[0] message_id, _ = self._get_msg_ids(kwargs, chat_id) params = self._get_msg_kwargs(kwargs) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index e932d010894..a51d4a371f1 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -895,6 +895,12 @@ "missing_allowed_chat_ids": { "message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}." }, + "invalid_chat_ids": { + "message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}." + }, + "failed_chat_ids": { + "message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured." + }, "missing_input": { "message": "{field} is required." }, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index fd313867561..190fed07ae3 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -677,13 +677,35 @@ async def test_send_message_with_config_entry( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: send message to invalid chat id + + with pytest.raises(HomeAssistantError) as err: + response = await hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + { + CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, + ATTR_MESSAGE: "mock message", + ATTR_TARGET: [123456, 1], + }, + blocking=True, + return_response=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "failed_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: send message to valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, { CONF_CONFIG_ENTRY_ID: mock_broadcast_config_entry.entry_id, ATTR_MESSAGE: "mock message", - ATTR_TARGET: 1, + ATTR_TARGET: 123456, }, blocking=True, return_response=True, @@ -767,6 +789,23 @@ async def test_delete_message( await hass.config_entries.async_setup(mock_broadcast_config_entry.entry_id) await hass.async_block_till_done() + # test: delete message with invalid chat id + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + SERVICE_DELETE_MESSAGE, + {ATTR_CHAT_ID: 1, ATTR_MESSAGEID: "last"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert err.value.translation_key == "invalid_chat_ids" + assert err.value.translation_placeholders["chat_ids"] == "1" + assert err.value.translation_placeholders["bot_name"] == "Mock Title" + + # test: delete message with valid chat id + response = await hass.services.async_call( DOMAIN, SERVICE_SEND_MESSAGE, @@ -808,7 +847,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_MESSAGE, - {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_MESSAGE: "mock message", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -822,7 +861,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_CAPTION, - {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_CAPTION: "mock caption", ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) @@ -836,7 +875,7 @@ async def test_edit_message( await hass.services.async_call( DOMAIN, SERVICE_EDIT_REPLYMARKUP, - {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 12345, ATTR_MESSAGEID: 12345}, + {ATTR_KEYBOARD_INLINE: [], ATTR_CHAT_ID: 123456, ATTR_MESSAGEID: 12345}, blocking=True, ) From 7d0e99da43d0766b66a9023cde0c35bde0d479f0 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 15:12:23 -0700 Subject: [PATCH 0724/1664] Fixes in Google AI TTS (#147501) * Fix Google AI not using correct config options after subentries migration * Fixes in Google AI TTS * Fix tests by @IvanLH * Change type name. --------- Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen --- .../__init__.py | 13 + .../config_flow.py | 105 +++-- .../const.py | 16 +- .../strings.json | 29 +- .../google_generative_ai_conversation/tts.py | 92 ++--- homeassistant/config_entries.py | 5 + .../conftest.py | 9 +- .../test_config_flow.py | 65 ++- .../test_init.py | 56 ++- .../test_tts.py | 385 ++++++------------ 10 files changed, 412 insertions(+), 363 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 40d441929a3..1802073f760 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio import mimetypes from pathlib import Path +from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError @@ -36,10 +37,12 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, LOGGER, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) @@ -242,6 +245,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) + if use_existing: + hass.config_entries.async_add_subentry( + parent_entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) conversation_entity = entity_registry.async_get_entity_id( "conversation", DOMAIN, diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 4b7c7a0dd47..bb526f95a21 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -47,13 +47,17 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, TIMEOUT_MILLIS, ) @@ -66,12 +70,6 @@ STEP_API_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -123,10 +121,16 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -172,10 +176,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": LLMSubentryFlowHandler, + "tts": LLMSubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): +class LLMSubentryFlowHandler(ConfigSubentryFlow): """Flow for managing conversation subentries.""" last_rendered_recommended = False @@ -202,7 +209,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input is None: if self._is_new: - options = RECOMMENDED_OPTIONS.copy() + options: dict[str, Any] + if self._subentry_type == "tts": + options = RECOMMENDED_TTS_OPTIONS.copy() + else: + options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: # If this is a reconfiguration, we need to copy the existing options # so that we can show the current values in the form. @@ -216,7 +227,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended: if not user_input.get(CONF_LLM_HASS_API): user_input.pop(CONF_LLM_HASS_API, None) - # Don't allow to save options that enable the Google Seearch tool with an Assist API + # Don't allow to save options that enable the Google Search tool with an Assist API if not ( user_input.get(CONF_LLM_HASS_API) and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True @@ -240,7 +251,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): options = user_input schema = await google_generative_ai_config_option_schema( - self.hass, self._is_new, options, self._genai_client + self.hass, self._is_new, self._subentry_type, options, self._genai_client ) return self.async_show_form( step_id="set_options", data_schema=vol.Schema(schema), errors=errors @@ -253,6 +264,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): async def google_generative_ai_config_option_schema( hass: HomeAssistant, is_new: bool, + subentry_type: str, options: Mapping[str, Any], genai_client: genai.Client, ) -> dict: @@ -270,26 +282,39 @@ async def google_generative_ai_config_option_schema( suggested_llm_apis = [suggested_llm_apis] if is_new: + if CONF_NAME in options: + default_name = options[CONF_NAME] + elif subentry_type == "tts": + default_name = DEFAULT_TTS_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { - vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str, + vol.Required(CONF_NAME, default=default_name): str, } else: schema = {} + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": suggested_llm_apis}, + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } + ) schema.update( { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": suggested_llm_apis}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), vol.Required( CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) ): bool, @@ -310,7 +335,7 @@ async def google_generative_ai_config_option_schema( if ( api_model.display_name and api_model.name - and "tts" not in api_model.name + and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions and "generateContent" in api_model.supported_actions @@ -341,12 +366,17 @@ async def google_generative_ai_config_option_schema( ) ) + if subentry_type == "tts": + default_model = RECOMMENDED_TTS_MODEL + else: + default_model = RECOMMENDED_CHAT_MODEL + schema.update( { vol.Optional( CONF_CHAT_MODEL, description={"suggested_value": options.get(CONF_CHAT_MODEL)}, - default=RECOMMENDED_CHAT_MODEL, + default=default_model, ): SelectSelector( SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models) ), @@ -396,13 +426,18 @@ async def google_generative_ai_config_option_schema( }, default=RECOMMENDED_HARM_BLOCK_THRESHOLD, ): harm_block_thresholds_selector, - vol.Optional( - CONF_USE_GOOGLE_SEARCH_TOOL, - description={ - "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), - }, - default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, - ): bool, } ) + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_USE_GOOGLE_SEARCH_TOOL, + description={ + "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL), + }, + default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + ): bool, + } + ) return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 0735e9015c2..9f4132a1e3e 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -2,17 +2,20 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "google_generative_ai_conversation" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_TTS_NAME = "Google AI TTS" -ATTR_MODEL = "model" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" -RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts" +RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_TOP_P = "top_p" @@ -31,3 +34,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_RECOMMENDED: True, +} + +RECOMMENDED_TTS_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index e523aecbaec..eef595ad05d 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -29,7 +29,6 @@ "reconfigure": "Reconfigure conversation agent" }, "entry_type": "Conversation agent", - "step": { "set_options": { "data": { @@ -61,6 +60,34 @@ "error": { "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } + }, + "tts": { + "initiate_flow": { + "user": "Add Text-to-Speech service", + "reconfigure": "Reconfigure Text-to-Speech service" + }, + "entry_type": "Text-to-Speech", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } } }, "services": { diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 50baec67db2..174f0a50dc3 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping from contextlib import suppress import io -import logging from typing import Any import wave from google.genai import types +from google.genai.errors import APIError, ClientError +from propcache.api import cached_property from homeassistant.components.tts import ( ATTR_VOICE, @@ -19,12 +21,10 @@ from homeassistant.components.tts import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -32,15 +32,23 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up TTS entity.""" - tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry) - async_add_entities([tts_entity]) + """Set up TTS entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "tts": + continue + + async_add_entities( + [GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) -class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): +class GoogleGenerativeAITextToSpeechEntity( + TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity +): """Google Generative AI text-to-speech entity.""" - _attr_supported_options = [ATTR_VOICE, ATTR_MODEL] + _attr_supported_options = [ATTR_VOICE] # See https://ai.google.dev/gemini-api/docs/speech-generation#languages _attr_supported_languages = [ "ar-EG", @@ -68,6 +76,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): "uk-UA", "vi-VN", ] + # Unused, but required by base class. + # The Gemini TTS models detect the input language automatically. _attr_default_language = "en-US" # See https://ai.google.dev/gemini-api/docs/speech-generation#voices _supported_voices = [ @@ -106,53 +116,41 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): ) ] - def __init__(self, entry: ConfigEntry) -> None: - """Initialize Google Generative AI Conversation speech entity.""" - self.entry = entry - self._attr_name = "Google Generative AI TTS" - self._attr_unique_id = f"{entry.entry_id}_tts" - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, entry.entry_id)}, - manufacturer="Google", - model="Generative AI", - entry_type=dr.DeviceEntryType.SERVICE, - ) - self._genai_client = entry.runtime_data - self._default_voice_id = self._supported_voices[0].voice_id - @callback - def async_get_supported_voices(self, language: str) -> list[Voice] | None: + def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" return self._supported_voices + @cached_property + def default_options(self) -> Mapping[str, Any]: + """Return a mapping with the default options.""" + return { + ATTR_VOICE: self._supported_voices[0].voice_id, + } + async def async_get_tts_audio( self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine.""" - try: - response = self._genai_client.models.generate_content( - model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL), - contents=message, - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig( - voice_name=options.get( - ATTR_VOICE, self._default_voice_id - ) - ) - ) - ), - ), + config = self.create_generate_content_config() + config.response_modalities = ["AUDIO"] + config.speech_config = types.SpeechConfig( + voice_config=types.VoiceConfig( + prebuilt_voice_config=types.PrebuiltVoiceConfig( + voice_name=options[ATTR_VOICE] + ) + ) + ) + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL), + contents=message, + config=config, ) - data = response.candidates[0].content.parts[0].inline_data.data mime_type = response.candidates[0].content.parts[0].inline_data.mime_type - except Exception as exc: - _LOGGER.warning( - "Error during processing of TTS request %s", exc, exc_info=True - ) + except (APIError, ClientError, ValueError) as exc: + LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc return "wav", self._convert_to_wav(data, mime_type) @@ -192,7 +190,7 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity): """ if not mime_type.startswith("audio/L"): - _LOGGER.warning("Received unexpected MIME type %s", mime_type) + LOGGER.warning("Received unexpected MIME type %s", mime_type) raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") bits_per_sample = 16 diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index c2481ae3fa3..ca3a78f8046 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3420,6 +3420,11 @@ class ConfigSubentryFlow( """Return config entry id.""" return self.handler[0] + @property + def _subentry_type(self) -> str: + """Return type of subentry we are editing/creating.""" + return self.handler[1] + @callback def _get_entry(self) -> ConfigEntry: """Return the config entry linked to the current context.""" diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 36d99cd2764..afea41bbb26 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_LLM_HASS_API @@ -34,7 +35,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "data": {}, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ], ) entry.runtime_data = Mock() diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index e02d85e41c4..b43c8a42275 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -6,9 +6,6 @@ import pytest from requests.exceptions import Timeout from homeassistant import config_entries -from homeassistant.components.google_generative_ai_conversation.config_flow import ( - RECOMMENDED_OPTIONS, -) from homeassistant.components.google_generative_ai_conversation.const import ( CONF_CHAT_MODEL, CONF_DANGEROUS_BLOCK_THRESHOLD, @@ -23,12 +20,15 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME @@ -115,10 +115,16 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "tts", + "data": RECOMMENDED_TTS_OPTIONS, + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -172,19 +178,64 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS}, + {CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" - processed_options = RECOMMENDED_OPTIONS.copy() + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() assert result2["data"] == processed_options +async def test_creating_tts_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a TTS subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "tts"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "set_options" + assert not result["errors"] + + old_subentries = set(mock_config_entry.subentries) + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock TTS" + assert result2["data"] == RECOMMENDED_TTS_OPTIONS + + assert len(mock_config_entry.subentries) == 3 + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == "tts" + assert new_subentry.data == RECOMMENDED_TTS_OPTIONS + assert new_subentry.title == "Mock TTS" + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component: None, diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 8de678213c2..a8a1e2840e3 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -7,7 +7,11 @@ import pytest from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion -from homeassistant.components.google_generative_ai_conversation.const import DOMAIN +from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_TTS_NAME, + DOMAIN, + RECOMMENDED_TTS_OPTIONS, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -469,13 +473,27 @@ async def test_migration_from_v1_to_v2( entry = entries[0] assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -493,7 +511,7 @@ async def test_migration_from_v1_to_v2( assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" @@ -591,11 +609,15 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 1 + assert len(entry.subentries) == 2 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + subentry = list(entry.subentries.values())[1] + assert subentry.subentry_type == "tts" + assert subentry.data == RECOMMENDED_TTS_OPTIONS + assert subentry.title == DEFAULT_TTS_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -680,13 +702,27 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 assert not entry.options - assert len(entry.subentries) == 2 - for subentry in entry.subentries.values(): + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME - subentry = list(entry.subentries.values())[0] + subentry = conversation_subentries[0] entity = entity_registry.async_get("conversation.google_generative_ai_conversation") assert entity.unique_id == subentry.subentry_id @@ -704,7 +740,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id - subentry = list(entry.subentries.values())[1] + subentry = conversation_subentries[1] entity = entity_registry.async_get( "conversation.google_generative_ai_conversation_2" diff --git a/tests/components/google_generative_ai_conversation/test_tts.py b/tests/components/google_generative_ai_conversation/test_tts.py index 4f197f0535f..108ac82947c 100644 --- a/tests/components/google_generative_ai_conversation/test_tts.py +++ b/tests/components/google_generative_ai_conversation/test_tts.py @@ -9,30 +9,37 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, patch from google.genai import types +from google.genai.errors import APIError import pytest from homeassistant.components import tts -from homeassistant.components.google_generative_ai_conversation.tts import ( - ATTR_MODEL, +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, DOMAIN, - RECOMMENDED_TTS_MODEL, + RECOMMENDED_HARM_BLOCK_THRESHOLD, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_K, + RECOMMENDED_TOP_P, ) from homeassistant.components.media_player import ( ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP, SERVICE_PLAY_MEDIA, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY, CONF_PLATFORM +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core_config import async_process_ha_core_config from homeassistant.setup import async_setup_component -from . import API_ERROR_500 - from tests.common import MockConfigEntry, async_mock_service from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +API_ERROR_500 = APIError("test", response=MagicMock()) +TEST_CHAT_MODEL = "models/some-tts-model" + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: @@ -63,20 +70,22 @@ def mock_genai_client() -> Generator[AsyncMock]: """Mock genai_client.""" client = Mock() client.aio.models.get = AsyncMock() - client.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=( + types.Candidate( + content=types.Content( + parts=( + types.Part( + inline_data=types.Blob( + data=b"raw-audio-bytes", + mime_type="audio/L16;rate=24000", + ) + ), + ) ) - ) - ), + ), + ) ) ) with patch( @@ -90,17 +99,29 @@ def mock_genai_client() -> Generator[AsyncMock]: async def setup_fixture( hass: HomeAssistant, config: dict[str, Any], - request: pytest.FixtureRequest, mock_genai_client: AsyncMock, ) -> None: """Set up the test environment.""" - if request.param == "mock_setup": - await mock_setup(hass, config) - if request.param == "mock_config_entry_setup": - await mock_config_entry_setup(hass, config) - else: - raise RuntimeError("Invalid setup fixture") + config_entry = MockConfigEntry(domain=DOMAIN, data=config, version=2) + config_entry.add_to_hass(hass) + sub_entry = ConfigSubentry( + data={ + tts.CONF_LANG: "en-US", + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + }, + subentry_type="tts", + title="Google AI TTS", + subentry_id="test_subentry_tts_id", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + + assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() @@ -112,105 +133,38 @@ def config_fixture() -> dict[str, Any]: } -async def mock_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock setup.""" - assert await async_setup_component( - hass, tts.DOMAIN, {tts.DOMAIN: {CONF_PLATFORM: DOMAIN} | config} - ) - - -async def mock_config_entry_setup(hass: HomeAssistant, config: dict[str, Any]) -> None: - """Mock config entry setup.""" - default_config = {tts.CONF_LANG: "en-US"} - config_entry = MockConfigEntry( - domain=DOMAIN, data=default_config | config, version=2 - ) - - client_mock = Mock() - client_mock.models.get = None - client_mock.models.generate_content.return_value = types.GenerateContentResponse( - candidates=( - types.Candidate( - content=types.Content( - parts=( - types.Part( - inline_data=types.Blob( - data=b"raw-audio-bytes", - mime_type="audio/L16;rate=24000", - ) - ), - ) - ) - ), - ) - ) - config_entry.runtime_data = client_mock - config_entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(config_entry.entry_id) - - @pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), + "service_data", [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {ATTR_MODEL: "model2"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2", ATTR_MODEL: "model2"}, - }, - ), + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {}, + }, + { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice2"}, + }, ], - indirect=["setup"], ) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, service_data: dict[str, Any], ) -> None: """Test tts service.""" + tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.reset_mock() await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -221,10 +175,9 @@ async def test_tts_service_speak( == HTTPStatus.OK ) voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "zephyr") - model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, RECOMMENDED_TTS_MODEL) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=model_id, + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], @@ -233,109 +186,52 @@ async def test_tts_service_speak( prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "de-DE", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_LANGUAGE: "it-IT", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_lang_config( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with languages in the config.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, - }, - ), - ], - indirect=["setup"], -) +@pytest.mark.usefixtures("setup") async def test_tts_service_speak_error( - setup: AsyncMock, hass: HomeAssistant, hass_client: ClientSessionGenerator, calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], ) -> None: """Test service call with HTTP response 500.""" + service_data = { + ATTR_ENTITY_ID: "tts.google_ai_tts", + tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_OPTIONS: {tts.ATTR_VOICE: "voice1"}, + } tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - tts_entity._genai_client.models.generate_content.side_effect = API_ERROR_500 + tts_entity._genai_client.aio.models.generate_content.reset_mock() + tts_entity._genai_client.aio.models.generate_content.side_effect = API_ERROR_500 await hass.services.async_call( tts.DOMAIN, - tts_service, + "speak", service_data, blocking=True, ) @@ -346,70 +242,39 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, + voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE) + + tts_entity._genai_client.aio.models.generate_content.assert_called_once_with( + model=TEST_CHAT_MODEL, contents="There is a person at the front door.", config=types.GenerateContentConfig( response_modalities=["AUDIO"], speech_config=types.SpeechConfig( voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="voice1") - ) - ), - ), - ) - - -@pytest.mark.parametrize( - ("setup", "tts_service", "service_data"), - [ - ( - "mock_config_entry_setup", - "speak", - { - ATTR_ENTITY_ID: "tts.google_generative_ai_tts", - tts.ATTR_MEDIA_PLAYER_ENTITY_ID: "media_player.something", - tts.ATTR_MESSAGE: "There is a person at the front door.", - tts.ATTR_OPTIONS: {}, - }, - ), - ], - indirect=["setup"], -) -async def test_tts_service_speak_without_options( - setup: AsyncMock, - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - calls: list[ServiceCall], - tts_service: str, - service_data: dict[str, Any], -) -> None: - """Test service call with HTTP response 200.""" - tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._genai_client.models.generate_content.reset_mock() - - await hass.services.async_call( - tts.DOMAIN, - tts_service, - service_data, - blocking=True, - ) - - assert len(calls) == 1 - assert ( - await retrieve_media(hass, hass_client, calls[0].data[ATTR_MEDIA_CONTENT_ID]) - == HTTPStatus.OK - ) - - tts_entity._genai_client.models.generate_content.assert_called_once_with( - model=RECOMMENDED_TTS_MODEL, - contents="There is a person at the front door.", - config=types.GenerateContentConfig( - response_modalities=["AUDIO"], - speech_config=types.SpeechConfig( - voice_config=types.VoiceConfig( - prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name="zephyr") + prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice_id) ) ), + temperature=RECOMMENDED_TEMPERATURE, + top_k=RECOMMENDED_TOP_K, + top_p=RECOMMENDED_TOP_P, + max_output_tokens=RECOMMENDED_MAX_TOKENS, + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=RECOMMENDED_HARM_BLOCK_THRESHOLD, + ), + ], ), ) From b9a7371996ae5a7afce6f5298fd884cc920650d3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Jun 2025 08:20:26 +0200 Subject: [PATCH 0725/1664] Set end date for when allowing unique id collisions in config entries (#147516) * Set end date for when allowing unique id collisions in config entries * Update test --- homeassistant/config_entries.py | 1 + tests/test_config_entries.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ca3a78f8046..e76b7ae099f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1646,6 +1646,7 @@ class ConfigEntriesFlowManager( report_usage( "creates a config entry when another entry with the same unique ID " "exists", + breaks_in_ha_version="2026.3", core_behavior=ReportBehavior.LOG, core_integration_behavior=ReportBehavior.LOG, custom_integration_behavior=ReportBehavior.LOG, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 45bb956b7a1..dc893e4c5fd 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -8823,7 +8823,7 @@ async def test_create_entry_existing_unique_id( log_text = ( f"Detected that integration '{domain}' creates a config entry " - "when another entry with the same unique ID exists. Please " - "create a bug report at https:" + "when another entry with the same unique ID exists. This will stop " + "working in Home Assistant 2026.3, please create a bug report at https:" ) assert (log_text in caplog.text) == expected_log From 150f41641b492d2bdaf67a802941c3e2e465018f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 11:52:14 +0300 Subject: [PATCH 0726/1664] Improve config flow strings for Alexa Devices (#147523) --- homeassistant/components/alexa_devices/strings.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index eb279e28d35..b3bb699d003 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -1,8 +1,7 @@ { "common": { - "data_country": "Country code", "data_code": "One-time password (OTP code)", - "data_description_country": "The country of your Amazon account.", + "data_description_country": "The country where your Amazon account is registered.", "data_description_username": "The email address of your Amazon account.", "data_description_password": "The password of your Amazon account.", "data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported." @@ -12,10 +11,10 @@ "step": { "user": { "data": { - "country": "[%key:component::alexa_devices::common::data_country%]", + "country": "[%key:common::config_flow::data::country%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "code": "[%key:component::alexa_devices::common::data_description_code%]" + "code": "[%key:component::alexa_devices::common::data_code%]" }, "data_description": { "country": "[%key:component::alexa_devices::common::data_description_country%]", From e627811f7a29d7de0d7213b09cccd97f9f8eff6a Mon Sep 17 00:00:00 2001 From: Anders Peter Fugmann Date: Thu, 26 Jun 2025 12:56:46 +0200 Subject: [PATCH 0727/1664] Bump dependency on pyW215 for DLink integration to 0.8.0 (#147534) --- homeassistant/components/dlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json index 8afc44a082e..00867e98511 100644 --- a/homeassistant/components/dlink/manifest.json +++ b/homeassistant/components/dlink/manifest.json @@ -12,5 +12,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["pyW215"], - "requirements": ["pyW215==0.7.0"] + "requirements": ["pyW215==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 76ef5d07d10..cc59f822156 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1817,7 +1817,7 @@ pySDCP==1 pyTibber==0.31.2 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.w800rf32 pyW800rf32==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9557e405e98..684b28b88d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1525,7 +1525,7 @@ pyRFXtrx==0.31.1 pyTibber==0.31.2 # homeassistant.components.dlink -pyW215==0.7.0 +pyW215==0.8.0 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 From 2c4ea0d584bf3c1d3f093fbcd3571f72ed4ee9a7 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 26 Jun 2025 11:07:07 +0200 Subject: [PATCH 0728/1664] Fix wind direction state class sensor for AEMET (#147535) --- homeassistant/components/aemet/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index a3aeab9deb9..2e7e977cf3d 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -336,7 +336,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = ( keys=[AOD_WEATHER, AOD_WIND_DIRECTION], name="Wind bearing", native_unit_of_measurement=DEGREE, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.MEASUREMENT_ANGLE, device_class=SensorDeviceClass.WIND_DIRECTION, ), AemetSensorEntityDescription( From 6b2aaf3fdb258dd3281d77a34bc6d771f6a63ae6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 10:55:31 +0200 Subject: [PATCH 0729/1664] Show current Lametric version if there is no newer version (#147538) --- homeassistant/components/lametric/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/update.py b/homeassistant/components/lametric/update.py index d486d9d27ba..3d93f919c58 100644 --- a/homeassistant/components/lametric/update.py +++ b/homeassistant/components/lametric/update.py @@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity): def latest_version(self) -> str | None: """Return the latest version of the entity.""" if not self.coordinator.data.update: - return None + return self.coordinator.data.os_version return self.coordinator.data.update.version From 03f9caf3eb92d66cde134c03f55a198fce5ee446 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 20:59:02 +0300 Subject: [PATCH 0730/1664] Add action exceptions to Alexa Devices (#147546) --- .../components/alexa_devices/notify.py | 2 + .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/strings.json | 8 +++ .../components/alexa_devices/switch.py | 2 + .../components/alexa_devices/utils.py | 40 +++++++++++++ tests/components/alexa_devices/test_utils.py | 56 +++++++++++++++++++ 6 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/alexa_devices/utils.py create mode 100644 tests/components/alexa_devices/test_utils.py diff --git a/homeassistant/components/alexa_devices/notify.py b/homeassistant/components/alexa_devices/notify.py index 46db294377a..08f2e214f38 100644 --- a/homeassistant/components/alexa_devices/notify.py +++ b/homeassistant/components/alexa_devices/notify.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity): entity_description: AmazonNotifyEntityDescription + @alexa_api_call async def async_send_message( self, message: str, title: str | None = None, **kwargs: Any ) -> None: diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 881a02bc6d3..afd12ca1df2 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -26,7 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: todo docs-installation-parameters: todo diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index b3bb699d003..d092cfaa2ae 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -70,5 +70,13 @@ "name": "Do not disturb" } } + }, + "exceptions": { + "cannot_connect": { + "message": "Error connecting: {error}" + }, + "cannot_retrieve_data": { + "message": "Error retrieving data: {error}" + } } } diff --git a/homeassistant/components/alexa_devices/switch.py b/homeassistant/components/alexa_devices/switch.py index b8f78134feb..e53ea40965a 100644 --- a/homeassistant/components/alexa_devices/switch.py +++ b/homeassistant/components/alexa_devices/switch.py @@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import AmazonConfigEntry from .entity import AmazonEntity +from .utils import alexa_api_call PARALLEL_UPDATES = 1 @@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity): entity_description: AmazonSwitchEntityDescription + @alexa_api_call async def _switch_set_state(self, state: bool) -> None: """Set desired switch state.""" method = getattr(self.coordinator.api, self.entity_description.method) diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py new file mode 100644 index 00000000000..4d1365d1d41 --- /dev/null +++ b/homeassistant/components/alexa_devices/utils.py @@ -0,0 +1,40 @@ +"""Utils for Alexa Devices.""" + +from collections.abc import Awaitable, Callable, Coroutine +from functools import wraps +from typing import Any, Concatenate + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData + +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN +from .entity import AmazonEntity + + +def alexa_api_call[_T: AmazonEntity, **_P]( + func: Callable[Concatenate[_T, _P], Awaitable[None]], +) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]: + """Catch Alexa API call exceptions.""" + + @wraps(func) + async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None: + """Wrap all command methods.""" + try: + await func(self, *args, **kwargs) + except CannotConnect as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + self.coordinator.last_update_success = False + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data", + translation_placeholders={"error": repr(err)}, + ) from err + + return cmd_wrapper diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py new file mode 100644 index 00000000000..12009719a2f --- /dev/null +++ b/tests/components/alexa_devices/test_utils.py @@ -0,0 +1,56 @@ +"""Tests for Alexa Devices utils.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData +import pytest + +from homeassistant.components.alexa_devices.const import DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_integration + +from tests.common import MockConfigEntry + +ENTITY_ID = "switch.echo_test_do_not_disturb" + + +@pytest.mark.parametrize( + ("side_effect", "key", "error"), + [ + (CannotConnect, "cannot_connect", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"), + ], +) +async def test_alexa_api_call_exceptions( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + key: str, + error: str, +) -> None: + """Test alexa_api_call decorator for exceptions.""" + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(ENTITY_ID)) + assert state.state == STATE_OFF + + mock_amazon_devices_client.set_do_not_disturb.side_effect = side_effect + + # Call API + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == key + assert exc_info.value.translation_placeholders == {"error": error} From cfa6746115eefc6c5130778da7971bc2a1a2bb6e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Jun 2025 02:55:58 +0300 Subject: [PATCH 0731/1664] Fix unload for Alexa Devices (#147548) --- homeassistant/components/alexa_devices/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index aff4c1bb391..fe623c10b33 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool: """Unload a config entry.""" - await entry.runtime_data.api.close() - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + coordinator = entry.runtime_data + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await coordinator.api.close() + + return unload_ok From 914bb3aa76759b34b5b8a5242ce8de0293c2aa79 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Jun 2025 19:30:41 -0700 Subject: [PATCH 0732/1664] Use default title for migrated Google Generative AI entries (#147551) --- .../components/google_generative_ai_conversation/__init__.py | 2 ++ .../google_generative_ai_conversation/config_flow.py | 3 ++- .../components/google_generative_ai_conversation/const.py | 1 + .../components/google_generative_ai_conversation/test_init.py | 4 ++++ 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1802073f760..7890af59f88 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -37,6 +37,7 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, @@ -289,6 +290,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_TITLE, options={}, version=2, ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index bb526f95a21..ad90cbcf553 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -47,6 +47,7 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_CONVERSATION_NAME, + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_CHAT_MODEL, @@ -116,7 +117,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) return self.async_create_entry( - title="Google Generative AI", + title=DEFAULT_TITLE, data=user_input, subentries=[ { diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 9f4132a1e3e..72665cd3437 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -6,6 +6,7 @@ from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm DOMAIN = "google_generative_ai_conversation" +DEFAULT_TITLE = "Google Generative AI" LOGGER = logging.getLogger(__package__) CONF_PROMPT = "prompt" diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index a8a1e2840e3..46a2d634b81 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -8,6 +8,7 @@ from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_TTS_OPTIONS, @@ -473,6 +474,7 @@ async def test_migration_from_v1_to_v2( entry = entries[0] assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 conversation_subentries = [ subentry @@ -609,6 +611,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 2 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" @@ -702,6 +705,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 assert not entry.options + assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 conversation_subentries = [ subentry From 5fe2e4b6ed7a7eba687dac86ea8704ad316ce97d Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Jun 2025 01:50:47 -0700 Subject: [PATCH 0733/1664] Include subentries in Google Generative AI diagnostics (#147558) --- .../diagnostics.py | 1 + .../conftest.py | 2 + .../snapshots/test_diagnostics.ambr | 40 ++++++++++++++----- .../test_diagnostics.py | 6 +-- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/diagnostics.py b/homeassistant/components/google_generative_ai_conversation/diagnostics.py index 13643da7e00..34b9f762355 100644 --- a/homeassistant/components/google_generative_ai_conversation/diagnostics.py +++ b/homeassistant/components/google_generative_ai_conversation/diagnostics.py @@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics( "title": entry.title, "data": entry.data, "options": entry.options, + "subentries": dict(entry.subentries), }, TO_REDACT, ) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index afea41bbb26..331afc723ae 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -34,12 +34,14 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "data": {}, "subentry_type": "conversation", "title": DEFAULT_CONVERSATION_NAME, + "subentry_id": "ulid-conversation", "unique_id": None, }, { "data": {}, "subentry_type": "tts", "title": DEFAULT_TTS_NAME, + "subentry_id": "ulid-tts", "unique_id": None, }, ], diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index a31827c7acc..48091d83a00 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -5,17 +5,35 @@ 'api_key': '**REDACTED**', }), 'options': dict({ - 'chat_model': 'models/gemini-2.5-flash', - 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 1500, - 'prompt': 'Speak like a pirate', - 'recommended': False, - 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'temperature': 1.0, - 'top_k': 64, - 'top_p': 0.95, + }), + 'subentries': dict({ + 'ulid-conversation': dict({ + 'data': dict({ + 'chat_model': 'models/gemini-2.5-flash', + 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'max_tokens': 1500, + 'prompt': 'Speak like a pirate', + 'recommended': False, + 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', + 'temperature': 1.0, + 'top_k': 64, + 'top_p': 0.95, + }), + 'subentry_id': 'ulid-conversation', + 'subentry_type': 'conversation', + 'title': 'Google AI Conversation', + 'unique_id': None, + }), + 'ulid-tts': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-tts', + 'subentry_type': 'tts', + 'title': 'Google AI TTS', + 'unique_id': None, + }), }), 'title': 'Google Generative AI Conversation', }) diff --git a/tests/components/google_generative_ai_conversation/test_diagnostics.py b/tests/components/google_generative_ai_conversation/test_diagnostics.py index ebc1b5e52a5..0f193238669 100644 --- a/tests/components/google_generative_ai_conversation/test_diagnostics.py +++ b/tests/components/google_generative_ai_conversation/test_diagnostics.py @@ -35,10 +35,10 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - mock_config_entry.add_to_hass(hass) - hass.config_entries.async_update_entry( + hass.config_entries.async_update_subentry( mock_config_entry, - options={ + next(iter(mock_config_entry.subentries.values())), + data={ CONF_RECOMMENDED: False, CONF_PROMPT: "Speak like a pirate", CONF_TEMPERATURE: RECOMMENDED_TEMPERATURE, From 1e81e5990e220f31c7658fe2f7d38aa41f11d7f9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 10:11:25 +0300 Subject: [PATCH 0734/1664] Bump zwave-js-server-python to 0.65.0 (#147561) * Bump zwave-js-server-python to 0.65.0 * update tests --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 082a3dd9f95..93d585d72a2 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.64.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.65.0"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index cc59f822156..abb3b15be3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3205,7 +3205,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.65.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 684b28b88d6..d6f5cc7ee06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2637,7 +2637,7 @@ zeversolar==0.3.2 zha==0.0.61 # homeassistant.components.zwave_js -zwave-js-server-python==0.64.0 +zwave-js-server-python==0.65.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 3f1f9b737bd..d6aed0b6d22 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5649,8 +5649,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) assert entry.unique_id == "1234" @@ -5684,8 +5685,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) assert ( "Failed to get server version, cannot update config entry" @@ -5738,8 +5740,9 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", + "migrateOptions": {}, }, - require_schema=14, + require_schema=42, ) client.async_send_command.reset_mock() From f28d6582c69c26a03e778aa4ae9e5ddb7b1001cc Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 26 Jun 2025 08:53:16 -0700 Subject: [PATCH 0735/1664] Refactor in Google AI TTS in preparation for STT (#147562) --- .../helpers.py | 73 +++++++++++++++++++ .../google_generative_ai_conversation/tts.py | 64 +--------------- 2 files changed, 75 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/helpers.py diff --git a/homeassistant/components/google_generative_ai_conversation/helpers.py b/homeassistant/components/google_generative_ai_conversation/helpers.py new file mode 100644 index 00000000000..3d053aa9f1a --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/helpers.py @@ -0,0 +1,73 @@ +"""Helper classes for Google Generative AI integration.""" + +from __future__ import annotations + +from contextlib import suppress +import io +import wave + +from homeassistant.exceptions import HomeAssistantError + +from .const import LOGGER + + +def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes: + """Generate a WAV file header for the given audio data and parameters. + + Args: + audio_data: The raw audio data as a bytes object. + mime_type: Mime type of the audio data. + + Returns: + A bytes object representing the WAV file header. + + """ + parameters = _parse_audio_mime_type(mime_type) + + wav_buffer = io.BytesIO() + with wave.open(wav_buffer, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(parameters["bits_per_sample"] // 8) + wf.setframerate(parameters["rate"]) + wf.writeframes(audio_data) + + return wav_buffer.getvalue() + + +# Below code is from https://aistudio.google.com/app/generate-speech +# when you select "Get SDK code to generate speech". +def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: + """Parse bits per sample and rate from an audio MIME type string. + + Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". + + Args: + mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). + + Returns: + A dictionary with "bits_per_sample" and "rate" keys. Values will be + integers if found, otherwise None. + + """ + if not mime_type.startswith("audio/L"): + LOGGER.warning("Received unexpected MIME type %s", mime_type) + raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") + + bits_per_sample = 16 + rate = 24000 + + # Extract rate from parameters + parts = mime_type.split(";") + for param in parts: # Skip the main type part + param = param.strip() + if param.lower().startswith("rate="): + # Handle cases like "rate=" with no value or non-integer value and keep rate as default + with suppress(ValueError, IndexError): + rate_str = param.split("=", 1)[1] + rate = int(rate_str) + elif param.startswith("audio/L"): + # Keep bits_per_sample as default if conversion fails + with suppress(ValueError, IndexError): + bits_per_sample = int(param.split("L", 1)[1]) + + return {"bits_per_sample": bits_per_sample, "rate": rate} diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 174f0a50dc3..9bd7d547100 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -3,10 +3,7 @@ from __future__ import annotations from collections.abc import Mapping -from contextlib import suppress -import io from typing import Any -import wave from google.genai import types from google.genai.errors import APIError, ClientError @@ -25,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav async def async_setup_entry( @@ -152,62 +150,4 @@ class GoogleGenerativeAITextToSpeechEntity( except (APIError, ClientError, ValueError) as exc: LOGGER.error("Error during TTS: %s", exc, exc_info=True) raise HomeAssistantError(exc) from exc - return "wav", self._convert_to_wav(data, mime_type) - - def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes: - """Generate a WAV file header for the given audio data and parameters. - - Args: - audio_data: The raw audio data as a bytes object. - mime_type: Mime type of the audio data. - - Returns: - A bytes object representing the WAV file header. - - """ - parameters = self._parse_audio_mime_type(mime_type) - - wav_buffer = io.BytesIO() - with wave.open(wav_buffer, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(parameters["bits_per_sample"] // 8) - wf.setframerate(parameters["rate"]) - wf.writeframes(audio_data) - - return wav_buffer.getvalue() - - def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]: - """Parse bits per sample and rate from an audio MIME type string. - - Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx". - - Args: - mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000"). - - Returns: - A dictionary with "bits_per_sample" and "rate" keys. Values will be - integers if found, otherwise None. - - """ - if not mime_type.startswith("audio/L"): - LOGGER.warning("Received unexpected MIME type %s", mime_type) - raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") - - bits_per_sample = 16 - rate = 24000 - - # Extract rate from parameters - parts = mime_type.split(";") - for param in parts: # Skip the main type part - param = param.strip() - if param.lower().startswith("rate="): - # Handle cases like "rate=" with no value or non-integer value and keep rate as default - with suppress(ValueError, IndexError): - rate_str = param.split("=", 1)[1] - rate = int(rate_str) - elif param.startswith("audio/L"): - # Keep bits_per_sample as default if conversion fails - with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) - - return {"bits_per_sample": bits_per_sample, "rate": rate} + return "wav", convert_to_wav(data, mime_type) From d523f854048e8bab3cf1ba126aeabc64325bb224 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 26 Jun 2025 10:47:24 +0200 Subject: [PATCH 0736/1664] Fix sending commands to Matter vacuum (#147567) --- homeassistant/components/matter/vacuum.py | 64 +++++++++++-------- .../matter/snapshots/test_vacuum.ambr | 4 +- tests/components/matter/test_vacuum.py | 58 ++++++++++------- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py index 96c6ba212de..141400c384b 100644 --- a/homeassistant/components/matter/vacuum.py +++ b/homeassistant/components/matter/vacuum.py @@ -62,14 +62,25 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): _last_accepted_commands: list[int] | None = None _supported_run_modes: ( - dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None + dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None ) = None entity_description: StateVacuumEntityDescription _platform_translation_key = "vacuum" async def async_stop(self, **kwargs: Any) -> None: """Stop the vacuum cleaner.""" - await self.send_device_command(clusters.OperationalState.Commands.Stop()) + # We simply set the RvcRunMode to the first runmode + # that has the idle tag to stop the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for tag in mode.modeTags: + if tag.value == ModeTag.IDLE: + # stop the vacuum by changing the run mode to idle + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + return async def async_return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" @@ -83,15 +94,30 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): """Start or resume the cleaning task.""" if TYPE_CHECKING: assert self._last_accepted_commands is not None + + accepted_operational_commands = self._last_accepted_commands if ( clusters.RvcOperationalState.Commands.Resume.command_id - in self._last_accepted_commands + in accepted_operational_commands + and self.state == VacuumActivity.PAUSED ): + # vacuum is paused and supports resume command await self.send_device_command( clusters.RvcOperationalState.Commands.Resume() ) - else: - await self.send_device_command(clusters.OperationalState.Commands.Start()) + return + + # We simply set the RvcRunMode to the first runmode + # that has the cleaning tag to start the vacuum cleaner. + # this is compatible with both Matter 1.2 and 1.3+ devices. + supported_run_modes = self._supported_run_modes or {} + for mode in supported_run_modes.values(): + for tag in mode.modeTags: + if tag.value == ModeTag.CLEANING: + await self.send_device_command( + clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode) + ) + return async def async_pause(self) -> None: """Pause the cleaning task.""" @@ -130,6 +156,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): state = VacuumActivity.CLEANING elif ModeTag.IDLE in tags: state = VacuumActivity.IDLE + elif ModeTag.MAPPING in tags: + state = VacuumActivity.CLEANING self._attr_activity = state @callback @@ -143,7 +171,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): return self._last_accepted_commands = accepted_operational_commands supported_features: VacuumEntityFeature = VacuumEntityFeature(0) + supported_features |= VacuumEntityFeature.START supported_features |= VacuumEntityFeature.STATE + supported_features |= VacuumEntityFeature.STOP + # optional battery attribute = battery feature if self.get_matter_attribute_value( clusters.PowerSource.Attributes.BatPercentRemaining @@ -153,7 +184,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType): supported_features |= VacuumEntityFeature.LOCATE # create a map of supported run modes - run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = ( + run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = ( self.get_matter_attribute_value( clusters.RvcRunMode.Attributes.SupportedModes ) @@ -165,22 +196,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity): in accepted_operational_commands ): supported_features |= VacuumEntityFeature.PAUSE - if ( - clusters.OperationalState.Commands.Stop.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.STOP - if ( - clusters.OperationalState.Commands.Start.command_id - in accepted_operational_commands - ): - # note that start has been replaced by resume in rev2 of the spec - supported_features |= VacuumEntityFeature.START - if ( - clusters.RvcOperationalState.Commands.Resume.command_id - in accepted_operational_commands - ): - supported_features |= VacuumEntityFeature.START if ( clusters.RvcOperationalState.Commands.GoHome.command_id in accepted_operational_commands @@ -202,10 +217,7 @@ DISCOVERY_SCHEMAS = [ clusters.RvcRunMode.Attributes.CurrentMode, clusters.RvcOperationalState.Attributes.OperationalState, ), - optional_attributes=( - clusters.RvcCleanMode.Attributes.CurrentMode, - clusters.PowerSource.Attributes.BatPercentRemaining, - ), + optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,), device_type=(device_types.RoboticVacuumCleaner,), allow_none_value=True, ), diff --git a/tests/components/matter/snapshots/test_vacuum.ambr b/tests/components/matter/snapshots/test_vacuum.ambr index cb859147d75..71e0f75614d 100644 --- a/tests/components/matter/snapshots/test_vacuum.ambr +++ b/tests/components/matter/snapshots/test_vacuum.ambr @@ -28,7 +28,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': None, 'unique_id': '00000000000004D2-0000000000000042-MatterNodeDevice-1-MatterVacuumCleaner-84-1', 'unit_of_measurement': None, @@ -38,7 +38,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Vacuum', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.mock_vacuum', diff --git a/tests/components/matter/test_vacuum.py b/tests/components/matter/test_vacuum.py index 2642ff39ef8..b464e9f1cd3 100644 --- a/tests/components/matter/test_vacuum.py +++ b/tests/components/matter/test_vacuum.py @@ -9,7 +9,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotSupported from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -61,7 +60,29 @@ async def test_vacuum_actions( ) matter_client.send_device_command.reset_mock() - # test start/resume action + # test start action (from idle state) + await hass.services.async_call( + "vacuum", + "start", + { + "entity_id": entity_id, + }, + 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.RvcRunMode.Commands.ChangeToMode(newMode=1), + ) + matter_client.send_device_command.reset_mock() + + # test resume action (from paused state) + # first set the operational state to paused + set_node_attribute(matter_node, 1, 97, 4, 0x02) + await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( "vacuum", "start", @@ -98,25 +119,6 @@ async def test_vacuum_actions( matter_client.send_device_command.reset_mock() # test stop action - # stop command is not supported by the vacuum fixture - with pytest.raises( - ServiceNotSupported, - match="Entity vacuum.mock_vacuum does not support action vacuum.stop", - ): - await hass.services.async_call( - "vacuum", - "stop", - { - "entity_id": entity_id, - }, - blocking=True, - ) - - # update accepted command list to add support for stop command - set_node_attribute( - matter_node, 1, 97, 65529, [clusters.OperationalState.Commands.Stop.command_id] - ) - await trigger_subscription_callback(hass, matter_client) await hass.services.async_call( "vacuum", "stop", @@ -129,7 +131,7 @@ async def test_vacuum_actions( assert matter_client.send_device_command.call_args == call( node_id=matter_node.node_id, endpoint_id=1, - command=clusters.OperationalState.Commands.Stop(), + command=clusters.RvcRunMode.Commands.ChangeToMode(newMode=0), ) matter_client.send_device_command.reset_mock() @@ -209,11 +211,21 @@ async def test_vacuum_updates( assert state assert state.state == "idle" + # confirm state is 'cleaning' by setting; + # - the operational state to 0x00 + # - the run mode is set to a mode which has mapping tag + set_node_attribute(matter_node, 1, 97, 4, 0) + set_node_attribute(matter_node, 1, 84, 1, 2) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "cleaning" + # confirm state is 'unknown' by setting; # - the operational state to 0x00 # - the run mode is set to a mode which has neither cleaning or idle tag set_node_attribute(matter_node, 1, 97, 4, 0) - set_node_attribute(matter_node, 1, 84, 1, 2) + set_node_attribute(matter_node, 1, 84, 1, 5) await trigger_subscription_callback(hass, matter_client) state = hass.states.get(entity_id) assert state From ae062b230c22a8187b21785a3fa2f115b7697bc2 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 12:06:36 +0300 Subject: [PATCH 0737/1664] Remove obsolete routing info when migrating a Z-Wave network (#147568) --- homeassistant/components/zwave_js/api.py | 2 +- homeassistant/components/zwave_js/config_flow.py | 4 +++- tests/components/zwave_js/test_api.py | 6 +++--- tests/components/zwave_js/test_config_flow.py | 10 +++++----- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 168df5edcaa..a17f13e0d07 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -3105,7 +3105,7 @@ async def websocket_restore_nvm( driver.once("driver ready", set_driver_ready), ] - await controller.async_restore_nvm_base64(msg["data"]) + await controller.async_restore_nvm_base64(msg["data"], {"preserveRoutes": False}) with suppress(TimeoutError): async with asyncio.timeout(DRIVER_READY_TIMEOUT): diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 5e8e7022839..35b54aa2e49 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -1400,7 +1400,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): driver.once("driver ready", set_driver_ready), ] try: - await controller.async_restore_nvm(self.backup_data) + await controller.async_restore_nvm( + self.backup_data, {"preserveRoutes": False} + ) except FailedCommand as err: raise AbortFlow(f"Failed to restore network: {err}") from err else: diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index d6aed0b6d22..bac0162ba74 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -5649,7 +5649,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) @@ -5685,7 +5685,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) @@ -5740,7 +5740,7 @@ async def test_restore_nvm( { "command": "controller.restore_nvm", "nvmData": "dGVzdA==", - "migrateOptions": {}, + "migrateOptions": {"preserveRoutes": False}, }, require_schema=42, ) diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index dd8838e0775..a7bb02d5920 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -900,7 +900,7 @@ async def test_usb_discovery_migration( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -1031,7 +1031,7 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3501,7 +3501,7 @@ async def test_reconfigure_migrate_with_addon( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3686,7 +3686,7 @@ async def test_reconfigure_migrate_reset_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, @@ -3835,7 +3835,7 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes): + async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, From 17fd850fa6bd09858ba04f52dd6ec0e863918d48 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Thu, 26 Jun 2025 13:15:02 +0300 Subject: [PATCH 0738/1664] Hide unnamed paths when selecting a USB Z-Wave adapter (#147571) * Hide unnamed paths when selecting a USB Z-Wave adapter * remove pointless sorting --- .../components/zwave_js/config_flow.py | 16 +-- tests/components/zwave_js/test_config_flow.py | 102 +++++++++++++++++- 2 files changed, 106 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 35b54aa2e49..2c37ee4b554 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -138,13 +138,15 @@ def get_usb_ports() -> dict[str, str]: ) port_descriptions[dev_path] = human_name - # Sort the dictionary by description, putting "n/a" last - return dict( - sorted( - port_descriptions.items(), - key=lambda x: x[1].lower().startswith("n/a"), - ) - ) + # Filter out "n/a" descriptions only if there are other ports available + non_na_ports = { + path: desc + for path, desc in port_descriptions.items() + if not desc.lower().startswith("n/a") + } + + # If we have non-"n/a" ports, return only those; otherwise return all ports as-is + return non_na_ports if non_na_ports else port_descriptions async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index a7bb02d5920..2e41a176a9c 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -4435,8 +4435,8 @@ async def test_configure_addon_usb_ports_failure( assert result["reason"] == "usb_ports_failed" -async def test_get_usb_ports_sorting() -> None: - """Test that get_usb_ports sorts ports with 'n/a' descriptions last.""" +async def test_get_usb_ports_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions when other ports are available.""" mock_ports = [ ListPortInfo("/dev/ttyUSB0"), ListPortInfo("/dev/ttyUSB1"), @@ -4453,13 +4453,105 @@ async def test_get_usb_ports_sorting() -> None: descriptions = list(result.values()) - # Verify that descriptions containing "n/a" are at the end - + # Verify that only non-"n/a" descriptions are returned assert descriptions == [ "Device A - /dev/ttyUSB1, s/n: n/a", "Device B - /dev/ttyUSB3, s/n: n/a", + ] + + +async def test_get_usb_ports_all_na() -> None: + """Test that get_usb_ports returns all ports as-is when only 'n/a' descriptions exist.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "N/A" + mock_ports[2].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that all ports are returned since they all have "n/a" descriptions + assert len(descriptions) == 3 + # Verify that all descriptions contain "n/a" (case-insensitive) + assert all("n/a" in desc.lower() for desc in descriptions) + # Verify that all expected device paths are present + device_paths = [desc.split(" - ")[1].split(",")[0] for desc in descriptions] + assert "/dev/ttyUSB0" in device_paths + assert "/dev/ttyUSB1" in device_paths + assert "/dev/ttyUSB2" in device_paths + + +async def test_get_usb_ports_mixed_case_filtering() -> None: + """Test that get_usb_ports filters out 'n/a' descriptions with different case variations.""" + mock_ports = [ + ListPortInfo("/dev/ttyUSB0"), + ListPortInfo("/dev/ttyUSB1"), + ListPortInfo("/dev/ttyUSB2"), + ListPortInfo("/dev/ttyUSB3"), + ListPortInfo("/dev/ttyUSB4"), + ] + mock_ports[0].description = "n/a" + mock_ports[1].description = "Device A" + mock_ports[2].description = "N/A" + mock_ports[3].description = "n/A" + mock_ports[4].description = "Device B" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that only non-"n/a" descriptions are returned (case-insensitive filtering) + assert descriptions == [ + "Device A - /dev/ttyUSB1, s/n: n/a", + "Device B - /dev/ttyUSB4, s/n: n/a", + ] + + +async def test_get_usb_ports_empty_list() -> None: + """Test that get_usb_ports handles empty port list.""" + with patch("serial.tools.list_ports.comports", return_value=[]): + result = get_usb_ports() + + # Verify that empty dict is returned + assert result == {} + + +async def test_get_usb_ports_single_na_port() -> None: + """Test that get_usb_ports returns single 'n/a' port when it's the only one available.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "n/a" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single "n/a" port is returned + assert descriptions == [ "n/a - /dev/ttyUSB0, s/n: n/a", - "N/A - /dev/ttyUSB2, s/n: n/a", + ] + + +async def test_get_usb_ports_single_valid_port() -> None: + """Test that get_usb_ports returns single valid port.""" + mock_ports = [ListPortInfo("/dev/ttyUSB0")] + mock_ports[0].description = "Device A" + + with patch("serial.tools.list_ports.comports", return_value=mock_ports): + result = get_usb_ports() + + descriptions = list(result.values()) + + # Verify that the single valid port is returned + assert descriptions == [ + "Device A - /dev/ttyUSB0, s/n: n/a", ] From 398dd3ae4638a05d83622b51497853a1c05e462a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 12:49:33 +0200 Subject: [PATCH 0739/1664] Set right model in OpenAI conversation (#147575) --- .../openai_conversation/conversation.py | 2 +- .../openai_conversation/conftest.py | 24 +++++--- .../snapshots/test_init.ambr | 55 +++++++++++++++++++ .../openai_conversation/test_init.py | 23 +++++++- 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 tests/components/openai_conversation/snapshots/test_init.ambr diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e63bbf32c35..e590a72cadb 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -247,7 +247,7 @@ class OpenAIConversationEntity( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="OpenAI", - model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), entry_type=dr.DeviceEntryType.SERVICE, ) if self.subentry.data.get(CONF_LLM_HASS_API): diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index aa17c333a79..b8944d837be 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,10 +1,12 @@ """Tests helpers.""" +from typing import Any from unittest.mock import patch import pytest from homeassistant.components.openai_conversation.const import DEFAULT_CONVERSATION_NAME +from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant from homeassistant.helpers import llm @@ -14,7 +16,15 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def mock_subentry_data() -> dict[str, Any]: + """Mock subentry data.""" + return {} + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, mock_subentry_data: dict[str, Any] +) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( title="OpenAI", @@ -24,12 +34,12 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, version=2, subentries_data=[ - { - "data": {}, - "subentry_type": "conversation", - "title": DEFAULT_CONVERSATION_NAME, - "unique_id": None, - } + ConfigSubentryData( + data=mock_subentry_data, + subentry_type="conversation", + title=DEFAULT_CONVERSATION_NAME, + unique_id=None, + ) ], ) entry.add_to_hass(hass) diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr new file mode 100644 index 00000000000..8648e47474e --- /dev/null +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_devices[mock_subentry_data0] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-4o-mini', + 'model_id': None, + 'name': 'OpenAI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }) +# --- +# name: test_devices[mock_subentry_data1] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'OpenAI', + 'model': 'gpt-1o', + 'model_id': None, + 'name': 'OpenAI Conversation', + '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/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d209554e8d3..b7f2a5434eb 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -13,8 +13,10 @@ from openai.types.image import Image from openai.types.images_response import ImagesResponse from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props -from homeassistant.components.openai_conversation import CONF_FILENAMES +from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES from homeassistant.components.openai_conversation.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -806,3 +808,22 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + + +@pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert exception when invalid config entry is provided.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(devices) == 1 + device = devices[0] + assert device == snapshot(exclude=props("identifiers")) + subentry = next(iter(mock_config_entry.subentries.values())) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} From 153e1e43e86271979d7537c0deaddaa3d0e0c26b Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 26 Jun 2025 11:49:06 +0200 Subject: [PATCH 0740/1664] Do not make the favorite button unavailable when no content playing on a Music Assistant player (#147579) --- .../components/music_assistant/button.py | 6 --- .../snapshots/test_button.ambr | 2 +- .../components/music_assistant/test_button.py | 42 ++++++++++++++++++- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/music_assistant/button.py b/homeassistant/components/music_assistant/button.py index 7969954e443..445ef2c3e98 100644 --- a/homeassistant/components/music_assistant/button.py +++ b/homeassistant/components/music_assistant/button.py @@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity): translation_key="favorite_now_playing", ) - @property - def available(self) -> bool: - """Return availability of entity.""" - # mark the button as unavailable if the player has no current media item - return super().available and self.player.current_media is not None - @catch_musicassistant_error async def async_press(self) -> None: """Handle the button press command.""" diff --git a/tests/components/music_assistant/snapshots/test_button.ambr b/tests/components/music_assistant/snapshots/test_button.ambr index ac9e4c660f6..d064916e044 100644 --- a/tests/components/music_assistant/snapshots/test_button.ambr +++ b/tests/components/music_assistant/snapshots/test_button.ambr @@ -140,6 +140,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'unknown', }) # --- diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 8a1a4b0e241..5a326b1d8ea 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -2,14 +2,20 @@ from unittest.mock import MagicMock, call +from music_assistant_models.enums import EventType +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.const import ATTR_ENTITY_ID, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import setup_integration_from_fixtures, snapshot_music_assistant_entities +from .common import ( + setup_integration_from_fixtures, + snapshot_music_assistant_entities, + trigger_subscription_callback, +) async def test_button_entities( @@ -46,3 +52,35 @@ async def test_button_press_action( "music/favorites/add_item", item="spotify://track/5d95dc5be77e4f7eb4939f62cfef527b", ) + + # test again without current_media + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].current_media = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) + + # test again without active source + mass_player_id = "00:00:00:00:00:02" + music_assistant_client.players._players[mass_player_id].active_source = None + await trigger_subscription_callback( + hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id + ) + with pytest.raises(HomeAssistantError, match="Player has no active source"): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: entity_id, + }, + blocking=True, + ) From 4cc10ca2e2711c71ce3864e33ba2758f2b13fcc6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:44:15 +0200 Subject: [PATCH 0741/1664] Set Google AI model as device model (#147582) * Set Google AI model as device model * fix --- .../entity.py | 9 ++- .../google_generative_ai_conversation/tts.py | 6 +- .../snapshots/test_init.ambr | 66 +++++++++++++++++++ .../test_init.py | 14 ++++ 4 files changed, 92 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 66acb6b158a..dea875212ef 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -301,7 +301,12 @@ async def _transform_stream( class GoogleGenerativeAILLMBaseEntity(Entity): """Google Generative AI base entity.""" - def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None: + def __init__( + self, + entry: ConfigEntry, + subentry: ConfigSubentry, + default_model: str = RECOMMENDED_CHAT_MODEL, + ) -> None: """Initialize the agent.""" self.entry = entry self.subentry = subentry @@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Google", - model="Generative AI", + model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1], entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/google_generative_ai_conversation/tts.py b/homeassistant/components/google_generative_ai_conversation/tts.py index 9bd7d547100..9bc5b0c6cb6 100644 --- a/homeassistant/components/google_generative_ai_conversation/tts.py +++ b/homeassistant/components/google_generative_ai_conversation/tts.py @@ -15,7 +15,7 @@ from homeassistant.components.tts import ( TtsAudioType, Voice, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -114,6 +114,10 @@ class GoogleGenerativeAITextToSpeechEntity( ) ] + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the TTS entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL) + @callback def async_get_supported_voices(self, language: str) -> list[Voice]: """Return a list of supported voices for a language.""" diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index f89871ff131..5722713bc56 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -1,4 +1,70 @@ # serializer version: 1 +# name: test_devices + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-conversation', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Conversation', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-tts', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash-preview-tts', + 'model_id': None, + 'name': 'Google AI TTS', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), + ]) +# --- # name: test_generate_content_file_processing_succeeds list([ tuple( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 46a2d634b81..85d6c70b658 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -762,3 +762,17 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + + +async def test_devices( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Assert that devices are created correctly.""" + devices = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert devices == snapshot From 1f57bba9cd13cb11329e9891647a7d280b939090 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:11:48 +0200 Subject: [PATCH 0742/1664] Add default conversation name for OpenAI integration (#147597) --- homeassistant/components/openai_conversation/__init__.py | 2 ++ homeassistant/components/openai_conversation/const.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index a5b13ded375..e14a8aabc1b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -49,6 +49,7 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_NAME, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL, @@ -351,6 +352,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_NAME, options={}, version=2, ) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index f90c05eed79..3f1c0dc7429 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -6,12 +6,12 @@ DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" -CONF_PROMPT = "prompt" CONF_REASONING_EFFORT = "reasoning_effort" CONF_RECOMMENDED = "recommended" CONF_TEMPERATURE = "temperature" From c7677b91da7224382d675091d1557ea77ea37997 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:11:13 +0200 Subject: [PATCH 0743/1664] Add default title to migrated Claude entry (#147598) --- homeassistant/components/anthropic/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c13c82f0020..c537a000c14 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -17,7 +17,13 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.typing import ConfigType -from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL +from .const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, +) PLATFORMS = (Platform.CONVERSATION,) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -123,6 +129,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_CONVERSATION_NAME, options={}, version=2, ) From a233b6b1e38568ce4b1d39f8e907b05bc40f26c8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 17:05:26 +0200 Subject: [PATCH 0744/1664] Add default title to migrated Ollama entry (#147599) --- homeassistant/components/ollama/__init__.py | 2 ++ homeassistant/components/ollama/const.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 90d2012766d..f174c709b65 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -27,6 +27,7 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN, ) @@ -138,6 +139,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: else: hass.config_entries.async_update_entry( entry, + title=DEFAULT_NAME, options={}, version=2, ) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index ebace6404b2..3175525c70d 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -2,6 +2,8 @@ DOMAIN = "ollama" +DEFAULT_NAME = "Ollama" + CONF_MODEL = "model" CONF_PROMPT = "prompt" CONF_THINK = "think" From 9cc75f345860f556a18b057caa25091bb0c7c6ed Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 26 Jun 2025 17:46:45 +0200 Subject: [PATCH 0745/1664] Update frontend to 20250626.0 (#147601) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 0028bda57be..8e4ea47da5b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250625.0"] + "requirements": ["home-assistant-frontend==20250626.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 725033f814e..5839a3ae014 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index abb3b15be3d..9bc728320a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6f5cc7ee06..8a5f97014e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250625.0 +home-assistant-frontend==20250626.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From f8207a2e0e2e00c065257d5ca30942c63c91fe12 Mon Sep 17 00:00:00 2001 From: Luca Angemi Date: Thu, 26 Jun 2025 17:05:59 +0200 Subject: [PATCH 0746/1664] Remove default icon for wind direction sensor for Buienradar (#147603) * Fix wind direction state class sensor * Remove default icon for wind direction sensor --- homeassistant/components/buienradar/sensor.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 586543de129..b32e630ef5c 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="windazimuth", translation_key="windazimuth", native_unit_of_measurement=DEGREE, - icon="mdi:compass-outline", device_class=SensorDeviceClass.WIND_DIRECTION, state_class=SensorStateClass.MEASUREMENT_ANGLE, ), From c8422c9fb8dd31cea18f64c5bd3ae8160b4203b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 18:21:56 +0200 Subject: [PATCH 0747/1664] Improve explanation on how to get API token in Telegram (#147605) --- homeassistant/components/telegram_bot/config_flow.py | 6 +++++- homeassistant/components/telegram_bot/strings.json | 5 ++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index d9b334a4ac1..67981cbd704 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -293,10 +293,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle a flow to create a new config entry for a Telegram bot.""" + description_placeholders: dict[str, str] = { + "botfather_username": "@BotFather", + "botfather_url": "https://t.me/botfather", + } if not user_input: return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, + description_placeholders=description_placeholders, ) # prevent duplicates @@ -305,7 +310,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} - description_placeholders: dict[str, str] = {} bot_name = await self._validate_bot( user_input, errors, description_placeholders ) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index a51d4a371f1..17b2e6f24d6 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -2,11 +2,10 @@ "config": { "step": { "user": { - "title": "Telegram bot setup", - "description": "Create a new Telegram bot", + "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_key%]", + "api_key": "[%key:common::config_flow::data::api_token%]", "proxy_url": "Proxy URL" }, "data_description": { From 4df1f702bfede0d6864708e0844760b171611763 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Thu, 26 Jun 2025 19:52:58 +0200 Subject: [PATCH 0748/1664] Fix asset url in Habitica integration (#147612) --- homeassistant/components/habitica/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index f9874c711f0..d7cede1db03 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/" SITE_DATA_URL = "https://habitica.com/user/settings/siteData" FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password" SIGN_UP_URL = "https://habitica.com/register" -HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png" +HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png" DOMAIN = "habitica" From 26521f8cc0dbe28fb3404882339879ac272c22bd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:55:38 +0200 Subject: [PATCH 0749/1664] Hide Telegram bot proxy URL behind section (#147613) Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> --- .../components/telegram_bot/config_flow.py | 53 +++++++++++++++---- .../components/telegram_bot/const.py | 2 +- .../components/telegram_bot/strings.json | 34 +++++++++--- .../telegram_bot/test_config_flow.py | 26 ++++++--- .../telegram_bot/test_telegram_bot.py | 2 + 5 files changed, 91 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 67981cbd704..1a77a5b9a81 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, section from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.network import NoURLAvailableError, get_url @@ -58,6 +58,7 @@ from .const import ( PLATFORM_BROADCAST, PLATFORM_POLLING, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) @@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema( autocomplete="current-password", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema( translation_key="platforms", ) ), - vol.Optional(CONF_PROXY_URL): TextSelector( - config=TextSelectorConfig(type=TextSelectorType.URL) + vol.Required(SECTION_ADVANCED_SETTINGS): section( + vol.Schema( + { + vol.Optional(CONF_PROXY_URL): TextSelector( + config=TextSelectorConfig(type=TextSelectorType.URL) + ), + }, + ), + {"collapsed": True}, ), } ) @@ -197,6 +212,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): import_data[CONF_TRUSTED_NETWORKS] = ",".join( import_data[CONF_TRUSTED_NETWORKS] ) + import_data[SECTION_ADVANCED_SETTINGS] = { + CONF_PROXY_URL: import_data.get(CONF_PROXY_URL) + } try: config_flow_result: ConfigFlowResult = await self.async_step_user( import_data @@ -332,7 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), }, options={ # this value may come from yaml import @@ -444,7 +462,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: self._step_user_data[CONF_PLATFORM], CONF_API_KEY: self._step_user_data[CONF_API_KEY], - CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL), + CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), CONF_URL: user_input.get(CONF_URL), CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS], }, @@ -509,9 +529,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - self._get_reconfigure_entry().data, + { + **self._get_reconfigure_entry().data, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: self._get_reconfigure_entry().data.get( + CONF_PROXY_URL + ), + }, + }, ), ) + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) errors: dict[str, str] = {} description_placeholders: dict[str, str] = {} @@ -527,7 +557,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reconfigure", data_schema=self.add_suggested_values_to_schema( STEP_RECONFIGURE_USER_DATA_SCHEMA, - user_input, + { + **user_input, + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: user_input.get(CONF_PROXY_URL), + }, + }, ), errors=errors, description_placeholders=description_placeholders, diff --git a/homeassistant/components/telegram_bot/const.py b/homeassistant/components/telegram_bot/const.py index d6da96d9a28..0f1d5193e2c 100644 --- a/homeassistant/components/telegram_bot/const.py +++ b/homeassistant/components/telegram_bot/const.py @@ -7,7 +7,7 @@ DOMAIN = "telegram_bot" PLATFORM_BROADCAST = "broadcast" PLATFORM_POLLING = "polling" PLATFORM_WEBHOOKS = "webhooks" - +SECTION_ADVANCED_SETTINGS = "advanced_settings" SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_BOT_COUNT = "bot_count" diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 17b2e6f24d6..4187b6311d9 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -5,13 +5,22 @@ "description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.", "data": { "platform": "Platform", - "api_key": "[%key:common::config_flow::data::api_token%]", - "proxy_url": "Proxy URL" + "api_key": "[%key:common::config_flow::data::api_token%]" }, "data_description": { "platform": "Telegram bot implementation", - "api_key": "The API token of your bot.", - "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + "api_key": "The API token of your bot." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "proxy_url": "Proxy URL" + }, + "data_description": { + "proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)" + } + } } }, "webhooks": { @@ -29,12 +38,21 @@ "title": "Telegram bot setup", "description": "Reconfigure Telegram bot", "data": { - "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data::platform%]" }, "data_description": { - "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]", - "proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]" + "platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]" + }, + "sections": { + "advanced_settings": { + "name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]", + "data": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]" + }, + "data_description": { + "proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]" + } + } } }, "reauth_confirm": { diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 0287ccc5dfa..e13fab8f28b 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -23,6 +23,7 @@ from homeassistant.components.telegram_bot.const import ( PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, PLATFORM_WEBHOOKS, + SECTION_ADVANCED_SETTINGS, SUBENTRY_TYPE_ALLOWED_CHAT_IDS, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, ConfigSubentry @@ -89,7 +90,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -104,7 +107,9 @@ async def test_reconfigure_flow_broadcast( result["flow_id"], { CONF_PLATFORM: PLATFORM_BROADCAST, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -131,7 +136,9 @@ async def test_reconfigure_flow_webhooks( result["flow_id"], { CONF_PLATFORM: PLATFORM_WEBHOOKS, - CONF_PROXY_URL: "https://test", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://test", + }, }, ) await hass.async_block_till_done() @@ -197,9 +204,7 @@ async def test_reconfigure_flow_webhooks( ] -async def test_create_entry( - hass: HomeAssistant, -) -> None: +async def test_create_entry(hass: HomeAssistant) -> None: """Test user flow.""" # test: no input @@ -225,7 +230,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "invalid", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "invalid", + }, }, ) await hass.async_block_till_done() @@ -245,7 +252,9 @@ async def test_create_entry( { CONF_PLATFORM: PLATFORM_WEBHOOKS, CONF_API_KEY: "mock api key", - CONF_PROXY_URL: "https://proxy", + SECTION_ADVANCED_SETTINGS: { + CONF_PROXY_URL: "https://proxy", + }, }, ) await hass.async_block_till_done() @@ -535,6 +544,7 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch( diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 190fed07ae3..6590bbed1cf 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -51,6 +51,7 @@ from homeassistant.components.telegram_bot.const import ( CONF_CONFIG_ENTRY_ID, DOMAIN, PLATFORM_BROADCAST, + SECTION_ADVANCED_SETTINGS, SERVICE_ANSWER_CALLBACK_QUERY, SERVICE_DELETE_MESSAGE, SERVICE_EDIT_CAPTION, @@ -722,6 +723,7 @@ async def test_send_message_no_chat_id_error( data = { CONF_PLATFORM: PLATFORM_BROADCAST, CONF_API_KEY: "mock api key", + SECTION_ADVANCED_SETTINGS: {}, } with patch("homeassistant.components.telegram_bot.config_flow.Bot.get_me"): From c0ec987b07423ebad79a0b930e47b88fef302ade Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 19:52:29 +0200 Subject: [PATCH 0750/1664] Fix meaters not being added after a reload (#147614) --- homeassistant/components/meater/__init__.py | 6 ++- .../components/meater/coordinator.py | 6 ++- tests/components/meater/test_init.py | 44 ++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/meater/__init__.py b/homeassistant/components/meater/__init__.py index 212e8a2a33a..9f35d941b65 100644 --- a/homeassistant/components/meater/__init__.py +++ b/homeassistant/components/meater/__init__.py @@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[MEATER_DATA] = ( + hass.data[MEATER_DATA] - entry.runtime_data.found_probes + ) + return unload_ok diff --git a/homeassistant/components/meater/coordinator.py b/homeassistant/components/meater/coordinator.py index 042a3c87b0c..9a9910f6e1a 100644 --- a/homeassistant/components/meater/coordinator.py +++ b/homeassistant/components/meater/coordinator.py @@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): ) session = async_get_clientsession(hass) self.client = MeaterApi(session) + self.found_probes: set[str] = set() async def _async_setup(self) -> None: """Set up the Meater Coordinator.""" @@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]): raise UpdateFailed( "Too many requests have been made to the API, rate limiting is in place" ) from err - - return {device.id: device for device in devices} + res = {device.id: device for device in devices} + self.found_probes.update(set(res.keys())) + return res diff --git a/tests/components/meater/test_init.py b/tests/components/meater/test_init.py index 52f6b29d488..8f4e4e75a86 100644 --- a/tests/components/meater/test_init.py +++ b/tests/components/meater/test_init.py @@ -5,8 +5,10 @@ from unittest.mock import AsyncMock from syrupy.assertion import SnapshotAssertion from homeassistant.components.meater.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import setup_integration from .const import PROBE_ID @@ -26,3 +28,43 @@ async def test_device_info( device_entry = device_registry.async_get_device(identifiers={(DOMAIN, PROBE_ID)}) assert device_entry is not None assert device_entry == snapshot + + +async def test_load_unload( + hass: HomeAssistant, + mock_meater_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test unload of Meater integration.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) + + assert await hass.config_entries.async_reload(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert ( + len( + er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + ) + == 8 + ) + assert ( + hass.states.get("sensor.meater_probe_40a72384_ambient_temperature").state + != STATE_UNAVAILABLE + ) From 6a7385590ae54d212d156b73cdeb06da1819f513 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Jun 2025 18:03:11 +0000 Subject: [PATCH 0751/1664] Bump version to 2025.7.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 02631a8f365..cf48d8b2427 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 24e290faed2..dfb0fc741fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b0" +version = "2025.7.0b1" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From babecdf32cc8816d11a71e1eca6ab246ef8bc90e Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Thu, 26 Jun 2025 14:52:07 -0400 Subject: [PATCH 0752/1664] Add Diagnostics to PlayStation Network (#147607) * Add Diagnostics support to PlayStation_Network * Remove unused constant * minor cleanup * Redact additional data * Redact additional data --- .../playstation_network/diagnostics.py | 55 +++++++++++++ .../playstation_network/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 79 +++++++++++++++++++ .../playstation_network/test_diagnostics.py | 28 +++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/playstation_network/diagnostics.py create mode 100644 tests/components/playstation_network/snapshots/test_diagnostics.ambr create mode 100644 tests/components/playstation_network/test_diagnostics.py diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py new file mode 100644 index 00000000000..8332572177d --- /dev/null +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for PlayStation Network.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from psnawp_api.models.trophies import PlatformType + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator + +TO_REDACT = { + "account_id", + "firstName", + "lastName", + "middleName", + "onlineId", + "url", + "username", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: PlaystationNetworkCoordinator = entry.runtime_data + + return { + "data": async_redact_data( + _serialize_platform_types(asdict(coordinator.data)), TO_REDACT + ), + } + + +def _serialize_platform_types(data: Any) -> Any: + """Recursively convert PlatformType enums to strings in dicts and sets.""" + if isinstance(data, dict): + return { + ( + platform.value if isinstance(platform, PlatformType) else platform + ): _serialize_platform_types(record) + for platform, record in data.items() + } + if isinstance(data, set): + return [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + if isinstance(data, PlatformType): + return data.value + return data diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index e173c4a710c..a98c30a7667 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -44,7 +44,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Discovery flow is not applicable for this integration diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..405cee04559 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'account_id': '**REDACTED**', + 'active_sessions': dict({ + 'PS5': dict({ + 'format': 'PS5', + 'media_image_url': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'platform': 'PS5', + 'status': 'online', + 'title_id': 'PPSA07784_00', + 'title_name': 'STAR WARS Jedi: Survivor™', + }), + }), + 'available': True, + 'presence': dict({ + 'basicPresence': dict({ + 'availability': 'availableToPlay', + 'gameTitleInfoList': list([ + dict({ + 'conceptIconUrl': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'format': 'PS5', + 'launchPlatform': 'PS5', + 'npTitleId': 'PPSA07784_00', + 'titleName': 'STAR WARS Jedi: Survivor™', + }), + ]), + 'primaryPlatformInfo': dict({ + 'onlineStatus': 'online', + 'platform': 'PS5', + }), + }), + }), + 'profile': dict({ + 'aboutMe': 'Never Gonna Give You Up', + 'avatars': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + 'isMe': True, + 'isOfficiallyVerified': False, + 'isPlus': True, + 'languages': list([ + 'de-DE', + ]), + 'onlineId': '**REDACTED**', + 'personalDetail': dict({ + 'firstName': '**REDACTED**', + 'lastName': '**REDACTED**', + 'profilePictures': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + }), + }), + 'registered_platforms': list([ + 'PS5', + ]), + 'trophy_summary': dict({ + 'account_id': '**REDACTED**', + 'earned_trophies': dict({ + 'bronze': 14450, + 'gold': 11754, + 'platinum': 1398, + 'silver': 8722, + }), + 'progress': 19, + 'tier': 10, + 'trophy_level': 1079, + }), + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/playstation_network/test_diagnostics.py b/tests/components/playstation_network/test_diagnostics.py new file mode 100644 index 00000000000..b803a213207 --- /dev/null +++ b/tests/components/playstation_network/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for PlayStation Network diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 06d04c001d1150d9f36d11354f3e419b976b7de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 26 Jun 2025 19:55:46 +0100 Subject: [PATCH 0753/1664] Use non-autospec mock for Reolink's host tests (#147619) --- tests/components/reolink/conftest.py | 1 + tests/components/reolink/test_host.py | 144 +++++++++++--------------- 2 files changed, 59 insertions(+), 86 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 6d5e7d2688e..256c50c9ea2 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -81,6 +81,7 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.set_audio = AsyncMock() host_mock.set_email = AsyncMock() host_mock.ONVIF_event_callback = AsyncMock() + host_mock.renew = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_host.py b/tests/components/reolink/test_host.py index f997a1ac08a..6ae7c66704c 100644 --- a/tests/components/reolink/test_host.py +++ b/tests/components/reolink/test_host.py @@ -39,11 +39,10 @@ async def test_setup_with_tcp_push( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test successful setup of the integration with TCP push callbacks.""" - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.events_active = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,47 +53,39 @@ async def test_setup_with_tcp_push( await hass.async_block_till_done() # ONVIF push subscription not called - assert not reolink_connect.subscribe.called - - reolink_connect.baichuan.events_active = False - reolink_connect.baichuan.subscribe_events.side_effect = ReolinkError("Test error") + assert not reolink_host.subscribe.called async def test_unloading_with_tcp_push( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test successful unloading of the integration with TCP push callbacks.""" - reolink_connect.baichuan.events_active = True - reolink_connect.baichuan.subscribe_events.reset_mock(side_effect=True) + reolink_host.baichuan.events_active = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.baichuan.unsubscribe_events.side_effect = ReolinkError("Test error") + reolink_host.baichuan.unsubscribe_events.side_effect = ReolinkError("Test error") # Unload the config entry assert await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - reolink_connect.baichuan.events_active = False - reolink_connect.baichuan.subscribe_events.side_effect = ReolinkError("Test error") - reolink_connect.baichuan.unsubscribe_events.reset_mock(side_effect=True) - async def test_webhook_callback( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, entity_registry: er.EntityRegistry, ) -> None: """Test webhook callback with motion sensor.""" - reolink_connect.motion_detected.return_value = False + reolink_host.motion_detected.return_value = False with patch("homeassistant.components.reolink.PLATFORMS", [Platform.BINARY_SENSOR]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -115,9 +106,9 @@ async def test_webhook_callback( assert hass.states.get(entity_id).state == STATE_OFF # test webhook callback success all channels - reolink_connect.get_motion_state_all_ch.return_value = True - reolink_connect.motion_detected.return_value = True - reolink_connect.ONVIF_event_callback.return_value = None + reolink_host.get_motion_state_all_ch.return_value = True + reolink_host.motion_detected.return_value = True + reolink_host.ONVIF_event_callback.return_value = None await client.post(f"/api/webhook/{webhook_id}") await hass.async_block_till_done() signal_all.assert_called_once() @@ -129,7 +120,7 @@ async def test_webhook_callback( # test webhook callback all channels with failure to read motion_state signal_all.reset_mock() - reolink_connect.get_motion_state_all_ch.return_value = False + reolink_host.get_motion_state_all_ch.return_value = False await client.post(f"/api/webhook/{webhook_id}") await hass.async_block_till_done() signal_all.assert_not_called() @@ -137,8 +128,8 @@ async def test_webhook_callback( assert hass.states.get(entity_id).state == STATE_ON # test webhook callback success single channel - reolink_connect.motion_detected.return_value = False - reolink_connect.ONVIF_event_callback.return_value = [0] + reolink_host.motion_detected.return_value = False + reolink_host.ONVIF_event_callback.return_value = [0] await client.post(f"/api/webhook/{webhook_id}", data="test_data") await hass.async_block_till_done() signal_ch.assert_called_once() @@ -146,7 +137,7 @@ async def test_webhook_callback( # test webhook callback single channel with error in event callback signal_ch.reset_mock() - reolink_connect.ONVIF_event_callback.side_effect = Exception("Test error") + reolink_host.ONVIF_event_callback.side_effect = Exception("Test error") await client.post(f"/api/webhook/{webhook_id}", data="test_data") await hass.async_block_till_done() signal_ch.assert_not_called() @@ -171,45 +162,42 @@ async def test_webhook_callback( await async_handle_webhook(hass, webhook_id, request) signal_all.assert_not_called() - reolink_connect.ONVIF_event_callback.reset_mock(side_effect=True) - async def test_no_mac( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test setup of host with no mac.""" - original = reolink_connect.mac_address - reolink_connect.mac_address = None + original = reolink_host.mac_address + reolink_host.mac_address = None assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_RETRY - reolink_connect.mac_address = original + reolink_host.mac_address = original async def test_subscribe_error( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test error when subscribing to ONVIF does not block startup.""" - reolink_connect.subscribe.side_effect = ReolinkError("Test Error") - reolink_connect.subscribed.return_value = False + reolink_host.subscribe.side_effect = ReolinkError("Test Error") + reolink_host.subscribed.return_value = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.subscribe.reset_mock(side_effect=True) async def test_subscribe_unsuccesfull( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test that a unsuccessful ONVIF subscription does not block startup.""" - reolink_connect.subscribed.return_value = False + reolink_host.subscribed.return_value = False assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -218,7 +206,7 @@ async def test_subscribe_unsuccesfull( async def test_initial_ONVIF_not_supported( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test setup when initial ONVIF is not supported.""" @@ -228,7 +216,7 @@ async def test_initial_ONVIF_not_supported( return False return True - reolink_connect.supported = test_supported + reolink_host.supported = test_supported assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -238,7 +226,7 @@ async def test_initial_ONVIF_not_supported( async def test_ONVIF_not_supported( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test setup is not blocked when ONVIF API returns NotSupportedError.""" @@ -248,26 +236,23 @@ async def test_ONVIF_not_supported( return False return True - reolink_connect.supported = test_supported - reolink_connect.subscribed.return_value = False - reolink_connect.subscribe.side_effect = NotSupportedError("Test error") + reolink_host.supported = test_supported + reolink_host.subscribed.return_value = False + reolink_host.subscribe.side_effect = NotSupportedError("Test error") assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.subscribe.reset_mock(side_effect=True) - reolink_connect.subscribed.return_value = True - async def test_renew( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test renew of the ONVIF subscription.""" - reolink_connect.renewtimer.return_value = 1 + reolink_host.renewtimer.return_value = 1 assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -277,56 +262,51 @@ async def test_renew( async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.renew.assert_called() + reolink_host.renew.assert_called() - reolink_connect.renew.side_effect = SubscriptionError("Test error") + reolink_host.renew.side_effect = SubscriptionError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.subscribe.assert_called() + reolink_host.subscribe.assert_called() - reolink_connect.subscribe.reset_mock() - reolink_connect.subscribe.side_effect = SubscriptionError("Test error") + reolink_host.subscribe.reset_mock() + reolink_host.subscribe.side_effect = SubscriptionError("Test error") freezer.tick(DEVICE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.subscribe.assert_called() - - reolink_connect.renew.reset_mock(side_effect=True) - reolink_connect.subscribe.reset_mock(side_effect=True) + reolink_host.subscribe.assert_called() async def test_long_poll_renew_fail( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ONVIF long polling errors while renewing.""" assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.subscribe.side_effect = NotSupportedError("Test error") + reolink_host.subscribe.side_effect = NotSupportedError("Test error") freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) async_fire_time_changed(hass) await hass.async_block_till_done() # ensure long polling continues - reolink_connect.pull_point_request.assert_called() - - reolink_connect.subscribe.reset_mock(side_effect=True) + reolink_host.pull_point_request.assert_called() async def test_register_webhook_errors( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors while registering the webhook.""" with patch( @@ -343,7 +323,7 @@ async def test_long_poll_stop_when_push( hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test ONVIF long polling stops when ONVIF push comes in.""" assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -357,7 +337,7 @@ async def test_long_poll_stop_when_push( # simulate ONVIF push callback client = await hass_client_no_auth() - reolink_connect.ONVIF_event_callback.return_value = None + reolink_host.ONVIF_event_callback.return_value = None webhook_id = config_entry.runtime_data.host.webhook_id await client.post(f"/api/webhook/{webhook_id}") @@ -365,31 +345,29 @@ async def test_long_poll_stop_when_push( async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) + reolink_host.unsubscribe.assert_called_with(sub_type=SubType.long_poll) async def test_long_poll_errors( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors during ONVIF long polling.""" - reolink_connect.pull_point_request.reset_mock() - assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") + reolink_host.pull_point_request.side_effect = ReolinkError("Test error") # start ONVIF long polling because ONVIF push did not came in freezer.tick(timedelta(seconds=FIRST_ONVIF_TIMEOUT)) async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.pull_point_request.assert_called_once() - reolink_connect.pull_point_request.side_effect = Exception("Test error") + reolink_host.pull_point_request.assert_called_once() + reolink_host.pull_point_request.side_effect = Exception("Test error") freezer.tick(timedelta(seconds=LONG_POLL_ERROR_COOLDOWN)) async_fire_time_changed(hass) @@ -399,21 +377,18 @@ async def test_long_poll_errors( async_fire_time_changed(hass) await hass.async_block_till_done() - reolink_connect.unsubscribe.assert_called_with(sub_type=SubType.long_poll) - - reolink_connect.pull_point_request.reset_mock(side_effect=True) + reolink_host.unsubscribe.assert_called_with(sub_type=SubType.long_poll) async def test_fast_polling_errors( hass: HomeAssistant, freezer: FrozenDateTimeFactory, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test errors during ONVIF fast polling.""" - reolink_connect.get_motion_state_all_ch.reset_mock() - reolink_connect.get_motion_state_all_ch.side_effect = ReolinkError("Test error") - reolink_connect.pull_point_request.side_effect = ReolinkError("Test error") + reolink_host.get_motion_state_all_ch.side_effect = ReolinkError("Test error") + reolink_host.pull_point_request.side_effect = ReolinkError("Test error") assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -429,17 +404,14 @@ async def test_fast_polling_errors( async_fire_time_changed(hass) await hass.async_block_till_done() - assert reolink_connect.get_motion_state_all_ch.call_count == 1 + assert reolink_host.get_motion_state_all_ch.call_count == 1 freezer.tick(timedelta(seconds=POLL_INTERVAL_NO_PUSH)) async_fire_time_changed(hass) await hass.async_block_till_done() # fast polling continues despite errors - assert reolink_connect.get_motion_state_all_ch.call_count == 2 - - reolink_connect.get_motion_state_all_ch.reset_mock(side_effect=True) - reolink_connect.pull_point_request.reset_mock(side_effect=True) + assert reolink_host.get_motion_state_all_ch.call_count == 2 async def test_diagnostics_event_connection( @@ -447,7 +419,7 @@ async def test_diagnostics_event_connection( hass_client: ClientSessionGenerator, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, ) -> None: """Test Reolink diagnostics event connection return values.""" @@ -468,7 +440,7 @@ async def test_diagnostics_event_connection( # simulate ONVIF push callback client = await hass_client_no_auth() - reolink_connect.ONVIF_event_callback.return_value = None + reolink_host.ONVIF_event_callback.return_value = None webhook_id = config_entry.runtime_data.host.webhook_id await client.post(f"/api/webhook/{webhook_id}") @@ -476,6 +448,6 @@ async def test_diagnostics_event_connection( assert diag["event connection"] == "ONVIF push" # set TCP push as active - reolink_connect.baichuan.events_active = True + reolink_host.baichuan.events_active = True diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag["event connection"] == "TCP push" From b3131355b098f0e5be99cd77a9bd4747e11424f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 26 Jun 2025 20:05:23 +0100 Subject: [PATCH 0754/1664] Use non-autospec mock for Reolink's light tests (#147621) --- tests/components/reolink/conftest.py | 2 + tests/components/reolink/test_light.py | 54 ++++++++++++-------------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index 256c50c9ea2..d34a27045fe 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -81,6 +81,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.set_audio = AsyncMock() host_mock.set_email = AsyncMock() host_mock.ONVIF_event_callback = AsyncMock() + host_mock.set_whiteled = AsyncMock() + host_mock.set_state_light = AsyncMock() host_mock.renew = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 948a7fce0fe..07f2c58eb43 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -25,11 +25,11 @@ from tests.common import MockConfigEntry async def test_light_state( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test light entity state with floodlight.""" - reolink_connect.whiteled_state.return_value = True - reolink_connect.whiteled_brightness.return_value = 100 + reolink_host.whiteled_state.return_value = True + reolink_host.whiteled_brightness.return_value = 100 with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -46,11 +46,11 @@ async def test_light_state( async def test_light_brightness_none( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test light entity with floodlight and brightness returning None.""" - reolink_connect.whiteled_state.return_value = True - reolink_connect.whiteled_brightness.return_value = None + reolink_host.whiteled_state.return_value = True + reolink_host.whiteled_brightness.return_value = None with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -67,7 +67,7 @@ async def test_light_brightness_none( async def test_light_turn_off( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test light turn off service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): @@ -83,9 +83,9 @@ async def test_light_turn_off( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_whiteled.assert_called_with(0, state=False) + reolink_host.set_whiteled.assert_called_with(0, state=False) - 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( LIGHT_DOMAIN, @@ -94,13 +94,11 @@ async def test_light_turn_off( blocking=True, ) - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_light_turn_on( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test light turn on service.""" with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): @@ -116,11 +114,11 @@ async def test_light_turn_on( {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, blocking=True, ) - reolink_connect.set_whiteled.assert_has_calls( + reolink_host.set_whiteled.assert_has_calls( [call(0, brightness=20), call(0, state=True)] ) - 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( LIGHT_DOMAIN, @@ -129,7 +127,7 @@ async def test_light_turn_on( blocking=True, ) - 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( LIGHT_DOMAIN, @@ -138,7 +136,7 @@ async def test_light_turn_on( blocking=True, ) - reolink_connect.set_whiteled.side_effect = InvalidParameterError("Test error") + reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, @@ -147,16 +145,14 @@ async def test_light_turn_on( blocking=True, ) - reolink_connect.set_whiteled.reset_mock(side_effect=True) - async def test_host_light_state( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host light entity state with status led.""" - reolink_connect.state_light = True + reolink_host.state_light = True with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -172,7 +168,7 @@ async def test_host_light_state( async def test_host_light_turn_off( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host light turn off service.""" @@ -181,7 +177,7 @@ async def test_host_light_turn_off( return False return True - reolink_connect.supported = mock_supported + reolink_host.supported = mock_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -196,9 +192,9 @@ async def test_host_light_turn_off( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_state_light.assert_called_with(False) + reolink_host.set_state_light.assert_called_with(False) - reolink_connect.set_state_light.side_effect = ReolinkError("Test error") + reolink_host.set_state_light.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, @@ -207,13 +203,11 @@ async def test_host_light_turn_off( blocking=True, ) - reolink_connect.set_state_light.reset_mock(side_effect=True) - async def test_host_light_turn_on( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, ) -> None: """Test host light turn on service.""" @@ -222,7 +216,7 @@ async def test_host_light_turn_on( return False return True - reolink_connect.supported = mock_supported + reolink_host.supported = mock_supported with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -237,9 +231,9 @@ async def test_host_light_turn_on( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - reolink_connect.set_state_light.assert_called_with(True) + reolink_host.set_state_light.assert_called_with(True) - reolink_connect.set_state_light.side_effect = ReolinkError("Test error") + reolink_host.set_state_light.side_effect = ReolinkError("Test error") with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, From 7a08edc3dd07dda56af445fdfadcc838d9242528 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Jun 2025 21:06:34 +0200 Subject: [PATCH 0755/1664] Add Claude to gitignore (#147622) --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5aa51c9d762..9bcf440a2f1 100644 --- a/.gitignore +++ b/.gitignore @@ -137,4 +137,8 @@ tmp_cache .ropeproject # Will be created from script/split_tests.py -pytest_buckets.txt \ No newline at end of file +pytest_buckets.txt + +# AI tooling +.claude + From 2655edcfc8e8d1c4081d88d01d8d5add1b99b1d5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 26 Jun 2025 23:00:02 +0200 Subject: [PATCH 0756/1664] Extend GitHub Copilot instructions and make it suitable for Claude Code (#147632) --- .github/copilot-instructions.md | 1102 ++++++++++++++++++++++++++++--- CLAUDE.md | 1 + 2 files changed, 1014 insertions(+), 89 deletions(-) create mode 120000 CLAUDE.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 06499d62b9e..10c01c492c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,100 +1,1024 @@ -# Instructions for GitHub Copilot +# GitHub Copilot & Claude Code Instructions -This repository holds the core of Home Assistant, a Python 3 based home -automation application. +This repository contains the core of Home Assistant, a Python 3 based home automation application. -- Python code must be compatible with Python 3.13 -- Use the newest Python language features if possible: +## Integration Quality Scale + +Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply: + +### Quality Scale Levels +- **Bronze**: Basic requirements (ALL Bronze rules are mandatory) +- **Silver**: Enhanced functionality +- **Gold**: Advanced features +- **Platinum**: Highest quality standards + +### How Rules Apply +1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level +2. **Bronze Rules**: Always required for any integration with quality scale +3. **Higher Tier Rules**: Only apply if integration targets that tier or higher +4. **Rule Status**: Check `quality_scale.yaml` in integration folder for: + - `done`: Rule implemented + - `exempt`: Rule doesn't apply (with reason in comment) + - `todo`: Rule needs implementation + +### Example `quality_scale.yaml` Structure +```yaml +rules: + # Bronze (mandatory) + config-flow: done + entity-unique-id: done + action-setup: + status: exempt + comment: Integration does not register custom actions. + + # Silver (if targeting Silver+) + entity-unavailable: done + parallel-updates: done + + # Gold (if targeting Gold+) + devices: done + diagnostics: done + + # Platinum (if targeting Platinum) + strict-typing: done +``` + +**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules. + +## Python Requirements + +- **Compatibility**: Python 3.13+ +- **Language Features**: Use the newest features when possible: - Pattern matching - Type hints - - f-strings for string formatting over `%` or `.format()` + - f-strings (preferred over `%` or `.format()`) - Dataclasses - Walrus operator -- Code quality tools: - - Formatting: Ruff - - Linting: PyLint and Ruff - - Type checking: MyPy - - Testing: pytest with plain functions and fixtures -- Inline code documentation: - - File headers should be short and concise: - ```python - """Integration for Peblar EV chargers.""" - ``` - - Every method and function needs a docstring: - ```python - async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: - """Set up Peblar from a config entry.""" - ... - ``` -- All code and comments and other text are written in American English -- Follow existing code style patterns as much as possible -- Core locations: - - Shared constants: `homeassistant/const.py`, use them instead of hardcoding - strings or creating duplicate integration constants. - - Integration files: - - Constants: `homeassistant/components/{domain}/const.py` - - Models: `homeassistant/components/{domain}/models.py` - - Coordinator: `homeassistant/components/{domain}/coordinator.py` - - Config flow: `homeassistant/components/{domain}/config_flow.py` - - Platform code: `homeassistant/components/{domain}/{platform}.py` + +### Strict Typing (Platinum) +- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables +- **Custom Config Entry Types**: When using runtime_data: + ```python + type MyIntegrationConfigEntry = ConfigEntry[MyClient] + ``` +- **Library Requirements**: Include `py.typed` file for PEP-561 compliance + +## Code Quality Standards + +- **Formatting**: Ruff +- **Linting**: PyLint and Ruff +- **Type Checking**: MyPy +- **Testing**: pytest with plain functions and fixtures +- **Language**: American English for all code, comments, and documentation (use sentence case, including titles) + +### Writing Style Guidelines +- **Tone**: Friendly and informative +- **Perspective**: Use second-person ("you" and "your") for user-facing messages +- **Inclusivity**: Use objective, non-discriminatory language +- **Clarity**: Write for non-native English speakers +- **Formatting in Messages**: + - Use backticks for: file paths, filenames, variable names, field entries + - Use sentence case for titles and messages (capitalize only the first word and proper nouns) + - Avoid abbreviations when possible + +## Code Organization + +### Core Locations +- Shared constants: `homeassistant/const.py` (use these instead of hardcoding) +- Integration structure: + - `homeassistant/components/{domain}/const.py` - Constants + - `homeassistant/components/{domain}/models.py` - Data models + - `homeassistant/components/{domain}/coordinator.py` - Update coordinator + - `homeassistant/components/{domain}/config_flow.py` - Configuration flow + - `homeassistant/components/{domain}/{platform}.py` - Platform implementations + +### Common Modules +- **coordinator.py**: Centralize data fetching logic + ```python + class MyCoordinator(DataUpdateCoordinator[MyData]): + def __init__(self, hass: HomeAssistant, client: MyClient) -> None: + super().__init__(hass, logger=LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)) + ``` +- **entity.py**: Base entity definitions to reduce duplication + ```python + class MyEntity(CoordinatorEntity[MyCoordinator]): + _attr_has_entity_name = True + ``` + +### Runtime Data Storage +- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data + ```python + type MyIntegrationConfigEntry = ConfigEntry[MyClient] + + async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool: + client = MyClient(entry.data[CONF_HOST]) + entry.runtime_data = client + ``` + +### Manifest Requirements +- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements` +- **Integration Types**: `device`, `hub`, `service`, `system`, `helper` +- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`) +- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb` +- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`) + +### Config Flow Patterns +- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1` +- **Unique ID Management**: + ```python + await self.async_set_unique_id(device_unique_id) + self._abort_if_unique_id_configured() + ``` +- **Error Handling**: Define errors in `strings.json` under `config.error` +- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.) + +### Integration Ownership +- **manifest.json**: Add GitHub usernames to `codeowners`: + ```json + { + "domain": "my_integration", + "name": "My Integration", + "codeowners": ["@me"] + } + ``` + +### Documentation Standards +- **File Headers**: Short and concise + ```python + """Integration for Peblar EV chargers.""" + ``` +- **Method/Function Docstrings**: Required for all + ```python + async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool: + """Set up Peblar from a config entry.""" + ``` +- **Comment Style**: + - Use clear, descriptive comments + - Explain the "why" not just the "what" + - Keep code block lines under 80 characters when possible + - Use progressive disclosure (simple explanation first, complex details later) + +## Async Programming + - All external I/O operations must be async -- Async patterns: +- **Best Practices**: - Avoid sleeping in loops - - Avoid awaiting in loops, gather instead + - Avoid awaiting in loops - use `gather` instead - No blocking calls -- Polling: - - Follow update coordinator pattern, when possible - - Polling interval may not be configurable by the user - - For local network polling, the minimum interval is 5 seconds - - For cloud polling, the minimum interval is 60 seconds -- Error handling: - - Use specific exceptions from `homeassistant.exceptions` - - Setup failures: - - Temporary: Raise `ConfigEntryNotReady` - - Permanent: Use `ConfigEntryError` -- Logging: - - Message format: - - No periods at end - - No integration names or domains (added automatically) - - No sensitive data (keys, tokens, passwords), even when those are incorrect. - - Be very restrictive on the use of logging info messages, use debug for - anything which is not targeting the user. - - Use lazy logging (no f-strings): - ```python - _LOGGER.debug("This is a log message with %s", variable) - ``` -- Entities: - - Ensure unique IDs for state persistence: - - Unique IDs should not contain values that are subject to user or network change. - - An ID needs to be unique per platform, not per integration. - - The ID does not have to contain the integration domain or platform. - - Acceptable examples: - - Serial number of a device - - MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac` - Do not obtain the MAC address through arp cache of local network access, - only use the MAC address provided by discovery or the device itself. - - Unique identifier that is physically printed on the device or burned into an EEPROM - - Not acceptable examples: - - IP Address - - Device name - - Hostname - - URL - - Email address - - Username - - For entities that are setup by a config entry, the config entry ID - can be used as a last resort if no other Unique ID is available. - For example: `f"{entry.entry_id}-battery"` - - If the state value is unknown, use `None` - - Do not use the `unavailable` string as a state value, - implement the `available()` property method instead - - Do not use the `unknown` string as a state value, use `None` instead -- Extra entity state attributes: - - The keys of all state attributes should always be present - - If the value is unknown, use `None` - - Provide descriptive state attributes -- Testing: - - Test location: `tests/components/{domain}/` + - Group executor jobs when possible - switching between event loop and executor is expensive + +### Async Dependencies (Platinum) +- **Requirement**: All dependencies must use asyncio +- Ensures efficient task handling without thread context switching + +### WebSession Injection (Platinum) +- **Pass WebSession**: Support passing web sessions to dependencies + ```python + async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: + """Set up integration from config entry.""" + client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass)) + ``` +- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx) + +### Blocking Operations +- **Use Executor**: For blocking I/O operations + ```python + result = await hass.async_add_executor_job(blocking_function, args) + ``` +- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls +- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()` + +### Thread Safety +- **@callback Decorator**: For event loop safe functions + ```python + @callback + def async_update_callback(self, event): + """Safe to run in event loop.""" + self.async_write_ha_state() + ``` +- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads +- **Registry Changes**: Must be done in event loop thread + +### Data Update Coordinator +- **Standard Pattern**: Use for efficient data management + ```python + class MyCoordinator(DataUpdateCoordinator): + async def _async_update_data(self): + try: + return await self.api.fetch_data() + except ApiError as err: + raise UpdateFailed(f"API communication error: {err}") + ``` +- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues + +## Integration Guidelines + +### Configuration Flow +- **UI Setup Required**: All integrations must support configuration via UI +- **Manifest**: Set `"config_flow": true` in `manifest.json` +- **Data Storage**: + - Connection-critical config: Store in `ConfigEntry.data` + - Non-critical settings: Store in `ConfigEntry.options` +- **Validation**: Always validate user input before creating entries +- **Connection Testing**: Test device/service connection during config flow: + ```python + try: + await client.get_data() + except MyException: + errors["base"] = "cannot_connect" + ``` +- **Duplicate Prevention**: Prevent duplicate configurations: + ```python + # Using unique ID + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + # Using unique data + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + ``` + +### Reauthentication Support +- **Required Method**: Implement `async_step_reauth` in config flow +- **Credential Updates**: Allow users to update credentials without re-adding +- **Validation**: Verify account matches existing unique ID: + ```python + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_mismatch(reason="wrong_account") + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]} + ) + ``` + +### Reconfiguration Flow +- **Purpose**: Allow configuration updates without removing device +- **Implementation**: Add `async_step_reconfigure` method +- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch` + +### Device Discovery +- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.) + ```json + { + "zeroconf": ["_mydevice._tcp.local."] + } + ``` +- **Discovery Handler**: Implement appropriate `async_step_*` method: + ```python + async def async_step_zeroconf(self, discovery_info): + """Handle zeroconf discovery.""" + await self.async_set_unique_id(discovery_info.properties["serialno"]) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host}) + ``` +- **Network Updates**: Use discovery to update dynamic IP addresses + +### Network Discovery Implementation +- **Zeroconf/mDNS**: Use async instances + ```python + aiozc = await zeroconf.async_get_async_instance(hass) + ``` +- **SSDP Discovery**: Register callbacks with cleanup + ```python + entry.async_on_unload( + ssdp.async_register_callback( + hass, _async_discovered_device, + {"st": "urn:schemas-upnp-org:device:ZonePlayer:1"} + ) + ) + ``` + +### Bluetooth Integration +- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies +- **Connectable**: Set `"connectable": true` for connection-required devices +- **Scanner Usage**: Always use shared scanner instance + ```python + scanner = bluetooth.async_get_scanner() + entry.async_on_unload( + bluetooth.async_register_callback( + hass, _async_discovered_device, + {"service_uuid": "example_uuid"}, + bluetooth.BluetoothScanningMode.ACTIVE + ) + ) + ``` +- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts + +### Setup Validation +- **Test Before Setup**: Verify integration can be set up in `async_setup_entry` +- **Exception Handling**: + - `ConfigEntryNotReady`: Device offline or temporary failure + - `ConfigEntryAuthFailed`: Authentication issues + - `ConfigEntryError`: Unresolvable setup problems + +### Config Entry Unloading +- **Required**: Implement `async_unload_entry` for runtime removal/reload +- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms` +- **Cleanup**: Register callbacks with `entry.async_on_unload`: + ```python + async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry.runtime_data.listener() # Clean up resources + return unload_ok + ``` + +### Service Actions +- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry` +- **Validation**: Check config entry existence and loaded state: + ```python + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + async def service_action(call: ServiceCall) -> ServiceResponse: + if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])): + raise ServiceValidationError("Entry not found") + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError("Entry not loaded") + ``` +- **Exception Handling**: Raise appropriate exceptions: + ```python + # For invalid input + if end_date < start_date: + raise ServiceValidationError("End date must be after start date") + + # For service errors + try: + await client.set_schedule(start_date, end_date) + except MyConnectionError as err: + raise HomeAssistantError("Could not connect to the schedule") from err + ``` + +### Service Registration Patterns +- **Entity Services**: Register on platform setup + ```python + platform.async_register_entity_service( + "my_entity_service", + {vol.Required("parameter"): cv.string}, + "handle_service_method" + ) + ``` +- **Service Schema**: Always validate input + ```python + SERVICE_SCHEMA = vol.Schema({ + vol.Required("entity_id"): cv.entity_ids, + vol.Required("parameter"): cv.string, + vol.Optional("timeout", default=30): cv.positive_int, + }) + ``` +- **Services File**: Create `services.yaml` with descriptions and field definitions + +### Polling +- Use update coordinator pattern when possible +- Polling intervals are NOT user-configurable +- **Minimum Intervals**: + - Local network: 5 seconds + - Cloud services: 60 seconds +- **Parallel Updates**: Specify number of concurrent updates: + ```python + PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device + # OR + PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only) + ``` + +### Error Handling +- **Exception Types**: Choose most specific exception available + - `ServiceValidationError`: User input errors (preferred over `ValueError`) + - `HomeAssistantError`: Device communication failures + - `ConfigEntryNotReady`: Temporary setup issues (device offline) + - `ConfigEntryAuthFailed`: Authentication problems + - `ConfigEntryError`: Permanent setup issues +- **Setup Failure Patterns**: + ```python + try: + await device.async_setup() + except (asyncio.TimeoutError, TimeoutException) as ex: + raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex + except AuthFailed as ex: + raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex + ``` + +### Logging +- **Format Guidelines**: + - No periods at end of messages + - No integration names/domains (added automatically) + - No sensitive data (keys, tokens, passwords) +- Use debug level for non-user-facing messages +- **Use Lazy Logging**: + ```python + _LOGGER.debug("This is a log message with %s", variable) + ``` + +### Unavailability Logging +- **Log Once**: When device/service becomes unavailable (info level) +- **Log Recovery**: When device/service comes back online +- **Implementation Pattern**: + ```python + _unavailable_logged: bool = False + + if not self._unavailable_logged: + _LOGGER.info("The sensor is unavailable: %s", ex) + self._unavailable_logged = True + # On recovery: + if self._unavailable_logged: + _LOGGER.info("The sensor is back online") + self._unavailable_logged = False + ``` + +## Entity Development + +### Unique IDs +- **Required**: Every entity must have a unique ID for registry tracking +- Must be unique per platform (not per integration) +- Don't include integration domain or platform in ID +- **Implementation**: + ```python + class MySensor(SensorEntity): + def __init__(self, device_id: str) -> None: + self._attr_unique_id = f"{device_id}_temperature" + ``` + +**Acceptable ID Sources**: +- Device serial numbers +- MAC addresses (formatted using `format_mac` from device registry) +- Physical identifiers (printed/EEPROM) +- Config entry ID as last resort: `f"{entry.entry_id}-battery"` + +**Never Use**: +- IP addresses, hostnames, URLs +- Device names +- Email addresses, usernames + +### Entity Naming +- **Use has_entity_name**: Set `_attr_has_entity_name = True` +- **For specific fields**: + ```python + class MySensor(SensorEntity): + _attr_has_entity_name = True + def __init__(self, device: Device, field: str) -> None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.id)}, + name=device.name, + ) + self._attr_name = field # e.g., "temperature", "humidity" + ``` +- **For device itself**: Set `_attr_name = None` + +### Event Lifecycle Management +- **Subscribe in `async_added_to_hass`**: + ```python + async def async_added_to_hass(self) -> None: + """Subscribe to events.""" + self.async_on_remove( + self.client.events.subscribe("my_event", self._handle_event) + ) + ``` +- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove` +- Never subscribe in `__init__` or other methods + +### State Handling +- Unknown values: Use `None` (not "unknown" or "unavailable") +- Availability: Implement `available()` property instead of using "unavailable" state + +### Entity Availability +- **Mark Unavailable**: When data cannot be fetched from device/service +- **Coordinator Pattern**: + ```python + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.identifier in self.coordinator.data + ``` +- **Direct Update Pattern**: + ```python + async def async_update(self) -> None: + """Update entity.""" + try: + data = await self.client.get_data() + except MyException: + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = data.value + ``` + +### Extra State Attributes +- All attribute keys must always be present +- Unknown values: Use `None` +- Provide descriptive attributes + +## Device Management + +### Device Registry +- **Create Devices**: Group related entities under devices +- **Device Info**: Provide comprehensive metadata: + ```python + _attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, device.id)}, + name=device.name, + manufacturer="My Company", + model="My Sensor", + sw_version=device.version, + ) + ``` +- For services: Add `entry_type=DeviceEntryType.SERVICE` + +### Dynamic Device Addition +- **Auto-detect New Devices**: After initial setup +- **Implementation Pattern**: + ```python + def _check_device() -> None: + current_devices = set(coordinator.data) + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices]) + + entry.async_on_unload(coordinator.async_add_listener(_check_device)) + ``` + +### Stale Device Removal +- **Auto-remove**: When devices disappear from hub/account +- **Device Registry Update**: + ```python + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + ``` +- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed + +## Diagnostics and Repairs + +### Integration Diagnostics +- **Required**: Implement diagnostic data collection +- **Implementation**: + ```python + TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE] + + async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: MyConfigEntry + ) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "entry_data": async_redact_data(entry.data, TO_REDACT), + "data": entry.runtime_data.data, + } + ``` +- **Security**: Never expose passwords, tokens, or sensitive coordinates + +### Repair Issues +- **Actionable Issues Required**: All repair issues must be actionable for end users +- **Issue Content Requirements**: + - Clearly explain what is happening + - Provide specific steps users need to take to resolve the issue + - Use friendly, helpful language + - Include relevant context (device names, error details, etc.) +- **Implementation**: + ```python + ir.async_create_issue( + hass, + DOMAIN, + "outdated_version", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.ERROR, + translation_key="outdated_version", + ) + ``` +- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`: + ```json + { + "issues": { + "outdated_version": { + "title": "Device firmware is outdated", + "description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant." + } + } + } + ``` +- **String Content Must Include**: + - What the problem is + - Why it matters + - Exact steps to resolve (numbered list when multiple steps) + - What to expect after following the steps +- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps +- **Severity Guidelines**: + - `CRITICAL`: Reserved for extreme scenarios only + - `ERROR`: Requires immediate user attention + - `WARNING`: Indicates future potential breakage +- **Additional Attributes**: + ```python + ir.async_create_issue( + hass, DOMAIN, "issue_id", + breaks_in_ha_version="2024.1.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.ERROR, + translation_key="issue_description", + ) + ``` +- Only create issues for problems users can potentially resolve + +### Entity Categories +- **Required**: Assign appropriate category to entities +- **Implementation**: Set `_attr_entity_category` + ```python + class MySensor(SensorEntity): + _attr_entity_category = EntityCategory.DIAGNOSTIC + ``` +- Categories include: `DIAGNOSTIC` for system/technical information + +### Device Classes +- **Use When Available**: Set appropriate device class for entity type + ```python + class MyTemperatureSensor(SensorEntity): + _attr_device_class = SensorDeviceClass.TEMPERATURE + ``` +- Provides context for: unit conversion, voice control, UI representation + +### Disabled by Default +- **Disable Noisy/Less Popular Entities**: Reduce resource usage + ```python + class MySignalStrengthSensor(SensorEntity): + _attr_entity_registry_enabled_default = False + ``` +- Target: frequently changing states, technical diagnostics + +### Entity Translations +- **Required with has_entity_name**: Support international users +- **Implementation**: + ```python + class MySensor(SensorEntity): + _attr_has_entity_name = True + _attr_translation_key = "phase_voltage" + ``` +- Create `strings.json` with translations: + ```json + { + "entity": { + "sensor": { + "phase_voltage": { + "name": "Phase voltage" + } + } + } + } + ``` + +### Exception Translations (Gold) +- **Translatable Errors**: Use translation keys for user-facing exceptions +- **Implementation**: + ```python + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="end_date_before_start_date", + ) + ``` +- Add to `strings.json`: + ```json + { + "exceptions": { + "end_date_before_start_date": { + "message": "The end date cannot be before the start date." + } + } + } + ``` + +### Icon Translations (Gold) +- **Dynamic Icons**: Support state and range-based icon selection +- **State-based Icons**: + ```json + { + "entity": { + "sensor": { + "tree_pollen": { + "default": "mdi:tree", + "state": { + "high": "mdi:tree-outline" + } + } + } + } + } + ``` +- **Range-based Icons** (for numeric values): + ```json + { + "entity": { + "sensor": { + "battery_level": { + "default": "mdi:battery-unknown", + "range": { + "0": "mdi:battery-outline", + "90": "mdi:battery-90", + "100": "mdi:battery" + } + } + } + } + } + ``` + +## Testing Requirements + +- **Location**: `tests/components/{domain}/` +- **Coverage Requirement**: Above 95% test coverage for all modules +- **Best Practices**: - Use pytest fixtures from `tests.common` - - Mock external dependencies - - Use snapshots for complex data + - Mock all external dependencies + - Use snapshots for complex data structures - Follow existing test patterns + +### Config Flow Testing +- **100% Coverage Required**: All config flow paths must be tested +- **Test Scenarios**: + - All flow initiation methods (user, discovery, import) + - Successful configuration paths + - Error recovery scenarios + - Prevention of duplicate entries + - Flow completion after errors + +## Development Commands + +### Code Quality & Linting +- **Run all linters on all files**: `pre-commit run --all-files` +- **Run linters on staged files only**: `pre-commit run` +- **PyLint on everything** (slow): `pylint homeassistant` +- **PyLint on specific folder**: `pylint homeassistant/components/my_integration` +- **MyPy type checking (whole project)**: `mypy homeassistant/` +- **MyPy on specific integration**: `mypy homeassistant/components/my_integration` + +### Testing +- **Integration-specific tests** (recommended): + ```bash + pytest ./tests/components/ \ + --cov=homeassistant.components. \ + --cov-report term-missing \ + --durations-min=1 \ + --durations=0 \ + --numprocesses=auto + ``` +- **Quick test of changed files**: `pytest --timeout=10 --picked` +- **Update test snapshots**: Add `--snapshot-update` to pytest command + - ⚠️ Omit test results after using `--snapshot-update` + - Always run tests again without the flag to verify snapshots +- **Full test suite** (AVOID - very slow): `pytest ./tests` + +### Dependencies & Requirements +- **Update generated files after dependency changes**: `python -m script.gen_requirements_all` +- **Install all Python requirements**: + ```bash + uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt + ``` +- **Install test requirements only**: + ```bash + uv pip install -r requirements_test_all.txt -r requirements.txt + ``` + +### Translations +- **Update translations after strings.json changes**: + ```bash + python -m script.translations develop --all + ``` + +### Project Validation +- **Run hassfest** (checks project structure and updates generated files): + ```bash + python -m script.hassfest + ``` + +### File Locations +- **Integration code**: `./homeassistant/components//` +- **Integration tests**: `./tests/components//` + +## Integration Templates + +### Standard Integration Structure +``` +homeassistant/components/my_integration/ +├── __init__.py # Entry point with async_setup_entry +├── manifest.json # Integration metadata and dependencies +├── const.py # Domain and constants +├── config_flow.py # UI configuration flow +├── coordinator.py # Data update coordinator (if needed) +├── entity.py # Base entity class (if shared patterns) +├── sensor.py # Sensor platform +├── strings.json # User-facing text and translations +├── services.yaml # Service definitions (if applicable) +└── quality_scale.yaml # Quality scale rule status +``` + +### Quality Scale Progression +- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows +- **Silver → Gold**: Add device management, diagnostics, translations +- **Gold → Platinum**: Add strict typing, async dependencies, websession injection + +### Minimal Integration Checklist +- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.) +- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry` +- [ ] `config_flow.py` with UI configuration support +- [ ] `const.py` with `DOMAIN` constant +- [ ] `strings.json` with at least config flow text +- [ ] Platform files (`sensor.py`, etc.) as needed +- [ ] `quality_scale.yaml` with rule status tracking + +## Common Anti-Patterns & Best Practices + +### ❌ **Avoid These Patterns** +```python +# Blocking operations in event loop +data = requests.get(url) # ❌ Blocks event loop +time.sleep(5) # ❌ Blocks event loop + +# Reusing BleakClient instances +self.client = BleakClient(address) +await self.client.connect() +# Later... +await self.client.connect() # ❌ Don't reuse + +# Hardcoded strings in code +self._attr_name = "Temperature Sensor" # ❌ Not translatable + +# Missing error handling +data = await self.api.get_data() # ❌ No exception handling + +# Storing sensitive data in diagnostics +return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets + +# Accessing hass.data directly in tests +coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data +``` + +### ✅ **Use These Patterns Instead** +```python +# Async operations with executor +data = await hass.async_add_executor_job(requests.get, url) +await asyncio.sleep(5) # ✅ Non-blocking + +# Fresh BleakClient instances +client = BleakClient(address) # ✅ New instance each time +await client.connect() + +# Translatable entity names +_attr_translation_key = "temperature_sensor" # ✅ Translatable + +# Proper error handling +try: + data = await self.api.get_data() +except ApiException as err: + raise UpdateFailed(f"API error: {err}") from err + +# Redacted diagnostics data +return async_redact_data(data, {"api_key", "password"}) # ✅ Safe + +# Test through proper integration setup and fixtures +@pytest.fixture +async def init_integration(hass, mock_config_entry, mock_api): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup +``` + +### Entity Performance Optimization +```python +# Use __slots__ for memory efficiency +class MySensor(SensorEntity): + __slots__ = ("_attr_native_value", "_attr_available") + + @property + def should_poll(self) -> bool: + """Disable polling when using coordinator.""" + return False # ✅ Let coordinator handle updates +``` + +## Testing Patterns + +### Testing Best Practices +- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead +- **Use snapshot testing** - For verifying entity states and attributes +- **Test through integration setup** - Don't test entities in isolation +- **Mock external APIs** - Use fixtures with realistic JSON data +- **Verify registries** - Ensure entities are properly registered with devices + +### Config Flow Testing Template +```python +async def test_user_flow_success(hass, mock_api): + """Test successful user flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + + # Test form submission + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=TEST_USER_INPUT + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My Device" + assert result["data"] == TEST_USER_INPUT + +async def test_flow_connection_error(hass, mock_api_error): + """Test connection error handling.""" + 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"], user_input=TEST_USER_INPUT + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} +``` + +### Entity Testing Patterns +```python +@pytest.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the sensor entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + # Ensure entities are correctly assigned to device + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "device_unique_id")} + ) + assert device_entry + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id == device_entry.id +``` + +### Mock Patterns +```python +# Modern integration fixture setup +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="My Integration", + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"}, + unique_id="device_unique_id", + ) + +@pytest.fixture +def mock_device_api() -> Generator[MagicMock]: + """Return a mocked device API.""" + with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock: + api = api_mock.return_value + api.get_data.return_value = MyDeviceData.from_json( + load_fixture("device_data.json", DOMAIN) + ) + yield api + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_device_api: MagicMock, +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry +``` + +## Debugging & Troubleshooting + +### Common Issues & Solutions +- **Integration won't load**: Check `manifest.json` syntax and required fields +- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation +- **Config flow errors**: Check `strings.json` entries and error handling +- **Discovery not working**: Verify manifest discovery configuration and callbacks +- **Tests failing**: Check mock setup and async context + +### Debug Logging Setup +```python +# Enable debug logging in tests +caplog.set_level(logging.DEBUG, logger="my_integration") + +# In integration code - use proper logging +_LOGGER = logging.getLogger(__name__) +_LOGGER.debug("Processing data: %s", data) # Use lazy logging +``` + +### Validation Commands +```bash +# Check specific integration +python -m script.hassfest --integration my_integration + +# Validate quality scale +# Check quality_scale.yaml against current rules + +# Run integration tests with coverage +pytest ./tests/components/my_integration \ + --cov=homeassistant.components.my_integration \ + --cov-report term-missing +``` \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000000..02dd134122e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +.github/copilot-instructions.md \ No newline at end of file From 1bb653b4f7aef7be3fc2c8f0508ac6c07de0a12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 26 Jun 2025 21:02:14 +0000 Subject: [PATCH 0757/1664] Remove unused config regexps (#147631) --- homeassistant/config.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index ca1c87e4a11..e77e5c32f40 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -13,7 +13,6 @@ import logging import operator import os from pathlib import Path -import re import shutil from types import ModuleType from typing import TYPE_CHECKING, Any @@ -39,8 +38,6 @@ from .util.yaml.objects import NodeStrClass _LOGGER = logging.getLogger(__name__) -RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml") -RE_ASCII = re.compile(r"\033\[[^m]*m") YAML_CONFIG_FILE = "configuration.yaml" VERSION_FILE = ".HA_VERSION" CONFIG_DIR_NAME = ".homeassistant" From 9bd0762799ecabe61c8ea132d038dea57e03010e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:02:35 +0200 Subject: [PATCH 0758/1664] Make sure Ollama integration migration is clean (#147630) --- homeassistant/components/ollama/__init__.py | 6 ++++++ tests/components/ollama/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f174c709b65..8890c498e9f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -133,6 +133,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index e11eb98451a..0747578c110 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -109,6 +109,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_urls( @@ -193,6 +197,8 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_urls( @@ -285,3 +291,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 43535ede8bc3d7bd7e73fbd17be85f884b3fe0aa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:02:59 +0200 Subject: [PATCH 0759/1664] Make sure Anthropic integration migration is clean (#147629) --- homeassistant/components/anthropic/__init__.py | 6 ++++++ tests/components/anthropic/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c537a000c14..68a46f19031 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -123,6 +123,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 6295bac67cb..16240ef8120 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -141,6 +141,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -231,6 +235,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -329,3 +335,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 4bdf3d6f30403776ef312621b1b9aa6a27a9398c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:03:11 +0200 Subject: [PATCH 0760/1664] Make sure OpenAI integration migration is clean (#147627) --- .../components/openai_conversation/__init__.py | 6 ++++++ tests/components/openai_conversation/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index e14a8aabc1b..7cac3bb7003 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -346,6 +346,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index b7f2a5434eb..274d09a9779 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -618,6 +618,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -709,6 +713,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -808,6 +814,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } @pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) From 1b2be083c26b724b5753b4c4ebfe857072195f3d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:03:36 +0200 Subject: [PATCH 0761/1664] Make sure Google Generative AI integration migration is clean (#147625) --- .../__init__.py | 6 ++++++ .../test_init.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 7890af59f88..5e4ad114adf 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -284,6 +284,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 85d6c70b658..08a94dd151c 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -512,6 +512,10 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } subentry = conversation_subentries[1] @@ -531,6 +535,10 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -626,6 +634,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == { + entry.entry_id: {list(entry.subentries.values())[0].subentry_id} + } async def test_migration_from_v1_to_v2_with_same_keys( @@ -743,6 +755,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } subentry = conversation_subentries[1] @@ -762,6 +778,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_devices( From 61b43ca1fcde3700c63dd11228ac39acdfbe1da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 26 Jun 2025 22:16:21 +0000 Subject: [PATCH 0762/1664] Remove unnecessary wilight trigger regex use (#147638) --- homeassistant/components/wilight/support.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/wilight/support.py b/homeassistant/components/wilight/support.py index 39578618d50..a88345bb1d6 100644 --- a/homeassistant/components/wilight/support.py +++ b/homeassistant/components/wilight/support.py @@ -4,7 +4,6 @@ from __future__ import annotations import calendar import locale -import re from typing import Any import voluptuous as vol @@ -26,7 +25,7 @@ def wilight_trigger(value: Any) -> str | None: if (step == 2) & isinstance(value, str): step = 3 err_desc = "String should only contain 8 decimals character" - if re.search(r"^([0-9]{8})$", value) is not None: + if len(value) == 8 and value.isdigit(): step = 4 err_desc = "First 3 character should be less than 128" result_128 = int(value[0:3]) < 128 From 1ca03c8ae9a872f43d57e2eba1f62673ffb6a3f7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Jun 2025 09:02:12 +0300 Subject: [PATCH 0763/1664] Do not factory reset old Z-Wave controller during migration (#147576) * Do not factory reset old Z-Wave controller during migration * PR comments * remove obsolete test --- .../components/zwave_js/config_flow.py | 63 +-- .../components/zwave_js/strings.json | 6 +- tests/components/zwave_js/test_config_flow.py | 365 +----------------- 3 files changed, 7 insertions(+), 427 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 2c37ee4b554..a109719965c 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -845,11 +845,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - if user_input is not None: - self._migrating = True - return await self.async_step_backup_nvm() - - return self.async_show_form(step_id="intent_migrate") + self._migrating = True + return await self.async_step_backup_nvm() async def async_step_backup_nvm( self, user_input: dict[str, Any] | None = None @@ -904,7 +901,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_instruct_unplug( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reset the current controller, and instruct the user to unplug it.""" + """Instruct the user to unplug the old controller.""" if user_input is not None: if self.usb_path: @@ -914,63 +911,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # Now that the old controller is gone, we can scan for serial ports again return await self.async_step_choose_serial_port() - try: - driver = self._get_driver() - except AbortFlow: - return self.async_abort(reason="config_entry_not_loaded") - - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - - unsubscribe = driver.once("driver ready", set_driver_ready) - - # reset the old controller - try: - await driver.async_hard_reset() - except FailedCommand as err: - unsubscribe() - _LOGGER.error("Failed to reset controller: %s", err) - return self.async_abort(reason="reset_failed") - - # Update the unique id of the config entry - # to the new home id, which requires waiting for the driver - # to be ready before getting the new home id. - # If the backup restore, done later in the flow, fails, - # the config entry unique id should be the new home id - # after the controller reset. - try: - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - except TimeoutError: - pass - finally: - unsubscribe() - config_entry = self._reconfigure_config_entry assert config_entry is not None - try: - version_info = await async_get_version_info( - self.hass, config_entry.data[CONF_URL] - ) - except CannotConnect: - # Just log this error, as there's nothing to do about it here. - # The stale unique id needs to be handled by a repair flow, - # after the config entry has been reloaded, if the backup restore - # also fails. - _LOGGER.debug( - "Failed to get server version, cannot update config entry " - "unique id with new home id, after controller reset" - ) - else: - self.hass.config_entries.async_update_entry( - config_entry, unique_id=str(version_info.home_id) - ) - # Unload the config entry before asking the user to unplug the controller. await self.hass.config_entries.async_unload(config_entry.entry_id) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d9a3b82a47c..f61d871cfb9 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,13 +108,9 @@ "intent_reconfigure": "Re-configure the current controller" } }, - "intent_migrate": { - "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", - "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" - }, "instruct_unplug": { "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 2e41a176a9c..e99cedbdcba 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -867,8 +867,6 @@ async def test_usb_discovery_migration( get_server_version: AsyncMock, ) -> None: """Test usb discovery migration.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 @@ -893,13 +891,6 @@ async def test_usb_discovery_migration( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -927,10 +918,6 @@ 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"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -947,7 +934,6 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" - assert entry.unique_id == "4321" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -962,6 +948,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 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -1024,13 +1011,6 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -1055,10 +1035,6 @@ 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"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3401,21 +3377,12 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "reset_server_version_side_effect", - "reset_unique_id", "restore_server_version_side_effect", "final_unique_id", ), [ - (None, "4321", None, "3245146787"), - (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), - (None, "4321", aiohttp.ClientError("Boom"), "5678"), - ( - aiohttp.ClientError("Boom"), - "3245146787", - aiohttp.ClientError("Boom"), - "5678", - ), + (None, "3245146787"), + (aiohttp.ClientError("Boom"), "5678"), ], ) async def test_reconfigure_migrate_with_addon( @@ -3428,15 +3395,11 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, - reset_server_version_side_effect: Exception | None, - reset_unique_id: str, restore_server_version_side_effect: Exception | None, final_unique_id: str, ) -> None: """Test migration flow with add-on.""" - get_server_version.side_effect = reset_server_version_side_effect version_info = get_server_version.return_value - version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 @@ -3494,13 +3457,6 @@ async def test_reconfigure_migrate_with_addon( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3531,11 +3487,6 @@ async def test_reconfigure_migrate_with_addon( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3552,7 +3503,6 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == reset_unique_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -3565,8 +3515,6 @@ async def test_reconfigure_migrate_with_addon( with pytest.raises(InInvalid): data_schema.schema[CONF_USB_PATH](addon_options["device"]) - # Reset side effect before starting the add-on. - get_server_version.side_effect = None version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( @@ -3646,156 +3594,6 @@ async def test_reconfigure_migrate_with_addon( assert client.driver.controller.home_id == 3245146787 -@pytest.mark.usefixtures("supervisor", "addon_running") -async def test_reconfigure_migrate_reset_driver_ready_timeout( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, - restart_addon: AsyncMock, - set_addon_options: AsyncMock, - get_server_version: AsyncMock, -) -> None: - """Test migration flow with driver ready timeout after controller reset.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 - entry = integration - assert client.connect.call_count == 1 - hass.config_entries.async_update_entry( - entry, - unique_id="1234", - data={ - "url": "ws://localhost:3000", - "use_addon": True, - "usb_path": "/dev/ttyUSB0", - }, - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - - async def mock_reset_controller(): - await asyncio.sleep(0) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): - client.driver.controller.emit( - "nvm convert progress", - {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, - ) - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm restore progress", - {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, - ) - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) - - events = async_capture_events( - hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - with ( - patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), - new=0, - ), - patch("pathlib.Path.write_bytes") as mock_file, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - assert len(events) == 1 - assert events[0].data["progress"] == 0.5 - events.clear() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "instruct_unplug" - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == "4321" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, - ) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) - ) - - await hass.async_block_till_done() - - assert restart_addon.call_args == call("core_zwave_js") - - version_info.home_id = 5678 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 - - await hass.async_block_till_done() - assert client.connect.call_count == 4 - assert entry.state is config_entries.ConfigEntryState.LOADED - assert client.driver.controller.async_restore_nvm.call_count == 1 - assert len(events) == 2 - assert events[0].data["progress"] == 0.25 - assert events[1].data["progress"] == 0.75 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "migration_successful" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" - assert entry.data["use_addon"] is True - assert entry.unique_id == "5678" - assert "keep_old_devices" not in entry.data - - @pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_restore_driver_ready_timeout( hass: HomeAssistant, @@ -3828,13 +3626,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3861,11 +3652,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3960,11 +3746,6 @@ async def test_reconfigure_migrate_backup_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" assert "keep_old_devices" not in entry.data @@ -3998,11 +3779,6 @@ async def test_reconfigure_migrate_backup_file_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4040,13 +3816,6 @@ async def test_reconfigure_migrate_start_addon_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4056,11 +3825,6 @@ async def test_reconfigure_migrate_start_addon_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4124,12 +3888,6 @@ async def test_reconfigure_migrate_restore_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) client.driver.controller.async_restore_nvm = AsyncMock( side_effect=FailedCommand("test_error", "unknown_error") ) @@ -4143,11 +3901,6 @@ async def test_reconfigure_migrate_restore_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4242,106 +3995,6 @@ async def test_get_driver_failure_intent_migrate( assert "keep_old_devices" not in entry.data -async def test_get_driver_failure_instruct_unplug( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test get driver failure in instruct unplug step.""" - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "config_entry_not_loaded" - - -async def test_hard_reset_failure( - hass: HomeAssistant, - integration: MockConfigEntry, - client: MagicMock, -) -> None: - """Test hard reset failure.""" - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - client.driver.async_hard_reset = AsyncMock( - side_effect=FailedCommand("test_error", "unknown_error") - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reset_failed" - - async def test_choose_serial_port_usb_ports_failure( hass: HomeAssistant, integration: MockConfigEntry, @@ -4361,13 +4014,6 @@ async def test_choose_serial_port_usb_ports_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4377,11 +4023,6 @@ async def test_choose_serial_port_usb_ports_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - 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 e481f1433516a181b4a7cd0a21d2243a6450efd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 27 Jun 2025 07:58:09 +0100 Subject: [PATCH 0764/1664] Simplify reolink light tests (#147637) --- tests/components/reolink/test_light.py | 76 ++++++++++++-------------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 07f2c58eb43..c3655ec00df 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -22,14 +22,23 @@ from .conftest import TEST_NVR_NAME from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("whiteled_brightness", "expected_brightness"), + [ + (100, 255), + (None, None), + ], +) async def test_light_state( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_host: MagicMock, + whiteled_brightness: int | None, + expected_brightness: int | None, ) -> None: """Test light entity state with floodlight.""" reolink_host.whiteled_state.return_value = True - reolink_host.whiteled_brightness.return_value = 100 + reolink_host.whiteled_brightness.return_value = whiteled_brightness with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -40,28 +49,7 @@ async def test_light_state( state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes["brightness"] == 255 - - -async def test_light_brightness_none( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, -) -> None: - """Test light entity with floodlight and brightness returning None.""" - reolink_host.whiteled_state.return_value = True - reolink_host.whiteled_brightness.return_value = None - - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" - - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes["brightness"] is None + assert state.attributes["brightness"] == expected_brightness async def test_light_turn_off( @@ -118,30 +106,36 @@ async def test_light_turn_on( [call(0, brightness=20), call(0, state=True)] ) - reolink_host.set_whiteled.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_host.set_whiteled.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, - blocking=True, - ) +@pytest.mark.parametrize( + ("exception", "service_data"), + [ + (ReolinkError("Test error"), {}), + (ReolinkError("Test error"), {ATTR_BRIGHTNESS: 51}), + (InvalidParameterError("Test error"), {ATTR_BRIGHTNESS: 51}), + ], +) +async def test_light_turn_on_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, + exception: Exception, + service_data: dict, +) -> None: + """Test light turn on service error cases.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED - reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" + + reolink_host.set_whiteled.side_effect = exception with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, + {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) From 55a37a29361cd1671b44c2f749eb8eb456d9ccca Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Jun 2025 09:01:09 +0200 Subject: [PATCH 0765/1664] Extend GitHub Copilot instructions with new learnings from reviews (#147652) --- .github/copilot-instructions.md | 145 +++++++++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 4 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10c01c492c4..c2b863b55be 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -96,8 +96,14 @@ rules: - **coordinator.py**: Centralize data fetching logic ```python class MyCoordinator(DataUpdateCoordinator[MyData]): - def __init__(self, hass: HomeAssistant, client: MyClient) -> None: - super().__init__(hass, logger=LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)) + def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=1), + config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended + ) ``` - **entity.py**: Base entity definitions to reduce duplication ```python @@ -203,13 +209,24 @@ rules: - **Standard Pattern**: Use for efficient data management ```python class MyCoordinator(DataUpdateCoordinator): + def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended + ) + self.client = client + async def _async_update_data(self): try: - return await self.api.fetch_data() + return await self.client.fetch_data() except ApiError as err: raise UpdateFailed(f"API communication error: {err}") ``` - **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues +- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended ## Integration Guidelines @@ -220,6 +237,10 @@ rules: - Connection-critical config: Store in `ConfigEntry.data` - Non-critical settings: Store in `ConfigEntry.options` - **Validation**: Always validate user input before creating entries +- **Config Entry Naming**: + - ❌ Do NOT allow users to set config entry names in config flows + - Names are automatically generated or can be customized later in UI + - ✅ Exception: Helper integrations MAY allow custom names in config flow - **Connection Testing**: Test device/service connection during config flow: ```python try: @@ -366,7 +387,8 @@ rules: ### Polling - Use update coordinator pattern when possible -- Polling intervals are NOT user-configurable +- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries +- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input - **Minimum Intervals**: - Local network: 5 seconds - Cloud services: 60 seconds @@ -384,6 +406,57 @@ rules: - `ConfigEntryNotReady`: Temporary setup issues (device offline) - `ConfigEntryAuthFailed`: Authentication problems - `ConfigEntryError`: Permanent setup issues +- **Try/Catch Best Practices**: + - Only wrap code that can throw exceptions + - Keep try blocks minimal - process data after the try/catch + - **Avoid bare exceptions** except in specific cases: + - ❌ Generally not allowed: `except:` or `except Exception:` + - ✅ Allowed in config flows to ensure robustness + - ✅ Allowed in functions/methods that run in background tasks + - Bad pattern: + ```python + try: + data = await device.get_data() # Can throw + # ❌ Don't process data inside try block + processed = data.get("value", 0) * 100 + self._attr_native_value = processed + except DeviceError: + _LOGGER.error("Failed to get data") + ``` + - Good pattern: + ```python + try: + data = await device.get_data() # Can throw + except DeviceError: + _LOGGER.error("Failed to get data") + return + + # ✅ Process data outside try block + processed = data.get("value", 0) * 100 + self._attr_native_value = processed + ``` +- **Bare Exception Usage**: + ```python + # ❌ Not allowed in regular code + try: + data = await device.get_data() + except Exception: # Too broad + _LOGGER.error("Failed") + + # ✅ Allowed in config flow for robustness + async def async_step_user(self, user_input=None): + try: + await self._test_connection(user_input) + except Exception: # Allowed here + errors["base"] = "unknown" + + # ✅ Allowed in background tasks + async def _background_refresh(): + try: + await coordinator.async_refresh() + except Exception: # Allowed in task + _LOGGER.exception("Unexpected error in background task") + ``` - **Setup Failure Patterns**: ```python try: @@ -445,6 +518,30 @@ rules: - Device names - Email addresses, usernames +### Entity Descriptions +- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation +- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability +- **Bad pattern**: + ```python + SensorEntityDescription( + key="temperature", + name="Temperature", + value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long + ) + ``` +- **Good pattern**: + ```python + SensorEntityDescription( + key="temperature", + name="Temperature", + value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda + round(data["temp_value"] * 1.8 + 32, 1) + if data.get("temp_value") is not None + else None + ), + ) + ``` + ### Entity Naming - **Use has_entity_name**: Set `_attr_has_entity_name = True` - **For specific fields**: @@ -846,6 +943,31 @@ return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets # Accessing hass.data directly in tests coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data + +# User-configurable polling intervals +# In config flow +vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed +# In coordinator +update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed + +# User-configurable config entry names (non-helper integrations) +vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations + +# Too much code in try block +try: + response = await client.get_data() # Can throw + # ❌ Data processing should be outside try block + temperature = response["temperature"] / 10 + humidity = response["humidity"] + self._attr_native_value = temperature +except ClientError: + _LOGGER.error("Failed to fetch data") + +# Bare exceptions in regular code +try: + value = await sensor.read_value() +except Exception: # ❌ Too broad - catch specific exceptions + _LOGGER.error("Failed to read sensor") ``` ### ✅ **Use These Patterns Instead** @@ -875,6 +997,21 @@ return async_redact_data(data, {"api_key", "password"}) # ✅ Safe async def init_integration(hass, mock_config_entry, mock_api): mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup + +# Integration-determined polling intervals (not user-configurable) +SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py + +class MyCoordinator(DataUpdateCoordinator[MyData]): + def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: + # ✅ Integration determines interval based on device capabilities, connection type, etc. + interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=interval, + config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended + ) ``` ### Entity Performance Optimization From c73346e6b3f100a7112a39afb0a0d02f0052e3d8 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:31:35 +0200 Subject: [PATCH 0766/1664] Bump pynecil to v4.1.1 (#147648) --- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 58cbdaa3bc6..be2309ab340 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.1.0"] + "requirements": ["pynecil==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9bc728320a7..da31a7fad53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2166,7 +2166,7 @@ pymsteams==0.1.12 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a5f97014e2..a131e2b9e68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1799,7 +1799,7 @@ pymonoprice==0.4 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 From a84313de33b1292640725737e199062ff108272d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 27 Jun 2025 03:50:45 -0400 Subject: [PATCH 0767/1664] Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549) Co-authored-by: Norbert Rittel --- .../firmware_config_flow.py | 100 ++++++++++++-- .../homeassistant_hardware/strings.json | 3 +- .../homeassistant_sky_connect/config_flow.py | 2 +- .../homeassistant_sky_connect/strings.json | 6 +- .../homeassistant_yellow/strings.json | 3 +- .../test_config_flow.py | 122 ++++++++++++++++-- .../test_config_flow_failures.py | 69 +++++++++- .../test_config_flow.py | 31 +++-- .../homeassistant_yellow/test_config_flow.py | 24 +++- 9 files changed, 318 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 7519e0ae394..a5e35749e1b 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,7 +7,10 @@ import asyncio import logging from typing import Any -from ha_silabs_firmware_client import FirmwareUpdateClient +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing +from universal_silabs_flasher.common import Version +from universal_silabs_flasher.firmware import NabuCasaMetadata from homeassistant.components.hassio import ( AddonError, @@ -149,15 +152,78 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None if not self.firmware_install_task: - session = async_get_clientsession(self.hass) - client = FirmwareUpdateClient(fw_update_url, session) - manifest = await client.async_update_data() - - fw_meta = next( - fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + # We 100% need to install new firmware only if the wrong firmware is + # currently installed + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type + != expected_installed_firmware_type ) - fw_data = await client.async_fetch_firmware(fw_meta) + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + _LOGGER.warning( + "Failed to fetch firmware update manifest", exc_info=True + ) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to index download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError) as err: + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to image download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + # Otherwise, fail + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( hass=self.hass, @@ -215,6 +281,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): }, ) + async def async_step_pre_confirm_zigbee( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm Zigbee setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_zigbee() + async def async_step_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -409,7 +483,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_start_task = None - return self.async_show_progress_done(next_step_id="confirm_otbr") + return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + + async def async_step_pre_confirm_otbr( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm OTBR setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_otbr() async def async_step_confirm_otbr( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 99172c963b8..d9c086cb040 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -36,7 +36,8 @@ "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 997edb54b18..197cb2ff2ce 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -93,7 +93,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 08c8a56c30d..f87a45febe4 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -92,7 +92,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -145,7 +146,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 980052f9ffb..b43f890b4e3 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -117,7 +117,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 530308fdf41..d5039f3b0bd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -6,6 +6,7 @@ import contextlib from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohttp import ClientError from ha_silabs_firmware_client import ( FirmwareManifest, FirmwareMetadata, @@ -80,7 +81,7 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( @@ -137,7 +138,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Zigbee firmware.""" - return await self.async_step_confirm_zigbee() + return await self.async_step_pre_confirm_zigbee() async def async_step_install_thread_firmware( self, user_input: dict[str, Any] | None = None @@ -208,6 +209,7 @@ def mock_firmware_info( *, is_hassio: bool = True, probe_app_type: ApplicationType | None = ApplicationType.EZSP, + probe_fw_version: str | None = "2.4.4.0", otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -217,6 +219,7 @@ def mock_firmware_info( version=None, ), flash_app_type: ApplicationType = ApplicationType.EZSP, + flash_fw_version: str | None = "7.4.4.0", ) -> Iterator[tuple[Mock, Mock]]: """Mock the main addon states for the config flow.""" mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) @@ -243,7 +246,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 460800, + "fw_type": "openthread_rcp", + "fw_variant": None, + "metadata_version": 2, + "ot_rcp_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl", ), FirmwareMetadata( @@ -251,7 +261,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", ), ], @@ -263,7 +280,7 @@ def mock_firmware_info( probed_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", # Not used firmware_type=probe_app_type, - firmware_version=None, + firmware_version=probe_fw_version, owners=[], source="probe", ) @@ -274,7 +291,7 @@ def mock_firmware_info( flashed_firmware_info = FirmwareInfo( device=TEST_DEVICE, firmware_type=flash_app_type, - firmware_version="7.4.4.0", + firmware_version=flash_fw_version, owners=[create_mock_owner()], source="probe", ) @@ -333,7 +350,7 @@ def mock_firmware_info( side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager + yield mock_otbr_manager, mock_update_client async def consume_progress_flow( @@ -411,6 +428,91 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +async def test_config_flow_firmware_index_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if index download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + hass, + # The correct firmware is already installed + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client): + # Mock the firmware download to fail + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_firmware_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if firmware download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_doesnt_downgrade( + hass: HomeAssistant, +) -> None: + """Test flow exits early, without downgrading firmware.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + probe_app_type=ApplicationType.EZSP, + # An newer version is probed than what we offer + probe_fw_version="7.5.0.0", + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware" + ) as mock_async_flash_silabs_firmware, + ): + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + assert len(mock_async_flash_silabs_firmware.mock_calls) == 0 + + async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None: """Test the config flow, skip installing the addon if necessary.""" result = await hass.config_entries.flow.async_init( @@ -480,7 +582,7 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -564,7 +666,7 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - update_available=False, version=None, ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -631,7 +733,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 65a5f58b17d..442cf8aea50 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState @@ -109,7 +110,7 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -147,7 +148,7 @@ async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -178,7 +179,7 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -209,7 +210,7 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): async def install_addon() -> None: mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( @@ -270,7 +271,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_start_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -341,6 +342,64 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non assert pick_thread_progress_result["reason"] == "unsupported_firmware" +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_index_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if OTA index download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if firmware download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 9dcac0732c9..4df3efab360 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -75,7 +75,7 @@ async def test_config_flow( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) @@ -100,14 +100,22 @@ async def test_config_flow( ), ), ): - result = await hass.config_entries.flow.async_configure( + confirm_result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) - config_entry = result["result"] + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, @@ -171,7 +179,7 @@ async def test_options_flow( assert result["description_placeholders"]["model"] == model async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() with ( patch( @@ -190,13 +198,20 @@ async def test_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index cd4a1941050..7f622e0ed8f 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -348,7 +348,7 @@ async def test_firmware_options_flow( assert result["description_placeholders"]["model"] == "Home Assistant Yellow" async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() async def mock_install_firmware_step( self, @@ -360,11 +360,16 @@ async def test_firmware_options_flow( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", return_value=None, @@ -385,13 +390,22 @@ async def test_firmware_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": fw_type.value, From 21131d00b3dc7de9164bda93937c0a951336b5b7 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:51:28 +0200 Subject: [PATCH 0768/1664] Fix config schema to make credentials optional in NUT flows (#147593) --- homeassistant/components/nut/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 69281e852a8..8a498b99680 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -39,10 +39,12 @@ def _base_schema( base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, - vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED) + ): str, vol.Optional( CONF_PASSWORD, - default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED, ): str, } From fda66c4be4ac3de9c9c94521a5942265ad079941 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 27 Jun 2025 09:52:00 +0200 Subject: [PATCH 0769/1664] Handle deleted devices dynamically in devolo Home Control (#147585) --- .../components/devolo_home_control/entity.py | 14 ++++++++++++-- .../devolo_home_control/test_binary_sensor.py | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index dbe53c21412..9edc7d54145 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -9,7 +9,7 @@ from devolo_home_control_api.devices.zwave import Zwave from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -35,7 +35,7 @@ class DevoloDeviceEntity(Entity): ) # This is not doing I/O. It fetches an internal state of the API self._attr_should_poll = False self._attr_unique_id = element_uid - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( configuration_url=f"https://{urlparse(device_instance.href).netloc}", identifiers={(DOMAIN, self._device_instance.uid)}, manufacturer=device_instance.brand, @@ -88,6 +88,16 @@ class DevoloDeviceEntity(Entity): elif len(message) == 3 and message[2] == "status": # Maybe the API wants to tell us, that the device went on- or offline. self._attr_available = self._device_instance.is_online() + elif message[1] == "del" and self.platform.config_entry: + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, self._device_instance.uid)} + ) + if device: + device_registry.async_update_device( + device.id, + remove_config_entry_id=self.platform.config_entry.entry_id, + ) else: _LOGGER.debug("No valid message received: %s", message) diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index b2a58ef5038..657e93a5b90 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -5,9 +5,10 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import configure_integration from .mocks import ( @@ -19,7 +20,10 @@ from .mocks import ( async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) @@ -55,6 +59,12 @@ async def test_binary_sensor( hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door").state == STATE_UNAVAILABLE ) + # Emulate websocket message: device was deleted + test_gateway.publisher.dispatch("Test", ("Test", "del")) + await hass.async_block_till_done() + device = device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) + assert not device + async def test_remote_control( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion From 78060e4833ee1ee54cead9928531524eedf54d9d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Jun 2025 10:01:44 +0200 Subject: [PATCH 0770/1664] Clarify descriptions of `subaru.unlock_specific_door` action (#147655) --- homeassistant/components/subaru/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 6aef0041874..e2399344544 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -102,11 +102,11 @@ "services": { "unlock_specific_door": { "name": "Unlock specific door", - "description": "Unlocks specific door(s).", + "description": "Unlocks the driver door, all doors, or the tailgate.", "fields": { "door": { "name": "Door", - "description": "Which door(s) to open." + "description": "The specific door(s) to unlock." } } } From 3879f6d2ef10d238afcade00995ebd449c566ae5 Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 27 Jun 2025 16:03:03 +0800 Subject: [PATCH 0771/1664] Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) --- .../components/telegram_bot/config_flow.py | 27 ++++++++++------- .../telegram_bot/test_config_flow.py | 29 ++++++++++++++----- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 1a77a5b9a81..7b441889b8c 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -412,12 +412,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for webhook Telegram bot.""" if not user_input: + default_trusted_networks = ",".join( + [str(network) for network in DEFAULT_TRUSTED_NETWORKS] + ) + if self.source == SOURCE_RECONFIGURE: + suggested_values = dict(self._get_reconfigure_entry().data) + if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data: + suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks + return self.async_show_form( step_id="webhooks", data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, - self._get_reconfigure_entry().data, + suggested_values, ), ) @@ -426,9 +434,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, { - CONF_TRUSTED_NETWORKS: ",".join( - [str(network) for network in DEFAULT_TRUSTED_NETWORKS] - ), + CONF_TRUSTED_NETWORKS: default_trusted_networks, }, ), ) @@ -479,12 +485,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders: dict[str, str], ) -> None: # validate URL - if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"): - errors["base"] = "invalid_url" - description_placeholders[ERROR_FIELD] = "URL" - description_placeholders[ERROR_MESSAGE] = "URL must start with https" - return - if CONF_URL not in user_input: + url: str | None = user_input.get(CONF_URL) + if url is None: try: get_url(self.hass, require_ssl=True, allow_internal=False) except NoURLAvailableError: @@ -494,6 +496,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): "URL is required since you have not configured an external URL in Home Assistant" ) return + elif not url.startswith("https"): + errors["base"] = "invalid_url" + description_placeholders[ERROR_FIELD] = "URL" + description_placeholders[ERROR_MESSAGE] = "URL must start with https" + return # validate trusted networks csv_trusted_networks: list[str] = [] diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index e13fab8f28b..2af90b9f7ef 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -121,13 +121,13 @@ async def test_reconfigure_flow_broadcast( async def test_reconfigure_flow_webhooks( hass: HomeAssistant, - mock_webhooks_config_entry: MockConfigEntry, + mock_broadcast_config_entry: MockConfigEntry, mock_external_calls: None, ) -> None: """Test reconfigure flow for webhook.""" - mock_webhooks_config_entry.add_to_hass(hass) + mock_broadcast_config_entry.add_to_hass(hass) - result = await mock_webhooks_config_entry.start_reconfigure_flow(hass) + result = await mock_broadcast_config_entry.start_reconfigure_flow(hass) assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -198,8 +198,8 @@ async def test_reconfigure_flow_webhooks( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_webhooks_config_entry.data[CONF_URL] == "https://reconfigure" - assert mock_webhooks_config_entry.data[CONF_TRUSTED_NETWORKS] == [ + assert mock_broadcast_config_entry.data[CONF_URL] == "https://reconfigure" + assert mock_broadcast_config_entry.data[CONF_TRUSTED_NETWORKS] == [ "149.154.160.0/20" ] @@ -499,9 +499,22 @@ async def test_import_multiple( CONF_BOT_COUNT: 2, } - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_chat", + return_value=ChatFullInfo( + id=987654321, + title="mock title", + first_name="mock first_name", + type="PRIVATE", + max_reaction_count=100, + accent_color_id=AccentColor.COLOR_000, + ), + ), ): # test: import first entry success From 917f1e4c6f13ff9d14aa28f100f1c1d8814cc289 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 10:03:14 +0200 Subject: [PATCH 0772/1664] Make entities unavailable when machine is physically off in lamarzocco (#147426) --- homeassistant/components/lamarzocco/entity.py | 20 ++++++++++++- homeassistant/components/lamarzocco/number.py | 4 --- homeassistant/components/lamarzocco/sensor.py | 6 ++-- tests/components/lamarzocco/test_sensor.py | 28 +++++++++++++++++-- tests/components/lamarzocco/test_switch.py | 26 +++++++++++++++-- 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6dc024645ce..6f9de083286 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,8 +2,10 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, MachineState, WidgetType +from pylamarzocco.models import MachineStatus from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity( """Common elements for all entities.""" _attr_has_entity_name = True + _unavailable_when_machine_off = True def __init__( self, @@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity( if connections: self._attr_device_info.update(DeviceInfo(connections=connections)) + @property + def available(self) -> bool: + """Return True if entity is available.""" + machine_state = ( + cast( + MachineStatus, + self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS], + ).status + if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config + else MachineState.OFF + ) + return super().available and not ( + self._unavailable_when_machine_off and machine_state is MachineState.OFF + ) + class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Common elements for all entities.""" diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f8cb8b1d6fe..b235cc7c5f9 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c76f51c3488..a432f5b8dae 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", @@ -188,6 +184,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): """Sensor for La Marzocco statistics.""" + _unavailable_when_machine_off = False + @property def native_value(self) -> StateType | datetime | None: """Return the value of the sensor.""" diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 183d3f2daa6..dee2fa0b79c 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,11 +2,11 @@ from unittest.mock import MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import MachineState, ModelName, WidgetType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -52,3 +52,27 @@ async def test_steam_ready_entity_for_all_machines( entry = entity_registry.async_get(state.entity_id) assert entry + + +async def test_sensors_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + SWITCHES_UNAVAILABLE = ( + ("sensor.gs012345_steam_boiler_ready_time", True), + ("sensor.gs012345_coffee_boiler_ready_time", True), + ("sensor.gs012345_total_coffees_made", False), + ) + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + + for sensor, available in SWITCHES_UNAVAILABLE: + state = hass.states.get(sensor) + assert state + assert (state.state == STATE_UNAVAILABLE) == available diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 0f1c4fd6ebb..c715c23b78f 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from pylamarzocco.const import SmartStandByType +from pylamarzocco.const import MachineState, SmartStandByType, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,3 +197,25 @@ async def test_switch_exceptions( blocking=True, ) assert exc_info.value.translation_key == "auto_on_off_error" + + +async def test_switches_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): + await async_init_integration(hass, mock_config_entry) + + switches = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for switch in switches: + state = hass.states.get(switch.entity_id) + assert state + assert state.state == STATE_UNAVAILABLE From cb359da79e6ed87ae65d3a3ebcb35d9d1677f60f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 10:03:14 +0200 Subject: [PATCH 0773/1664] Make entities unavailable when machine is physically off in lamarzocco (#147426) --- homeassistant/components/lamarzocco/entity.py | 20 ++++++++++++- homeassistant/components/lamarzocco/number.py | 4 --- homeassistant/components/lamarzocco/sensor.py | 6 ++-- tests/components/lamarzocco/test_sensor.py | 28 +++++++++++++++++-- tests/components/lamarzocco/test_switch.py | 26 +++++++++++++++-- 5 files changed, 71 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6dc024645ce..6f9de083286 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,8 +2,10 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, MachineState, WidgetType +from pylamarzocco.models import MachineStatus from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity( """Common elements for all entities.""" _attr_has_entity_name = True + _unavailable_when_machine_off = True def __init__( self, @@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity( if connections: self._attr_device_info.update(DeviceInfo(connections=connections)) + @property + def available(self) -> bool: + """Return True if entity is available.""" + machine_state = ( + cast( + MachineStatus, + self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS], + ).status + if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config + else MachineState.OFF + ) + return super().available and not ( + self._unavailable_when_machine_off and machine_state is MachineState.OFF + ) + class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Common elements for all entities.""" diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f8cb8b1d6fe..b235cc7c5f9 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = ( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c76f51c3488..a432f5b8dae 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", @@ -188,6 +184,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity): class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): """Sensor for La Marzocco statistics.""" + _unavailable_when_machine_off = False + @property def native_value(self) -> StateType | datetime | None: """Return the value of the sensor.""" diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 183d3f2daa6..dee2fa0b79c 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,11 +2,11 @@ from unittest.mock import MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import MachineState, ModelName, WidgetType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -52,3 +52,27 @@ async def test_steam_ready_entity_for_all_machines( entry = entity_registry.async_get(state.entity_id) assert entry + + +async def test_sensors_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + SWITCHES_UNAVAILABLE = ( + ("sensor.gs012345_steam_boiler_ready_time", True), + ("sensor.gs012345_coffee_boiler_ready_time", True), + ("sensor.gs012345_total_coffees_made", False), + ) + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + + for sensor, available in SWITCHES_UNAVAILABLE: + state = hass.states.get(sensor) + assert state + assert (state.state == STATE_UNAVAILABLE) == available diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 0f1c4fd6ebb..c715c23b78f 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from pylamarzocco.const import SmartStandByType +from pylamarzocco.const import MachineState, SmartStandByType, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,3 +197,25 @@ async def test_switch_exceptions( blocking=True, ) assert exc_info.value.translation_key == "auto_on_off_error" + + +async def test_switches_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): + await async_init_integration(hass, mock_config_entry) + + switches = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for switch in switches: + state = hass.states.get(switch.entity_id) + assert state + assert state.state == STATE_UNAVAILABLE From f93ab8d5198464c2a31d587d8882c8d81ebf4a7d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 27 Jun 2025 03:50:45 -0400 Subject: [PATCH 0774/1664] Allow setup of Zigbee/Thread for ZBT-1 and Yellow without internet access (#147549) Co-authored-by: Norbert Rittel --- .../firmware_config_flow.py | 100 ++++++++++++-- .../homeassistant_hardware/strings.json | 3 +- .../homeassistant_sky_connect/config_flow.py | 2 +- .../homeassistant_sky_connect/strings.json | 6 +- .../homeassistant_yellow/strings.json | 3 +- .../test_config_flow.py | 122 ++++++++++++++++-- .../test_config_flow_failures.py | 69 +++++++++- .../test_config_flow.py | 31 +++-- .../homeassistant_yellow/test_config_flow.py | 24 +++- 9 files changed, 318 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 7519e0ae394..a5e35749e1b 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,7 +7,10 @@ import asyncio import logging from typing import Any -from ha_silabs_firmware_client import FirmwareUpdateClient +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing +from universal_silabs_flasher.common import Version +from universal_silabs_flasher.firmware import NabuCasaMetadata from homeassistant.components.hassio import ( AddonError, @@ -149,15 +152,78 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None if not self.firmware_install_task: - session = async_get_clientsession(self.hass) - client = FirmwareUpdateClient(fw_update_url, session) - manifest = await client.async_update_data() - - fw_meta = next( - fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + # We 100% need to install new firmware only if the wrong firmware is + # currently installed + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type + != expected_installed_firmware_type ) - fw_data = await client.async_fetch_firmware(fw_meta) + session = async_get_clientsession(self.hass) + client = FirmwareUpdateClient(fw_update_url, session) + + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + _LOGGER.warning( + "Failed to fetch firmware update manifest", exc_info=True + ) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to index download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError) as err: + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to image download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + # Otherwise, fail + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( hass=self.hass, @@ -215,6 +281,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): }, ) + async def async_step_pre_confirm_zigbee( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm Zigbee setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_zigbee() + async def async_step_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -409,7 +483,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): finally: self.addon_start_task = None - return self.async_show_progress_done(next_step_id="confirm_otbr") + return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + + async def async_step_pre_confirm_otbr( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm OTBR setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_otbr() async def async_step_confirm_otbr( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 99172c963b8..d9c086cb040 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -36,7 +36,8 @@ "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 997edb54b18..197cb2ff2ce 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -93,7 +93,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol): firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 08c8a56c30d..f87a45febe4 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -92,7 +92,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -145,7 +146,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 980052f9ffb..b43f890b4e3 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -117,7 +117,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 530308fdf41..d5039f3b0bd 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -6,6 +6,7 @@ import contextlib from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohttp import ClientError from ha_silabs_firmware_client import ( FirmwareManifest, FirmwareMetadata, @@ -80,7 +81,7 @@ class FakeFirmwareConfigFlow(BaseFirmwareConfigFlow, domain=TEST_DOMAIN): firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( @@ -137,7 +138,7 @@ class FakeFirmwareOptionsFlowHandler(BaseFirmwareOptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Zigbee firmware.""" - return await self.async_step_confirm_zigbee() + return await self.async_step_pre_confirm_zigbee() async def async_step_install_thread_firmware( self, user_input: dict[str, Any] | None = None @@ -208,6 +209,7 @@ def mock_firmware_info( *, is_hassio: bool = True, probe_app_type: ApplicationType | None = ApplicationType.EZSP, + probe_fw_version: str | None = "2.4.4.0", otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -217,6 +219,7 @@ def mock_firmware_info( version=None, ), flash_app_type: ApplicationType = ApplicationType.EZSP, + flash_fw_version: str | None = "7.4.4.0", ) -> Iterator[tuple[Mock, Mock]]: """Mock the main addon states for the config flow.""" mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) @@ -243,7 +246,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 460800, + "fw_type": "openthread_rcp", + "fw_variant": None, + "metadata_version": 2, + "ot_rcp_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl", ), FirmwareMetadata( @@ -251,7 +261,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", ), ], @@ -263,7 +280,7 @@ def mock_firmware_info( probed_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", # Not used firmware_type=probe_app_type, - firmware_version=None, + firmware_version=probe_fw_version, owners=[], source="probe", ) @@ -274,7 +291,7 @@ def mock_firmware_info( flashed_firmware_info = FirmwareInfo( device=TEST_DEVICE, firmware_type=flash_app_type, - firmware_version="7.4.4.0", + firmware_version=flash_fw_version, owners=[create_mock_owner()], source="probe", ) @@ -333,7 +350,7 @@ def mock_firmware_info( side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager + yield mock_otbr_manager, mock_update_client async def consume_progress_flow( @@ -411,6 +428,91 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +async def test_config_flow_firmware_index_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if index download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + hass, + # The correct firmware is already installed + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client): + # Mock the firmware download to fail + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_firmware_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if firmware download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_doesnt_downgrade( + hass: HomeAssistant, +) -> None: + """Test flow exits early, without downgrading firmware.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + probe_app_type=ApplicationType.EZSP, + # An newer version is probed than what we offer + probe_fw_version="7.5.0.0", + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware" + ) as mock_async_flash_silabs_firmware, + ): + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + assert len(mock_async_flash_silabs_firmware.mock_calls) == 0 + + async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None: """Test the config flow, skip installing the addon if necessary.""" result = await hass.config_entries.flow.async_init( @@ -480,7 +582,7 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -564,7 +666,7 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - update_available=False, version=None, ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -631,7 +733,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 65a5f58b17d..442cf8aea50 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState @@ -109,7 +110,7 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -147,7 +148,7 @@ async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -178,7 +179,7 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -209,7 +210,7 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): async def install_addon() -> None: mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( @@ -270,7 +271,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_start_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -341,6 +342,64 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non assert pick_thread_progress_result["reason"] == "unsupported_firmware" +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_index_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if OTA index download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if firmware download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 9dcac0732c9..4df3efab360 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -75,7 +75,7 @@ async def test_config_flow( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) @@ -100,14 +100,22 @@ async def test_config_flow( ), ), ): - result = await hass.config_entries.flow.async_configure( + confirm_result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) - config_entry = result["result"] + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, @@ -171,7 +179,7 @@ async def test_options_flow( assert result["description_placeholders"]["model"] == model async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() with ( patch( @@ -190,13 +198,20 @@ async def test_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index cd4a1941050..7f622e0ed8f 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -348,7 +348,7 @@ async def test_firmware_options_flow( assert result["description_placeholders"]["model"] == "Home Assistant Yellow" async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() async def mock_install_firmware_step( self, @@ -360,11 +360,16 @@ async def test_firmware_options_flow( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", return_value=None, @@ -385,13 +390,22 @@ async def test_firmware_options_flow( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": fw_type.value, From 28dfc997f3fe561e1dfcae35562b67fbfb368ea7 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Jun 2025 09:02:12 +0300 Subject: [PATCH 0775/1664] Do not factory reset old Z-Wave controller during migration (#147576) * Do not factory reset old Z-Wave controller during migration * PR comments * remove obsolete test --- .../components/zwave_js/config_flow.py | 63 +-- .../components/zwave_js/strings.json | 6 +- tests/components/zwave_js/test_config_flow.py | 365 +----------------- 3 files changed, 7 insertions(+), 427 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 2c37ee4b554..a109719965c 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -845,11 +845,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): }, ) - if user_input is not None: - self._migrating = True - return await self.async_step_backup_nvm() - - return self.async_show_form(step_id="intent_migrate") + self._migrating = True + return await self.async_step_backup_nvm() async def async_step_backup_nvm( self, user_input: dict[str, Any] | None = None @@ -904,7 +901,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_instruct_unplug( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reset the current controller, and instruct the user to unplug it.""" + """Instruct the user to unplug the old controller.""" if user_input is not None: if self.usb_path: @@ -914,63 +911,9 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): # Now that the old controller is gone, we can scan for serial ports again return await self.async_step_choose_serial_port() - try: - driver = self._get_driver() - except AbortFlow: - return self.async_abort(reason="config_entry_not_loaded") - - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - - unsubscribe = driver.once("driver ready", set_driver_ready) - - # reset the old controller - try: - await driver.async_hard_reset() - except FailedCommand as err: - unsubscribe() - _LOGGER.error("Failed to reset controller: %s", err) - return self.async_abort(reason="reset_failed") - - # Update the unique id of the config entry - # to the new home id, which requires waiting for the driver - # to be ready before getting the new home id. - # If the backup restore, done later in the flow, fails, - # the config entry unique id should be the new home id - # after the controller reset. - try: - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - except TimeoutError: - pass - finally: - unsubscribe() - config_entry = self._reconfigure_config_entry assert config_entry is not None - try: - version_info = await async_get_version_info( - self.hass, config_entry.data[CONF_URL] - ) - except CannotConnect: - # Just log this error, as there's nothing to do about it here. - # The stale unique id needs to be handled by a repair flow, - # after the config entry has been reloaded, if the backup restore - # also fails. - _LOGGER.debug( - "Failed to get server version, cannot update config entry " - "unique id with new home id, after controller reset" - ) - else: - self.hass.config_entries.async_update_entry( - config_entry, unique_id=str(version_info.home_id) - ) - # Unload the config entry before asking the user to unplug the controller. await self.hass.config_entries.async_unload(config_entry.entry_id) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d9a3b82a47c..f61d871cfb9 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,13 +108,9 @@ "intent_reconfigure": "Re-configure the current controller" } }, - "intent_migrate": { - "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", - "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" - }, "instruct_unplug": { "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 2e41a176a9c..e99cedbdcba 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -867,8 +867,6 @@ async def test_usb_discovery_migration( get_server_version: AsyncMock, ) -> None: """Test usb discovery migration.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 @@ -893,13 +891,6 @@ async def test_usb_discovery_migration( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -927,10 +918,6 @@ 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"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -947,7 +934,6 @@ async def test_usb_discovery_migration( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" - assert entry.unique_id == "4321" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -962,6 +948,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 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -1024,13 +1011,6 @@ async def test_usb_discovery_migration_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -1055,10 +1035,6 @@ 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"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3401,21 +3377,12 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "reset_server_version_side_effect", - "reset_unique_id", "restore_server_version_side_effect", "final_unique_id", ), [ - (None, "4321", None, "3245146787"), - (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), - (None, "4321", aiohttp.ClientError("Boom"), "5678"), - ( - aiohttp.ClientError("Boom"), - "3245146787", - aiohttp.ClientError("Boom"), - "5678", - ), + (None, "3245146787"), + (aiohttp.ClientError("Boom"), "5678"), ], ) async def test_reconfigure_migrate_with_addon( @@ -3428,15 +3395,11 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, - reset_server_version_side_effect: Exception | None, - reset_unique_id: str, restore_server_version_side_effect: Exception | None, final_unique_id: str, ) -> None: """Test migration flow with add-on.""" - get_server_version.side_effect = reset_server_version_side_effect version_info = get_server_version.return_value - version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 @@ -3494,13 +3457,6 @@ async def test_reconfigure_migrate_with_addon( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3531,11 +3487,6 @@ async def test_reconfigure_migrate_with_addon( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3552,7 +3503,6 @@ async def test_reconfigure_migrate_with_addon( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == reset_unique_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -3565,8 +3515,6 @@ async def test_reconfigure_migrate_with_addon( with pytest.raises(InInvalid): data_schema.schema[CONF_USB_PATH](addon_options["device"]) - # Reset side effect before starting the add-on. - get_server_version.side_effect = None version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( @@ -3646,156 +3594,6 @@ async def test_reconfigure_migrate_with_addon( assert client.driver.controller.home_id == 3245146787 -@pytest.mark.usefixtures("supervisor", "addon_running") -async def test_reconfigure_migrate_reset_driver_ready_timeout( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, - restart_addon: AsyncMock, - set_addon_options: AsyncMock, - get_server_version: AsyncMock, -) -> None: - """Test migration flow with driver ready timeout after controller reset.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 - entry = integration - assert client.connect.call_count == 1 - hass.config_entries.async_update_entry( - entry, - unique_id="1234", - data={ - "url": "ws://localhost:3000", - "use_addon": True, - "usb_path": "/dev/ttyUSB0", - }, - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - - async def mock_reset_controller(): - await asyncio.sleep(0) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): - client.driver.controller.emit( - "nvm convert progress", - {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, - ) - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm restore progress", - {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, - ) - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) - - events = async_capture_events( - hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - with ( - patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), - new=0, - ), - patch("pathlib.Path.write_bytes") as mock_file, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - assert len(events) == 1 - assert events[0].data["progress"] == 0.5 - events.clear() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "instruct_unplug" - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == "4321" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, - ) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) - ) - - await hass.async_block_till_done() - - assert restart_addon.call_args == call("core_zwave_js") - - version_info.home_id = 5678 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 - - await hass.async_block_till_done() - assert client.connect.call_count == 4 - assert entry.state is config_entries.ConfigEntryState.LOADED - assert client.driver.controller.async_restore_nvm.call_count == 1 - assert len(events) == 2 - assert events[0].data["progress"] == 0.25 - assert events[1].data["progress"] == 0.75 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "migration_successful" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" - assert entry.data["use_addon"] is True - assert entry.unique_id == "5678" - assert "keep_old_devices" not in entry.data - - @pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_restore_driver_ready_timeout( hass: HomeAssistant, @@ -3828,13 +3626,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3861,11 +3652,6 @@ async def test_reconfigure_migrate_restore_driver_ready_timeout( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3960,11 +3746,6 @@ async def test_reconfigure_migrate_backup_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" assert "keep_old_devices" not in entry.data @@ -3998,11 +3779,6 @@ async def test_reconfigure_migrate_backup_file_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4040,13 +3816,6 @@ async def test_reconfigure_migrate_start_addon_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4056,11 +3825,6 @@ async def test_reconfigure_migrate_start_addon_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4124,12 +3888,6 @@ async def test_reconfigure_migrate_restore_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) client.driver.controller.async_restore_nvm = AsyncMock( side_effect=FailedCommand("test_error", "unknown_error") ) @@ -4143,11 +3901,6 @@ async def test_reconfigure_migrate_restore_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4242,106 +3995,6 @@ async def test_get_driver_failure_intent_migrate( assert "keep_old_devices" not in entry.data -async def test_get_driver_failure_instruct_unplug( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test get driver failure in instruct unplug step.""" - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "config_entry_not_loaded" - - -async def test_hard_reset_failure( - hass: HomeAssistant, - integration: MockConfigEntry, - client: MagicMock, -) -> None: - """Test hard reset failure.""" - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - client.driver.async_hard_reset = AsyncMock( - side_effect=FailedCommand("test_error", "unknown_error") - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reset_failed" - - async def test_choose_serial_port_usb_ports_failure( hass: HomeAssistant, integration: MockConfigEntry, @@ -4361,13 +4014,6 @@ async def test_choose_serial_port_usb_ports_failure( side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4377,11 +4023,6 @@ async def test_choose_serial_port_usb_ports_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - 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 e5e6ed601b6d088b657d832918cbfae1b175a3aa Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 27 Jun 2025 16:03:03 +0800 Subject: [PATCH 0776/1664] Fix Telegram bot yaml import for webhooks containing None value for URL (#147586) --- .../components/telegram_bot/config_flow.py | 27 ++++++++++------- .../telegram_bot/test_config_flow.py | 29 ++++++++++++++----- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 1a77a5b9a81..7b441889b8c 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -412,12 +412,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Handle config flow for webhook Telegram bot.""" if not user_input: + default_trusted_networks = ",".join( + [str(network) for network in DEFAULT_TRUSTED_NETWORKS] + ) + if self.source == SOURCE_RECONFIGURE: + suggested_values = dict(self._get_reconfigure_entry().data) + if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data: + suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks + return self.async_show_form( step_id="webhooks", data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, - self._get_reconfigure_entry().data, + suggested_values, ), ) @@ -426,9 +434,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, { - CONF_TRUSTED_NETWORKS: ",".join( - [str(network) for network in DEFAULT_TRUSTED_NETWORKS] - ), + CONF_TRUSTED_NETWORKS: default_trusted_networks, }, ), ) @@ -479,12 +485,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders: dict[str, str], ) -> None: # validate URL - if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"): - errors["base"] = "invalid_url" - description_placeholders[ERROR_FIELD] = "URL" - description_placeholders[ERROR_MESSAGE] = "URL must start with https" - return - if CONF_URL not in user_input: + url: str | None = user_input.get(CONF_URL) + if url is None: try: get_url(self.hass, require_ssl=True, allow_internal=False) except NoURLAvailableError: @@ -494,6 +496,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): "URL is required since you have not configured an external URL in Home Assistant" ) return + elif not url.startswith("https"): + errors["base"] = "invalid_url" + description_placeholders[ERROR_FIELD] = "URL" + description_placeholders[ERROR_MESSAGE] = "URL must start with https" + return # validate trusted networks csv_trusted_networks: list[str] = [] diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index e13fab8f28b..2af90b9f7ef 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -121,13 +121,13 @@ async def test_reconfigure_flow_broadcast( async def test_reconfigure_flow_webhooks( hass: HomeAssistant, - mock_webhooks_config_entry: MockConfigEntry, + mock_broadcast_config_entry: MockConfigEntry, mock_external_calls: None, ) -> None: """Test reconfigure flow for webhook.""" - mock_webhooks_config_entry.add_to_hass(hass) + mock_broadcast_config_entry.add_to_hass(hass) - result = await mock_webhooks_config_entry.start_reconfigure_flow(hass) + result = await mock_broadcast_config_entry.start_reconfigure_flow(hass) assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -198,8 +198,8 @@ async def test_reconfigure_flow_webhooks( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_webhooks_config_entry.data[CONF_URL] == "https://reconfigure" - assert mock_webhooks_config_entry.data[CONF_TRUSTED_NETWORKS] == [ + assert mock_broadcast_config_entry.data[CONF_URL] == "https://reconfigure" + assert mock_broadcast_config_entry.data[CONF_TRUSTED_NETWORKS] == [ "149.154.160.0/20" ] @@ -499,9 +499,22 @@ async def test_import_multiple( CONF_BOT_COUNT: 2, } - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_chat", + return_value=ChatFullInfo( + id=987654321, + title="mock title", + first_name="mock first_name", + type="PRIVATE", + max_reaction_count=100, + accent_color_id=AccentColor.COLOR_000, + ), + ), ): # test: import first entry success From 263823c92cc85bd8201fc6f61d86a5986b7d707f Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:51:28 +0200 Subject: [PATCH 0777/1664] Fix config schema to make credentials optional in NUT flows (#147593) --- homeassistant/components/nut/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 69281e852a8..8a498b99680 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -39,10 +39,12 @@ def _base_schema( base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, - vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED) + ): str, vol.Optional( CONF_PASSWORD, - default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED, ): str, } From efb29d024e0d90aaed48ce5ab1f0037a845df3f6 Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Thu, 26 Jun 2025 14:52:07 -0400 Subject: [PATCH 0778/1664] Add Diagnostics to PlayStation Network (#147607) * Add Diagnostics support to PlayStation_Network * Remove unused constant * minor cleanup * Redact additional data * Redact additional data --- .../playstation_network/diagnostics.py | 55 +++++++++++++ .../playstation_network/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 79 +++++++++++++++++++ .../playstation_network/test_diagnostics.py | 28 +++++++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/playstation_network/diagnostics.py create mode 100644 tests/components/playstation_network/snapshots/test_diagnostics.ambr create mode 100644 tests/components/playstation_network/test_diagnostics.py diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py new file mode 100644 index 00000000000..8332572177d --- /dev/null +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -0,0 +1,55 @@ +"""Diagnostics support for PlayStation Network.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from psnawp_api.models.trophies import PlatformType + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator + +TO_REDACT = { + "account_id", + "firstName", + "lastName", + "middleName", + "onlineId", + "url", + "username", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: PlaystationNetworkConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: PlaystationNetworkCoordinator = entry.runtime_data + + return { + "data": async_redact_data( + _serialize_platform_types(asdict(coordinator.data)), TO_REDACT + ), + } + + +def _serialize_platform_types(data: Any) -> Any: + """Recursively convert PlatformType enums to strings in dicts and sets.""" + if isinstance(data, dict): + return { + ( + platform.value if isinstance(platform, PlatformType) else platform + ): _serialize_platform_types(record) + for platform, record in data.items() + } + if isinstance(data, set): + return [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + if isinstance(data, PlatformType): + return data.value + return data diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index e173c4a710c..a98c30a7667 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -44,7 +44,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Discovery flow is not applicable for this integration diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..405cee04559 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'account_id': '**REDACTED**', + 'active_sessions': dict({ + 'PS5': dict({ + 'format': 'PS5', + 'media_image_url': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'platform': 'PS5', + 'status': 'online', + 'title_id': 'PPSA07784_00', + 'title_name': 'STAR WARS Jedi: Survivor™', + }), + }), + 'available': True, + 'presence': dict({ + 'basicPresence': dict({ + 'availability': 'availableToPlay', + 'gameTitleInfoList': list([ + dict({ + 'conceptIconUrl': 'https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png', + 'format': 'PS5', + 'launchPlatform': 'PS5', + 'npTitleId': 'PPSA07784_00', + 'titleName': 'STAR WARS Jedi: Survivor™', + }), + ]), + 'primaryPlatformInfo': dict({ + 'onlineStatus': 'online', + 'platform': 'PS5', + }), + }), + }), + 'profile': dict({ + 'aboutMe': 'Never Gonna Give You Up', + 'avatars': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + 'isMe': True, + 'isOfficiallyVerified': False, + 'isPlus': True, + 'languages': list([ + 'de-DE', + ]), + 'onlineId': '**REDACTED**', + 'personalDetail': dict({ + 'firstName': '**REDACTED**', + 'lastName': '**REDACTED**', + 'profilePictures': list([ + dict({ + 'size': 'xl', + 'url': '**REDACTED**', + }), + ]), + }), + }), + 'registered_platforms': list([ + 'PS5', + ]), + 'trophy_summary': dict({ + 'account_id': '**REDACTED**', + 'earned_trophies': dict({ + 'bronze': 14450, + 'gold': 11754, + 'platinum': 1398, + 'silver': 8722, + }), + 'progress': 19, + 'tier': 10, + 'trophy_level': 1079, + }), + 'username': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/playstation_network/test_diagnostics.py b/tests/components/playstation_network/test_diagnostics.py new file mode 100644 index 00000000000..b803a213207 --- /dev/null +++ b/tests/components/playstation_network/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for PlayStation Network diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From 3fc154e1d76625cf83a792ccb274521057fb6359 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:03:36 +0200 Subject: [PATCH 0779/1664] Make sure Google Generative AI integration migration is clean (#147625) --- .../__init__.py | 6 ++++++ .../test_init.py | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 7890af59f88..5e4ad114adf 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -284,6 +284,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 85d6c70b658..08a94dd151c 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -512,6 +512,10 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } subentry = conversation_subentries[1] @@ -531,6 +535,10 @@ async def test_migration_from_v1_to_v2( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -626,6 +634,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == { + entry.entry_id: {list(entry.subentries.values())[0].subentry_id} + } async def test_migration_from_v1_to_v2_with_same_keys( @@ -743,6 +755,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } subentry = conversation_subentries[1] @@ -762,6 +778,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( ) assert device.identifiers == {(DOMAIN, subentry.subentry_id)} assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_devices( From c2c388e0cca31356a466ddfd75af6ed333df6cd9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:03:11 +0200 Subject: [PATCH 0780/1664] Make sure OpenAI integration migration is clean (#147627) --- .../components/openai_conversation/__init__.py | 6 ++++++ tests/components/openai_conversation/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index e14a8aabc1b..7cac3bb7003 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -346,6 +346,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index b7f2a5434eb..274d09a9779 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -618,6 +618,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -709,6 +713,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -808,6 +814,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } @pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) From bc607dd013e39665c98ecf4b602e9e36dfdf0609 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:02:59 +0200 Subject: [PATCH 0781/1664] Make sure Anthropic integration migration is clean (#147629) --- homeassistant/components/anthropic/__init__.py | 6 ++++++ tests/components/anthropic/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index c537a000c14..68a46f19031 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -123,6 +123,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 6295bac67cb..16240ef8120 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -141,6 +141,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_keys( @@ -231,6 +235,8 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_keys( @@ -329,3 +335,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 85343a9f53cada3e645a9e086df6eaa9c623a326 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Jun 2025 23:02:35 +0200 Subject: [PATCH 0782/1664] Make sure Ollama integration migration is clean (#147630) --- homeassistant/components/ollama/__init__.py | 6 ++++++ tests/components/ollama/test_init.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f174c709b65..8890c498e9f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -133,6 +133,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: device.id, remove_config_entry_id=entry.entry_id, ) + else: + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) if not use_existing: await hass.config_entries.async_remove(entry.entry_id) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index e11eb98451a..0747578c110 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -109,6 +109,10 @@ async def test_migration_from_v1_to_v2( ) assert migrated_device.identifiers == {(DOMAIN, subentry.subentry_id)} assert migrated_device.id == device.id + assert migrated_device.config_entries == {mock_config_entry.entry_id} + assert migrated_device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } async def test_migration_from_v1_to_v2_with_multiple_urls( @@ -193,6 +197,8 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) assert dev is not None + assert dev.config_entries == {entry.entry_id} + assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} async def test_migration_from_v1_to_v2_with_same_urls( @@ -285,3 +291,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None + assert dev.config_entries == {mock_config_entry.entry_id} + assert dev.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 6bd6fa65d2c4469b5577706bdf177f87f86e3676 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:31:35 +0200 Subject: [PATCH 0783/1664] Bump pynecil to v4.1.1 (#147648) --- homeassistant/components/iron_os/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 58cbdaa3bc6..be2309ab340 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.1.0"] + "requirements": ["pynecil==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9bc728320a7..da31a7fad53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2166,7 +2166,7 @@ pymsteams==0.1.12 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a5f97014e2..a131e2b9e68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1799,7 +1799,7 @@ pymonoprice==0.4 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 From 9782637ec86ab11c6413b7aaedf4776c7f33134b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Jun 2025 10:01:44 +0200 Subject: [PATCH 0784/1664] Clarify descriptions of `subaru.unlock_specific_door` action (#147655) --- homeassistant/components/subaru/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 6aef0041874..e2399344544 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -102,11 +102,11 @@ "services": { "unlock_specific_door": { "name": "Unlock specific door", - "description": "Unlocks specific door(s).", + "description": "Unlocks the driver door, all doors, or the tailgate.", "fields": { "door": { "name": "Door", - "description": "Which door(s) to open." + "description": "The specific door(s) to unlock." } } } From 41b9a7a9a3ca65d55c2fcccea3d1216a7c77d306 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Jun 2025 08:08:02 +0000 Subject: [PATCH 0785/1664] Bump version to 2025.7.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index cf48d8b2427..5ded5fc83bb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index dfb0fc741fa..870c22f2a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b1" +version = "2025.7.0b2" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 8cc4105984e29c1eeaa24004cce737f0d9a65f78 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 10:31:13 +0200 Subject: [PATCH 0786/1664] Make jellyfin not single config entry (#147656) --- .../components/jellyfin/manifest.json | 3 +- homeassistant/generated/integrations.json | 3 +- .../jellyfin/fixtures/get-user-settings.json | 2 +- tests/components/jellyfin/test_config_flow.py | 37 +++++++++++++------ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index d6b2261acaa..a1bf3268721 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"], - "single_config_entry": true + "requirements": ["jellyfin-apiclient-python==1.10.0"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bd88338c4b9..a44be6059ec 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3165,8 +3165,7 @@ "name": "Jellyfin", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "single_config_entry": true + "iot_class": "local_polling" }, "jewish_calendar": { "name": "Jewish Calendar", diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json index 5e28f87d8f2..5ed59661a60 100644 --- a/tests/components/jellyfin/fixtures/get-user-settings.json +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -1,5 +1,5 @@ { - "Id": "string", + "Id": "USER-UUID", "ViewType": "string", "SortBy": "string", "IndexBy": "string", diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index a8ffbcbf46c..fd9d3b1d773 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -23,17 +23,6 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - async def test_form( hass: HomeAssistant, mock_jellyfin: MagicMock, @@ -201,6 +190,32 @@ async def test_form_persists_device_id_on_error( } +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the case where the user tries to configure an already configured entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 78c2405e61efbdcc0c6ca1058d81d168371e38cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 27 Jun 2025 09:33:49 +0100 Subject: [PATCH 0787/1664] Bump whirlpool to 0.21.1 (#147611) --- .../components/whirlpool/binary_sensor.py | 21 +- .../components/whirlpool/config_flow.py | 6 +- .../components/whirlpool/diagnostics.py | 10 +- .../components/whirlpool/manifest.json | 2 +- homeassistant/components/whirlpool/sensor.py | 244 +++++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/whirlpool/conftest.py | 25 +- .../whirlpool/snapshots/test_diagnostics.ambr | 8 +- .../whirlpool/snapshots/test_sensor.ambr | 12 +- .../components/whirlpool/test_config_flow.py | 3 +- tests/components/whirlpool/test_init.py | 3 +- tests/components/whirlpool/test_sensor.py | 158 +++++++----- 13 files changed, 320 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py index d8ec373f026..d26f5764313 100644 --- a/homeassistant/components/whirlpool/binary_sensor.py +++ b/homeassistant/components/whirlpool/binary_sensor.py @@ -42,14 +42,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whirlpool binary sensors.""" - entities: list = [] appliances_manager = config_entry.runtime_data - for washer_dryer in appliances_manager.washer_dryers: - entities.extend( - WhirlpoolBinarySensor(washer_dryer, description) - for description in WASHER_DRYER_SENSORS - ) - async_add_entities(entities) + + washer_binary_sensors = [ + WhirlpoolBinarySensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_DRYER_SENSORS + ] + + dryer_binary_sensors = [ + WhirlpoolBinarySensor(dryer, description) + for dryer in appliances_manager.dryers + for description in WASHER_DRYER_SENSORS + ] + + async_add_entities([*washer_binary_sensors, *dryer_binary_sensors]) class WhirlpoolBinarySensor(WhirlpoolEntity, BinarySensorEntity): diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 61d6883d70f..8c216109731 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -70,7 +70,11 @@ async def authenticate( appliances_manager = AppliancesManager(backend_selector, auth, session) await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: + if ( + not appliances_manager.aircons + and not appliances_manager.washers + and not appliances_manager.dryers + ): return "no_appliances" return None diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 09338396de4..fed999b881c 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -37,9 +37,13 @@ async def async_get_config_entry_diagnostics( appliances_manager = config_entry.runtime_data diagnostics_data = { - "washer_dryers": { - wd.name: get_appliance_diagnostics(wd) - for wd in appliances_manager.washer_dryers + "washers": { + washer.name: get_appliance_diagnostics(washer) + for washer in appliances_manager.washers + }, + "dryers": { + dryer.name: get_appliance_diagnostics(dryer) + for dryer in appliances_manager.dryers }, "aircons": { ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 919fa54c834..2712e6b2f64 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["whirlpool"], "quality_scale": "bronze", - "requirements": ["whirlpool-sixth-sense==0.20.0"] + "requirements": ["whirlpool-sixth-sense==0.21.1"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 6b052834656..164e1b6e5fe 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,12 +1,14 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" +from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta from typing import override from whirlpool.appliance import Appliance -from whirlpool.washerdryer import MachineState, WasherDryer +from whirlpool.dryer import Dryer, MachineState as DryerMachineState +from whirlpool.washer import MachineState as WasherMachineState, Washer from homeassistant.components.sensor import ( RestoreSensor, @@ -33,26 +35,49 @@ WASHER_TANK_FILL = { 5: "active", } -WASHER_DRYER_MACHINE_STATE = { - MachineState.Standby: "standby", - MachineState.Setting: "setting", - MachineState.DelayCountdownMode: "delay_countdown", - MachineState.DelayPause: "delay_paused", - MachineState.SmartDelay: "smart_delay", - MachineState.SmartGridPause: "smart_grid_pause", - MachineState.Pause: "pause", - MachineState.RunningMainCycle: "running_maincycle", - MachineState.RunningPostCycle: "running_postcycle", - MachineState.Exceptions: "exception", - MachineState.Complete: "complete", - MachineState.PowerFailure: "power_failure", - MachineState.ServiceDiagnostic: "service_diagnostic_mode", - MachineState.FactoryDiagnostic: "factory_diagnostic_mode", - MachineState.LifeTest: "life_test", - MachineState.CustomerFocusMode: "customer_focus_mode", - MachineState.DemoMode: "demo_mode", - MachineState.HardStopOrError: "hard_stop_or_error", - MachineState.SystemInit: "system_initialize", +WASHER_MACHINE_STATE = { + WasherMachineState.Standby: "standby", + WasherMachineState.Setting: "setting", + WasherMachineState.DelayCountdownMode: "delay_countdown", + WasherMachineState.DelayPause: "delay_paused", + WasherMachineState.SmartDelay: "smart_delay", + WasherMachineState.SmartGridPause: "smart_grid_pause", + WasherMachineState.Pause: "pause", + WasherMachineState.RunningMainCycle: "running_maincycle", + WasherMachineState.RunningPostCycle: "running_postcycle", + WasherMachineState.Exceptions: "exception", + WasherMachineState.Complete: "complete", + WasherMachineState.PowerFailure: "power_failure", + WasherMachineState.ServiceDiagnostic: "service_diagnostic_mode", + WasherMachineState.FactoryDiagnostic: "factory_diagnostic_mode", + WasherMachineState.LifeTest: "life_test", + WasherMachineState.CustomerFocusMode: "customer_focus_mode", + WasherMachineState.DemoMode: "demo_mode", + WasherMachineState.HardStopOrError: "hard_stop_or_error", + WasherMachineState.SystemInit: "system_initialize", +} + +DRYER_MACHINE_STATE = { + DryerMachineState.Standby: "standby", + DryerMachineState.Setting: "setting", + DryerMachineState.DelayCountdownMode: "delay_countdown", + DryerMachineState.DelayPause: "delay_paused", + DryerMachineState.SmartDelay: "smart_delay", + DryerMachineState.SmartGridPause: "smart_grid_pause", + DryerMachineState.Pause: "pause", + DryerMachineState.RunningMainCycle: "running_maincycle", + DryerMachineState.RunningPostCycle: "running_postcycle", + DryerMachineState.Exceptions: "exception", + DryerMachineState.Complete: "complete", + DryerMachineState.PowerFailure: "power_failure", + DryerMachineState.ServiceDiagnostic: "service_diagnostic_mode", + DryerMachineState.FactoryDiagnostic: "factory_diagnostic_mode", + DryerMachineState.LifeTest: "life_test", + DryerMachineState.CustomerFocusMode: "customer_focus_mode", + DryerMachineState.DemoMode: "demo_mode", + DryerMachineState.HardStopOrError: "hard_stop_or_error", + DryerMachineState.SystemInit: "system_initialize", + DryerMachineState.Cancelled: "cancelled", } STATE_CYCLE_FILLING = "cycle_filling" @@ -64,29 +89,44 @@ STATE_CYCLE_WASHING = "cycle_washing" STATE_DOOR_OPEN = "door_open" -def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: - """Determine correct states for a washer/dryer.""" +def washer_state(washer: Washer) -> str | None: + """Determine correct states for a washer.""" - if washer_dryer.get_door_open(): + if washer.get_door_open(): return STATE_DOOR_OPEN - machine_state = washer_dryer.get_machine_state() + machine_state = washer.get_machine_state() - if machine_state == MachineState.RunningMainCycle: - if washer_dryer.get_cycle_status_filling(): + if machine_state == WasherMachineState.RunningMainCycle: + if washer.get_cycle_status_filling(): return STATE_CYCLE_FILLING - if washer_dryer.get_cycle_status_rinsing(): + if washer.get_cycle_status_rinsing(): return STATE_CYCLE_RINSING - if washer_dryer.get_cycle_status_sensing(): + if washer.get_cycle_status_sensing(): return STATE_CYCLE_SENSING - if washer_dryer.get_cycle_status_soaking(): + if washer.get_cycle_status_soaking(): return STATE_CYCLE_SOAKING - if washer_dryer.get_cycle_status_spinning(): + if washer.get_cycle_status_spinning(): return STATE_CYCLE_SPINNING - if washer_dryer.get_cycle_status_washing(): + if washer.get_cycle_status_washing(): return STATE_CYCLE_WASHING - return WASHER_DRYER_MACHINE_STATE.get(machine_state) + return WASHER_MACHINE_STATE.get(machine_state) + + +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: + if dryer.get_cycle_status_sensing(): + return STATE_CYCLE_SENSING + + return DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) @@ -96,8 +136,8 @@ class WhirlpoolSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Appliance], str | None] -WASHER_DRYER_STATE_OPTIONS = [ - *WASHER_DRYER_MACHINE_STATE.values(), +WASHER_STATE_OPTIONS = [ + *WASHER_MACHINE_STATE.values(), STATE_CYCLE_FILLING, STATE_CYCLE_RINSING, STATE_CYCLE_SENSING, @@ -107,13 +147,19 @@ WASHER_DRYER_STATE_OPTIONS = [ STATE_DOOR_OPEN, ] +DRYER_STATE_OPTIONS = [ + *DRYER_MACHINE_STATE.values(), + STATE_CYCLE_SENSING, + STATE_DOOR_OPEN, +] + WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=WASHER_DRYER_STATE_OPTIONS, - value_fn=washer_dryer_state, + options=WASHER_STATE_OPTIONS, + value_fn=washer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", @@ -130,8 +176,8 @@ DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( key="state", translation_key="dryer_state", device_class=SensorDeviceClass.ENUM, - options=WASHER_DRYER_STATE_OPTIONS, - value_fn=washer_dryer_state, + options=DRYER_STATE_OPTIONS, + value_fn=dryer_state, ), ) @@ -151,24 +197,40 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whirlpool sensors.""" - entities: list = [] appliances_manager = config_entry.runtime_data - for washer_dryer in appliances_manager.washer_dryers: - sensor_descriptions = ( - DRYER_SENSORS - if "dryer" in washer_dryer.appliance_info.data_model.lower() - else WASHER_SENSORS - ) - entities.extend( - WhirlpoolSensor(washer_dryer, description) - for description in sensor_descriptions - ) - entities.extend( - WasherDryerTimeSensor(washer_dryer, description) - for description in WASHER_DRYER_TIME_SENSORS - ) - async_add_entities(entities) + washer_sensors = [ + WhirlpoolSensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_SENSORS + ] + + washer_time_sensors = [ + WasherTimeSensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_DRYER_TIME_SENSORS + ] + + dryer_sensors = [ + WhirlpoolSensor(dryer, description) + for dryer in appliances_manager.dryers + for description in DRYER_SENSORS + ] + + dryer_time_sensors = [ + DryerTimeSensor(dryer, description) + for dryer in appliances_manager.dryers + for description in WASHER_DRYER_TIME_SENSORS + ] + + async_add_entities( + [ + *washer_sensors, + *washer_time_sensors, + *dryer_sensors, + *dryer_time_sensors, + ] + ) class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): @@ -187,22 +249,30 @@ class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): - """A timestamp class for the Whirlpool washer/dryer.""" +class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool washer/dryer time sensors.""" _attr_should_poll = True + _appliance: Washer | Dryer def __init__( - self, washer_dryer: WasherDryer, description: SensorEntityDescription + self, appliance: Washer | Dryer, description: SensorEntityDescription ) -> None: - """Initialize the washer sensor.""" - super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + """Initialize the washer/dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description = description - self._wd = washer_dryer self._running: bool | None = None self._value: datetime | None = None + @abstractmethod + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + + @abstractmethod + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + async def async_added_to_hass(self) -> None: """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): @@ -212,28 +282,62 @@ class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): async def async_update(self) -> None: """Update status of Whirlpool.""" - await self._wd.fetch_data() + await self._appliance.fetch_data() @override @property def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" - machine_state = self._wd.get_machine_state() now = utcnow() - if ( - machine_state.value - in {MachineState.Complete.value, MachineState.Standby.value} - and self._running - ): + + if self._is_machine_state_finished() and self._running: self._running = False self._value = now - if machine_state is MachineState.RunningMainCycle: + if self._is_machine_state_running(): self._running = True - new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) + new_timestamp = now + timedelta( + seconds=self._appliance.get_time_remaining() + ) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) ): self._value = new_timestamp return self._value + + +class WasherTimeSensor(WasherDryerTimeSensorBase): + """A timestamp class for Whirlpool washers.""" + + _appliance: Washer + + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + return self._appliance.get_machine_state() in { + WasherMachineState.Complete, + WasherMachineState.Standby, + } + + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + return ( + self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle + ) + + +class DryerTimeSensor(WasherDryerTimeSensorBase): + """A timestamp class for Whirlpool dryers.""" + + _appliance: Dryer + + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + return self._appliance.get_machine_state() in { + DryerMachineState.Complete, + DryerMachineState.Standby, + } + + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle diff --git a/requirements_all.txt b/requirements_all.txt index da31a7fad53..90fcb9aab96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3102,7 +3102,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.6.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.20.0 +whirlpool-sixth-sense==0.21.1 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a131e2b9e68..757f5a412a3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2555,7 +2555,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.6.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.20.0 +whirlpool-sixth-sense==0.21.1 # homeassistant.components.whois whois==0.9.27 diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 7447c1edd5a..fb82750924a 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -4,7 +4,7 @@ from unittest import mock from unittest.mock import Mock import pytest -from whirlpool import aircon, appliancesmanager, auth, washerdryer +from whirlpool import aircon, appliancesmanager, auth, dryer, washer from whirlpool.backendselector import Brand, Region from .const import MOCK_SAID1, MOCK_SAID2 @@ -66,10 +66,8 @@ def fixture_mock_appliances_manager_api( mock_aircon1_api, mock_aircon2_api, ] - mock_appliances_manager.return_value.washer_dryers = [ - mock_washer_api, - mock_dryer_api, - ] + mock_appliances_manager.return_value.washers = [mock_washer_api] + mock_appliances_manager.return_value.dryers = [mock_dryer_api] yield mock_appliances_manager @@ -123,15 +121,13 @@ def fixture_mock_aircon2_api(): @pytest.fixture def mock_washer_api(): """Get a mock of a washer.""" - mock_washer = Mock(spec=washerdryer.WasherDryer, said="said_washer") + mock_washer = Mock(spec=washer.Washer, said="said_washer") mock_washer.name = "Washer" mock_washer.appliance_info = Mock( data_model="washer", category="washer_dryer", model_number="12345" ) mock_washer.get_online.return_value = True - mock_washer.get_machine_state.return_value = ( - washerdryer.MachineState.RunningMainCycle - ) + mock_washer.get_machine_state.return_value = washer.MachineState.RunningMainCycle mock_washer.get_door_open.return_value = False mock_washer.get_dispense_1_level.return_value = 3 mock_washer.get_time_remaining.return_value = 3540 @@ -148,21 +144,14 @@ def mock_washer_api(): @pytest.fixture def mock_dryer_api(): """Get a mock of a dryer.""" - mock_dryer = mock.Mock(spec=washerdryer.WasherDryer, said="said_dryer") + mock_dryer = mock.Mock(spec=dryer.Dryer, said="said_dryer") mock_dryer.name = "Dryer" mock_dryer.appliance_info = Mock( data_model="dryer", category="washer_dryer", model_number="12345" ) mock_dryer.get_online.return_value = True - mock_dryer.get_machine_state.return_value = ( - washerdryer.MachineState.RunningMainCycle - ) + mock_dryer.get_machine_state.return_value = dryer.MachineState.RunningMainCycle mock_dryer.get_door_open.return_value = False mock_dryer.get_time_remaining.return_value = 3540 - mock_dryer.get_cycle_status_filling.return_value = False - mock_dryer.get_cycle_status_rinsing.return_value = False mock_dryer.get_cycle_status_sensing.return_value = False - mock_dryer.get_cycle_status_soaking.return_value = False - mock_dryer.get_cycle_status_spinning.return_value = False - mock_dryer.get_cycle_status_washing.return_value = False return mock_dryer diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index f1eef6f7dfc..b48ed46d186 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -14,14 +14,16 @@ 'model_number': '12345', }), }), - 'ovens': dict({ - }), - 'washer_dryers': dict({ + 'dryers': dict({ 'Dryer': dict({ 'category': 'washer_dryer', 'data_model': 'dryer', 'model_number': '12345', }), + }), + 'ovens': dict({ + }), + 'washers': dict({ 'Washer': dict({ 'category': 'washer_dryer', 'data_model': 'washer', diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index 843e71b62ea..fa67b5ecc05 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -75,12 +75,8 @@ 'demo_mode', 'hard_stop_or_error', 'system_initialize', - 'cycle_filling', - 'cycle_rinsing', + 'cancelled', 'cycle_sensing', - 'cycle_soaking', - 'cycle_spinning', - 'cycle_washing', 'door_open', ]), }), @@ -138,12 +134,8 @@ 'demo_mode', 'hard_stop_or_error', 'system_initialize', - 'cycle_filling', - 'cycle_rinsing', + 'cancelled', 'cycle_sensing', - 'cycle_soaking', - 'cycle_spinning', - 'cycle_washing', 'door_open', ]), }), diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 6563f88515f..92546acd773 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -208,7 +208,8 @@ async def test_no_appliances_flow( original_aircons = mock_appliances_manager_api.return_value.aircons mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] + mock_appliances_manager_api.return_value.washers = [] + mock_appliances_manager_api.return_value.dryers = [] result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index d33bd8be0e1..848a77c6b9e 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -80,7 +80,8 @@ async def test_setup_no_appliances( ) -> None: """Test setup when there are no appliances available.""" mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] + mock_appliances_manager_api.return_value.washers = [] + mock_appliances_manager_api.return_value.dryers = [] await init_integration(hass) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 6e28539d661..eaed27c95f8 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -5,7 +5,8 @@ from datetime import UTC, datetime, timedelta from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from whirlpool.washerdryer import MachineState +from whirlpool.dryer import MachineState as DryerMachineState +from whirlpool.washer import MachineState as WasherMachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform @@ -63,7 +64,7 @@ async def test_washer_dryer_time_sensor( ) mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.get_machine_state.return_value = MachineState.Pause + mock_instance.get_machine_state.return_value = WasherMachineState.Pause await init_integration(hass) # Test restored state. @@ -77,7 +78,15 @@ async def test_washer_dryer_time_sensor( assert state.state == restored_datetime.isoformat() # Test new time when machine starts a cycle. - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = ( + WasherMachineState.RunningMainCycle + ) + else: + mock_instance.get_machine_state.return_value = ( + DryerMachineState.RunningMainCycle + ) + mock_instance.get_time_remaining.return_value = 60 await trigger_attr_callback(hass, mock_instance) @@ -127,7 +136,10 @@ async def test_washer_dryer_time_sensor_no_restore( now = utcnow() mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.get_machine_state.return_value = MachineState.Pause + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = WasherMachineState.Pause + else: + mock_instance.get_machine_state.return_value = DryerMachineState.Pause await init_integration(hass) state = hass.states.get(entity_id) @@ -140,7 +152,14 @@ async def test_washer_dryer_time_sensor_no_restore( assert state.state == STATE_UNKNOWN # Test new time when machine starts a cycle. - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = ( + WasherMachineState.RunningMainCycle + ) + else: + mock_instance.get_machine_state.return_value = ( + DryerMachineState.RunningMainCycle + ) mock_instance.get_time_remaining.return_value = 60 await trigger_attr_callback(hass, mock_instance) @@ -149,63 +168,87 @@ async def test_washer_dryer_time_sensor_no_restore( assert state.state == expected_time -@pytest.mark.parametrize( - ("entity_id", "mock_fixture"), - [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), - ], -) @pytest.mark.parametrize( ("machine_state", "expected_state"), [ - (MachineState.Standby, "standby"), - (MachineState.Setting, "setting"), - (MachineState.DelayCountdownMode, "delay_countdown"), - (MachineState.DelayPause, "delay_paused"), - (MachineState.SmartDelay, "smart_delay"), - (MachineState.SmartGridPause, "smart_grid_pause"), - (MachineState.Pause, "pause"), - (MachineState.RunningMainCycle, "running_maincycle"), - (MachineState.RunningPostCycle, "running_postcycle"), - (MachineState.Exceptions, "exception"), - (MachineState.Complete, "complete"), - (MachineState.PowerFailure, "power_failure"), - (MachineState.ServiceDiagnostic, "service_diagnostic_mode"), - (MachineState.FactoryDiagnostic, "factory_diagnostic_mode"), - (MachineState.LifeTest, "life_test"), - (MachineState.CustomerFocusMode, "customer_focus_mode"), - (MachineState.DemoMode, "demo_mode"), - (MachineState.HardStopOrError, "hard_stop_or_error"), - (MachineState.SystemInit, "system_initialize"), + (WasherMachineState.Standby, "standby"), + (WasherMachineState.Setting, "setting"), + (WasherMachineState.DelayCountdownMode, "delay_countdown"), + (WasherMachineState.DelayPause, "delay_paused"), + (WasherMachineState.SmartDelay, "smart_delay"), + (WasherMachineState.SmartGridPause, "smart_grid_pause"), + (WasherMachineState.Pause, "pause"), + (WasherMachineState.RunningMainCycle, "running_maincycle"), + (WasherMachineState.RunningPostCycle, "running_postcycle"), + (WasherMachineState.Exceptions, "exception"), + (WasherMachineState.Complete, "complete"), + (WasherMachineState.PowerFailure, "power_failure"), + (WasherMachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (WasherMachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (WasherMachineState.LifeTest, "life_test"), + (WasherMachineState.CustomerFocusMode, "customer_focus_mode"), + (WasherMachineState.DemoMode, "demo_mode"), + (WasherMachineState.HardStopOrError, "hard_stop_or_error"), + (WasherMachineState.SystemInit, "system_initialize"), ], ) -async def test_washer_dryer_machine_states( +async def test_washer_machine_states( hass: HomeAssistant, - entity_id: str, - mock_fixture: str, - machine_state: MachineState, + machine_state: WasherMachineState, expected_state: str, - request: pytest.FixtureRequest, + mock_washer_api, ) -> None: - """Test Washer/Dryer machine states.""" - mock_instance = request.getfixturevalue(mock_fixture) + """Test Washer machine states.""" await init_integration(hass) - mock_instance.get_machine_state.return_value = machine_state - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) + mock_washer_api.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_washer_api) + state = hass.states.get("sensor.washer_state") assert state is not None assert state.state == expected_state @pytest.mark.parametrize( - ("entity_id", "mock_fixture"), + ("machine_state", "expected_state"), [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), + (DryerMachineState.Standby, "standby"), + (DryerMachineState.Setting, "setting"), + (DryerMachineState.DelayCountdownMode, "delay_countdown"), + (DryerMachineState.DelayPause, "delay_paused"), + (DryerMachineState.SmartDelay, "smart_delay"), + (DryerMachineState.SmartGridPause, "smart_grid_pause"), + (DryerMachineState.Pause, "pause"), + (DryerMachineState.RunningMainCycle, "running_maincycle"), + (DryerMachineState.RunningPostCycle, "running_postcycle"), + (DryerMachineState.Exceptions, "exception"), + (DryerMachineState.Complete, "complete"), + (DryerMachineState.PowerFailure, "power_failure"), + (DryerMachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (DryerMachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (DryerMachineState.LifeTest, "life_test"), + (DryerMachineState.CustomerFocusMode, "customer_focus_mode"), + (DryerMachineState.DemoMode, "demo_mode"), + (DryerMachineState.HardStopOrError, "hard_stop_or_error"), + (DryerMachineState.SystemInit, "system_initialize"), + (DryerMachineState.Cancelled, "cancelled"), ], ) +async def test_dryer_machine_states( + hass: HomeAssistant, + machine_state: DryerMachineState, + expected_state: str, + mock_dryer_api, +) -> None: + """Test Dryer machine states.""" + await init_integration(hass) + + mock_dryer_api.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_dryer_api) + state = hass.states.get("sensor.dryer_state") + assert state is not None + assert state.state == expected_state + + @pytest.mark.parametrize( ( "filling", @@ -225,10 +268,8 @@ async def test_washer_dryer_machine_states( (False, False, False, False, False, True, "cycle_washing"), ], ) -async def test_washer_dryer_running_states( +async def test_washer_running_states( hass: HomeAssistant, - entity_id: str, - mock_fixture: str, filling: bool, rinsing: bool, sensing: bool, @@ -236,22 +277,21 @@ async def test_washer_dryer_running_states( spinning: bool, washing: bool, expected_state: str, - request: pytest.FixtureRequest, + mock_washer_api, ) -> None: - """Test Washer/Dryer machine states for RunningMainCycle.""" - mock_instance = request.getfixturevalue(mock_fixture) + """Test Washer machine states for RunningMainCycle.""" await init_integration(hass) - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = filling - mock_instance.get_cycle_status_rinsing.return_value = rinsing - mock_instance.get_cycle_status_sensing.return_value = sensing - mock_instance.get_cycle_status_soaking.return_value = soaking - mock_instance.get_cycle_status_spinning.return_value = spinning - mock_instance.get_cycle_status_washing.return_value = washing + mock_washer_api.get_machine_state.return_value = WasherMachineState.RunningMainCycle + mock_washer_api.get_cycle_status_filling.return_value = filling + mock_washer_api.get_cycle_status_rinsing.return_value = rinsing + mock_washer_api.get_cycle_status_sensing.return_value = sensing + mock_washer_api.get_cycle_status_soaking.return_value = soaking + mock_washer_api.get_cycle_status_spinning.return_value = spinning + mock_washer_api.get_cycle_status_washing.return_value = washing - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) + await trigger_attr_callback(hass, mock_washer_api) + state = hass.states.get("sensor.washer_state") assert state is not None assert state.state == expected_state From 58c434887e0f5f760a121da0cf72222f6a4ca17d Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 11:00:23 +0200 Subject: [PATCH 0788/1664] Fix: Unhandled NoneType sessions in jellyfin (#147659) --- homeassistant/components/jellyfin/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index cd22ad4ab39..30149453ba3 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An self.api_client.jellyfin.sessions ) + if sessions is None: + return {} + sessions_by_id: dict[str, dict[str, Any]] = { session["Id"]: session for session in sessions From 4a192a7b0921fbc6fd96cd9c86ccd1025a27a2aa Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 11:07:14 +0200 Subject: [PATCH 0789/1664] Bump jellyfin-apiclient-python to 1.11.0 (#147658) --- homeassistant/components/jellyfin/client_wrapper.py | 3 +-- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 91fe0885e4c..4855231184e 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -66,8 +66,7 @@ def _connect_to_address( ) -> dict[str, Any]: """Connect to the Jellyfin server.""" result: dict[str, Any] = connection_manager.connect_to_address(url) - - if result["State"] != CONNECTION_STATE["ServerSignIn"]: + if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn: raise CannotConnect return result diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index a1bf3268721..839d9e685fc 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"] + "requirements": ["jellyfin-apiclient-python==1.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90fcb9aab96..32bbdb0ce9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1279,7 +1279,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 757f5a412a3..5e132c7ee33 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest From d83eddf13b5c4b41feb1372e6ce90a5724d40f23 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Jun 2025 15:53:18 +0200 Subject: [PATCH 0790/1664] Fix sentence-casing and spacing of button in `thermopro` (#147671) --- homeassistant/components/thermopro/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 5789de410b2..77722b6e986 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -21,7 +21,7 @@ "entity": { "button": { "set_datetime": { - "name": "Set Date&Time" + "name": "Set date & time" } } } From 7229c2ca2cdef0df6ccfad9a128f4f08faf50d5a Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:32:25 -0400 Subject: [PATCH 0791/1664] Bump aiosomecomfort to 0.0.33 (#147673) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 7fa102c6599..d2cd5a3c6a4 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.32"] + "requirements": ["AIOSomecomfort==0.0.33"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32bbdb0ce9a..891153c2f46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e132c7ee33..34bdf6fe994 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 From 4b02f22724d0060c37a3c1fd68adf7d8f34d84ea Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:02:52 +0200 Subject: [PATCH 0792/1664] Bump aioautomower to 1.0.0 (#147676) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/husqvarna_automower/fixtures/mower.json | 3 ++- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 1 + 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 29a4fafb8c0..0fc05c56fb5 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==2025.6.0"] + "requirements": ["aioautomower==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 891153c2f46..c7a5323c831 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==2025.6.0 +aioautomower==1.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34bdf6fe994..8a04a84adde 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==2025.6.0 +aioautomower==1.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 06e11ec1252..73f9c5e2aaa 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -86,7 +86,8 @@ "override": { "action": "NOT_ACTIVE" }, - "restrictedReason": "WEEK_SCHEDULE" + "restrictedReason": "WEEK_SCHEDULE", + "externalReason": 4000 }, "metadata": { "connected": true, diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index d5546b0d2af..772eef761db 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -80,6 +80,7 @@ 'work_area_name': 'Front lawn', }), 'planner': dict({ + 'external_reason': 'iftt_wildlife', 'next_start_datetime': '2023-06-05T19:00:00+02:00', 'override': dict({ 'action': 'not_active', From 8a18dea8c73097b20bbcdb7b7dd1362709e9d2ef Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:15:34 +0200 Subject: [PATCH 0793/1664] UniFi Protect removing early access checks and issue creation (#147432) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/unifiprotect/__init__.py | 68 ++++------- .../components/unifiprotect/config_flow.py | 6 - .../components/unifiprotect/repairs.py | 50 -------- .../components/unifiprotect/strings.json | 22 +--- .../unifiprotect/test_config_flow.py | 6 +- .../unifiprotect/test_diagnostics.py | 3 - tests/components/unifiprotect/test_init.py | 22 ++++ tests/components/unifiprotect/test_repairs.py | 108 +----------------- 8 files changed, 50 insertions(+), 235 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index ba255bb7f7c..2d75010b4e5 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -8,7 +8,6 @@ import logging from aiohttp.client_exceptions import ServerDisconnectedError from uiprotect.api import DEVICE_UPDATE_INTERVAL from uiprotect.data import Bootstrap -from uiprotect.data.types import FirmwareReleaseChannel from uiprotect.exceptions import ClientError, NotAuthorized # Import the test_util.anonymize module from the uiprotect package @@ -16,6 +15,7 @@ from uiprotect.exceptions import ClientError, NotAuthorized # diagnostics module will not be imported in the executor. 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.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -58,10 +58,6 @@ SCAN_INTERVAL = timedelta(seconds=DEVICE_UPDATE_INTERVAL) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -EARLY_ACCESS_URL = ( - "https://www.home-assistant.io/integrations/unifiprotect#software-support" -) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the UniFi Protect.""" @@ -123,47 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) - if not entry.options.get(CONF_ALLOW_EA, False) and ( - await nvr_info.get_is_prerelease() - or nvr_info.release_channel != FirmwareReleaseChannel.RELEASE - ): - ir.async_create_issue( - hass, - DOMAIN, - "ea_channel_warning", - is_fixable=True, - is_persistent=False, - learn_more_url=EARLY_ACCESS_URL, - severity=IssueSeverity.WARNING, - translation_key="ea_channel_warning", - translation_placeholders={"version": str(nvr_info.version)}, - data={"entry_id": entry.entry_id}, - ) - - try: - await _async_setup_entry(hass, entry, data_service, bootstrap) - except Exception as err: - if await nvr_info.get_is_prerelease(): - # If they are running a pre-release, its quite common for setup - # to fail so we want to create a repair issue for them so its - # obvious what the problem is. - ir.async_create_issue( - hass, - DOMAIN, - f"ea_setup_failed_{nvr_info.version}", - is_fixable=False, - is_persistent=False, - learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access", - severity=IssueSeverity.ERROR, - translation_key="ea_setup_failed", - translation_placeholders={ - "error": str(err), - "version": str(nvr_info.version), - }, - ) - ir.async_delete_issue(hass, DOMAIN, "ea_channel_warning") - _LOGGER.exception("Error setting up UniFi Protect integration") - raise + await _async_setup_entry(hass, entry, data_service, bootstrap) return True @@ -211,3 +167,23 @@ async def async_remove_config_entry_device( if device.is_adopted_by_us and device.mac in unifi_macs: return False return True + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating configuration from version %s", entry.version) + + if entry.version > 1: + return False + + if entry.version == 1: + options = dict(entry.options) + if CONF_ALLOW_EA in options: + options.pop(CONF_ALLOW_EA) + hass.config_entries.async_update_entry( + entry, unique_id=str(entry.unique_id), version=2, options=options + ) + + _LOGGER.debug("Migration to configuration version %s successful", entry.version) + + return True diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 22af2fb135d..a3833b355d7 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -44,7 +44,6 @@ from homeassistant.util.network import is_ip_address from .const import ( CONF_ALL_UPDATES, - CONF_ALLOW_EA, CONF_DISABLE_RTSP, CONF_MAX_MEDIA, CONF_OVERRIDE_CHOST, @@ -238,7 +237,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): CONF_ALL_UPDATES: False, CONF_OVERRIDE_CHOST: False, CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA, - CONF_ALLOW_EA: False, }, ) @@ -408,10 +406,6 @@ class OptionsFlowHandler(OptionsFlow): CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA ), ): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)), - vol.Optional( - CONF_ALLOW_EA, - default=self.config_entry.options.get(CONF_ALLOW_EA, False), - ): bool, } ), ) diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 020da0a03f6..8f24d9046ae 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -6,7 +6,6 @@ from typing import cast from uiprotect import ProtectApiClient from uiprotect.data import Bootstrap, Camera, ModelType -from uiprotect.data.types import FirmwareReleaseChannel import voluptuous as vol from homeassistant import data_entry_flow @@ -15,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir -from .const import CONF_ALLOW_EA from .data import UFPConfigEntry, async_get_data_for_entry_id from .utils import async_create_api_client @@ -45,52 +43,6 @@ class ProtectRepair(RepairsFlow): return description_placeholders -class EAConfirmRepair(ProtectRepair): - """Handler for an issue fixing flow.""" - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - - return await self.async_step_start() - - async def async_step_start( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is None: - placeholders = self._async_get_placeholders() - return self.async_show_form( - step_id="start", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - nvr = await self._api.get_nvr() - if nvr.release_channel != FirmwareReleaseChannel.RELEASE: - return await self.async_step_confirm() - await self.hass.config_entries.async_reload(self._entry.entry_id) - return self.async_create_entry(data={}) - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - if user_input is not None: - options = dict(self._entry.options) - options[CONF_ALLOW_EA] = True - self.hass.config_entries.async_update_entry(self._entry, options=options) - return self.async_create_entry(data={}) - - placeholders = self._async_get_placeholders() - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - class CloudAccountRepair(ProtectRepair): """Handler for an issue fixing flow.""" @@ -242,8 +194,6 @@ async def async_create_fix_flow( and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"]))) ): api = _async_get_or_create_api_client(hass, entry) - if issue_id == "ea_channel_warning": - return EAConfirmRepair(api=api, entry=entry) if issue_id == "cloud_user": return CloudAccountRepair(api=api, entry=entry) if issue_id.startswith("rtsp_disabled_"): diff --git a/homeassistant/components/unifiprotect/strings.json b/homeassistant/components/unifiprotect/strings.json index 46a60f4abfd..23c662f5d71 100644 --- a/homeassistant/components/unifiprotect/strings.json +++ b/homeassistant/components/unifiprotect/strings.json @@ -55,32 +55,12 @@ "disable_rtsp": "Disable the RTSP stream", "all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)", "override_connection_host": "Override connection host", - "max_media": "Max number of event to load for Media Browser (increases RAM usage)", - "allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)" + "max_media": "Max number of event to load for Media Browser (increases RAM usage)" } } } }, "issues": { - "ea_channel_warning": { - "title": "UniFi Protect Early Access enabled", - "fix_flow": { - "step": { - "start": { - "title": "UniFi Protect Early Access enabled", - "description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message." - }, - "confirm": { - "title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]", - "description": "Are you sure you want to run unsupported versions of UniFi Protect? This may cause your Home Assistant integration to break." - } - } - } - }, - "ea_setup_failed": { - "title": "Setup error using Early Access version", - "description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please restore a backup of a stable release of UniFi Protect to continue using the integration.\n\nError: {error}" - }, "cloud_user": { "title": "Ubiquiti Cloud Users are not Supported", "fix_flow": { diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 0eae2a48fea..880578719cd 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import asdict import socket -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch import pytest from uiprotect import NotAuthorized, NvrError, ProtectApiClient @@ -325,7 +325,6 @@ async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) - "disable_rtsp": True, "override_connection_host": True, "max_media": 1000, - "allow_ea_channel": False, } await hass.async_block_till_done() await hass.config_entries.async_unload(mock_config.entry_id) @@ -794,6 +793,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa }, unique_id="FFFFFFAAAAAA", ) + mock_config.runtime_data = Mock(async_stop=AsyncMock()) mock_config.add_to_hass(hass) other_ip_dict = UNIFI_DISCOVERY_DICT.copy() @@ -855,7 +855,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa "port": 443, "verify_ssl": True, } - assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 2 assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index fd882929e96..b478d7bbd2c 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -2,7 +2,6 @@ from uiprotect.data import NVR, Light -from homeassistant.components.unifiprotect.const import CONF_ALLOW_EA from homeassistant.core import HomeAssistant from .utils import MockUFPFixture, init_entry @@ -22,7 +21,6 @@ async def test_diagnostics( await init_entry(hass, ufp, [light]) options = dict(ufp.entry.options) - options[CONF_ALLOW_EA] = True hass.config_entries.async_update_entry(ufp.entry, options=options) await hass.async_block_till_done() @@ -30,7 +28,6 @@ async def test_diagnostics( assert "options" in diag and isinstance(diag["options"], dict) options = diag["options"] - assert options[CONF_ALLOW_EA] is True assert "bootstrap" in diag and isinstance(diag["bootstrap"], dict) bootstrap = diag["bootstrap"] diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index b01c7e0cf4a..3064c66f009 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -11,6 +11,7 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount, Light from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, + CONF_ALLOW_EA, CONF_DISABLE_RTSP, DOMAIN, ) @@ -345,3 +346,24 @@ async def test_async_ufp_instance_for_config_entry_ids( result = async_ufp_instance_for_config_entry_ids(hass, entry_ids) assert result == expected_result + + +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 ( + patch( + "homeassistant.components.unifiprotect.async_setup_entry", return_value=True + ), + patch("homeassistant.components.unifiprotect.async_start_discovery"), + ): + entry = MockConfigEntry( + domain=DOMAIN, + data={"test": "1", "test2": "2", CONF_ALLOW_EA: "True"}, + version=1, + unique_id="123456", + ) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.version == 2 + assert entry.options.get(CONF_ALLOW_EA) is None + assert entry.unique_id == "123456" diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 1117038bbd0..2d08630e520 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -2,8 +2,8 @@ from __future__ import annotations -from copy import copy, deepcopy -from unittest.mock import AsyncMock, Mock +from copy import deepcopy +from unittest.mock import AsyncMock from uiprotect.data import Camera, CloudAccount, ModelType, Version @@ -21,110 +21,6 @@ from tests.components.repairs import ( from tests.typing import ClientSessionGenerator, WebSocketGenerator -async def test_ea_warning_ignore( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test EA warning is created if using prerelease version of Protect.""" - - ufp.api.bootstrap.nvr.release_channel = "beta" - ufp.api.bootstrap.nvr.version = Version("1.21.0-beta.2") - version = ufp.api.bootstrap.nvr.version - assert version.is_prerelease - await init_entry(hass, ufp, []) - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "ea_channel_warning": - issue = i - assert issue is not None - - data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning") - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", - "version": str(version), - } - assert data["step_id"] == "start" - - data = await process_repair_fix_flow(client, flow_id) - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", - "version": str(version), - } - assert data["step_id"] == "confirm" - - data = await process_repair_fix_flow(client, flow_id) - - assert data["type"] == "create_entry" - - -async def test_ea_warning_fix( - hass: HomeAssistant, - ufp: MockUFPFixture, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, -) -> None: - """Test EA warning is created if using prerelease version of Protect.""" - - ufp.api.bootstrap.nvr.release_channel = "beta" - ufp.api.bootstrap.nvr.version = Version("1.21.0-beta.2") - version = ufp.api.bootstrap.nvr.version - assert version.is_prerelease - await init_entry(hass, ufp, []) - await async_process_repairs_platforms(hass) - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["issue_id"] == "ea_channel_warning": - issue = i - assert issue is not None - - data = await start_repair_fix_flow(client, DOMAIN, "ea_channel_warning") - - flow_id = data["flow_id"] - assert data["description_placeholders"] == { - "learn_more": "https://www.home-assistant.io/integrations/unifiprotect#software-support", - "version": str(version), - } - assert data["step_id"] == "start" - - new_nvr = copy(ufp.api.bootstrap.nvr) - new_nvr.release_channel = "release" - new_nvr.version = Version("2.2.6") - mock_msg = Mock() - mock_msg.changed_data = {"version": "2.2.6", "releaseChannel": "release"} - mock_msg.new_obj = new_nvr - - ufp.api.bootstrap.nvr = new_nvr - ufp.ws_msg(mock_msg) - await hass.async_block_till_done() - - data = await process_repair_fix_flow(client, flow_id) - - assert data["type"] == "create_entry" - - async def test_cloud_user_fix( hass: HomeAssistant, ufp: MockUFPFixture, From 8a5671af767176288df5351c23c6ab3b130f6ed1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:23:42 +0200 Subject: [PATCH 0794/1664] Remove dweet.io integration (#147645) --- homeassistant/components/dweet/__init__.py | 79 ------------ homeassistant/components/dweet/manifest.json | 10 -- homeassistant/components/dweet/sensor.py | 124 ------------------- homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 5 files changed, 222 deletions(-) delete mode 100644 homeassistant/components/dweet/__init__.py delete mode 100644 homeassistant/components/dweet/manifest.json delete mode 100644 homeassistant/components/dweet/sensor.py diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py deleted file mode 100644 index b43ce3db8c1..00000000000 --- a/homeassistant/components/dweet/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for sending data to Dweet.io.""" - -from datetime import timedelta -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_NAME, - CONF_WHITELIST, - EVENT_STATE_CHANGED, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "dweet" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_WHITELIST, default=[]): vol.All( - cv.ensure_list, [cv.entity_id] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Dweet.io component.""" - conf = config[DOMAIN] - name = conf.get(CONF_NAME) - whitelist = conf.get(CONF_WHITELIST) - json_body = {} - - def dweet_event_listener(event): - """Listen for new messages on the bus and sends them to Dweet.io.""" - state = event.data.get("new_state") - if ( - state is None - or state.state in (STATE_UNKNOWN, "") - or state.entity_id not in whitelist - ): - return - - try: - _state = state_helper.state_as_number(state) - except ValueError: - _state = state.state - - json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state - - send_data(name, json_body) - - hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener) - - return True - - -@Throttle(MIN_TIME_BETWEEN_UPDATES) -def send_data(name, msg): - """Send the collected data to Dweet.io.""" - try: - dweepy.dweet_for(name, msg) - except dweepy.DweepyError: - _LOGGER.error("Error saving data to Dweet.io: %s", msg) diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json deleted file mode 100644 index b4efd0744fb..00000000000 --- a/homeassistant/components/dweet/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "dweet", - "name": "dweet.io", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/dweet", - "iot_class": "cloud_polling", - "loggers": ["dweepy"], - "quality_scale": "legacy", - "requirements": ["dweepy==0.3.0"] -} diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py deleted file mode 100644 index 6110f17f826..00000000000 --- a/homeassistant/components/dweet/sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for showing values from Dweet.io.""" - -from __future__ import annotations - -from datetime import timedelta -import json -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.const import ( - CONF_DEVICE, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - CONF_VALUE_TEMPLATE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Dweet.io Sensor" - -SCAN_INTERVAL = timedelta(minutes=1) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dweet sensor.""" - name = config.get(CONF_NAME) - device = config.get(CONF_DEVICE) - value_template = config.get(CONF_VALUE_TEMPLATE) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - - try: - content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"]) - except dweepy.DweepyError: - _LOGGER.error("Device/thing %s could not be found", device) - return - - if value_template and value_template.render_with_possible_json_value(content) == "": - _LOGGER.error("%s was not found", value_template) - return - - dweet = DweetData(device) - - add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True) - - -class DweetSensor(SensorEntity): - """Representation of a Dweet sensor.""" - - def __init__(self, hass, dweet, name, value_template, unit_of_measurement): - """Initialize the sensor.""" - self.hass = hass - self.dweet = dweet - self._name = name - self._value_template = value_template - self._state = None - self._unit_of_measurement = unit_of_measurement - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state.""" - return self._state - - def update(self) -> None: - """Get the latest data from REST API.""" - self.dweet.update() - - if self.dweet.data is None: - self._state = None - else: - values = json.dumps(self.dweet.data[0]["content"]) - self._state = self._value_template.render_with_possible_json_value( - values, None - ) - - -class DweetData: - """The class for handling the data retrieval.""" - - def __init__(self, device): - """Initialize the sensor.""" - self._device = device - self.data = None - - def update(self): - """Get the latest data from Dweet.io.""" - try: - self.data = dweepy.get_latest_dweet_for(self._device) - except dweepy.DweepyError: - _LOGGER.warning("Device %s doesn't contain any data", self._device) - self.data = None diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a44be6059ec..6bf63b260de 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1483,12 +1483,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "dweet": { - "name": "dweet.io", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "eafm": { "name": "Environment Agency Flood Gauges", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c7a5323c831..bc60bd0e008 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -820,9 +820,6 @@ dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 -# homeassistant.components.dweet -dweepy==0.3.0 - # homeassistant.components.dynalite dynalite-devices==0.1.47 From bba7f5c3f007d90c14081a1a13b90d7f03b23a73 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Jun 2025 18:27:43 +0300 Subject: [PATCH 0795/1664] Z-WaveJS config flow: Change keys question (#147518) Co-authored-by: Norbert Rittel --- .../components/zwave_js/config_flow.py | 109 +++++++--- .../components/zwave_js/strings.json | 44 ++-- tests/components/zwave_js/test_config_flow.py | 192 ++++++++++++++++-- 3 files changed, 289 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index a109719965c..7e95e274713 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -40,7 +40,6 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from homeassistant.helpers.typing import VolDictType from .addon import get_addon_manager from .const import ( @@ -90,6 +89,9 @@ ADDON_USER_INPUT_MAP = { ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") +NETWORK_TYPE_NEW = "new" +NETWORK_TYPE_EXISTING = "existing" + def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: """Return a schema for the manual step.""" @@ -632,6 +634,81 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" + + if user_input is not None: + self.usb_path = user_input[CONF_USB_PATH] + return await self.async_step_network_type() + + if self._usb_discovery: + return await self.async_step_network_type() + + usb_path = self.usb_path or "" + + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), + } + ) + + return self.async_show_form( + step_id="configure_addon_user", data_schema=data_schema + ) + + async def async_step_network_type( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for network type (new or existing).""" + # For recommended installation, automatically set network type to "new" + if self._recommended_install: + user_input = {"network_type": NETWORK_TYPE_NEW} + + if user_input is not None: + if user_input["network_type"] == NETWORK_TYPE_NEW: + # Set all keys to empty strings for new network + self.s0_legacy_key = "" + self.s2_access_control_key = "" + self.s2_authenticated_key = "" + self.s2_unauthenticated_key = "" + self.lr_s2_access_control_key = "" + self.lr_s2_authenticated_key = "" + + addon_config_updates = { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + } + + await self._async_set_addon_config(addon_config_updates) + return await self.async_step_start_addon() + + # Network already exists, go to security keys step + return await self.async_step_configure_security_keys() + + return self.async_show_form( + step_id="network_type", + data_schema=vol.Schema( + { + vol.Required("network_type", default=""): vol.In( + [NETWORK_TYPE_NEW, NETWORK_TYPE_EXISTING] + ) + } + ), + ) + + async def async_step_configure_security_keys( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for security keys for existing Z-Wave network.""" addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -654,10 +731,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - if self._recommended_install and self._usb_discovery: - # Recommended installation with USB discovery, skip asking for keys - user_input = {} - if user_input is not None: self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key) self.s2_access_control_key = user_input.get( @@ -675,8 +748,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = user_input.get( CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key ) - if not self._usb_discovery: - self.usb_path = user_input[CONF_USB_PATH] addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, @@ -689,14 +760,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } await self._async_set_addon_config(addon_config_updates) - return await self.async_step_start_addon() - usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" - schema: VolDictType = ( - {} - if self._recommended_install - else { + data_schema = vol.Schema( + { vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, vol.Optional( CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key @@ -716,22 +783,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } ) - if not self._usb_discovery: - try: - ports = await async_get_usb_ports(self.hass) - except OSError as err: - _LOGGER.error("Failed to get USB ports: %s", err) - return self.async_abort(reason="usb_ports_failed") - - schema = { - vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), - **schema, - } - - data_schema = vol.Schema(schema) - return self.async_show_form( - step_id="configure_addon_user", data_schema=data_schema + step_id="configure_security_keys", data_schema=data_schema ) async def async_step_finish_addon_setup_user( diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index f61d871cfb9..b7f9b180624 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -39,25 +39,37 @@ "step": { "configure_addon_user": { "data": { - "lr_s2_access_control_key": "Long Range S2 Access Control Key", - "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_access_control_key": "S2 Access Control Key", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "Select your Z-Wave adapter", "title": "Enter the Z-Wave add-on configuration" }, + "network_type": { + "data": { + "network_type": "Is your network new or does it already exist?" + }, + "title": "Z-Wave network" + }, + "configure_security_keys": { + "data": { + "lr_s2_access_control_key": "Long Range S2 Access Control Key", + "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key" + }, + "description": "Enter the security keys for your existing Z-Wave network", + "title": "Security keys" + }, "configure_addon_reconfigure": { "data": { - "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", - "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]", + "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_access_control_key%]", + "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_authenticated_key%]", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_unauthenticated_key%]", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", @@ -622,5 +634,13 @@ }, "name": "Set a value (advanced)" } + }, + "selector": { + "network_type": { + "options": { + "new": "It's new", + "existing": "It already exists" + } + } } } diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e99cedbdcba..a1642746d03 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -29,12 +29,6 @@ from homeassistant.components.zwave_js.const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, - CONF_LR_S2_ACCESS_CONTROL_KEY, - CONF_LR_S2_AUTHENTICATED_KEY, - CONF_S0_LEGACY_KEY, - CONF_S2_ACCESS_CONTROL_KEY, - CONF_S2_AUTHENTICATED_KEY, - CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, DOMAIN, ) @@ -687,7 +681,17 @@ async def test_usb_discovery( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -778,9 +782,18 @@ async def test_usb_discovery_addon_not_running( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" - # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] assert data_schema is not None assert data_schema({}) == { @@ -1126,6 +1139,25 @@ async def test_discovery_addon_not_running( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1226,6 +1258,25 @@ async def test_discovery_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1728,6 +1779,25 @@ async def test_addon_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1822,6 +1892,25 @@ async def test_addon_installed_start_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1911,6 +2000,25 @@ async def test_addon_installed_failures( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1981,6 +2089,25 @@ async def test_addon_installed_set_options_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2091,6 +2218,25 @@ async def test_addon_installed_already_configured( result["flow_id"], { "usb_path": "/new", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2178,6 +2324,25 @@ async def test_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -4229,13 +4394,8 @@ async def test_intent_recommended_user( assert result["step_id"] == "configure_addon_user" data_schema = result["data_schema"] assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] is not None - assert data_schema.schema.get(CONF_S0_LEGACY_KEY) is None - assert data_schema.schema.get(CONF_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_S2_AUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_S2_UNAUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_AUTHENTICATED_KEY) is None + assert len(data_schema.schema) == 1 + assert data_schema.schema.get(CONF_USB_PATH) is not None result = await hass.config_entries.flow.async_configure( result["flow_id"], From a1518b96c4125ea2453e56f42fb718c85835ae06 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Jun 2025 17:28:14 +0200 Subject: [PATCH 0796/1664] Update frontend to 20250627.0 (#147668) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8e4ea47da5b..cf83ce90237 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250626.0"] + "requirements": ["home-assistant-frontend==20250627.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5839a3ae014..80fccb1bf78 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bc60bd0e008..a967a011afd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a04a84adde..c9633fdd0ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 8ee5c30754308467d5e6bf6b8ad076ec9a345d49 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:40:08 +0200 Subject: [PATCH 0797/1664] Update ruff to 0.12.1 (#147677) --- .pre-commit-config.yaml | 2 +- homeassistant/bootstrap.py | 4 ++-- homeassistant/components/system_health/__init__.py | 2 +- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 30351a9381e..610fed902ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.0 + rev: v0.12.1 hooks: - id: ruff-check args: diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index afe8ea6f356..f70237645e0 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -607,7 +607,7 @@ async def async_enable_logging( ) threading.excepthook = lambda args: logging.getLogger().exception( "Uncaught thread exception", - exc_info=( # type: ignore[arg-type] # noqa: LOG014 + exc_info=( # type: ignore[arg-type] args.exc_type, args.exc_value, args.exc_traceback, @@ -1061,5 +1061,5 @@ async def _async_setup_multi_components( _LOGGER.error( "Error setting up integration %s - received exception", domain, - exc_info=(type(result), result, result.__traceback__), # noqa: LOG014 + exc_info=(type(result), result, result.__traceback__), ) diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 7ab6d77e137..37e9ee3d929 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -231,7 +231,7 @@ async def handle_info( "Error fetching system info for %s - %s", domain, key, - exc_info=(type(exception), exception, exception.__traceback__), # noqa: LOG014 + exc_info=(type(exception), exception, exception.__traceback__), ) event_msg["success"] = False event_msg["error"] = {"type": "failed", "error": "unknown"} diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 1abbf3977cf..b9c800be3ca 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.12.0 +ruff==0.12.1 yamllint==1.37.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index afd58539853..5168388c934 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -27,7 +27,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.7.1,source=/uv,target=/bin/uv \ stdlib-list==0.10.0 \ pipdeptree==2.26.1 \ tqdm==4.67.1 \ - ruff==0.12.0 \ + ruff==0.12.1 \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ From 2120ff6a0af3cd591efff9b1eae3d81e5fbf9420 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 27 Jun 2025 18:50:35 +0300 Subject: [PATCH 0798/1664] Fix Shelly entity removal (#147665) --- homeassistant/components/shelly/entity.py | 8 ++- tests/components/shelly/test_switch.py | 60 +++++++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 5a420a4543b..587eb00b979 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -192,8 +192,12 @@ def async_setup_rpc_attribute_entities( if description.removal_condition and description.removal_condition( coordinator.device.config, coordinator.device.status, key ): - domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{key}-{sensor_id}" + entity_class = get_entity_class(sensor_class, description) + domain = entity_class.__module__.split(".")[-1] + unique_id = entity_class( + coordinator, key, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) elif description.use_polling_coordinator: if not sleep_period: diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 54923b538f6..3234e3eb0b9 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,15 +1,18 @@ """Tests for Shelly switch platform.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, Mock from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.shelly.const import ( DOMAIN, + ENTRY_RELOAD_COOLDOWN, MODEL_WALL_DISPLAY, MOTION_MODELS, ) @@ -28,9 +31,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import ( + init_integration, + inject_rpc_device_event, + register_device, + register_entity, +) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -374,15 +382,57 @@ async def test_rpc_device_unique_ids( async def test_rpc_device_switch_type_lights_mode( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device with switch in consumption type lights mode.""" + switch_entity_id = "switch.test_name_test_switch_0" + light_entity_id = "light.test_name_test_switch_0" + + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + await init_integration(hass, 2) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) - await init_integration(hass, 2) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "config_changed", + "id": 1, + "ts": 1668522399.2, + }, + { + "data": [], + "id": 2, + "ts": 1668522399.2, + }, + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is None + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) @pytest.mark.parametrize( From 113e7dc003eef25e91426f29f0ec228515e0f5ef Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 27 Jun 2025 18:16:38 +0200 Subject: [PATCH 0799/1664] Add data descriptions to PEGELONLINE integration (#147594) --- homeassistant/components/pegel_online/strings.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pegel_online/strings.json b/homeassistant/components/pegel_online/strings.json index 7d0702754af..65fecbfb825 100644 --- a/homeassistant/components/pegel_online/strings.json +++ b/homeassistant/components/pegel_online/strings.json @@ -2,17 +2,23 @@ "config": { "step": { "user": { - "description": "Select the area in which you want to search for water measuring stations", "data": { "location": "[%key:common::config_flow::data::location%]", "radius": "Search radius" + }, + "data_description": { + "location": "Pick the location where to search for water measuring stations.", + "radius": "The radius to search for water measuring stations around the selected location." } }, "select_station": { - "title": "Select the measuring station to add", + "title": "Select the station to add", "description": "Found {stations_count} stations in radius", "data": { "station": "Station" + }, + "data_description": { + "station": "Select the water measuring station you want to add to Home Assistant." } } }, From ff711324d5defd167c70fe71a7d76f3a83015ce1 Mon Sep 17 00:00:00 2001 From: hanwg Date: Sat, 28 Jun 2025 00:18:01 +0800 Subject: [PATCH 0800/1664] Add codeowner for Telegram bot (#147680) --- CODEOWNERS | 2 ++ homeassistant/components/telegram_bot/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4e224f8802b..28deb93492c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor /tests/components/technove/ @Moustachauve /homeassistant/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj +/homeassistant/components/telegram_bot/ @hanwg +/tests/components/telegram_bot/ @hanwg /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @Petro31 @home-assistant/core diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 27c10602350..7a01f43c528 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -1,7 +1,7 @@ { "domain": "telegram_bot", "name": "Telegram bot", - "codeowners": [], + "codeowners": ["@hanwg"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/telegram_bot", From 4cab3a04658b1d423e0acc7beaefabadbacf4557 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 27 Jun 2025 19:44:01 +0300 Subject: [PATCH 0801/1664] Bump aioamazondevices to 3.1.22 (#147681) --- 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 e82cd471ac7..cdf942e836d 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": "bronze", - "requirements": ["aioamazondevices==3.1.19"] + "requirements": ["aioamazondevices==3.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index a967a011afd..81f0c5f8426 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.19 +aioamazondevices==3.1.22 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9633fdd0ff..d74e5570350 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.19 +aioamazondevices==3.1.22 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From b8500b338aaa5c5b14b38d1d65e3ccf8fcd930d1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 27 Jun 2025 18:58:16 +0200 Subject: [PATCH 0802/1664] Improve tests for binary sensor template (#147657) --- .../components/template/test_binary_sensor.py | 267 +++++++++++++----- 1 file changed, 192 insertions(+), 75 deletions(-) diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 122801e6c59..29ef524a4ab 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,9 +1,10 @@ """The tests for the Template Binary sensor platform.""" +from collections.abc import Generator from copy import deepcopy from datetime import UTC, datetime, timedelta import logging -from unittest.mock import patch +from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -22,8 +23,8 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -33,6 +34,16 @@ from tests.common import ( mock_restore_cache_with_extra_data, ) +_BEER_TRIGGER_VALUE_TEMPLATE = ( + "{% if trigger.event.data.beer < 0 %}" + "{{ 1 / 0 == 10 }}" + "{% elif trigger.event.data.beer == 0 %}" + "{{ None }}" + "{% else %}" + "{{ trigger.event.data.beer == 2 }}" + "{% endif %}" +) + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( @@ -70,7 +81,9 @@ from tests.common import ( ], ) @pytest.mark.usefixtures("start_ha") -async def test_setup_minimal(hass: HomeAssistant, entity_id, name, attributes) -> None: +async def test_setup_minimal( + hass: HomeAssistant, entity_id: str, name: str, attributes: dict[str, str] +) -> None: """Test the setup.""" state = hass.states.get(entity_id) assert state is not None @@ -115,7 +128,7 @@ async def test_setup_minimal(hass: HomeAssistant, entity_id, name, attributes) - ], ) @pytest.mark.usefixtures("start_ha") -async def test_setup(hass: HomeAssistant, entity_id) -> None: +async def test_setup(hass: HomeAssistant, entity_id: str) -> None: """Test the setup.""" state = hass.states.get(entity_id) assert state is not None @@ -232,11 +245,59 @@ async def test_setup_config_entry( ], ) @pytest.mark.usefixtures("start_ha") -async def test_setup_invalid_sensors(hass: HomeAssistant, count) -> None: +async def test_setup_invalid_sensors(hass: HomeAssistant, count: int) -> None: """Test setup with no sensors.""" assert len(hass.states.async_entity_ids("binary_sensor")) == count +@pytest.mark.parametrize( + ("state_template", "expected_result"), + [ + ("{{ None }}", STATE_OFF), + ("{{ True }}", STATE_ON), + ("{{ False }}", STATE_OFF), + ("{{ 1 }}", STATE_ON), + ( + "{% if states('binary_sensor.three') in ('unknown','unavailable') %}" + "{{ None }}" + "{% else %}" + "{{ states('binary_sensor.three') == 'off' }}" + "{% endif %}", + STATE_OFF, + ), + ("{{ 1 / 0 == 10 }}", STATE_UNAVAILABLE), + ], +) +async def test_state( + hass: HomeAssistant, + state_template: str, + expected_result: str, +) -> None: + """Test the config flow.""" + hass.states.async_set("binary_sensor.one", "on") + hass.states.async_set("binary_sensor.two", "off") + hass.states.async_set("binary_sensor.three", "unknown") + + template_config_entry = MockConfigEntry( + data={}, + domain=template.DOMAIN, + options={ + "name": "My template", + "state": state_template, + "template_type": binary_sensor.DOMAIN, + }, + title="My template", + ) + template_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(template_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.my_template") + assert state is not None + assert state.state == expected_result + + @pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( ("config", "domain", "entity_id"), @@ -279,7 +340,7 @@ async def test_setup_invalid_sensors(hass: HomeAssistant, count) -> None: ], ) @pytest.mark.usefixtures("start_ha") -async def test_icon_template(hass: HomeAssistant, entity_id) -> None: +async def test_icon_template(hass: HomeAssistant, entity_id: str) -> None: """Test icon template.""" state = hass.states.get(entity_id) assert state.attributes.get("icon") == "" @@ -332,7 +393,7 @@ async def test_icon_template(hass: HomeAssistant, entity_id) -> None: ], ) @pytest.mark.usefixtures("start_ha") -async def test_entity_picture_template(hass: HomeAssistant, entity_id) -> None: +async def test_entity_picture_template(hass: HomeAssistant, entity_id: str) -> None: """Test entity_picture template.""" state = hass.states.get(entity_id) assert state.attributes.get("entity_picture") == "" @@ -381,7 +442,7 @@ async def test_entity_picture_template(hass: HomeAssistant, entity_id) -> None: ], ) @pytest.mark.usefixtures("start_ha") -async def test_attribute_templates(hass: HomeAssistant, entity_id) -> None: +async def test_attribute_templates(hass: HomeAssistant, entity_id: str) -> None: """Test attribute_templates template.""" state = hass.states.get(entity_id) assert state.attributes.get("test_attribute") == "It ." @@ -394,7 +455,7 @@ async def test_attribute_templates(hass: HomeAssistant, entity_id) -> None: @pytest.fixture -async def setup_mock(): +def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." @@ -426,7 +487,7 @@ async def setup_mock(): ], ) @pytest.mark.usefixtures("start_ha") -async def test_match_all(hass: HomeAssistant, setup_mock) -> None: +async def test_match_all(hass: HomeAssistant, setup_mock: Mock) -> None: """Test template that is rerendered on any state lifecycle.""" init_calls = len(setup_mock.mock_calls) @@ -565,7 +626,9 @@ async def test_event(hass: HomeAssistant) -> None: ], ) @pytest.mark.usefixtures("start_ha") -async def test_template_delay_on_off(hass: HomeAssistant) -> None: +async def test_template_delay_on_off( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: """Test binary sensor template delay on.""" # Ensure the initial state is not on assert hass.states.get("binary_sensor.test_on").state != STATE_ON @@ -577,8 +640,8 @@ async def test_template_delay_on_off(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.test_on").state == STATE_OFF assert hass.states.get("binary_sensor.test_off").state == STATE_ON - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("binary_sensor.test_on").state == STATE_ON assert hass.states.get("binary_sensor.test_off").state == STATE_ON @@ -599,8 +662,8 @@ async def test_template_delay_on_off(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.test_on").state == STATE_OFF assert hass.states.get("binary_sensor.test_off").state == STATE_ON - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("binary_sensor.test_on").state == STATE_OFF assert hass.states.get("binary_sensor.test_off").state == STATE_OFF @@ -645,7 +708,7 @@ async def test_template_delay_on_off(hass: HomeAssistant) -> None: ) @pytest.mark.usefixtures("start_ha") async def test_available_without_availability_template( - hass: HomeAssistant, entity_id + hass: HomeAssistant, entity_id: str ) -> None: """Ensure availability is true without an availability_template.""" state = hass.states.get(entity_id) @@ -694,7 +757,7 @@ async def test_available_without_availability_template( ], ) @pytest.mark.usefixtures("start_ha") -async def test_availability_template(hass: HomeAssistant, entity_id) -> None: +async def test_availability_template(hass: HomeAssistant, entity_id: str) -> None: """Test availability template.""" hass.states.async_set("sensor.test_state", STATE_OFF) await hass.async_block_till_done() @@ -731,7 +794,7 @@ async def test_availability_template(hass: HomeAssistant, entity_id) -> None: ) @pytest.mark.usefixtures("start_ha") async def test_invalid_attribute_template( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text: str ) -> None: """Test that errors are logged if rendering template fails.""" hass.states.async_set("binary_sensor.test_sensor", STATE_ON) @@ -759,7 +822,7 @@ async def test_invalid_attribute_template( ) @pytest.mark.usefixtures("start_ha") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text + hass: HomeAssistant, caplog_setup_text: str ) -> None: """Test that an invalid availability keeps the device available.""" @@ -767,9 +830,7 @@ async def test_invalid_availability_template_keeps_component_available( assert "UndefinedError: 'x' is undefined" in caplog_setup_text -async def test_no_update_template_match_all( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +async def test_no_update_template_match_all(hass: HomeAssistant) -> None: """Test that we do not update sensors that match on all.""" hass.set_state(CoreState.not_running) @@ -966,7 +1027,7 @@ async def test_template_validation_error( ], ) @pytest.mark.usefixtures("start_ha") -async def test_availability_icon_picture(hass: HomeAssistant, entity_id) -> None: +async def test_availability_icon_picture(hass: HomeAssistant, entity_id: str) -> None: """Test name, icon and picture templates are rendered at setup.""" state = hass.states.get(entity_id) assert state.state == "unavailable" @@ -996,7 +1057,7 @@ async def test_availability_icon_picture(hass: HomeAssistant, entity_id) -> None "template": { "binary_sensor": { "name": "test", - "state": "{{ states.sensor.test_state.state == 'on' }}", + "state": "{{ states.sensor.test_state.state }}", }, }, }, @@ -1029,17 +1090,29 @@ async def test_availability_icon_picture(hass: HomeAssistant, entity_id) -> None ({"delay_on": 5}, STATE_ON, STATE_OFF, STATE_OFF), ({"delay_on": 5}, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN), ({"delay_on": 5}, STATE_ON, STATE_UNKNOWN, STATE_UNKNOWN), + ({}, None, STATE_ON, STATE_OFF), + ({}, None, STATE_OFF, STATE_OFF), + ({}, None, STATE_UNAVAILABLE, STATE_OFF), + ({}, None, STATE_UNKNOWN, STATE_OFF), + ({"delay_off": 5}, None, STATE_ON, STATE_ON), + ({"delay_off": 5}, None, STATE_OFF, STATE_OFF), + ({"delay_off": 5}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_UNKNOWN, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_ON, STATE_OFF), + ({"delay_on": 5}, None, STATE_OFF, STATE_OFF), + ({"delay_on": 5}, None, STATE_UNAVAILABLE, STATE_OFF), + ({"delay_on": 5}, None, STATE_UNKNOWN, STATE_OFF), ], ) async def test_restore_state( hass: HomeAssistant, - count, - domain, - config, - extra_config, - source_state, - restored_state, - initial_state, + count: int, + domain: str, + config: ConfigType, + extra_config: ConfigType, + source_state: str | None, + restored_state: str, + initial_state: str, ) -> None: """Test restoring template binary sensor.""" @@ -1088,7 +1161,7 @@ async def test_restore_state( "friendly_name": "Hello Name", "unique_id": "hello_name-id", "device_class": "battery", - "value_template": "{{ trigger.event.data.beer == 2 }}", + "value_template": _BEER_TRIGGER_VALUE_TEMPLATE, "entity_picture_template": "{{ '/local/dogs.png' }}", "icon_template": "{{ 'mdi:pirate' }}", "attribute_templates": { @@ -1101,7 +1174,7 @@ async def test_restore_state( "name": "via list", "unique_id": "via_list-id", "device_class": "battery", - "state": "{{ trigger.event.data.beer == 2 }}", + "state": _BEER_TRIGGER_VALUE_TEMPLATE, "picture": "{{ '/local/dogs.png' }}", "icon": "{{ 'mdi:pirate' }}", "attributes": { @@ -1123,9 +1196,34 @@ async def test_restore_state( }, ], ) +@pytest.mark.parametrize( + ( + "beer_count", + "final_state", + "icon_attr", + "entity_picture_attr", + "plus_one_attr", + "another_attr", + "another_attr_update", + ), + [ + (2, STATE_ON, "mdi:pirate", "/local/dogs.png", 3, 1, "si"), + (1, STATE_OFF, "mdi:pirate", "/local/dogs.png", 2, 1, "si"), + (0, STATE_OFF, "mdi:pirate", "/local/dogs.png", 1, 1, "si"), + (-1, STATE_UNAVAILABLE, None, None, None, None, None), + ], +) @pytest.mark.usefixtures("start_ha") async def test_trigger_entity( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + beer_count: int, + final_state: str, + icon_attr: str | None, + entity_picture_attr: str | None, + plus_one_attr: int | None, + another_attr: int | None, + another_attr_update: str | None, + entity_registry: er.EntityRegistry, ) -> None: """Test trigger entity works.""" await hass.async_block_till_done() @@ -1138,15 +1236,15 @@ async def test_trigger_entity( assert state.state == STATE_UNKNOWN context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) + hass.bus.async_fire("test_event", {"beer": beer_count}, context=context) await hass.async_block_till_done() state = hass.states.get("binary_sensor.hello_name") - assert state.state == STATE_ON + assert state.state == final_state assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == "mdi:pirate" - assert state.attributes.get("entity_picture") == "/local/dogs.png" - assert state.attributes.get("plus_one") == 3 + assert state.attributes.get("icon") == icon_attr + assert state.attributes.get("entity_picture") == entity_picture_attr + assert state.attributes.get("plus_one") == plus_one_attr assert state.context is context assert len(entity_registry.entities) == 2 @@ -1160,20 +1258,20 @@ async def test_trigger_entity( ) state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_ON + assert state.state == final_state assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == "mdi:pirate" - assert state.attributes.get("entity_picture") == "/local/dogs.png" - assert state.attributes.get("plus_one") == 3 - assert state.attributes.get("another") == 1 + assert state.attributes.get("icon") == icon_attr + assert state.attributes.get("entity_picture") == entity_picture_attr + assert state.attributes.get("plus_one") == plus_one_attr + assert state.attributes.get("another") == another_attr assert state.context is context # Even if state itself didn't change, attributes might have changed - hass.bus.async_fire("test_event", {"beer": 2, "uno_mas": "si"}) + hass.bus.async_fire("test_event", {"beer": beer_count, "uno_mas": "si"}) await hass.async_block_till_done() state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_ON - assert state.attributes.get("another") == "si" + assert state.state == final_state + assert state.attributes.get("another") == another_attr_update @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @@ -1185,7 +1283,7 @@ async def test_trigger_entity( "trigger": {"platform": "event", "event_type": "test_event"}, "binary_sensor": { "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", + "state": _BEER_TRIGGER_VALUE_TEMPLATE, "device_class": "motion", "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', @@ -1195,34 +1293,50 @@ async def test_trigger_entity( ], ) @pytest.mark.usefixtures("start_ha") -async def test_template_with_trigger_templated_delay_on(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("beer_count", "first_state", "second_state", "final_state"), + [ + (2, STATE_UNKNOWN, STATE_ON, STATE_OFF), + (1, STATE_OFF, STATE_OFF, STATE_OFF), + (0, STATE_OFF, STATE_OFF, STATE_OFF), + (-1, STATE_UNAVAILABLE, STATE_UNAVAILABLE, STATE_UNAVAILABLE), + ], +) +async def test_template_with_trigger_templated_delay_on( + hass: HomeAssistant, + beer_count: int, + first_state: str, + second_state: str, + final_state: str, + freezer: FrozenDateTimeFactory, +) -> None: """Test binary sensor template with template delay on.""" state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) + hass.bus.async_fire("test_event", {"beer": beer_count}, context=context) await hass.async_block_till_done() # State should still be unknown state = hass.states.get("binary_sensor.test") - assert state.state == STATE_UNKNOWN + assert state.state == first_state # Now wait for the on delay - future = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + assert state.state == second_state # Now wait for the auto-off - future = dt_util.utcnow() + timedelta(seconds=2) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + assert state.state == final_state @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @@ -1261,10 +1375,9 @@ async def test_template_with_trigger_templated_delay_on(hass: HomeAssistant) -> ) @pytest.mark.usefixtures("start_ha") async def test_trigger_template_delay_with_multiple_triggers( - hass: HomeAssistant, delay_state: str + hass: HomeAssistant, delay_state: str, freezer: FrozenDateTimeFactory ) -> None: """Test trigger based binary sensor with multiple triggers occurring during the delay.""" - future = dt_util.utcnow() for _ in range(10): # State should still be unknown state = hass.states.get("binary_sensor.test") @@ -1273,8 +1386,8 @@ async def test_trigger_template_delay_with_multiple_triggers( hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) await hass.async_block_till_done() - future += timedelta(seconds=1) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") @@ -1290,7 +1403,7 @@ async def test_trigger_template_delay_with_multiple_triggers( "trigger": {"platform": "event", "event_type": "test_event"}, "binary_sensor": { "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", + "state": _BEER_TRIGGER_VALUE_TEMPLATE, "device_class": "motion", "picture": "{{ '/local/dogs.png' }}", "icon": "{{ 'mdi:pirate' }}", @@ -1314,12 +1427,12 @@ async def test_trigger_template_delay_with_multiple_triggers( ) async def test_trigger_entity_restore_state( hass: HomeAssistant, - count, - domain, - config, - restored_state, - initial_state, - initial_attributes, + count: int, + domain: str, + config: ConfigType, + restored_state: str, + initial_state: str, + initial_attributes: list[str], ) -> None: """Test restoring trigger template binary sensor.""" @@ -1378,7 +1491,7 @@ async def test_trigger_entity_restore_state( "trigger": {"platform": "event", "event_type": "test_event"}, "binary_sensor": { "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", + "state": _BEER_TRIGGER_VALUE_TEMPLATE, "device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', }, @@ -1389,10 +1502,10 @@ async def test_trigger_entity_restore_state( @pytest.mark.parametrize("restored_state", [STATE_ON, STATE_OFF]) async def test_trigger_entity_restore_state_auto_off( hass: HomeAssistant, - count, - domain, - config, - restored_state, + count: int, + domain: str, + config: ConfigType, + restored_state: str, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" @@ -1442,7 +1555,7 @@ async def test_trigger_entity_restore_state_auto_off( "trigger": {"platform": "event", "event_type": "test_event"}, "binary_sensor": { "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", + "state": _BEER_TRIGGER_VALUE_TEMPLATE, "device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', }, @@ -1451,7 +1564,11 @@ async def test_trigger_entity_restore_state_auto_off( ], ) async def test_trigger_entity_restore_state_auto_off_expired( - hass: HomeAssistant, count, domain, config, freezer: FrozenDateTimeFactory + hass: HomeAssistant, + count: int, + domain: str, + config: ConfigType, + freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" From 0be0e22e7629466ea2a8ee74ed8b49da2637232b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 27 Jun 2025 16:59:10 +0000 Subject: [PATCH 0803/1664] Simplify rflink dimmable set_level parsing (#147636) --- homeassistant/components/rflink/light.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index af8d2c76844..7eb53433d88 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -221,8 +221,8 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity): elif command in ["off", "alloff"]: self._state = False # dimmable device accept 'set_level=(0-15)' commands - elif re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE): - self._brightness = rflink_to_brightness(int(command.split("=")[1])) + elif match := re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE): + self._brightness = rflink_to_brightness(int(match.group(1))) self._state = True @property From 5129f890869393bd3a1a30b30d9f66c6c1156099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 27 Jun 2025 17:00:01 +0000 Subject: [PATCH 0804/1664] Finish config flow in huawei_lte SSDP test (#147542) --- .../components/huawei_lte/test_config_flow.py | 48 +++++++++++++------ 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index f75b0e7f2b0..5e018e73f2a 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -330,24 +330,25 @@ async def test_ssdp( url = FIXTURE_USER_INPUT[CONF_URL][:-1] # strip trailing slash for appending port context = {"source": config_entries.SOURCE_SSDP} login_requests_mock.request(**requests_mock_request_kwargs) + service_info = SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="upnp:rootdevice", + ssdp_location=f"{url}:60957/rootDesc.xml", + upnp={ + ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", + ATTR_UPNP_MANUFACTURER: "Huawei", + ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", + ATTR_UPNP_MODEL_NAME: "Huawei router", + ATTR_UPNP_MODEL_NUMBER: "12345678", + ATTR_UPNP_PRESENTATION_URL: url, + ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", + **upnp_data, + }, + ) result = await hass.config_entries.flow.async_init( DOMAIN, context=context, - data=SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="upnp:rootdevice", - ssdp_location=f"{url}:60957/rootDesc.xml", - upnp={ - ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:InternetGatewayDevice:1", - ATTR_UPNP_MANUFACTURER: "Huawei", - ATTR_UPNP_MANUFACTURER_URL: "http://www.huawei.com/", - ATTR_UPNP_MODEL_NAME: "Huawei router", - ATTR_UPNP_MODEL_NUMBER: "12345678", - ATTR_UPNP_PRESENTATION_URL: url, - ATTR_UPNP_UDN: "uuid:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", - **upnp_data, - }, - ), + data=service_info, ) for k, v in expected_result.items(): @@ -356,6 +357,23 @@ async def test_ssdp( assert result["data_schema"] is not None assert result["data_schema"]({})[CONF_URL] == url + "/" + if result["type"] == FlowResultType.ABORT: + return + + login_requests_mock.request( + ANY, + f"{FIXTURE_USER_INPUT[CONF_URL]}api/user/login", + text="OK", + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == service_info.upnp[ATTR_UPNP_MODEL_NAME] + @pytest.mark.parametrize( ("login_response_text", "expected_result", "expected_entry_data"), From b630fb0520b7006f8a91dae8b6cc3e0b0b08bdae Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 27 Jun 2025 19:38:42 +0200 Subject: [PATCH 0805/1664] Respect availability of parent class in Husqvarna Automower (#147649) --- homeassistant/components/husqvarna_automower/button.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 1f7ed7127e0..281669aad04 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): @property def available(self) -> bool: """Return the available attribute of the entity.""" - return self.entity_description.available_fn(self.mower_attributes) + return super().available and self.entity_description.available_fn( + self.mower_attributes + ) @handle_sending_exception() async def async_press(self) -> None: From e3ba1f34ca59c57e8012ed5757c0e6b44710756c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Fri, 27 Jun 2025 19:41:39 +0200 Subject: [PATCH 0806/1664] Matter TemperatureControl (#145706) * TemperatureControl * Add tests * Commands.SetTemperature * Update homeassistant/components/matter/number.py Co-authored-by: Martin Hjelmare * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update number.py * Update number.py * Update number.py * Update homeassistant/components/matter/number.py Co-authored-by: Martin Hjelmare * Refactor MatterRangeNumber to streamline command handling in async_set_native_value * testing requested changes --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/number.py | 79 ++++++ homeassistant/components/matter/strings.json | 3 + .../matter/snapshots/test_number.ambr | 232 ++++++++++++++++++ tests/components/matter/test_number.py | 39 +++ 4 files changed, 353 insertions(+) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 4b469fa85e4..b811a3c19d3 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -2,9 +2,12 @@ from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass +from typing import Any, cast from chip.clusters import Objects as clusters +from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand from matter_server.common import custom_clusters from homeassistant.components.number import ( @@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip """Describe Matter Number Input entities.""" +@dataclass(frozen=True, kw_only=True) +class MatterRangeNumberEntityDescription( + NumberEntityDescription, MatterEntityDescription +): + """Describe Matter Number Input entities with min and max values.""" + + ha_to_native_value: Callable[[Any], Any] + + # attribute descriptors to get the min and max value + min_attribute: type[ClusterAttributeDescriptor] + max_attribute: type[ClusterAttributeDescriptor] + + # 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] + + class MatterNumber(MatterEntity, NumberEntity): """Representation of a Matter Attribute as a Number entity.""" @@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity): self._attr_native_value = value +class MatterRangeNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity with min and max values.""" + + entity_description: MatterRangeNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + send_value = self.entity_description.ha_to_native_value(value) + # custom command defined to set the new value + await self.send_device_command( + self.entity_description.command(send_value), + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_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), + ) + / 100 + ) + self._attr_native_max_value = ( + cast( + int, + self.get_matter_attribute_value(self.entity_description.max_attribute), + ) + / 100 + ) + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterNumber, required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterRangeNumberEntityDescription( + key="TemperatureControlTemperatureSetpoint", + name=None, + translation_key="temperature_setpoint", + command=lambda value: clusters.TemperatureControl.Commands.SetTemperature( + targetTemperature=value + ), + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + measurement_to_ha=lambda x: None if x is None else x / 100, + ha_to_native_value=lambda x: round(x * 100), + min_attribute=clusters.TemperatureControl.Attributes.MinTemperature, + max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature, + mode=NumberMode.SLIDER, + ), + entity_class=MatterRangeNumber, + required_attributes=( + clusters.TemperatureControl.Attributes.TemperatureSetpoint, + clusters.TemperatureControl.Attributes.MinTemperature, + clusters.TemperatureControl.Attributes.MaxTemperature, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 35a9daa2370..d1367ba66e2 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -183,6 +183,9 @@ "temperature_offset": { "name": "Temperature offset" }, + "temperature_setpoint": { + "name": "Temperature setpoint" + }, "pir_occupied_to_unoccupied_delay": { "name": "Occupied to unoccupied delay" }, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 5ba0f275f8d..d71980c0613 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1846,6 +1846,64 @@ 'state': '0.0', }) # --- +# name: test_numbers[oven][number.mock_oven_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 288.0, + 'min': 76.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_oven_temperature_setpoint', + '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': 'Temperature setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-0000000000000002-MatterNodeDevice-2-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[oven][number.mock_oven_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Oven Temperature setpoint', + 'max': 288.0, + 'min': 76.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.mock_oven_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '76.0', + }) +# --- # name: test_numbers[pump][number.mock_pump_on_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1903,3 +1961,177 @@ 'state': '0', }) # --- +# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 0.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.laundrywasher_temperature_setpoint', + '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': 'Temperature setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000001D-MatterNodeDevice-1-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'LaundryWasher Temperature setpoint', + 'max': 0.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.laundrywasher_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': -15.0, + 'min': -18.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.refrigerator_temperature_setpoint_2', + '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': 'Temperature setpoint (2)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-2-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Temperature setpoint (2)', + 'max': -15.0, + 'min': -18.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_temperature_setpoint_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 4.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.refrigerator_temperature_setpoint_3', + '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': 'Temperature setpoint (3)', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_setpoint', + 'unique_id': '00000000000004D2-000000000000003A-MatterNodeDevice-3-TemperatureControlTemperatureSetpoint-86-0', + 'unit_of_measurement': , + }) +# --- +# name: test_numbers[silabs_refrigerator][number.refrigerator_temperature_setpoint_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Refrigerator Temperature setpoint (3)', + 'max': 4.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.refrigerator_temperature_setpoint_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index c94b92dbc46..d1ccc1a229b 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, call +from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode from matter_server.common import custom_clusters from matter_server.common.errors import MatterError @@ -101,6 +102,44 @@ async def test_eve_weather_sensor_altitude( ) +@pytest.mark.parametrize("node_fixture", ["silabs_refrigerator"]) +async def test_temperature_control_temperature_setpoint( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test TemperatureSetpoint from TemperatureControl.""" + # TemperatureSetpoint + state = hass.states.get("number.refrigerator_temperature_setpoint_2") + assert state + assert state.state == "-18.0" + + set_node_attribute(matter_node, 2, 86, 0, -1600) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.refrigerator_temperature_setpoint_2") + assert state + assert state.state == "-16.0" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.refrigerator_temperature_setpoint_2", + "value": -17, + }, + 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=2, + command=clusters.TemperatureControl.Commands.SetTemperature( + targetTemperature=-1700 + ), + ) + + @pytest.mark.parametrize("node_fixture", ["dimmable_light"]) async def test_matter_exception_on_write_attribute( hass: HomeAssistant, From 19d89c89528d5aabcbed7e83370a75dbd313e3b6 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 28 Jun 2025 03:43:03 +1000 Subject: [PATCH 0807/1664] Fix energy history in Teslemetry (#147646) --- .../components/teslemetry/coordinator.py | 12 ++++---- tests/components/teslemetry/const.py | 1 + .../fixtures/energy_history_empty.json | 8 +++++ tests/components/teslemetry/test_sensor.py | 30 +++++++++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/components/teslemetry/fixtures/energy_history_empty.json diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index c31bdc2a34e..e6b453402e9 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -194,14 +194,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): except TeslaFleetError as e: raise UpdateFailed(e.message) from e + if not data or not isinstance(data.get("time_series"), list): + raise UpdateFailed("Received invalid data") + # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) - for period in data.get("time_series", []): + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + for period in data["time_series"]: for key in ENERGY_HISTORY_FIELDS: if key in period: - if output[key] is None: - output[key] = period[key] - else: - output[key] += period[key] + output[key] += period[key] return output diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index b658c1e2271..3bfa452e38d 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -20,6 +20,7 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +ENERGY_HISTORY_EMPTY = load_json_object_fixture("energy_history_empty.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/energy_history_empty.json b/tests/components/teslemetry/fixtures/energy_history_empty.json new file mode 100644 index 00000000000..cc54000115a --- /dev/null +++ b/tests/components/teslemetry/fixtures/energy_history_empty.json @@ -0,0 +1,8 @@ +{ + "response": { + "serial_number": "xxxxxx", + "period": "day", + "installation_time_zone": "Australia/Brisbane", + "time_series": null + } +} diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f50dc93bde4..d2d6d88b3e3 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -8,12 +8,13 @@ from syrupy.assertion import SnapshotAssertion from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL -from homeassistant.const import Platform +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 . import assert_entities, assert_entities_alt, setup_platform -from .const import VEHICLE_DATA_ALT +from .const import ENERGY_HISTORY_EMPTY, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -101,3 +102,28 @@ async def test_sensors_streaming( ): state = hass.states.get(entity_id) assert state.state == snapshot(name=f"{entity_id}-state") + + +async def test_energy_history_no_time_series( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_energy_history: AsyncMock, +) -> None: + """Test energy history coordinator when time_series is not a list.""" + # Mock energy history to return data without time_series as a list + + entry = await setup_platform(hass, [Platform.SENSOR]) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "sensor.energy_site_battery_discharged" + state = hass.states.get(entity_id) + assert state.state == "0.036" + + mock_energy_history.return_value = ENERGY_HISTORY_EMPTY + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE From d874c28dc9c5416d4ce2189c442a21a897d0fca7 Mon Sep 17 00:00:00 2001 From: Bernardus Jansen Date: Fri, 27 Jun 2025 19:45:36 +0200 Subject: [PATCH 0808/1664] Add previously missing state classes to dsmr sensors (#147633) --- homeassistant/components/dsmr/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 918d4e33971..03e89b971fc 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="SHORT_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="LONG_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( From 1829acd0e1a7531f343285ea1b7ac1f26af1bdc4 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 27 Jun 2025 18:27:43 +0300 Subject: [PATCH 0809/1664] Z-WaveJS config flow: Change keys question (#147518) Co-authored-by: Norbert Rittel --- .../components/zwave_js/config_flow.py | 109 +++++++--- .../components/zwave_js/strings.json | 44 ++-- tests/components/zwave_js/test_config_flow.py | 192 ++++++++++++++++-- 3 files changed, 289 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index a109719965c..7e95e274713 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -40,7 +40,6 @@ from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.service_info.hassio import HassioServiceInfo from homeassistant.helpers.service_info.usb import UsbServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo -from homeassistant.helpers.typing import VolDictType from .addon import get_addon_manager from .const import ( @@ -90,6 +89,9 @@ ADDON_USER_INPUT_MAP = { ON_SUPERVISOR_SCHEMA = vol.Schema({vol.Optional(CONF_USE_ADDON, default=True): bool}) MIN_MIGRATION_SDK_VERSION = AwesomeVersion("6.61") +NETWORK_TYPE_NEW = "new" +NETWORK_TYPE_EXISTING = "existing" + def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema: """Return a schema for the manual step.""" @@ -632,6 +634,81 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Ask for config for Z-Wave JS add-on.""" + + if user_input is not None: + self.usb_path = user_input[CONF_USB_PATH] + return await self.async_step_network_type() + + if self._usb_discovery: + return await self.async_step_network_type() + + usb_path = self.usb_path or "" + + try: + ports = await async_get_usb_ports(self.hass) + except OSError as err: + _LOGGER.error("Failed to get USB ports: %s", err) + return self.async_abort(reason="usb_ports_failed") + + data_schema = vol.Schema( + { + vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), + } + ) + + return self.async_show_form( + step_id="configure_addon_user", data_schema=data_schema + ) + + async def async_step_network_type( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for network type (new or existing).""" + # For recommended installation, automatically set network type to "new" + if self._recommended_install: + user_input = {"network_type": NETWORK_TYPE_NEW} + + if user_input is not None: + if user_input["network_type"] == NETWORK_TYPE_NEW: + # Set all keys to empty strings for new network + self.s0_legacy_key = "" + self.s2_access_control_key = "" + self.s2_authenticated_key = "" + self.s2_unauthenticated_key = "" + self.lr_s2_access_control_key = "" + self.lr_s2_authenticated_key = "" + + addon_config_updates = { + CONF_ADDON_DEVICE: self.usb_path, + CONF_ADDON_S0_LEGACY_KEY: self.s0_legacy_key, + CONF_ADDON_S2_ACCESS_CONTROL_KEY: self.s2_access_control_key, + CONF_ADDON_S2_AUTHENTICATED_KEY: self.s2_authenticated_key, + CONF_ADDON_S2_UNAUTHENTICATED_KEY: self.s2_unauthenticated_key, + CONF_ADDON_LR_S2_ACCESS_CONTROL_KEY: self.lr_s2_access_control_key, + CONF_ADDON_LR_S2_AUTHENTICATED_KEY: self.lr_s2_authenticated_key, + } + + await self._async_set_addon_config(addon_config_updates) + return await self.async_step_start_addon() + + # Network already exists, go to security keys step + return await self.async_step_configure_security_keys() + + return self.async_show_form( + step_id="network_type", + data_schema=vol.Schema( + { + vol.Required("network_type", default=""): vol.In( + [NETWORK_TYPE_NEW, NETWORK_TYPE_EXISTING] + ) + } + ), + ) + + async def async_step_configure_security_keys( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Ask for security keys for existing Z-Wave network.""" addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -654,10 +731,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): CONF_ADDON_LR_S2_AUTHENTICATED_KEY, self.lr_s2_authenticated_key or "" ) - if self._recommended_install and self._usb_discovery: - # Recommended installation with USB discovery, skip asking for keys - user_input = {} - if user_input is not None: self.s0_legacy_key = user_input.get(CONF_S0_LEGACY_KEY, s0_legacy_key) self.s2_access_control_key = user_input.get( @@ -675,8 +748,6 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.lr_s2_authenticated_key = user_input.get( CONF_LR_S2_AUTHENTICATED_KEY, lr_s2_authenticated_key ) - if not self._usb_discovery: - self.usb_path = user_input[CONF_USB_PATH] addon_config_updates = { CONF_ADDON_DEVICE: self.usb_path, @@ -689,14 +760,10 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } await self._async_set_addon_config(addon_config_updates) - return await self.async_step_start_addon() - usb_path = self.usb_path or addon_config.get(CONF_ADDON_DEVICE) or "" - schema: VolDictType = ( - {} - if self._recommended_install - else { + data_schema = vol.Schema( + { vol.Optional(CONF_S0_LEGACY_KEY, default=s0_legacy_key): str, vol.Optional( CONF_S2_ACCESS_CONTROL_KEY, default=s2_access_control_key @@ -716,22 +783,8 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): } ) - if not self._usb_discovery: - try: - ports = await async_get_usb_ports(self.hass) - except OSError as err: - _LOGGER.error("Failed to get USB ports: %s", err) - return self.async_abort(reason="usb_ports_failed") - - schema = { - vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports), - **schema, - } - - data_schema = vol.Schema(schema) - return self.async_show_form( - step_id="configure_addon_user", data_schema=data_schema + step_id="configure_security_keys", data_schema=data_schema ) async def async_step_finish_addon_setup_user( diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index f61d871cfb9..b7f9b180624 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -39,25 +39,37 @@ "step": { "configure_addon_user": { "data": { - "lr_s2_access_control_key": "Long Range S2 Access Control Key", - "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", - "s0_legacy_key": "S0 Key (Legacy)", - "s2_access_control_key": "S2 Access Control Key", - "s2_authenticated_key": "S2 Authenticated Key", - "s2_unauthenticated_key": "S2 Unauthenticated Key", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "Select your Z-Wave adapter", "title": "Enter the Z-Wave add-on configuration" }, + "network_type": { + "data": { + "network_type": "Is your network new or does it already exist?" + }, + "title": "Z-Wave network" + }, + "configure_security_keys": { + "data": { + "lr_s2_access_control_key": "Long Range S2 Access Control Key", + "lr_s2_authenticated_key": "Long Range S2 Authenticated Key", + "s0_legacy_key": "S0 Key (Legacy)", + "s2_access_control_key": "S2 Access Control Key", + "s2_authenticated_key": "S2 Authenticated Key", + "s2_unauthenticated_key": "S2 Unauthenticated Key" + }, + "description": "Enter the security keys for your existing Z-Wave network", + "title": "Security keys" + }, "configure_addon_reconfigure": { "data": { - "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_access_control_key%]", - "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::lr_s2_authenticated_key%]", - "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s0_legacy_key%]", - "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_access_control_key%]", - "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_authenticated_key%]", - "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_addon_user::data::s2_unauthenticated_key%]", + "lr_s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_access_control_key%]", + "lr_s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::lr_s2_authenticated_key%]", + "s0_legacy_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s0_legacy_key%]", + "s2_access_control_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_access_control_key%]", + "s2_authenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_authenticated_key%]", + "s2_unauthenticated_key": "[%key:component::zwave_js::config::step::configure_security_keys::data::s2_unauthenticated_key%]", "usb_path": "[%key:common::config_flow::data::usb_path%]" }, "description": "[%key:component::zwave_js::config::step::configure_addon_user::description%]", @@ -622,5 +634,13 @@ }, "name": "Set a value (advanced)" } + }, + "selector": { + "network_type": { + "options": { + "new": "It's new", + "existing": "It already exists" + } + } } } diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index e99cedbdcba..a1642746d03 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -29,12 +29,6 @@ from homeassistant.components.zwave_js.const import ( CONF_ADDON_S2_ACCESS_CONTROL_KEY, CONF_ADDON_S2_AUTHENTICATED_KEY, CONF_ADDON_S2_UNAUTHENTICATED_KEY, - CONF_LR_S2_ACCESS_CONTROL_KEY, - CONF_LR_S2_AUTHENTICATED_KEY, - CONF_S0_LEGACY_KEY, - CONF_S2_ACCESS_CONTROL_KEY, - CONF_S2_AUTHENTICATED_KEY, - CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, DOMAIN, ) @@ -687,7 +681,17 @@ async def test_usb_discovery( assert install_addon.call_args == call("core_zwave_js") assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -778,9 +782,18 @@ async def test_usb_discovery_addon_not_running( ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "configure_addon_user" + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" - # Make sure the discovered usb device is preferred. data_schema = result["data_schema"] assert data_schema is not None assert data_schema({}) == { @@ -1126,6 +1139,25 @@ async def test_discovery_addon_not_running( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1226,6 +1258,25 @@ async def test_discovery_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1728,6 +1779,25 @@ async def test_addon_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1822,6 +1892,25 @@ async def test_addon_installed_start_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1911,6 +2000,25 @@ async def test_addon_installed_failures( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -1981,6 +2089,25 @@ async def test_addon_installed_set_options_failure( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2091,6 +2218,25 @@ async def test_addon_installed_already_configured( result["flow_id"], { "usb_path": "/new", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -2178,6 +2324,25 @@ async def test_addon_not_installed( result["flow_id"], { "usb_path": "/test", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "network_type" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "network_type": "existing", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_security_keys" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { "s0_legacy_key": "new123", "s2_access_control_key": "new456", "s2_authenticated_key": "new789", @@ -4229,13 +4394,8 @@ async def test_intent_recommended_user( assert result["step_id"] == "configure_addon_user" data_schema = result["data_schema"] assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] is not None - assert data_schema.schema.get(CONF_S0_LEGACY_KEY) is None - assert data_schema.schema.get(CONF_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_S2_AUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_S2_UNAUTHENTICATED_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_ACCESS_CONTROL_KEY) is None - assert data_schema.schema.get(CONF_LR_S2_AUTHENTICATED_KEY) is None + assert len(data_schema.schema) == 1 + assert data_schema.schema.get(CONF_USB_PATH) is not None result = await hass.config_entries.flow.async_configure( result["flow_id"], From b9e2c5d34c7bddce636034946a20d4b1e77463dd Mon Sep 17 00:00:00 2001 From: Bernardus Jansen Date: Fri, 27 Jun 2025 19:45:36 +0200 Subject: [PATCH 0810/1664] Add previously missing state classes to dsmr sensors (#147633) --- homeassistant/components/dsmr/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 918d4e33971..03e89b971fc 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="SHORT_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="LONG_POWER_FAILURE_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SAG_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L1_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L2_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( @@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( obis_reference="VOLTAGE_SWELL_L3_COUNT", dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), DSMRSensorEntityDescription( From 62f7cbb51ea575e41f934bd27327e92dfa544f59 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 17:23:42 +0200 Subject: [PATCH 0811/1664] Remove dweet.io integration (#147645) --- homeassistant/components/dweet/__init__.py | 79 ------------ homeassistant/components/dweet/manifest.json | 10 -- homeassistant/components/dweet/sensor.py | 124 ------------------- homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - 5 files changed, 222 deletions(-) delete mode 100644 homeassistant/components/dweet/__init__.py delete mode 100644 homeassistant/components/dweet/manifest.json delete mode 100644 homeassistant/components/dweet/sensor.py diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py deleted file mode 100644 index b43ce3db8c1..00000000000 --- a/homeassistant/components/dweet/__init__.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Support for sending data to Dweet.io.""" - -from datetime import timedelta -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - CONF_NAME, - CONF_WHITELIST, - EVENT_STATE_CHANGED, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, state as state_helper -from homeassistant.helpers.typing import ConfigType -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "dweet" - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_NAME): cv.string, - vol.Required(CONF_WHITELIST, default=[]): vol.All( - cv.ensure_list, [cv.entity_id] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Dweet.io component.""" - conf = config[DOMAIN] - name = conf.get(CONF_NAME) - whitelist = conf.get(CONF_WHITELIST) - json_body = {} - - def dweet_event_listener(event): - """Listen for new messages on the bus and sends them to Dweet.io.""" - state = event.data.get("new_state") - if ( - state is None - or state.state in (STATE_UNKNOWN, "") - or state.entity_id not in whitelist - ): - return - - try: - _state = state_helper.state_as_number(state) - except ValueError: - _state = state.state - - json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state - - send_data(name, json_body) - - hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener) - - return True - - -@Throttle(MIN_TIME_BETWEEN_UPDATES) -def send_data(name, msg): - """Send the collected data to Dweet.io.""" - try: - dweepy.dweet_for(name, msg) - except dweepy.DweepyError: - _LOGGER.error("Error saving data to Dweet.io: %s", msg) diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json deleted file mode 100644 index b4efd0744fb..00000000000 --- a/homeassistant/components/dweet/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "dweet", - "name": "dweet.io", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/dweet", - "iot_class": "cloud_polling", - "loggers": ["dweepy"], - "quality_scale": "legacy", - "requirements": ["dweepy==0.3.0"] -} diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py deleted file mode 100644 index 6110f17f826..00000000000 --- a/homeassistant/components/dweet/sensor.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Support for showing values from Dweet.io.""" - -from __future__ import annotations - -from datetime import timedelta -import json -import logging - -import dweepy -import voluptuous as vol - -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.const import ( - CONF_DEVICE, - CONF_NAME, - CONF_UNIT_OF_MEASUREMENT, - CONF_VALUE_TEMPLATE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Dweet.io Sensor" - -SCAN_INTERVAL = timedelta(minutes=1) - -PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE): cv.string, - vol.Required(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Dweet sensor.""" - name = config.get(CONF_NAME) - device = config.get(CONF_DEVICE) - value_template = config.get(CONF_VALUE_TEMPLATE) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - - try: - content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"]) - except dweepy.DweepyError: - _LOGGER.error("Device/thing %s could not be found", device) - return - - if value_template and value_template.render_with_possible_json_value(content) == "": - _LOGGER.error("%s was not found", value_template) - return - - dweet = DweetData(device) - - add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True) - - -class DweetSensor(SensorEntity): - """Representation of a Dweet sensor.""" - - def __init__(self, hass, dweet, name, value_template, unit_of_measurement): - """Initialize the sensor.""" - self.hass = hass - self.dweet = dweet - self._name = name - self._value_template = value_template - self._state = None - self._unit_of_measurement = unit_of_measurement - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the state.""" - return self._state - - def update(self) -> None: - """Get the latest data from REST API.""" - self.dweet.update() - - if self.dweet.data is None: - self._state = None - else: - values = json.dumps(self.dweet.data[0]["content"]) - self._state = self._value_template.render_with_possible_json_value( - values, None - ) - - -class DweetData: - """The class for handling the data retrieval.""" - - def __init__(self, device): - """Initialize the sensor.""" - self._device = device - self.data = None - - def update(self): - """Get the latest data from Dweet.io.""" - try: - self.data = dweepy.get_latest_dweet_for(self._device) - except dweepy.DweepyError: - _LOGGER.warning("Device %s doesn't contain any data", self._device) - self.data = None diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bd88338c4b9..98670484450 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1483,12 +1483,6 @@ "config_flow": true, "iot_class": "cloud_polling" }, - "dweet": { - "name": "dweet.io", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_polling" - }, "eafm": { "name": "Environment Agency Flood Gauges", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index da31a7fad53..d2d0503a59f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -820,9 +820,6 @@ dsmr-parser==1.4.3 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.7 -# homeassistant.components.dweet -dweepy==0.3.0 - # homeassistant.components.dynalite dynalite-devices==0.1.47 From 47f3bf29dd65d2093989574845c25eff5a6229c8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sat, 28 Jun 2025 03:43:03 +1000 Subject: [PATCH 0812/1664] Fix energy history in Teslemetry (#147646) --- .../components/teslemetry/coordinator.py | 12 ++++---- tests/components/teslemetry/const.py | 1 + .../fixtures/energy_history_empty.json | 8 +++++ tests/components/teslemetry/test_sensor.py | 30 +++++++++++++++++-- 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/components/teslemetry/fixtures/energy_history_empty.json diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index c31bdc2a34e..e6b453402e9 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -194,14 +194,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): except TeslaFleetError as e: raise UpdateFailed(e.message) from e + if not data or not isinstance(data.get("time_series"), list): + raise UpdateFailed("Received invalid data") + # Add all time periods together - output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None) - for period in data.get("time_series", []): + output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0) + for period in data["time_series"]: for key in ENERGY_HISTORY_FIELDS: if key in period: - if output[key] is None: - output[key] = period[key] - else: - output[key] += period[key] + output[key] += period[key] return output diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index b658c1e2271..3bfa452e38d 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -20,6 +20,7 @@ VEHICLE_DATA_ALT = load_json_object_fixture("vehicle_data_alt.json", DOMAIN) LIVE_STATUS = load_json_object_fixture("live_status.json", DOMAIN) SITE_INFO = load_json_object_fixture("site_info.json", DOMAIN) ENERGY_HISTORY = load_json_object_fixture("energy_history.json", DOMAIN) +ENERGY_HISTORY_EMPTY = load_json_object_fixture("energy_history_empty.json", DOMAIN) COMMAND_OK = {"response": {"result": True, "reason": ""}} COMMAND_REASON = {"response": {"result": False, "reason": "already closed"}} diff --git a/tests/components/teslemetry/fixtures/energy_history_empty.json b/tests/components/teslemetry/fixtures/energy_history_empty.json new file mode 100644 index 00000000000..cc54000115a --- /dev/null +++ b/tests/components/teslemetry/fixtures/energy_history_empty.json @@ -0,0 +1,8 @@ +{ + "response": { + "serial_number": "xxxxxx", + "period": "day", + "installation_time_zone": "Australia/Brisbane", + "time_series": null + } +} diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index f50dc93bde4..d2d6d88b3e3 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -8,12 +8,13 @@ from syrupy.assertion import SnapshotAssertion from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL -from homeassistant.const import Platform +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 . import assert_entities, assert_entities_alt, setup_platform -from .const import VEHICLE_DATA_ALT +from .const import ENERGY_HISTORY_EMPTY, VEHICLE_DATA_ALT from tests.common import async_fire_time_changed @@ -101,3 +102,28 @@ async def test_sensors_streaming( ): state = hass.states.get(entity_id) assert state.state == snapshot(name=f"{entity_id}-state") + + +async def test_energy_history_no_time_series( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_energy_history: AsyncMock, +) -> None: + """Test energy history coordinator when time_series is not a list.""" + # Mock energy history to return data without time_series as a list + + entry = await setup_platform(hass, [Platform.SENSOR]) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "sensor.energy_site_battery_discharged" + state = hass.states.get(entity_id) + assert state.state == "0.036" + + mock_energy_history.return_value = ENERGY_HISTORY_EMPTY + + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE From 0b5d2ab8e4c9210fec82292e1f50b232a86bb6f2 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 27 Jun 2025 19:38:42 +0200 Subject: [PATCH 0813/1664] Respect availability of parent class in Husqvarna Automower (#147649) --- homeassistant/components/husqvarna_automower/button.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py index 1f7ed7127e0..281669aad04 100644 --- a/homeassistant/components/husqvarna_automower/button.py +++ b/homeassistant/components/husqvarna_automower/button.py @@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity): @property def available(self) -> bool: """Return the available attribute of the entity.""" - return self.entity_description.available_fn(self.mower_attributes) + return super().available and self.entity_description.available_fn( + self.mower_attributes + ) @handle_sending_exception() async def async_press(self) -> None: From 5c0f2d37f0ffff038f0676b1a8a1f38b323e5598 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 10:31:13 +0200 Subject: [PATCH 0814/1664] Make jellyfin not single config entry (#147656) --- .../components/jellyfin/manifest.json | 3 +- homeassistant/generated/integrations.json | 3 +- .../jellyfin/fixtures/get-user-settings.json | 2 +- tests/components/jellyfin/test_config_flow.py | 37 +++++++++++++------ 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index d6b2261acaa..a1bf3268721 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"], - "single_config_entry": true + "requirements": ["jellyfin-apiclient-python==1.10.0"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 98670484450..6bf63b260de 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3159,8 +3159,7 @@ "name": "Jellyfin", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "single_config_entry": true + "iot_class": "local_polling" }, "jewish_calendar": { "name": "Jewish Calendar", diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json index 5e28f87d8f2..5ed59661a60 100644 --- a/tests/components/jellyfin/fixtures/get-user-settings.json +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -1,5 +1,5 @@ { - "Id": "string", + "Id": "USER-UUID", "ViewType": "string", "SortBy": "string", "IndexBy": "string", diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index a8ffbcbf46c..fd9d3b1d773 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -23,17 +23,6 @@ from tests.common import MockConfigEntry pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - async def test_form( hass: HomeAssistant, mock_jellyfin: MagicMock, @@ -201,6 +190,32 @@ async def test_form_persists_device_id_on_error( } +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the case where the user tries to configure an already configured entry.""" + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 4977ee49982ba33e27e12c8834da7032aedbbf30 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 11:07:14 +0200 Subject: [PATCH 0815/1664] Bump jellyfin-apiclient-python to 1.11.0 (#147658) --- homeassistant/components/jellyfin/client_wrapper.py | 3 +-- homeassistant/components/jellyfin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 91fe0885e4c..4855231184e 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -66,8 +66,7 @@ def _connect_to_address( ) -> dict[str, Any]: """Connect to the Jellyfin server.""" result: dict[str, Any] = connection_manager.connect_to_address(url) - - if result["State"] != CONNECTION_STATE["ServerSignIn"]: + if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn: raise CannotConnect return result diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index a1bf3268721..839d9e685fc 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"] + "requirements": ["jellyfin-apiclient-python==1.11.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d2d0503a59f..bcca765b043 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1276,7 +1276,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a131e2b9e68..1706bf61430 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest From 77ccfbd3a94f813f11ddffaadcb3272e8a289780 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Fri, 27 Jun 2025 11:00:23 +0200 Subject: [PATCH 0816/1664] Fix: Unhandled NoneType sessions in jellyfin (#147659) --- homeassistant/components/jellyfin/coordinator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index cd22ad4ab39..30149453ba3 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An self.api_client.jellyfin.sessions ) + if sessions is None: + return {} + sessions_by_id: dict[str, dict[str, Any]] = { session["Id"]: session for session in sessions From 8cdc7523a4ad7f1e98fb3f32ce584024bf7bbc15 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 27 Jun 2025 18:50:35 +0300 Subject: [PATCH 0817/1664] Fix Shelly entity removal (#147665) --- homeassistant/components/shelly/entity.py | 8 ++- tests/components/shelly/test_switch.py | 60 +++++++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 5a420a4543b..587eb00b979 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -192,8 +192,12 @@ def async_setup_rpc_attribute_entities( if description.removal_condition and description.removal_condition( coordinator.device.config, coordinator.device.status, key ): - domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{key}-{sensor_id}" + entity_class = get_entity_class(sensor_class, description) + domain = entity_class.__module__.split(".")[-1] + unique_id = entity_class( + coordinator, key, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) elif description.use_polling_coordinator: if not sleep_period: diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 54923b538f6..3234e3eb0b9 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -1,15 +1,18 @@ """Tests for Shelly switch platform.""" from copy import deepcopy +from datetime import timedelta from unittest.mock import AsyncMock, Mock from aioshelly.const import MODEL_1PM, MODEL_GAS, MODEL_MOTION from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.components.shelly.const import ( DOMAIN, + ENTRY_RELOAD_COOLDOWN, MODEL_WALL_DISPLAY, MOTION_MODELS, ) @@ -28,9 +31,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from . import init_integration, register_device, register_entity +from . import ( + init_integration, + inject_rpc_device_event, + register_device, + register_entity, +) -from tests.common import mock_restore_cache +from tests.common import async_fire_time_changed, mock_restore_cache RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 @@ -374,15 +382,57 @@ async def test_rpc_device_unique_ids( async def test_rpc_device_switch_type_lights_mode( - hass: HomeAssistant, mock_rpc_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test RPC device with switch in consumption type lights mode.""" + switch_entity_id = "switch.test_name_test_switch_0" + light_entity_id = "light.test_name_test_switch_0" + + monkeypatch.delitem(mock_rpc_device.status, "cover:0") + monkeypatch.setitem(mock_rpc_device.status["sys"], "relay_in_thermostat", False) + await init_integration(hass, 2) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light monkeypatch.setitem( mock_rpc_device.config["sys"]["ui_data"], "consumption_types", ["lights"] ) - await init_integration(hass, 2) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "data": [], + "event": "config_changed", + "id": 1, + "ts": 1668522399.2, + }, + { + "data": [], + "id": 2, + "ts": 1668522399.2, + }, + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() - assert hass.states.get("switch.test_switch_0") is None + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) @pytest.mark.parametrize( From 54510637143b1d973180dcea76e7810be46b256f Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Jun 2025 17:28:14 +0200 Subject: [PATCH 0818/1664] Update frontend to 20250627.0 (#147668) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8e4ea47da5b..cf83ce90237 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250626.0"] + "requirements": ["home-assistant-frontend==20250627.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5839a3ae014..80fccb1bf78 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index bcca765b043..b0727d8dfc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1706bf61430..611186f391e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250626.0 +home-assistant-frontend==20250627.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 8230557aef77b5d1c8313912e94270a69b48636d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 27 Jun 2025 15:53:18 +0200 Subject: [PATCH 0819/1664] Fix sentence-casing and spacing of button in `thermopro` (#147671) --- homeassistant/components/thermopro/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 5789de410b2..77722b6e986 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -21,7 +21,7 @@ "entity": { "button": { "set_datetime": { - "name": "Set Date&Time" + "name": "Set date & time" } } } From 013a35176acc2f89a8e0ff5bd3076d030c88f55c Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Fri, 27 Jun 2025 10:32:25 -0400 Subject: [PATCH 0820/1664] Bump aiosomecomfort to 0.0.33 (#147673) --- homeassistant/components/honeywell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json index 7fa102c6599..d2cd5a3c6a4 100644 --- a/homeassistant/components/honeywell/manifest.json +++ b/homeassistant/components/honeywell/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/honeywell", "iot_class": "cloud_polling", "loggers": ["somecomfort"], - "requirements": ["AIOSomecomfort==0.0.32"] + "requirements": ["AIOSomecomfort==0.0.33"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0727d8dfc8..62867d3aeb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 611186f391e..cbf715ac377 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.6.4 # homeassistant.components.honeywell -AIOSomecomfort==0.0.32 +AIOSomecomfort==0.0.33 # homeassistant.components.adax Adax-local==0.1.5 From e4d820799fecc5b6b031eb018b3440c1e6013308 Mon Sep 17 00:00:00 2001 From: hanwg Date: Sat, 28 Jun 2025 00:18:01 +0800 Subject: [PATCH 0821/1664] Add codeowner for Telegram bot (#147680) --- CODEOWNERS | 2 ++ homeassistant/components/telegram_bot/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 4e224f8802b..28deb93492c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor /tests/components/technove/ @Moustachauve /homeassistant/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj +/homeassistant/components/telegram_bot/ @hanwg +/tests/components/telegram_bot/ @hanwg /homeassistant/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike /homeassistant/components/template/ @Petro31 @home-assistant/core diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 27c10602350..7a01f43c528 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -1,7 +1,7 @@ { "domain": "telegram_bot", "name": "Telegram bot", - "codeowners": [], + "codeowners": ["@hanwg"], "config_flow": true, "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/telegram_bot", From 18834849c2e5a1cbe6c80dcb0f641c349455f651 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 27 Jun 2025 19:44:01 +0300 Subject: [PATCH 0822/1664] Bump aioamazondevices to 3.1.22 (#147681) --- 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 e82cd471ac7..cdf942e836d 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": "bronze", - "requirements": ["aioamazondevices==3.1.19"] + "requirements": ["aioamazondevices==3.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62867d3aeb8..e9e273572f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.19 +aioamazondevices==3.1.22 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cbf715ac377..f7bf7d6ccdd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.19 +aioamazondevices==3.1.22 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 16c6bd08f82ffbfd7e940fed57d7d2873824a114 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Jun 2025 17:55:31 +0000 Subject: [PATCH 0823/1664] Bump version to 2025.7.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5ded5fc83bb..ed82ed41594 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 870c22f2a12..735915581d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b2" +version = "2025.7.0b3" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 18c1953bc54deb52ccec1a86e003262a709628f1 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sat, 28 Jun 2025 02:16:21 +0800 Subject: [PATCH 0824/1664] Add lock models to switchbot cloud (#147569) --- homeassistant/components/switchbot_cloud/__init__.py | 7 ++++++- .../components/switchbot_cloud/binary_sensor.py | 9 ++++++++- homeassistant/components/switchbot_cloud/sensor.py | 5 +++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 7b7f60589f0..b87a569abda 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -153,7 +153,12 @@ async def make_device_data( ) devices_data.vacuums.append((device, coordinator)) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + if isinstance(device, Device) and device.device_type in [ + "Smart Lock", + "Smart Lock Lite", + "Smart Lock Pro", + "Smart Lock Ultra", + ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 752c428fa6c..cd0e6e8968c 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -48,10 +48,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Lite": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), "Smart Lock Pro": ( CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Ultra": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), } @@ -69,7 +77,6 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type ] - if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 9920717a8d7..5a424ea7892 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -134,8 +134,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, CO2_DESCRIPTION, ), - "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock": (BATTERY_DESCRIPTION,), + "Smart Lock Lite": (BATTERY_DESCRIPTION,), + "Smart Lock Pro": (BATTERY_DESCRIPTION,), + "Smart Lock Ultra": (BATTERY_DESCRIPTION,), } @@ -151,7 +153,6 @@ async def async_setup_entry( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] - if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) From 32236b2f4ded5052d0d63382d226735ae0eb94d5 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:17:06 +0200 Subject: [PATCH 0825/1664] Add reconfiguration flow to PlayStation Network (#147552) --- .../playstation_network/config_flow.py | 16 ++++++++-- .../playstation_network/quality_scale.yaml | 2 +- .../playstation_network/strings.json | 13 ++++++++- .../playstation_network/test_config_flow.py | 29 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index 29ba8d4de90..b4a4a9374fa 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -14,7 +14,7 @@ from psnawp_api.models.user import User from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK @@ -76,13 +76,23 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure flow for PlayStation Network integration.""" + return await self.async_step_reauth_confirm(user_input) + async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Confirm reauthentication dialog.""" errors: dict[str, str] = {} - entry = self._get_reauth_entry() + entry = ( + self._get_reauth_entry() + if self.source == SOURCE_REAUTH + else self._get_reconfigure_entry() + ) if user_input is not None: try: @@ -113,7 +123,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): ) return self.async_show_form( - step_id="reauth_confirm", + step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure", data_schema=self.add_suggested_values_to_schema( data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input ), diff --git a/homeassistant/components/playstation_network/quality_scale.yaml b/homeassistant/components/playstation_network/quality_scale.yaml index a98c30a7667..954276e7243 100644 --- a/homeassistant/components/playstation_network/quality_scale.yaml +++ b/homeassistant/components/playstation_network/quality_scale.yaml @@ -63,7 +63,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: todo # Platinum diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 5d8333e785f..a26f45d8973 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -19,6 +19,16 @@ "data_description": { "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" } + }, + "reconfigure": { + "title": "Update PlayStation Network configuration", + "description": "[%key:component::playstation_network::config::step::user::description%]", + "data": { + "npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]" + }, + "data_description": { + "npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]" + } } }, "error": { @@ -30,7 +40,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**" + "unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "exceptions": { diff --git a/tests/components/playstation_network/test_config_flow.py b/tests/components/playstation_network/test_config_flow.py index 981e459d283..dc3ad55c64f 100644 --- a/tests/components/playstation_network/test_config_flow.py +++ b/tests/components/playstation_network/test_config_flow.py @@ -296,3 +296,32 @@ async def test_flow_reauth_account_mismatch( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unique_id_mismatch" + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_NPSSO: "NEW_NPSSO_TOKEN"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + assert config_entry.data[CONF_NPSSO] == "NEW_NPSSO_TOKEN" + + assert len(hass.config_entries.async_entries()) == 1 From 571376badc9a3fff2e8836bfd4e22fb4f97ccf88 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:28:45 +0200 Subject: [PATCH 0826/1664] Bump aioautomower to 1.0.1 (#147683) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 0fc05c56fb5..34ec6693865 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==1.0.0"] + "requirements": ["aioautomower==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 81f0c5f8426..c1048afcebb 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==1.0.0 +aioautomower==1.0.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d74e5570350..bb63020e4de 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==1.0.0 +aioautomower==1.0.1 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 772eef761db..2c3352ecf8e 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -80,7 +80,7 @@ 'work_area_name': 'Front lawn', }), 'planner': dict({ - 'external_reason': 'iftt_wildlife', + 'external_reason': 'ifttt_wildlife', 'next_start_datetime': '2023-06-05T19:00:00+02:00', 'override': dict({ 'action': 'not_active', From 1d82d44794e12b062bbfc1847cf3ee88551a678b Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:34:50 +0200 Subject: [PATCH 0827/1664] Add device prefix to summary in Husqvarna Automower (#147405) --- .../husqvarna_automower/calendar.py | 20 +++++++++++-- .../snapshots/test_calendar.ambr | 30 +++++++++---------- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index 26e939ec7d9..a26b9bf72bd 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -2,15 +2,18 @@ from datetime import datetime import logging +from typing import TYPE_CHECKING from aioautomower.model import make_name_string from homeassistant.components.calendar import CalendarEntity, CalendarEvent from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util from . import AutomowerConfigEntry +from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerBaseEntity @@ -51,6 +54,19 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): self._attr_unique_id = mower_id self._event: CalendarEvent | None = None + @property + def device_name(self) -> str: + """Return the prefix for the event summary.""" + device_registry = dr.async_get(self.hass) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, self.mower_id)} + ) + if TYPE_CHECKING: + assert device_entry is not None + assert device_entry.name is not None + + return device_entry.name_by_user or device_entry.name + @property def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" @@ -66,7 +82,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): program_event.work_area_id ] return CalendarEvent( - summary=make_name_string(work_area_name, program_event.schedule_no), + summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}", start=program_event.start, end=program_event.end, rrule=program_event.rrule_str, @@ -93,7 +109,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): ] calendar_events.append( CalendarEvent( - summary=make_name_string(work_area_name, program_event.schedule_no), + summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}", start=program_event.start.replace(tzinfo=start_date.tzinfo), end=program_event.end.replace(tzinfo=start_date.tzinfo), rrule=program_event.rrule_str, diff --git a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr index 7cd8c68b624..7ff32f69df0 100644 --- a/tests/components/husqvarna_automower/snapshots/test_calendar.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_calendar.ambr @@ -6,72 +6,72 @@ dict({ 'end': '2023-06-05T09:00:00+02:00', 'start': '2023-06-05T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-06T00:00:00+02:00', 'start': '2023-06-05T19:00:00+02:00', - 'summary': 'Front lawn schedule 1', + 'summary': 'Test Mower 1 Front lawn schedule 1', }), dict({ 'end': '2023-06-06T08:00:00+02:00', 'start': '2023-06-06T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Test Mower 1 Back lawn schedule 1', }), dict({ 'end': '2023-06-06T08:00:00+02:00', 'start': '2023-06-06T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Test Mower 1 Front lawn schedule 2', }), dict({ 'end': '2023-06-06T09:00:00+02:00', 'start': '2023-06-06T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-08T00:00:00+02:00', 'start': '2023-06-07T19:00:00+02:00', - 'summary': 'Front lawn schedule 1', + 'summary': 'Test Mower 1 Front lawn schedule 1', }), dict({ 'end': '2023-06-08T08:00:00+02:00', 'start': '2023-06-08T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Test Mower 1 Back lawn schedule 1', }), dict({ 'end': '2023-06-08T08:00:00+02:00', 'start': '2023-06-08T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Test Mower 1 Front lawn schedule 2', }), dict({ 'end': '2023-06-08T09:00:00+02:00', 'start': '2023-06-08T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-10T00:00:00+02:00', 'start': '2023-06-09T19:00:00+02:00', - 'summary': 'Front lawn schedule 1', + 'summary': 'Test Mower 1 Front lawn schedule 1', }), dict({ 'end': '2023-06-10T08:00:00+02:00', 'start': '2023-06-10T00:00:00+02:00', - 'summary': 'Back lawn schedule 1', + 'summary': 'Test Mower 1 Back lawn schedule 1', }), dict({ 'end': '2023-06-10T08:00:00+02:00', 'start': '2023-06-10T00:00:00+02:00', - 'summary': 'Front lawn schedule 2', + 'summary': 'Test Mower 1 Front lawn schedule 2', }), dict({ 'end': '2023-06-10T09:00:00+02:00', 'start': '2023-06-10T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), dict({ 'end': '2023-06-12T09:00:00+02:00', 'start': '2023-06-12T01:00:00+02:00', - 'summary': 'Back lawn schedule 2', + 'summary': 'Test Mower 1 Back lawn schedule 2', }), ]), }), @@ -80,7 +80,7 @@ dict({ 'end': '2023-06-05T02:49:00+02:00', 'start': '2023-06-05T02:00:00+02:00', - 'summary': 'Schedule 1', + 'summary': 'Test Mower 2 Schedule 1', }), ]), }), From 91c3b43d7fd0e95ebbbf12acfbec79ad51b4a8bc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Jun 2025 20:54:19 +0200 Subject: [PATCH 0828/1664] Improve comment for helpers.entity.entity_sources (#146529) --- homeassistant/helpers/entity.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 832bbf219f8..39629d07494 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -92,7 +92,11 @@ def async_setup(hass: HomeAssistant) -> None: @bind_hass @singleton.singleton(DATA_ENTITY_SOURCE) def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: - """Get the entity sources.""" + """Get the entity sources. + + Items are added to this dict by Entity.async_internal_added_to_hass and + removed by Entity.async_internal_will_remove_from_hass. + """ return {} From ea6332ee423412f86cfc2449a36d962443b0bb60 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 27 Jun 2025 20:54:56 +0200 Subject: [PATCH 0829/1664] Move backup services to separate module (#146427) --- homeassistant/components/backup/__init__.py | 27 ++-------------- homeassistant/components/backup/services.py | 36 +++++++++++++++++++++ tests/components/backup/common.py | 4 +++ 3 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/backup/services.py diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 51503230530..973f354060a 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -2,7 +2,7 @@ from homeassistant.config_entries import SOURCE_SYSTEM from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio @@ -45,6 +45,7 @@ from .manager import ( WrittenBackup, ) from .models import AddonInfo, AgentBackup, BackupNotFound, Folder +from .services import async_setup_services from .util import suggested_filename, suggested_filename_from_name_date from .websocket import async_register_websocket_handlers @@ -113,29 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async_register_websocket_handlers(hass, with_hassio) - async def async_handle_create_service(call: ServiceCall) -> None: - """Service handler for creating backups.""" - agent_id = list(backup_manager.local_backup_agents)[0] - await backup_manager.async_create_backup( - agent_ids=[agent_id], - include_addons=None, - include_all_addons=False, - include_database=True, - include_folders=None, - include_homeassistant=True, - name=None, - password=None, - ) - - async def async_handle_create_automatic_service(call: ServiceCall) -> None: - """Service handler for creating automatic backups.""" - await backup_manager.async_create_automatic_backup() - - if not with_hassio: - hass.services.async_register(DOMAIN, "create", async_handle_create_service) - hass.services.async_register( - DOMAIN, "create_automatic", async_handle_create_automatic_service - ) + async_setup_services(hass) async_register_http_views(hass) diff --git a/homeassistant/components/backup/services.py b/homeassistant/components/backup/services.py new file mode 100644 index 00000000000..17448f7bb06 --- /dev/null +++ b/homeassistant/components/backup/services.py @@ -0,0 +1,36 @@ +"""The Backup integration.""" + +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers.hassio import is_hassio + +from .const import DATA_MANAGER, DOMAIN + + +async def _async_handle_create_service(call: ServiceCall) -> None: + """Service handler for creating backups.""" + backup_manager = call.hass.data[DATA_MANAGER] + agent_id = list(backup_manager.local_backup_agents)[0] + await backup_manager.async_create_backup( + agent_ids=[agent_id], + include_addons=None, + include_all_addons=False, + include_database=True, + include_folders=None, + include_homeassistant=True, + name=None, + password=None, + ) + + +async def _async_handle_create_automatic_service(call: ServiceCall) -> None: + """Service handler for creating automatic backups.""" + await call.hass.data[DATA_MANAGER].async_create_automatic_backup() + + +def async_setup_services(hass: HomeAssistant) -> None: + """Register services.""" + if not is_hassio(hass): + hass.services.async_register(DOMAIN, "create", _async_handle_create_service) + hass.services.async_register( + DOMAIN, "create_automatic", _async_handle_create_automatic_service + ) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index 3197cbfadeb..e6c5aab08cc 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -138,6 +138,10 @@ async def setup_backup_integration( patch( "homeassistant.components.backup.backup.is_hassio", return_value=with_hassio ), + patch( + "homeassistant.components.backup.services.is_hassio", + return_value=with_hassio, + ), ): remote_agents = remote_agents or [] remote_agents_dict = {} From d2e8a48b2cd88390b1d217c8b59144b27862b00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 28 Jun 2025 10:11:17 +0200 Subject: [PATCH 0830/1664] Bump pytibber to 0.31.6 (#147703) --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 43cbd79afef..db08f422500 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.31.2"] + "requirements": ["pyTibber==0.31.6"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 26b8f5400a0..327812cdf99 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -280,7 +280,7 @@ async def async_setup_entry( except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady from err - except aiohttp.ClientError as err: + except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err diff --git a/requirements_all.txt b/requirements_all.txt index c1048afcebb..51d5b915e10 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1811,7 +1811,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb63020e4de..d3842578eb1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1522,7 +1522,7 @@ pyHomee==1.2.10 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 From 969809456eb11405a7cebe5474840635d2935982 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 28 Jun 2025 11:25:59 +0200 Subject: [PATCH 0831/1664] Move MQTT device sw and hw version to collapsed section in subentry flow (#147685) Move MQTT device sw and hw version to collapsed section --- homeassistant/components/mqtt/config_flow.py | 41 ++++++++++++++++---- homeassistant/components/mqtt/strings.json | 15 +++++-- tests/components/mqtt/test_config_flow.py | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 2ef881ceaf4..b022a46cbe7 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1904,8 +1904,12 @@ ENTITY_CONFIG_VALIDATOR: dict[ MQTT_DEVICE_PLATFORM_FIELDS = { ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), - ATTR_SW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_HW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_CONFIGURATION_URL: PlatformField( @@ -2725,6 +2729,19 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): for field_key, value in data_schema.schema.items() } + @callback + def get_suggested_values_from_device_data( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from device data based on the data schema.""" + device_data = self._subentry_data["device"] + return { + field_key: self.get_suggested_values_from_device_data(value.schema) + if isinstance(value, section) + else device_data.get(field_key) + for field_key, value in data_schema.schema.items() + } + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -2754,15 +2771,25 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + new_device_data, errors = validate_user_input( + user_input, MQTT_DEVICE_PLATFORM_FIELDS + ) + if "mqtt_settings" in user_input: + new_device_data["mqtt_settings"] = user_input["mqtt_settings"] if not errors: - self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() return await self.async_step_entity() - data_schema = self.add_suggested_values_to_schema( - data_schema, device_data if user_input is None else user_input - ) + data_schema = self.add_suggested_values_to_schema( + data_schema, device_data if user_input is None else user_input + ) + elif self.source == SOURCE_RECONFIGURE: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_device_data(data_schema), + ) + return self.async_show_form( step_id=CONF_DEVICE, data_schema=data_schema, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 592ea8686e1..96b5bd15d28 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -134,20 +134,27 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "configuration_url": "Configuration URL", - "sw_version": "Software version", - "hw_version": "Hardware version", "model": "Model", "model_id": "Model ID" }, "data_description": { "name": "The name of the manually added MQTT device.", "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", - "sw_version": "The software version of the device. E.g. '2025.1.0'.", - "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", "model": "E.g. 'Cleanmaster Pro'.", "model_id": "E.g. '123NK2PRO'." }, "sections": { + "advanced_settings": { + "name": "Advanced device settings", + "data": { + "sw_version": "Software version", + "hw_version": "Hardware version" + }, + "data_description": { + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'." + } + }, "mqtt_settings": { "name": "MQTT settings", "data": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 2177a7de8e1..12f77a95c48 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -4073,7 +4073,7 @@ async def test_subentry_reconfigure_update_device_properties( result["flow_id"], user_input={ "name": "Beer notifier", - "sw_version": "1.1", + "advanced_settings": {"sw_version": "1.1"}, "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", From 227760f2032f0788e7dd78cc4561f1a65d9a723f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 28 Jun 2025 20:31:01 +0200 Subject: [PATCH 0832/1664] Fix RuntimeWarnings in homeassistant_yellow tests (#147724) --- tests/components/homeassistant_yellow/test_config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 7f622e0ed8f..d5f1c380971 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -314,6 +314,7 @@ async def test_option_flow_led_settings_fail_2( (STEP_PICK_FIRMWARE_THREAD, ApplicationType.SPINEL, "2.4.4.0"), ], ) +@pytest.mark.usefixtures("addon_store_info") async def test_firmware_options_flow( step: str, fw_type: ApplicationType, fw_version: str, hass: HomeAssistant ) -> None: @@ -371,7 +372,7 @@ async def test_firmware_options_flow( side_effect=mock_async_step_pick_firmware_zigbee, ), patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareInstallFlow._ensure_thread_addon_setup", return_value=None, ), patch( From 39abae36f08c150456830dbd73fbac24f1f5cef3 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 28 Jun 2025 22:40:58 +0300 Subject: [PATCH 0833/1664] Fix Shelly Block entity removal (#147694) --- homeassistant/components/shelly/entity.py | 5 ++- tests/components/shelly/test_switch.py | 45 +++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 587eb00b979..b80ac877a84 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -86,7 +86,10 @@ def async_setup_block_attribute_entities( coordinator.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}" + unique_id = sensor_class( + coordinator, block, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) else: entities.append( diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 3234e3eb0b9..f1866d83e2a 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -40,6 +40,8 @@ from . import ( from tests.common import async_fire_time_changed, mock_restore_cache +DEVICE_BLOCK_ID = 4 +LIGHT_BLOCK_ID = 2 RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 MOTION_BLOCK_ID = 3 @@ -326,14 +328,51 @@ async def test_block_device_mode_roller( async def test_block_device_app_type_light( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device in app type set to light mode.""" + switch_entity_id = "switch.test_name_channel_1" + light_entity_id = "light.test_name_channel_1" + + # Remove light blocks to prevent light entity creation + monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "sensor") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "gain") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "brightness") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "effect") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "colorTemp") + + await init_integration(hass, 1) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) + mock_block_device.mock_update() + monkeypatch.setitem( mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" ) - await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1") is None + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 2) + mock_block_device.mock_update() + await hass.async_block_till_done() + + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) async def test_rpc_device_services( From 134967b817e8bc1eeeb4b43091a17e8355c3e4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sat, 28 Jun 2025 21:57:26 +0200 Subject: [PATCH 0834/1664] Fix error if cover position is not available or unknown (#147732) --- homeassistant/components/wmspro/cover.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 0d9ccb8547d..77dd928bc95 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -53,6 +53,8 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return current position of cover.""" action = self._dest.action(self._drive_action_desc) + if action is None or action["percentage"] is None: + return None return 100 - action["percentage"] async def async_set_cover_position(self, **kwargs: Any) -> None: From 832261109989b3b44ebbf142a119625890745e13 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:57:51 +0200 Subject: [PATCH 0835/1664] Use test parametrization in ista EcoTrend integration (#147729) --- .../ista_ecotrend/snapshots/test_util.ambr | 100 +++++++-------- tests/components/ista_ecotrend/test_util.py | 116 +++++++++--------- 2 files changed, 106 insertions(+), 110 deletions(-) diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr index 9536c5336db..9069cb617e3 100644 --- a/tests/components/ista_ecotrend/snapshots/test_util.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_get_statistics +# name: test_get_statistics[heating-None] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -11,19 +11,7 @@ }), ]) # --- -# name: test_get_statistics.1 - list([ - dict({ - 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 113.0, - }), - dict({ - 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 38.0, - }), - ]) -# --- -# name: test_get_statistics.2 +# name: test_get_statistics[heating-costs] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -35,7 +23,19 @@ }), ]) # --- -# name: test_get_statistics.3 +# name: test_get_statistics[heating-energy] + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 113.0, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 38.0, + }), + ]) +# --- +# name: test_get_statistics[warmwater-None] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -47,7 +47,19 @@ }), ]) # --- -# name: test_get_statistics.4 +# name: test_get_statistics[warmwater-costs] + list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 7, + }), + ]) +# --- +# name: test_get_statistics[warmwater-energy] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -59,19 +71,7 @@ }), ]) # --- -# name: test_get_statistics.5 - list([ - dict({ - 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 7, - }), - dict({ - 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), - 'value': 7, - }), - ]) -# --- -# name: test_get_statistics.6 +# name: test_get_statistics[water-None] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -83,11 +83,7 @@ }), ]) # --- -# name: test_get_statistics.7 - list([ - ]) -# --- -# name: test_get_statistics.8 +# name: test_get_statistics[water-costs] list([ dict({ 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), @@ -99,39 +95,43 @@ }), ]) # --- -# name: test_get_values_by_type +# name: test_get_statistics[water-energy] + list([ + ]) +# --- +# name: test_get_values_by_type[heating] dict({ 'additionalValue': '38,0', 'type': 'heating', 'value': '35', }) # --- -# name: test_get_values_by_type.1 +# name: test_get_values_by_type[heating].1 + dict({ + 'type': 'heating', + 'value': 21, + }) +# --- +# name: test_get_values_by_type[warmwater] dict({ 'additionalValue': '57,0', 'type': 'warmwater', 'value': '1,0', }) # --- -# name: test_get_values_by_type.2 - dict({ - 'type': 'water', - 'value': '5,0', - }) -# --- -# name: test_get_values_by_type.3 - dict({ - 'type': 'heating', - 'value': 21, - }) -# --- -# name: test_get_values_by_type.4 +# name: test_get_values_by_type[warmwater].1 dict({ 'type': 'warmwater', 'value': 7, }) # --- -# name: test_get_values_by_type.5 +# name: test_get_values_by_type[water] + dict({ + 'type': 'water', + 'value': '5,0', + }) +# --- +# name: test_get_values_by_type[water].1 dict({ 'type': 'water', 'value': 3, diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py index 616abdea8d6..f518a40b4b1 100644 --- a/tests/components/ista_ecotrend/test_util.py +++ b/tests/components/ista_ecotrend/test_util.py @@ -1,5 +1,6 @@ """Tests for the ista EcoTrend utility functions.""" +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.ista_ecotrend.util import ( @@ -34,7 +35,17 @@ def test_last_day_of_month(snapshot: SnapshotAssertion) -> None: assert last_day_of_month(month=month + 1, year=2024) == snapshot -def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + "consumption_type", + [ + IstaConsumptionType.HEATING, + IstaConsumptionType.HOT_WATER, + IstaConsumptionType.WATER, + ], +) +def test_get_values_by_type( + snapshot: SnapshotAssertion, consumption_type: IstaConsumptionType +) -> None: """Test get_values_by_type function.""" consumptions = { "readings": [ @@ -55,9 +66,7 @@ def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: ], } - assert get_values_by_type(consumptions, IstaConsumptionType.HEATING) == snapshot - assert get_values_by_type(consumptions, IstaConsumptionType.HOT_WATER) == snapshot - assert get_values_by_type(consumptions, IstaConsumptionType.WATER) == snapshot + assert get_values_by_type(consumptions, consumption_type) == snapshot costs = { "costsByEnergyType": [ @@ -76,71 +85,58 @@ def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: ], } - assert get_values_by_type(costs, IstaConsumptionType.HEATING) == snapshot - assert get_values_by_type(costs, IstaConsumptionType.HOT_WATER) == snapshot - assert get_values_by_type(costs, IstaConsumptionType.WATER) == snapshot + assert get_values_by_type(costs, consumption_type) == snapshot - assert get_values_by_type({}, IstaConsumptionType.HEATING) == {} - assert get_values_by_type({"readings": []}, IstaConsumptionType.HEATING) == {} + assert get_values_by_type({}, consumption_type) == {} + assert get_values_by_type({"readings": []}, consumption_type) == {} -def test_get_native_value() -> None: +@pytest.mark.parametrize( + ("consumption_type", "value_type", "expected_value"), + [ + (IstaConsumptionType.HEATING, None, 35), + (IstaConsumptionType.HOT_WATER, None, 1.0), + (IstaConsumptionType.WATER, None, 5.0), + (IstaConsumptionType.HEATING, IstaValueType.COSTS, 21), + (IstaConsumptionType.HOT_WATER, IstaValueType.COSTS, 7), + (IstaConsumptionType.WATER, IstaValueType.COSTS, 3), + (IstaConsumptionType.HEATING, IstaValueType.ENERGY, 38.0), + (IstaConsumptionType.HOT_WATER, IstaValueType.ENERGY, 57.0), + ], +) +def test_get_native_value( + consumption_type: IstaConsumptionType, + value_type: IstaValueType | None, + expected_value: float, +) -> None: """Test getting native value for sensor states.""" test_data = get_consumption_data("26e93f1a-c828-11ea-87d0-0242ac130003") - assert get_native_value(test_data, IstaConsumptionType.HEATING) == 35 - assert get_native_value(test_data, IstaConsumptionType.HOT_WATER) == 1.0 - assert get_native_value(test_data, IstaConsumptionType.WATER) == 5.0 - - assert ( - get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) - == 21 - ) - assert ( - get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.COSTS) - == 7 - ) - assert ( - get_native_value(test_data, IstaConsumptionType.WATER, IstaValueType.COSTS) == 3 - ) - - assert ( - get_native_value(test_data, IstaConsumptionType.HEATING, IstaValueType.ENERGY) - == 38.0 - ) - assert ( - get_native_value(test_data, IstaConsumptionType.HOT_WATER, IstaValueType.ENERGY) - == 57.0 - ) + assert get_native_value(test_data, consumption_type, value_type) == expected_value no_data = {"consumptions": None, "costs": None} - assert get_native_value(no_data, IstaConsumptionType.HEATING) is None - assert ( - get_native_value(no_data, IstaConsumptionType.HEATING, IstaValueType.COSTS) - is None - ) + assert get_native_value(no_data, consumption_type, value_type) is None -def test_get_statistics(snapshot: SnapshotAssertion) -> None: +@pytest.mark.parametrize( + "value_type", + [None, IstaValueType.ENERGY, IstaValueType.COSTS], +) +@pytest.mark.parametrize( + "consumption_type", + [ + IstaConsumptionType.HEATING, + IstaConsumptionType.HOT_WATER, + IstaConsumptionType.WATER, + ], +) +def test_get_statistics( + snapshot: SnapshotAssertion, + value_type: IstaValueType | None, + consumption_type: IstaConsumptionType, +) -> None: """Test get_statistics function.""" test_data = get_consumption_data("26e93f1a-c828-11ea-87d0-0242ac130003") - for consumption_type in IstaConsumptionType: - assert get_statistics(test_data, consumption_type) == snapshot - assert get_statistics({"consumptions": None}, consumption_type) is None - assert ( - get_statistics(test_data, consumption_type, IstaValueType.ENERGY) - == snapshot - ) - assert ( - get_statistics( - {"consumptions": None}, consumption_type, IstaValueType.ENERGY - ) - is None - ) - assert ( - get_statistics(test_data, consumption_type, IstaValueType.COSTS) == snapshot - ) - assert ( - get_statistics({"costs": None}, consumption_type, IstaValueType.COSTS) - is None - ) + assert get_statistics(test_data, consumption_type, value_type) == snapshot + + assert get_statistics({"consumptions": None}, consumption_type, value_type) is None From 0652bffd6837143349cd38550c5a4982cd98863b Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:11:59 +0200 Subject: [PATCH 0836/1664] Bump vulcan-api to 2.4.2 (#146857) --- homeassistant/components/vulcan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vulcan/fixtures/fake_student_1.json | 8 +++++++- tests/components/vulcan/fixtures/fake_student_2.json | 8 +++++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index 554a82e9c2c..f9385262f05 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", - "requirements": ["vulcan-api==2.3.2"] + "requirements": ["vulcan-api==2.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 51d5b915e10..db40f067370 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3062,7 +3062,7 @@ vsure==2.6.7 vtjp==0.2.1 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3842578eb1..d4b4d765368 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2524,7 +2524,7 @@ volvooncall==0.10.3 vsure==2.6.7 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/fixtures/fake_student_1.json b/tests/components/vulcan/fixtures/fake_student_1.json index 0e6c79e4b03..fef69684550 100644 --- a/tests/components/vulcan/fixtures/fake_student_1.json +++ b/tests/components/vulcan/fixtures/fake_student_1.json @@ -25,5 +25,11 @@ "Surname": "Kowalski", "Sex": true }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } diff --git a/tests/components/vulcan/fixtures/fake_student_2.json b/tests/components/vulcan/fixtures/fake_student_2.json index 0176b72d4fc..e5200c12e17 100644 --- a/tests/components/vulcan/fixtures/fake_student_2.json +++ b/tests/components/vulcan/fixtures/fake_student_2.json @@ -25,5 +25,11 @@ "Surname": "Kowalska", "Sex": false }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } From 1f3bdfc7b7f6564deaca836c3bc814ab3ff5edf6 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Sat, 28 Jun 2025 22:13:51 +0200 Subject: [PATCH 0837/1664] bump pypaperless to 4.1.1 (#147735) --- homeassistant/components/paperless_ngx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json index 0be3562c76f..43c61185f3a 100644 --- a/homeassistant/components/paperless_ngx/manifest.json +++ b/homeassistant/components/paperless_ngx/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pypaperless"], "quality_scale": "silver", - "requirements": ["pypaperless==4.1.0"] + "requirements": ["pypaperless==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index db40f067370..b198661ce18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4b4d765368..09f4a62b597 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.lcn pypck==0.8.9 From f8c052e0ce619c4a5d82a15a63a977843437258f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jun 2025 15:18:46 -0500 Subject: [PATCH 0838/1664] Improve rest error logging (#147736) * Improve rest error logging * Improve rest error logging * Improve rest error logging * Improve rest error logging * Improve rest error logging * top level --- homeassistant/components/rest/data.py | 41 ++- tests/components/rest/test_data.py | 444 ++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 tests/components/rest/test_data.py diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3c02f62f852..f20b811a887 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -6,6 +6,7 @@ import logging from typing import Any import aiohttp +from aiohttp import hdrs from multidict import CIMultiDictProxy import xmltodict @@ -77,6 +78,12 @@ class RestData: """Set url.""" self._resource = url + def _is_expected_content_type(self, content_type: str) -> bool: + """Check if the content type is one we expect (JSON or XML).""" + return content_type.startswith( + ("application/json", "text/json", *XML_MIME_TYPES) + ) + def data_without_xml(self) -> str | None: """If the data is an XML string, convert it to a JSON string.""" _LOGGER.debug("Data fetched from resource: %s", self.data) @@ -84,7 +91,7 @@ class RestData: (value := self.data) is not None # If the http request failed, headers will be None and (headers := self.headers) is not None - and (content_type := headers.get("content-type")) + and (content_type := headers.get(hdrs.CONTENT_TYPE)) and content_type.startswith(XML_MIME_TYPES) ): value = json_dumps(xmltodict.parse(value)) @@ -120,6 +127,7 @@ class RestData: # Handle data/content if self._request_data: request_kwargs["data"] = self._request_data + response = None try: # Make the request async with self._session.request( @@ -143,3 +151,34 @@ class RestData: self.last_exception = ex self.data = None self.headers = None + + # Log response details outside the try block so we always get logging + if response is None: + return + + # Log response details for debugging + content_type = response.headers.get(hdrs.CONTENT_TYPE) + _LOGGER.debug( + "REST response from %s: status=%s, content-type=%s, length=%s", + self._resource, + response.status, + content_type or "not set", + len(self.data) if self.data else 0, + ) + + # If we got an error response with non-JSON/XML content, log a sample + # This helps debug issues like servers blocking with HTML error pages + if ( + response.status >= 400 + and content_type + and not self._is_expected_content_type(content_type) + ): + sample = self.data[:500] if self.data else "" + _LOGGER.warning( + "REST request to %s returned status %s with %s response: %s%s", + self._resource, + response.status, + content_type, + sample, + "..." if self.data and len(self.data) > 500 else "", + ) diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py new file mode 100644 index 00000000000..3add886a451 --- /dev/null +++ b/tests/components/rest/test_data.py @@ -0,0 +1,444 @@ +"""Test REST data module logging improvements.""" + +import logging + +import pytest + +from homeassistant.components.rest import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_rest_data_log_warning_on_error_status( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status codes.""" + # Mock a 403 response with HTML content + aioclient_mock.get( + "http://example.com/api", + status=403, + text="Access Denied", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged + assert ( + "REST request to http://example.com/api returned status 403 " + "with text/html response" in caplog.text + ) + assert "Access Denied" in caplog.text + + +async def test_rest_data_no_warning_on_200_with_wrong_content_type( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for 200 status with wrong content.""" + # Mock a 200 response with HTML - users might still want to parse this + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

This is HTML, not JSON!

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for 200 status, even with HTML content type + assert ( + "REST request to http://example.com/api returned status 200" not in caplog.text + ) + + +async def test_rest_data_no_warning_on_success_json( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful JSON responses.""" + # Mock a successful JSON response + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok", "value": 42}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_no_warning_on_success_xml( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful XML responses.""" + # Mock a successful XML response + aioclient_mock.get( + "http://example.com/api", + status=200, + text='42', + headers={"Content-Type": "application/xml"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.root.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_warning_truncates_long_responses( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning truncates very long response bodies.""" + # Create a very long error message + long_message = "Error: " + "x" * 1000 + + aioclient_mock.get( + "http://example.com/api", + status=500, + text=long_message, + headers={"Content-Type": "text/plain"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with truncation + # Set the logger filter to only check our specific logger + caplog.set_level(logging.WARNING, logger="homeassistant.components.rest.data") + + # Verify the truncated warning appears + assert ( + "REST request to http://example.com/api returned status 500 " + "with text/plain response: Error: " + "x" * 493 + "..." in caplog.text + ) + + +async def test_rest_data_debug_logging_shows_response_details( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that debug logging shows response details.""" + caplog.set_level(logging.DEBUG) + + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"test": "data"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log + assert ( + "REST response from http://example.com/api: status=200, " + "content-type=application/json, length=" in caplog.text + ) + + +async def test_rest_data_no_content_type_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of responses without Content-Type header.""" + caplog.set_level(logging.DEBUG) + + # Mock response without Content-Type header + aioclient_mock.get( + "http://example.com/api", + status=200, + text="plain text response", + headers={}, # No Content-Type + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log shows "not set" + assert "content-type=not set" in caplog.text + # No warning for 200 with missing content-type + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_real_world_bom_blocking_scenario( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test real-world scenario where BOM blocks with HTML response.""" + # Mock BOM blocking response + bom_block_html = "

Your access is blocked due to automated access

" + + aioclient_mock.get( + "http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json", + status=403, + text=bom_block_html, + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": ("http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json"), + "method": "GET", + "sensor": [ + { + "name": "bom_temperature", + "value_template": ( + "{{ value_json.observations.data[0].air_temp }}" + ), + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with clear indication of the issue + assert ( + "REST request to http://www.bom.gov.au/fwo/IDN60901/" + "IDN60901.94767.json returned status 403 with text/html response" + ) in caplog.text + assert "Your access is blocked" in caplog.text + + +async def test_rest_data_warning_on_html_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status with HTML content.""" + # Mock a 404 response with HTML error page + aioclient_mock.get( + "http://example.com/api", + status=404, + text="

404 Not Found

", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should warn for error status with HTML + assert ( + "REST request to http://example.com/api returned status 404 " + "with text/html response" in caplog.text + ) + assert "

404 Not Found

" in caplog.text + + +async def test_rest_data_no_warning_on_json_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test POST request that returns JSON error - no warning expected.""" + aioclient_mock.post( + "http://example.com/api", + status=400, + text='{"error": "Invalid request payload"}', + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "POST", + "payload": '{"data": "test"}', + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.error }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for JSON error responses - users can parse these + assert ( + "REST request to http://example.com/api returned status 400" not in caplog.text + ) + + +async def test_rest_data_timeout_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test timeout error logging.""" + aioclient_mock.get( + "http://example.com/api", + exc=TimeoutError(), + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "timeout": 10, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check timeout error is logged or platform reports not ready + assert ( + "Timeout while fetching data: http://example.com/api" in caplog.text + or "Platform rest not ready yet" in caplog.text + ) From 43450d4489f0d9af44dccf49eac44b2f064f8434 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 28 Jun 2025 22:20:47 +0200 Subject: [PATCH 0839/1664] Reduce idle timeout of HLS stream to conserve camera battery life (#147728) * Reduce IDLE timeout of HLS stream to conserve camera battery life * adjust tests --- homeassistant/components/stream/__init__.py | 12 ++++++++---- homeassistant/components/stream/const.py | 3 ++- homeassistant/components/stream/core.py | 4 +++- tests/components/stream/test_hls.py | 8 ++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 9426b5b04de..a31ce433c06 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -55,6 +55,7 @@ from .const import ( MAX_SEGMENTS, OUTPUT_FORMATS, OUTPUT_IDLE_TIMEOUT, + OUTPUT_STARTUP_TIMEOUT, RECORDER_PROVIDER, RTSP_TRANSPORTS, SEGMENT_DURATION_ADJUSTER, @@ -363,11 +364,14 @@ class Stream: # without concern about self._outputs being modified from another thread. return MappingProxyType(self._outputs.copy()) - def add_provider( - self, fmt: str, timeout: int = OUTPUT_IDLE_TIMEOUT - ) -> StreamOutput: + def add_provider(self, fmt: str, timeout: int | None = None) -> StreamOutput: """Add provider output stream.""" if not (provider := self._outputs.get(fmt)): + startup_timeout = OUTPUT_STARTUP_TIMEOUT + if timeout is None: + timeout = OUTPUT_IDLE_TIMEOUT + else: + startup_timeout = timeout async def idle_callback() -> None: if ( @@ -379,7 +383,7 @@ class Stream: provider = PROVIDERS[fmt]( self.hass, - IdleTimer(self.hass, timeout, idle_callback), + IdleTimer(self.hass, timeout, idle_callback, startup_timeout), self._stream_settings, self.dynamic_stream_settings, ) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index c81d2f6cb18..df50ecefd62 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -22,7 +22,8 @@ AUDIO_CODECS = {"aac", "mp3"} FORMAT_CONTENT_TYPE = {HLS_PROVIDER: "application/vnd.apple.mpegurl"} -OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity +OUTPUT_STARTUP_TIMEOUT = 60 # timeout due to no startup +OUTPUT_IDLE_TIMEOUT = 30 # Idle timeout due to inactivity NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist MAX_SEGMENTS = 5 # Max number of segments to keep around diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 44dfe2c323d..7dc6bab16b9 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -234,10 +234,12 @@ class IdleTimer: hass: HomeAssistant, timeout: int, idle_callback: Callable[[], Coroutine[Any, Any, None]], + startup_timeout: int | None = None, ) -> None: """Initialize IdleTimer.""" self._hass = hass self._timeout = timeout + self._startup_timeout = startup_timeout or timeout self._callback = idle_callback self._unsub: CALLBACK_TYPE | None = None self.idle = False @@ -246,7 +248,7 @@ class IdleTimer: """Start the idle timer if not already started.""" self.idle = False if self._unsub is None: - self._unsub = async_call_later(self._hass, self._timeout, self.fire) + self._unsub = async_call_later(self._hass, self._startup_timeout, self.fire) def awake(self) -> None: """Keep the idle time alive by resetting the timeout.""" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index c96b7d9427f..eb554f2cf19 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -230,8 +230,8 @@ async def test_stream_timeout( playlist_response = await http_client.get(parsed_url.path) assert playlist_response.status == HTTPStatus.OK - # Wait a minute - future = dt_util.utcnow() + timedelta(minutes=1) + # Wait 40 seconds + future = dt_util.utcnow() + timedelta(seconds=40) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -241,8 +241,8 @@ async def test_stream_timeout( stream_worker_sync.resume() - # Wait 5 minutes - future = dt_util.utcnow() + timedelta(minutes=5) + # Wait 2 minutes + future = dt_util.utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() From b537850f522f71791e1532c46e7b0dc9d055c10a Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Sat, 28 Jun 2025 22:11:59 +0200 Subject: [PATCH 0840/1664] Bump vulcan-api to 2.4.2 (#146857) --- homeassistant/components/vulcan/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vulcan/fixtures/fake_student_1.json | 8 +++++++- tests/components/vulcan/fixtures/fake_student_2.json | 8 +++++++- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vulcan/manifest.json b/homeassistant/components/vulcan/manifest.json index 554a82e9c2c..f9385262f05 100644 --- a/homeassistant/components/vulcan/manifest.json +++ b/homeassistant/components/vulcan/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vulcan", "iot_class": "cloud_polling", - "requirements": ["vulcan-api==2.3.2"] + "requirements": ["vulcan-api==2.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e9e273572f3..fdf67f91abb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3062,7 +3062,7 @@ vsure==2.6.7 vtjp==0.2.1 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7bf7d6ccdd..a5d59456959 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2524,7 +2524,7 @@ volvooncall==0.10.3 vsure==2.6.7 # homeassistant.components.vulcan -vulcan-api==2.3.2 +vulcan-api==2.4.2 # homeassistant.components.vultr vultr==0.1.2 diff --git a/tests/components/vulcan/fixtures/fake_student_1.json b/tests/components/vulcan/fixtures/fake_student_1.json index 0e6c79e4b03..fef69684550 100644 --- a/tests/components/vulcan/fixtures/fake_student_1.json +++ b/tests/components/vulcan/fixtures/fake_student_1.json @@ -25,5 +25,11 @@ "Surname": "Kowalski", "Sex": true }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } diff --git a/tests/components/vulcan/fixtures/fake_student_2.json b/tests/components/vulcan/fixtures/fake_student_2.json index 0176b72d4fc..e5200c12e17 100644 --- a/tests/components/vulcan/fixtures/fake_student_2.json +++ b/tests/components/vulcan/fixtures/fake_student_2.json @@ -25,5 +25,11 @@ "Surname": "Kowalska", "Sex": false }, - "Periods": [] + "Periods": [], + "State": 0, + "MessageBox": { + "Id": 1, + "GlobalKey": "00000000-0000-0000-0000-000000000000", + "Name": "Test" + } } From a65eb5753901852b13139d32b18a0fde25675fdc Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Sat, 28 Jun 2025 02:16:21 +0800 Subject: [PATCH 0841/1664] Add lock models to switchbot cloud (#147569) --- homeassistant/components/switchbot_cloud/__init__.py | 7 ++++++- .../components/switchbot_cloud/binary_sensor.py | 9 ++++++++- homeassistant/components/switchbot_cloud/sensor.py | 5 +++-- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 7b7f60589f0..b87a569abda 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -153,7 +153,12 @@ async def make_device_data( ) devices_data.vacuums.append((device, coordinator)) - if isinstance(device, Device) and device.device_type.startswith("Smart Lock"): + if isinstance(device, Device) and device.device_type in [ + "Smart Lock", + "Smart Lock Lite", + "Smart Lock Pro", + "Smart Lock Ultra", + ]: coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id ) diff --git a/homeassistant/components/switchbot_cloud/binary_sensor.py b/homeassistant/components/switchbot_cloud/binary_sensor.py index 752c428fa6c..cd0e6e8968c 100644 --- a/homeassistant/components/switchbot_cloud/binary_sensor.py +++ b/homeassistant/components/switchbot_cloud/binary_sensor.py @@ -48,10 +48,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Lite": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), "Smart Lock Pro": ( CALIBRATION_DESCRIPTION, DOOR_OPEN_DESCRIPTION, ), + "Smart Lock Ultra": ( + CALIBRATION_DESCRIPTION, + DOOR_OPEN_DESCRIPTION, + ), } @@ -69,7 +77,6 @@ async def async_setup_entry( for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[ device.device_type ] - if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 9920717a8d7..5a424ea7892 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -134,8 +134,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { BATTERY_DESCRIPTION, CO2_DESCRIPTION, ), - "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock": (BATTERY_DESCRIPTION,), + "Smart Lock Lite": (BATTERY_DESCRIPTION,), + "Smart Lock Pro": (BATTERY_DESCRIPTION,), + "Smart Lock Ultra": (BATTERY_DESCRIPTION,), } @@ -151,7 +153,6 @@ async def async_setup_entry( SwitchBotCloudSensor(data.api, device, coordinator, description) for device, coordinator in data.devices.sensors for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] - if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES ) From 862b7460b525f7f4e1f8c38fa87d8d8de607cb2b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 28 Jun 2025 11:25:59 +0200 Subject: [PATCH 0842/1664] Move MQTT device sw and hw version to collapsed section in subentry flow (#147685) Move MQTT device sw and hw version to collapsed section --- homeassistant/components/mqtt/config_flow.py | 41 ++++++++++++++++---- homeassistant/components/mqtt/strings.json | 15 +++++-- tests/components/mqtt/test_config_flow.py | 2 +- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 2ef881ceaf4..b022a46cbe7 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1904,8 +1904,12 @@ ENTITY_CONFIG_VALIDATOR: dict[ MQTT_DEVICE_PLATFORM_FIELDS = { ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True), - ATTR_SW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), - ATTR_HW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False), + ATTR_SW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), + ATTR_HW_VERSION: PlatformField( + selector=TEXT_SELECTOR, required=False, section="advanced_settings" + ), ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False), ATTR_CONFIGURATION_URL: PlatformField( @@ -2725,6 +2729,19 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): for field_key, value in data_schema.schema.items() } + @callback + def get_suggested_values_from_device_data( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from device data based on the data schema.""" + device_data = self._subentry_data["device"] + return { + field_key: self.get_suggested_values_from_device_data(value.schema) + if isinstance(value, section) + else device_data.get(field_key) + for field_key, value in data_schema.schema.items() + } + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -2754,15 +2771,25 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + new_device_data, errors = validate_user_input( + user_input, MQTT_DEVICE_PLATFORM_FIELDS + ) + if "mqtt_settings" in user_input: + new_device_data["mqtt_settings"] = user_input["mqtt_settings"] if not errors: - self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input) + self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() return await self.async_step_entity() - data_schema = self.add_suggested_values_to_schema( - data_schema, device_data if user_input is None else user_input - ) + data_schema = self.add_suggested_values_to_schema( + data_schema, device_data if user_input is None else user_input + ) + elif self.source == SOURCE_RECONFIGURE: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_device_data(data_schema), + ) + return self.async_show_form( step_id=CONF_DEVICE, data_schema=data_schema, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 592ea8686e1..96b5bd15d28 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -134,20 +134,27 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "configuration_url": "Configuration URL", - "sw_version": "Software version", - "hw_version": "Hardware version", "model": "Model", "model_id": "Model ID" }, "data_description": { "name": "The name of the manually added MQTT device.", "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.", - "sw_version": "The software version of the device. E.g. '2025.1.0'.", - "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.", "model": "E.g. 'Cleanmaster Pro'.", "model_id": "E.g. '123NK2PRO'." }, "sections": { + "advanced_settings": { + "name": "Advanced device settings", + "data": { + "sw_version": "Software version", + "hw_version": "Hardware version" + }, + "data_description": { + "sw_version": "The software version of the device. E.g. '2025.1.0'.", + "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'." + } + }, "mqtt_settings": { "name": "MQTT settings", "data": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 2177a7de8e1..12f77a95c48 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -4073,7 +4073,7 @@ async def test_subentry_reconfigure_update_device_properties( result["flow_id"], user_input={ "name": "Beer notifier", - "sw_version": "1.1", + "advanced_settings": {"sw_version": "1.1"}, "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", From d3c5684cd05538e335c20ba9afa1372be0f6308c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 28 Jun 2025 22:40:58 +0300 Subject: [PATCH 0843/1664] Fix Shelly Block entity removal (#147694) --- homeassistant/components/shelly/entity.py | 5 ++- tests/components/shelly/test_switch.py | 45 +++++++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 587eb00b979..b80ac877a84 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -86,7 +86,10 @@ def async_setup_block_attribute_entities( coordinator.device.settings, block ): domain = sensor_class.__module__.split(".")[-1] - unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}" + unique_id = sensor_class( + coordinator, block, sensor_id, description + ).unique_id + LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id) async_remove_shelly_entity(hass, domain, unique_id) else: entities.append( diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index 3234e3eb0b9..f1866d83e2a 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -40,6 +40,8 @@ from . import ( from tests.common import async_fire_time_changed, mock_restore_cache +DEVICE_BLOCK_ID = 4 +LIGHT_BLOCK_ID = 2 RELAY_BLOCK_ID = 0 GAS_VALVE_BLOCK_ID = 6 MOTION_BLOCK_ID = 3 @@ -326,14 +328,51 @@ async def test_block_device_mode_roller( async def test_block_device_app_type_light( - hass: HomeAssistant, mock_block_device: Mock, monkeypatch: pytest.MonkeyPatch + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Test block device in app type set to light mode.""" + switch_entity_id = "switch.test_name_channel_1" + light_entity_id = "light.test_name_channel_1" + + # Remove light blocks to prevent light entity creation + monkeypatch.setattr(mock_block_device.blocks[LIGHT_BLOCK_ID], "type", "sensor") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "red") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "green") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "blue") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "mode") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "gain") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "brightness") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "effect") + monkeypatch.delattr(mock_block_device.blocks[RELAY_BLOCK_ID], "colorTemp") + + await init_integration(hass, 1) + + # Entity is created as switch + assert hass.states.get(switch_entity_id) + assert hass.states.get(light_entity_id) is None + + # Generate config change from switch to light + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 1) + mock_block_device.mock_update() + monkeypatch.setitem( mock_block_device.settings["relays"][RELAY_BLOCK_ID], "appliance_type", "light" ) - await init_integration(hass, 1) - assert hass.states.get("switch.test_name_channel_1") is None + monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "cfgChanged", 2) + mock_block_device.mock_update() + await hass.async_block_till_done() + + # Wait for debouncer + freezer.tick(timedelta(seconds=ENTRY_RELOAD_COOLDOWN)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Switch entity should be removed and light entity created + assert hass.states.get(switch_entity_id) is None + assert hass.states.get(light_entity_id) async def test_rpc_device_services( From 81e712ea496a75ae8a11860e2cd8f59dd5e2906a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 28 Jun 2025 10:11:17 +0200 Subject: [PATCH 0844/1664] Bump pytibber to 0.31.6 (#147703) --- homeassistant/components/tibber/manifest.json | 2 +- homeassistant/components/tibber/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 43cbd79afef..db08f422500 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/tibber", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.31.2"] + "requirements": ["pyTibber==0.31.6"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 26b8f5400a0..327812cdf99 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -280,7 +280,7 @@ async def async_setup_entry( except TimeoutError as err: _LOGGER.error("Timeout connecting to Tibber home: %s ", err) raise PlatformNotReady from err - except aiohttp.ClientError as err: + except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err: _LOGGER.error("Error connecting to Tibber home: %s ", err) raise PlatformNotReady from err diff --git a/requirements_all.txt b/requirements_all.txt index fdf67f91abb..54d8180fc0c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1811,7 +1811,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5d59456959..28fbbe0e97d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1522,7 +1522,7 @@ pyHomee==1.2.10 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.31.2 +pyTibber==0.31.6 # homeassistant.components.dlink pyW215==0.8.0 From 33e1c6de68386ea25c072fd3cee19aa85638d0d2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 28 Jun 2025 22:20:47 +0200 Subject: [PATCH 0845/1664] Reduce idle timeout of HLS stream to conserve camera battery life (#147728) * Reduce IDLE timeout of HLS stream to conserve camera battery life * adjust tests --- homeassistant/components/stream/__init__.py | 12 ++++++++---- homeassistant/components/stream/const.py | 3 ++- homeassistant/components/stream/core.py | 4 +++- tests/components/stream/test_hls.py | 8 ++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 9426b5b04de..a31ce433c06 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -55,6 +55,7 @@ from .const import ( MAX_SEGMENTS, OUTPUT_FORMATS, OUTPUT_IDLE_TIMEOUT, + OUTPUT_STARTUP_TIMEOUT, RECORDER_PROVIDER, RTSP_TRANSPORTS, SEGMENT_DURATION_ADJUSTER, @@ -363,11 +364,14 @@ class Stream: # without concern about self._outputs being modified from another thread. return MappingProxyType(self._outputs.copy()) - def add_provider( - self, fmt: str, timeout: int = OUTPUT_IDLE_TIMEOUT - ) -> StreamOutput: + def add_provider(self, fmt: str, timeout: int | None = None) -> StreamOutput: """Add provider output stream.""" if not (provider := self._outputs.get(fmt)): + startup_timeout = OUTPUT_STARTUP_TIMEOUT + if timeout is None: + timeout = OUTPUT_IDLE_TIMEOUT + else: + startup_timeout = timeout async def idle_callback() -> None: if ( @@ -379,7 +383,7 @@ class Stream: provider = PROVIDERS[fmt]( self.hass, - IdleTimer(self.hass, timeout, idle_callback), + IdleTimer(self.hass, timeout, idle_callback, startup_timeout), self._stream_settings, self.dynamic_stream_settings, ) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index c81d2f6cb18..df50ecefd62 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -22,7 +22,8 @@ AUDIO_CODECS = {"aac", "mp3"} FORMAT_CONTENT_TYPE = {HLS_PROVIDER: "application/vnd.apple.mpegurl"} -OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity +OUTPUT_STARTUP_TIMEOUT = 60 # timeout due to no startup +OUTPUT_IDLE_TIMEOUT = 30 # Idle timeout due to inactivity NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist MAX_SEGMENTS = 5 # Max number of segments to keep around diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 44dfe2c323d..7dc6bab16b9 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -234,10 +234,12 @@ class IdleTimer: hass: HomeAssistant, timeout: int, idle_callback: Callable[[], Coroutine[Any, Any, None]], + startup_timeout: int | None = None, ) -> None: """Initialize IdleTimer.""" self._hass = hass self._timeout = timeout + self._startup_timeout = startup_timeout or timeout self._callback = idle_callback self._unsub: CALLBACK_TYPE | None = None self.idle = False @@ -246,7 +248,7 @@ class IdleTimer: """Start the idle timer if not already started.""" self.idle = False if self._unsub is None: - self._unsub = async_call_later(self._hass, self._timeout, self.fire) + self._unsub = async_call_later(self._hass, self._startup_timeout, self.fire) def awake(self) -> None: """Keep the idle time alive by resetting the timeout.""" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index c96b7d9427f..eb554f2cf19 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -230,8 +230,8 @@ async def test_stream_timeout( playlist_response = await http_client.get(parsed_url.path) assert playlist_response.status == HTTPStatus.OK - # Wait a minute - future = dt_util.utcnow() + timedelta(minutes=1) + # Wait 40 seconds + future = dt_util.utcnow() + timedelta(seconds=40) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -241,8 +241,8 @@ async def test_stream_timeout( stream_worker_sync.resume() - # Wait 5 minutes - future = dt_util.utcnow() + timedelta(minutes=5) + # Wait 2 minutes + future = dt_util.utcnow() + timedelta(minutes=2) async_fire_time_changed(hass, future) await hass.async_block_till_done() From 4b3449fe0c6b73c1329270d71b6d9a435942f5f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sat, 28 Jun 2025 21:57:26 +0200 Subject: [PATCH 0846/1664] Fix error if cover position is not available or unknown (#147732) --- homeassistant/components/wmspro/cover.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 0d9ccb8547d..77dd928bc95 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -53,6 +53,8 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): def current_cover_position(self) -> int | None: """Return current position of cover.""" action = self._dest.action(self._drive_action_desc) + if action is None or action["percentage"] is None: + return None return 100 - action["percentage"] async def async_set_cover_position(self, **kwargs: Any) -> None: From 2f69ed4a8ae56274532aeb596e9e7c80c711d259 Mon Sep 17 00:00:00 2001 From: Florian von Garrel Date: Sat, 28 Jun 2025 22:13:51 +0200 Subject: [PATCH 0847/1664] bump pypaperless to 4.1.1 (#147735) --- homeassistant/components/paperless_ngx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/paperless_ngx/manifest.json b/homeassistant/components/paperless_ngx/manifest.json index 0be3562c76f..43c61185f3a 100644 --- a/homeassistant/components/paperless_ngx/manifest.json +++ b/homeassistant/components/paperless_ngx/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["pypaperless"], "quality_scale": "silver", - "requirements": ["pypaperless==4.1.0"] + "requirements": ["pypaperless==4.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 54d8180fc0c..48b8605b346 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.elv pypca==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28fbbe0e97d..2ba5c97e364 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1861,7 +1861,7 @@ pyownet==0.10.0.post1 pypalazzetti==0.1.19 # homeassistant.components.paperless_ngx -pypaperless==4.1.0 +pypaperless==4.1.1 # homeassistant.components.lcn pypck==0.8.9 From c32b44b77490f12150bec242175a6a62d4fff8d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jun 2025 15:18:46 -0500 Subject: [PATCH 0848/1664] Improve rest error logging (#147736) * Improve rest error logging * Improve rest error logging * Improve rest error logging * Improve rest error logging * Improve rest error logging * top level --- homeassistant/components/rest/data.py | 41 ++- tests/components/rest/test_data.py | 444 ++++++++++++++++++++++++++ 2 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 tests/components/rest/test_data.py diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 3c02f62f852..f20b811a887 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -6,6 +6,7 @@ import logging from typing import Any import aiohttp +from aiohttp import hdrs from multidict import CIMultiDictProxy import xmltodict @@ -77,6 +78,12 @@ class RestData: """Set url.""" self._resource = url + def _is_expected_content_type(self, content_type: str) -> bool: + """Check if the content type is one we expect (JSON or XML).""" + return content_type.startswith( + ("application/json", "text/json", *XML_MIME_TYPES) + ) + def data_without_xml(self) -> str | None: """If the data is an XML string, convert it to a JSON string.""" _LOGGER.debug("Data fetched from resource: %s", self.data) @@ -84,7 +91,7 @@ class RestData: (value := self.data) is not None # If the http request failed, headers will be None and (headers := self.headers) is not None - and (content_type := headers.get("content-type")) + and (content_type := headers.get(hdrs.CONTENT_TYPE)) and content_type.startswith(XML_MIME_TYPES) ): value = json_dumps(xmltodict.parse(value)) @@ -120,6 +127,7 @@ class RestData: # Handle data/content if self._request_data: request_kwargs["data"] = self._request_data + response = None try: # Make the request async with self._session.request( @@ -143,3 +151,34 @@ class RestData: self.last_exception = ex self.data = None self.headers = None + + # Log response details outside the try block so we always get logging + if response is None: + return + + # Log response details for debugging + content_type = response.headers.get(hdrs.CONTENT_TYPE) + _LOGGER.debug( + "REST response from %s: status=%s, content-type=%s, length=%s", + self._resource, + response.status, + content_type or "not set", + len(self.data) if self.data else 0, + ) + + # If we got an error response with non-JSON/XML content, log a sample + # This helps debug issues like servers blocking with HTML error pages + if ( + response.status >= 400 + and content_type + and not self._is_expected_content_type(content_type) + ): + sample = self.data[:500] if self.data else "" + _LOGGER.warning( + "REST request to %s returned status %s with %s response: %s%s", + self._resource, + response.status, + content_type, + sample, + "..." if self.data and len(self.data) > 500 else "", + ) diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py new file mode 100644 index 00000000000..3add886a451 --- /dev/null +++ b/tests/components/rest/test_data.py @@ -0,0 +1,444 @@ +"""Test REST data module logging improvements.""" + +import logging + +import pytest + +from homeassistant.components.rest import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_rest_data_log_warning_on_error_status( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status codes.""" + # Mock a 403 response with HTML content + aioclient_mock.get( + "http://example.com/api", + status=403, + text="Access Denied", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged + assert ( + "REST request to http://example.com/api returned status 403 " + "with text/html response" in caplog.text + ) + assert "Access Denied" in caplog.text + + +async def test_rest_data_no_warning_on_200_with_wrong_content_type( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for 200 status with wrong content.""" + # Mock a 200 response with HTML - users might still want to parse this + aioclient_mock.get( + "http://example.com/api", + status=200, + text="

This is HTML, not JSON!

", + headers={"Content-Type": "text/html; charset=utf-8"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for 200 status, even with HTML content type + assert ( + "REST request to http://example.com/api returned status 200" not in caplog.text + ) + + +async def test_rest_data_no_warning_on_success_json( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful JSON responses.""" + # Mock a successful JSON response + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok", "value": 42}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_no_warning_on_success_xml( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that no warning is logged for successful XML responses.""" + # Mock a successful XML response + aioclient_mock.get( + "http://example.com/api", + status=200, + text='42', + headers={"Content-Type": "application/xml"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.root.value }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that no warning was logged + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_warning_truncates_long_responses( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning truncates very long response bodies.""" + # Create a very long error message + long_message = "Error: " + "x" * 1000 + + aioclient_mock.get( + "http://example.com/api", + status=500, + text=long_message, + headers={"Content-Type": "text/plain"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with truncation + # Set the logger filter to only check our specific logger + caplog.set_level(logging.WARNING, logger="homeassistant.components.rest.data") + + # Verify the truncated warning appears + assert ( + "REST request to http://example.com/api returned status 500 " + "with text/plain response: Error: " + "x" * 493 + "..." in caplog.text + ) + + +async def test_rest_data_debug_logging_shows_response_details( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that debug logging shows response details.""" + caplog.set_level(logging.DEBUG) + + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"test": "data"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log + assert ( + "REST response from http://example.com/api: status=200, " + "content-type=application/json, length=" in caplog.text + ) + + +async def test_rest_data_no_content_type_header( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test handling of responses without Content-Type header.""" + caplog.set_level(logging.DEBUG) + + # Mock response without Content-Type header + aioclient_mock.get( + "http://example.com/api", + status=200, + text="plain text response", + headers={}, # No Content-Type + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check debug log shows "not set" + assert "content-type=not set" in caplog.text + # No warning for 200 with missing content-type + assert "REST request to http://example.com/api returned status" not in caplog.text + + +async def test_rest_data_real_world_bom_blocking_scenario( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test real-world scenario where BOM blocks with HTML response.""" + # Mock BOM blocking response + bom_block_html = "

Your access is blocked due to automated access

" + + aioclient_mock.get( + "http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json", + status=403, + text=bom_block_html, + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": ("http://www.bom.gov.au/fwo/IDN60901/IDN60901.94767.json"), + "method": "GET", + "sensor": [ + { + "name": "bom_temperature", + "value_template": ( + "{{ value_json.observations.data[0].air_temp }}" + ), + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that warning was logged with clear indication of the issue + assert ( + "REST request to http://www.bom.gov.au/fwo/IDN60901/" + "IDN60901.94767.json returned status 403 with text/html response" + ) in caplog.text + assert "Your access is blocked" in caplog.text + + +async def test_rest_data_warning_on_html_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that warning is logged for error status with HTML content.""" + # Mock a 404 response with HTML error page + aioclient_mock.get( + "http://example.com/api", + status=404, + text="

404 Not Found

", + headers={"Content-Type": "text/html"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should warn for error status with HTML + assert ( + "REST request to http://example.com/api returned status 404 " + "with text/html response" in caplog.text + ) + assert "

404 Not Found

" in caplog.text + + +async def test_rest_data_no_warning_on_json_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test POST request that returns JSON error - no warning expected.""" + aioclient_mock.post( + "http://example.com/api", + status=400, + text='{"error": "Invalid request payload"}', + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "POST", + "payload": '{"data": "test"}', + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.error }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Should NOT warn for JSON error responses - users can parse these + assert ( + "REST request to http://example.com/api returned status 400" not in caplog.text + ) + + +async def test_rest_data_timeout_error( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test timeout error logging.""" + aioclient_mock.get( + "http://example.com/api", + exc=TimeoutError(), + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "timeout": 10, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.test }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check timeout error is logged or platform reports not ready + assert ( + "Timeout while fetching data: http://example.com/api" in caplog.text + or "Platform rest not ready yet" in caplog.text + ) From cf2e69ed7454a2439455b64ae060d500841a54f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Jun 2025 20:27:42 +0000 Subject: [PATCH 0849/1664] Bump version to 2025.7.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index ed82ed41594..e3b9424292e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 735915581d1..eb02751785e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b3" +version = "2025.7.0b4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From bbd1cbf5c9e06bb6df892b42a6c5770e84fd69d8 Mon Sep 17 00:00:00 2001 From: cnico Date: Sat, 28 Jun 2025 23:29:24 +0200 Subject: [PATCH 0850/1664] Correct Chlorine unit definition in flipr integration (#147537) * Correction of bug 145683 * constant for chlorine unit correction * constant name correction * Review correction --- homeassistant/components/flipr/sensor.py | 2 +- tests/components/flipr/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 296bcaac68d..f96edbc0f71 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -19,7 +19,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="chlorine", translation_key="chlorine", - native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + native_unit_of_measurement="mg/L", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index 77937e3af54..d4568747d01 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -54,7 +54,7 @@ async def test_sensors( state = hass.states.get("sensor.flipr_myfliprid_chlorine") assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mg/L" assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "0.23654886" From 6d28b993444ea93c4d76ae000e46e22c84984ace Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jun 2025 17:24:09 -0500 Subject: [PATCH 0851/1664] Preserve httpx boolean behavior in REST integration after aiohttp conversion (#147738) --- homeassistant/components/rest/data.py | 6 ++++ tests/components/rest/test_data.py | 49 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index f20b811a887..731d1ffe9c3 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -110,6 +110,12 @@ class RestData: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) + # Convert boolean values to lowercase strings for compatibility with aiohttp/yarl + if rendered_params: + for key, value in rendered_params.items(): + if isinstance(value, bool): + rendered_params[key] = str(value).lower() + _LOGGER.debug("Updating from %s", self._resource) # Create request kwargs request_kwargs: dict[str, Any] = { diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 3add886a451..4d6bc000fac 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -442,3 +442,52 @@ async def test_rest_data_timeout_error( "Timeout while fetching data: http://example.com/api" in caplog.text or "Platform rest not ready yet" in caplog.text ) + + +async def test_rest_data_boolean_params_converted_to_strings( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that boolean parameters are converted to lowercase strings.""" + # Mock the request and capture the actual URL + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "params": { + "boolTrue": True, + "boolFalse": False, + "stringParam": "test", + "intParam": 123, + }, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.status }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that the request was made with boolean values converted to strings + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + + # Check that the URL query parameters have boolean values converted to strings + assert url.query["boolTrue"] == "true" + assert url.query["boolFalse"] == "false" + assert url.query["stringParam"] == "test" + assert url.query["intParam"] == "123" From 8bacab4f9c1a6ebdc2c7a5a7563ca840d5d9b21a Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sat, 28 Jun 2025 23:22:04 -0600 Subject: [PATCH 0852/1664] Fix Vesync set_percentage error (#147751) --- homeassistant/components/vesync/fan.py | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index d9336552744..5b0197606ae 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -165,28 +165,36 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return attr def set_percentage(self, percentage: int) -> None: - """Set the speed of the device.""" + """Set the speed of the device. + + If percentage is 0, turn off the fan. Otherwise, ensure the fan is on, + set manual mode if needed, and set the speed. + """ + device_type = SKU_TO_BASE_DEVICE[self.device.device_type] + speed_range = SPEED_RANGE[device_type] + if percentage == 0: - success = self.device.turn_off() - if not success: + # Turning off is a special case: do not set speed or mode + if not self.device.turn_off(): raise HomeAssistantError("An error occurred while turning off.") - elif not self.device.is_on: - success = self.device.turn_on() - if not success: + self.schedule_update_ha_state() + return + + # If the fan is off, turn it on first + if not self.device.is_on: + if not self.device.turn_on(): raise HomeAssistantError("An error occurred while turning on.") - success = self.device.manual_mode() - if not success: - raise HomeAssistantError("An error occurred while manual mode.") - success = self.device.change_fan_speed( - math.ceil( - percentage_to_ranged_value( - SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage - ) - ) - ) - if not success: + # Switch to manual mode if not already set + if self.device.mode != VS_FAN_MODE_MANUAL: + if not self.device.manual_mode(): + raise HomeAssistantError("An error occurred while setting manual mode.") + + # Calculate the speed level and set it + speed_level = math.ceil(percentage_to_ranged_value(speed_range, percentage)) + if not self.device.change_fan_speed(speed_level): raise HomeAssistantError("An error occurred while changing fan speed.") + self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: From 617ea1925c4fe41e5ece7c5354278a08774b4d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sun, 29 Jun 2025 07:33:44 +0200 Subject: [PATCH 0853/1664] Update pywmspro to 0.3.0 to wait for short-lived actions (#147679) Replace action delays with detailed action responses. --- homeassistant/components/wmspro/cover.py | 9 ++------ homeassistant/components/wmspro/light.py | 21 +++++++++++-------- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 77dd928bc95..b6f100280ad 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -2,13 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any from wmspro.const import ( WMS_WebControl_pro_API_actionDescription, WMS_WebControl_pro_API_actionType, + WMS_WebControl_pro_API_responseType, ) from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 @@ -61,7 +60,6 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Move the cover to a specific position.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100 - kwargs[ATTR_POSITION]) - await asyncio.sleep(ACTION_DELAY) @property def is_closed(self) -> bool | None: @@ -72,13 +70,11 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Open the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=0) - await asyncio.sleep(ACTION_DELAY) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100) - await asyncio.sleep(ACTION_DELAY) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" @@ -86,8 +82,7 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): WMS_WebControl_pro_API_actionDescription.ManualCommand, WMS_WebControl_pro_API_actionType.Stop, ) - await action() - await asyncio.sleep(ACTION_DELAY) + await action(responseType=WMS_WebControl_pro_API_responseType.Detailed) class WebControlProAwning(WebControlProCover): diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index d828c8a26e8..52d092ed9f0 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -2,11 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any -from wmspro.const import WMS_WebControl_pro_API_actionDescription +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_responseType, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant @@ -17,7 +19,6 @@ from . import WebControlProConfigEntry from .const import BRIGHTNESS_SCALE from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=15) PARALLEL_UPDATES = 1 @@ -56,14 +57,16 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=True) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=True, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=False) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=False, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) class WebControlProDimmer(WebControlProLight): @@ -90,6 +93,6 @@ class WebControlProDimmer(WebControlProLight): WMS_WebControl_pro_API_actionDescription.LightDimming ) await action( - percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) + percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]), + responseType=WMS_WebControl_pro_API_responseType.Detailed, ) - await asyncio.sleep(ACTION_DELAY) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index d4eda3a90a6..9185768165a 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.2.2"] + "requirements": ["pywmspro==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b198661ce18..93fb2a42536 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2596,7 +2596,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.0 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 09f4a62b597..c9d6b6349a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2154,7 +2154,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.0 # homeassistant.components.ws66i pyws66i==1.1 From 25ab47a587c574108163baa8c21024ac937b7530 Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 28 Jun 2025 22:56:37 -0700 Subject: [PATCH 0854/1664] Move the async_reload on updates in async_setup_entry in Google Generative AI (#147748) Move the async_reload on updates in async_setup_entry --- .../google_generative_ai_conversation/__init__.py | 9 +++++++++ .../google_generative_ai_conversation/conversation.py | 10 ---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 5e4ad114adf..e3278eb3cb5 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -207,6 +207,8 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -220,6 +222,13 @@ async def async_unload_entry( return True +async def async_update_options( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d8eae3f6d0d..0b24e8bbc38 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -61,9 +61,6 @@ class GoogleGenerativeAIConversationEntity( self.hass, "conversation", self.entry.entry_id, self.entity_id ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -103,10 +100,3 @@ class GoogleGenerativeAIConversationEntity( conversation_id=chat_log.conversation_id, continue_conversation=chat_log.continue_conversation, ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) From 369c8d1e0dc36ce426455abcc144b0db07cb2b63 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sun, 29 Jun 2025 19:58:41 +0200 Subject: [PATCH 0855/1664] Bump pypck to 0.8.10 (#147774) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 97e1bbcd390..8a47f1c1359 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["pypck"], "quality_scale": "bronze", - "requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.10", "lcn-frontend==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 93fb2a42536..51f89944625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2240,7 +2240,7 @@ pypaperless==4.1.1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.8.9 +pypck==0.8.10 # homeassistant.components.pglab pypglab==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9d6b6349a0..ef8e897926b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1864,7 +1864,7 @@ pypalazzetti==0.1.19 pypaperless==4.1.1 # homeassistant.components.lcn -pypck==0.8.9 +pypck==0.8.10 # homeassistant.components.pglab pypglab==0.0.5 From 4add346272e6af00083c27a0de6cd1c7604f2044 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 29 Jun 2025 20:00:16 +0200 Subject: [PATCH 0856/1664] Deduplicate strings and fix sentence-casing in `proximity` (#147777) * Deduplicate strings and fix sentence-casing in `proximity` * Update test_init.py --- .../components/proximity/strings.json | 24 +++++++++---------- tests/components/proximity/test_init.py | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json index 5f713174f50..fa3be70f247 100644 --- a/homeassistant/components/proximity/strings.json +++ b/homeassistant/components/proximity/strings.json @@ -1,13 +1,13 @@ { "title": "Proximity", "config": { - "flow_title": "Proximity", + "flow_title": "[%key:component::proximity::title%]", "step": { "user": { "data": { "zone": "Zone to track distance to", "ignored_zones": "Zones to ignore", - "tracked_entities": "Devices or Persons to track", + "tracked_entities": "Devices or persons to track", "tolerance": "Tolerance distance" } } @@ -21,10 +21,10 @@ "step": { "init": { "data": { - "zone": "Zone to track distance to", - "ignored_zones": "Zones to ignore", - "tracked_entities": "Devices or Persons to track", - "tolerance": "Tolerance distance" + "zone": "[%key:component::proximity::config::step::user::data::zone%]", + "ignored_zones": "[%key:component::proximity::config::step::user::data::ignored_zones%]", + "tracked_entities": "[%key:component::proximity::config::step::user::data::tracked_entities%]", + "tolerance": "[%key:component::proximity::config::step::user::data::tolerance%]" } } } @@ -32,7 +32,7 @@ "entity": { "sensor": { "dir_of_travel": { - "name": "{tracked_entity} Direction of travel", + "name": "{tracked_entity} direction of travel", "state": { "arrived": "Arrived", "away_from": "Away from", @@ -40,15 +40,15 @@ "towards": "Towards" } }, - "dist_to_zone": { "name": "{tracked_entity} Distance" }, + "dist_to_zone": { "name": "{tracked_entity} distance" }, "nearest": { "name": "Nearest device" }, "nearest_dir_of_travel": { "name": "Nearest direction of travel", "state": { - "arrived": "Arrived", - "away_from": "Away from", - "stationary": "Stationary", - "towards": "Towards" + "arrived": "[%key:component::proximity::entity::sensor::dir_of_travel::state::arrived%]", + "away_from": "[%key:component::proximity::entity::sensor::dir_of_travel::state::away_from%]", + "stationary": "[%key:component::proximity::entity::sensor::dir_of_travel::state::stationary%]", + "towards": "[%key:component::proximity::entity::sensor::dir_of_travel::state::towards%]" } }, "nearest_dist_to_zone": { "name": "Nearest distance" } diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index e9340014207..22783c0598a 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -871,7 +871,7 @@ async def test_sensor_unique_ids( assert entity assert entity.unique_id == f"{mock_config.entry_id}_{t1.id}_dist_to_zone" state = hass.states.get(sensor_t1) - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Home Test tracker 1 Distance" + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Home Test tracker 1 distance" entity = entity_registry.async_get("sensor.home_test2_distance") assert entity From 08a6b386990f8f64f1b8b685809fcf067bf5fe71 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 29 Jun 2025 21:41:50 +0300 Subject: [PATCH 0857/1664] Bump aioshelly to 13.7.1 (#146221) * Bump aioshelly to 13.8.0 * Change version to 13.7.1 --- 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 c6a255b1bbb..1db8dbf55c6 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.0"], + "requirements": ["aioshelly==13.7.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 51f89944625..9aa22cdd315 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.0 +aioshelly==13.7.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef8e897926b..cc8cae22ef7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.0 +aioshelly==13.7.1 # homeassistant.components.skybell aioskybell==22.7.0 From 05ceee568ea7c5dd7b7d595f9ed1d02ccf7919fd Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Sun, 29 Jun 2025 15:22:59 -0400 Subject: [PATCH 0858/1664] Honeywell: Don't use shared session (#147772) --- .../components/honeywell/__init__.py | 22 ++++++------------- .../components/honeywell/config_flow.py | 8 +++++-- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index eb89ba2a681..6c4c7091840 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -9,17 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import ( - async_create_clientsession, - async_get_clientsession, -) +from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import ( - _LOGGER, - CONF_COOL_AWAY_TEMPERATURE, - CONF_HEAT_AWAY_TEMPERATURE, - DOMAIN, -) +from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE UPDATE_LOOP_SLEEP_TIME = 5 PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH] @@ -56,11 +48,11 @@ async def async_setup_entry( username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - if len(hass.config_entries.async_entries(DOMAIN)) > 1: - session = async_create_clientsession(hass) - else: - session = async_get_clientsession(hass) - + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. + session = async_create_clientsession(hass) client = aiosomecomfort.AIOSomeComfort(username, password, session=session) try: await client.login() diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index c7cda500692..15199cdda24 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( CONF_COOL_AWAY_TEMPERATURE, @@ -114,10 +114,14 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): async def is_valid(self, **kwargs) -> bool: """Check if login credentials are valid.""" + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. client = aiosomecomfort.AIOSomeComfort( kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD], - session=async_get_clientsession(self.hass), + session=async_create_clientsession(self.hass), ) await client.login() From c9a6b1fd4574db81969a65290269401b6f25baef Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 30 Jun 2025 09:39:02 +0200 Subject: [PATCH 0859/1664] Bump reolink_aio to 0.14.2 (#147797) --- 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 04996689bf7..c422af292b9 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.1"] + "requirements": ["reolink-aio==0.14.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9aa22cdd315..3c63782bacc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2656,7 +2656,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc8cae22ef7..a82c6fad437 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2202,7 +2202,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.2 # homeassistant.components.rflink rflink==0.0.67 From 97c1e21a69c054cbb18ebdcfd9ee2d6f087955c7 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Mon, 30 Jun 2025 10:05:07 +0200 Subject: [PATCH 0860/1664] Add possibility to synchronize automatically all available feeds in emoncms (#128122) * Add checkbox in options to sync all feeds once * Add sync mode selector in async_step_user Remove checkbox in options * Correct use of SYNC_MODE & SYNC_MODE_AUTO in tests * Use dropdown for mode selection * rmv_unused_const * Add separate tests + use SelectSelector --- .../components/emoncms/config_flow.py | 30 +++++++- homeassistant/components/emoncms/const.py | 3 + homeassistant/components/emoncms/strings.json | 11 ++- tests/components/emoncms/test_config_flow.py | 73 ++++++++++++++----- 4 files changed, 97 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index 8b3067b2cf4..c34aa1b629b 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -16,7 +16,12 @@ from homeassistant.config_entries import ( from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import selector +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + selector, +) from .const import ( CONF_MESSAGE, @@ -26,6 +31,9 @@ from .const import ( FEED_ID, FEED_NAME, FEED_TAG, + SYNC_MODE, + SYNC_MODE_AUTO, + SYNC_MODE_MANUAL, ) @@ -102,6 +110,17 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): "mode": "dropdown", "multiple": True, } + if user_input.get(SYNC_MODE) == SYNC_MODE_AUTO: + return self.async_create_entry( + title=sensor_name(self.url), + data={ + CONF_URL: self.url, + CONF_API_KEY: self.api_key, + CONF_ONLY_INCLUDE_FEEDID: [ + feed[FEED_ID] for feed in result[CONF_MESSAGE] + ], + }, + ) return await self.async_step_choose_feeds() return self.async_show_form( step_id="user", @@ -110,6 +129,15 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, + vol.Required( + SYNC_MODE, default=SYNC_MODE_MANUAL + ): SelectSelector( + SelectSelectorConfig( + options=[SYNC_MODE_MANUAL, SYNC_MODE_AUTO], + mode=SelectSelectorMode.DROPDOWN, + translation_key=SYNC_MODE, + ) + ), } ), user_input, diff --git a/homeassistant/components/emoncms/const.py b/homeassistant/components/emoncms/const.py index c53f7cc8a9f..a3b4629493f 100644 --- a/homeassistant/components/emoncms/const.py +++ b/homeassistant/components/emoncms/const.py @@ -14,6 +14,9 @@ EMONCMS_UUID_DOC_URL = ( FEED_ID = "id" FEED_NAME = "name" FEED_TAG = "tag" +SYNC_MODE = "sync_mode" +SYNC_MODE_AUTO = "auto" +SYNC_MODE_MANUAL = "manual" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 451a3fb88e5..3efb0720eab 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -7,7 +7,8 @@ "user": { "data": { "url": "[%key:common::config_flow::data::url%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "api_key": "[%key:common::config_flow::data::api_key%]", + "sync_mode": "Synchronization mode" }, "data_description": { "url": "Server URL starting with the protocol (http or https)", @@ -24,6 +25,14 @@ "already_configured": "This server is already configured" } }, + "selector": { + "sync_mode": { + "options": { + "auto": "Synchronize all available Feeds", + "manual": "Select which Feeds to synchronize" + } + } + }, "entity": { "sensor": { "energy": { diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index fa8ae7ce068..3157ccdd574 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -2,14 +2,20 @@ from unittest.mock import AsyncMock -from homeassistant.components.emoncms.const import CONF_ONLY_INCLUDE_FEEDID, DOMAIN +from homeassistant.components.emoncms.const import ( + CONF_ONLY_INCLUDE_FEEDID, + DOMAIN, + SYNC_MODE, + SYNC_MODE_AUTO, + SYNC_MODE_MANUAL, +) from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration -from .conftest import EMONCMS_FAILURE, SENSOR_NAME +from .conftest import EMONCMS_FAILURE, FLOW_RESULT, SENSOR_NAME from tests.common import MockConfigEntry @@ -19,12 +25,29 @@ USER_INPUT = { } -async def test_user_flow( - hass: HomeAssistant, - mock_setup_entry: AsyncMock, - emoncms_client: AsyncMock, +async def test_user_flow_failure( + hass: HomeAssistant, emoncms_client: AsyncMock ) -> None: - """Test we get the user form.""" + """Test emoncms failure when adding a new entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + emoncms_client.async_request.return_value = EMONCMS_FAILURE + assert result["type"] is FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + assert result["errors"]["base"] == "api_error" + assert result["description_placeholders"]["details"] == "failure" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + +async def test_user_flow_manual_mode( + hass: HomeAssistant, mock_setup_entry: AsyncMock, emoncms_client: AsyncMock +) -> None: + """Test we get the user forms and the entry in manual mode.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -33,11 +56,10 @@ async def test_user_flow( result = await hass.config_entries.flow.async_configure( result["flow_id"], - USER_INPUT, + {**USER_INPUT, SYNC_MODE: SYNC_MODE_MANUAL}, ) assert result["type"] is FlowResultType.FORM - result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_ONLY_INCLUDE_FEEDID: ["1"]}, @@ -46,16 +68,32 @@ async def test_user_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == SENSOR_NAME assert result["data"] == {**USER_INPUT, CONF_ONLY_INCLUDE_FEEDID: ["1"]} + # assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_flow_auto_mode( + hass: HomeAssistant, mock_setup_entry: AsyncMock, emoncms_client: AsyncMock +) -> None: + """Test we get the user form and the entry in automatic mode.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {**USER_INPUT, SYNC_MODE: SYNC_MODE_AUTO}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == SENSOR_NAME + assert result["data"] == { + **USER_INPUT, + CONF_ONLY_INCLUDE_FEEDID: FLOW_RESULT[CONF_ONLY_INCLUDE_FEEDID], + } assert len(mock_setup_entry.mock_calls) == 1 -CONFIG_ENTRY = { - CONF_API_KEY: "my_api_key", - CONF_ONLY_INCLUDE_FEEDID: ["1"], - CONF_URL: "http://1.1.1.1", -} - - async def test_options_flow( hass: HomeAssistant, emoncms_client: AsyncMock, @@ -80,13 +118,12 @@ async def test_options_flow( async def test_options_flow_failure( hass: HomeAssistant, - mock_setup_entry: AsyncMock, emoncms_client: AsyncMock, config_entry: MockConfigEntry, ) -> None: """Options flow - test failure.""" - emoncms_client.async_request.return_value = EMONCMS_FAILURE await setup_integration(hass, config_entry) + emoncms_client.async_request.return_value = EMONCMS_FAILURE result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() assert result["errors"]["base"] == "api_error" From c17ee0d1232046670d23e6b58eb39b450b923453 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:06:05 +0200 Subject: [PATCH 0861/1664] Allow binary sensor template to return state unknown (#128861) * Allow binary sensor template to return state unknown * Add tests * Adjust TriggerBinarySensorEntity * Add restore tests for BinarySensorTemplate * Add tests for TriggerBinarySensorEntity * Tweak * Tweak * Adjust tests * Adjust --- .../components/template/binary_sensor.py | 18 ++++---- .../components/template/test_binary_sensor.py | 44 +++++++++++++------ 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index f0ec64eae2a..b3bbf37712f 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -303,11 +303,9 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): self._delay_cancel() self._delay_cancel = None - state = ( - None - if isinstance(result, TemplateError) - else template.result_as_boolean(result) - ) + state: bool | None = None + if result is not None and not isinstance(result, TemplateError): + state = template.result_as_boolean(result) if state == self._attr_is_on: return @@ -347,7 +345,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity """Initialize the entity.""" super().__init__(hass, coordinator, config) - for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): + for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF): if isinstance(config.get(key), template.Template): self._to_render_simple.append(key) self._parse_result.add(key) @@ -391,7 +389,9 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self._process_data() raw = self._rendered.get(CONF_STATE) - state = template.result_as_boolean(raw) + state: bool | None = None + if raw is not None: + state = template.result_as_boolean(raw) key = CONF_DELAY_ON if state else CONF_DELAY_OFF delay = self._rendered.get(key) or self._config.get(key) @@ -417,8 +417,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity self.async_write_ha_state() return - # state without delay. None means rendering failed. - if self._attr_is_on == state or state is None or delay is None: + # state without delay. + if self._attr_is_on == state or delay is None: self._set_state(state) return diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 29ef524a4ab..a3b7edea919 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -253,7 +253,7 @@ async def test_setup_invalid_sensors(hass: HomeAssistant, count: int) -> None: @pytest.mark.parametrize( ("state_template", "expected_result"), [ - ("{{ None }}", STATE_OFF), + ("{{ None }}", STATE_UNKNOWN), ("{{ True }}", STATE_ON), ("{{ False }}", STATE_OFF), ("{{ 1 }}", STATE_ON), @@ -263,7 +263,7 @@ async def test_setup_invalid_sensors(hass: HomeAssistant, count: int) -> None: "{% else %}" "{{ states('binary_sensor.three') == 'off' }}" "{% endif %}", - STATE_OFF, + STATE_UNKNOWN, ), ("{{ 1 / 0 == 10 }}", STATE_UNAVAILABLE), ], @@ -1090,18 +1090,18 @@ async def test_availability_icon_picture(hass: HomeAssistant, entity_id: str) -> ({"delay_on": 5}, STATE_ON, STATE_OFF, STATE_OFF), ({"delay_on": 5}, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN), ({"delay_on": 5}, STATE_ON, STATE_UNKNOWN, STATE_UNKNOWN), - ({}, None, STATE_ON, STATE_OFF), - ({}, None, STATE_OFF, STATE_OFF), - ({}, None, STATE_UNAVAILABLE, STATE_OFF), - ({}, None, STATE_UNKNOWN, STATE_OFF), - ({"delay_off": 5}, None, STATE_ON, STATE_ON), - ({"delay_off": 5}, None, STATE_OFF, STATE_OFF), + ({}, None, STATE_ON, STATE_UNKNOWN), + ({}, None, STATE_OFF, STATE_UNKNOWN), + ({}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({}, None, STATE_UNKNOWN, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_ON, STATE_UNKNOWN), + ({"delay_off": 5}, None, STATE_OFF, STATE_UNKNOWN), ({"delay_off": 5}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), ({"delay_off": 5}, None, STATE_UNKNOWN, STATE_UNKNOWN), - ({"delay_on": 5}, None, STATE_ON, STATE_OFF), - ({"delay_on": 5}, None, STATE_OFF, STATE_OFF), - ({"delay_on": 5}, None, STATE_UNAVAILABLE, STATE_OFF), - ({"delay_on": 5}, None, STATE_UNKNOWN, STATE_OFF), + ({"delay_on": 5}, None, STATE_ON, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_OFF, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_UNAVAILABLE, STATE_UNKNOWN), + ({"delay_on": 5}, None, STATE_UNKNOWN, STATE_UNKNOWN), ], ) async def test_restore_state( @@ -1209,7 +1209,7 @@ async def test_restore_state( [ (2, STATE_ON, "mdi:pirate", "/local/dogs.png", 3, 1, "si"), (1, STATE_OFF, "mdi:pirate", "/local/dogs.png", 2, 1, "si"), - (0, STATE_OFF, "mdi:pirate", "/local/dogs.png", 1, 1, "si"), + (0, STATE_UNKNOWN, "mdi:pirate", "/local/dogs.png", 1, 1, "si"), (-1, STATE_UNAVAILABLE, None, None, None, None, None), ], ) @@ -1273,6 +1273,22 @@ async def test_trigger_entity( assert state.state == final_state assert state.attributes.get("another") == another_attr_update + # Check None values + hass.bus.async_fire("test_event", {"beer": 0}) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.hello_name") + assert state.state == STATE_UNKNOWN + state = hass.states.get("binary_sensor.via_list") + assert state.state == STATE_UNKNOWN + + # Check impossible values + hass.bus.async_fire("test_event", {"beer": -1}) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.hello_name") + assert state.state == STATE_UNAVAILABLE + state = hass.states.get("binary_sensor.via_list") + assert state.state == STATE_UNAVAILABLE + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( @@ -1298,7 +1314,7 @@ async def test_trigger_entity( [ (2, STATE_UNKNOWN, STATE_ON, STATE_OFF), (1, STATE_OFF, STATE_OFF, STATE_OFF), - (0, STATE_OFF, STATE_OFF, STATE_OFF), + (0, STATE_UNKNOWN, STATE_UNKNOWN, STATE_UNKNOWN), (-1, STATE_UNAVAILABLE, STATE_UNAVAILABLE, STATE_UNAVAILABLE), ], ) From c7603b39eca8b16075184275046dec06d0c5327d Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 10:44:39 +0200 Subject: [PATCH 0862/1664] Fix inputs to correctly handle Fahrenheit in IronOS (#135421) * Fix inputs to correctly handle Fahrenheit in IronOS * some refactoring * add boost switch entity * Revert switch entity * refactor * remove commented code * some changes --- homeassistant/components/iron_os/const.py | 4 + .../components/iron_os/coordinator.py | 4 +- homeassistant/components/iron_os/number.py | 233 ++++++++++++------ .../iron_os/snapshots/test_number.ambr | 13 +- tests/components/iron_os/test_number.py | 48 +++- 5 files changed, 214 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/iron_os/const.py b/homeassistant/components/iron_os/const.py index 34889636808..0ed645f8f7b 100644 --- a/homeassistant/components/iron_os/const.py +++ b/homeassistant/components/iron_os/const.py @@ -10,4 +10,8 @@ OHM = "Ω" DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533" MAX_TEMP: int = 450 +MAX_TEMP_F: int = 850 MIN_TEMP: int = 10 +MIN_TEMP_F: int = 50 +MIN_BOOST_TEMP: int = 250 +MIN_BOOST_TEMP_F: int = 480 diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py index 99c688ea855..7214db0a12f 100644 --- a/homeassistant/components/iron_os/coordinator.py +++ b/homeassistant/components/iron_os/coordinator.py @@ -168,7 +168,9 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]): if self.device.is_connected and characteristics: try: - return await self.device.get_settings(list(characteristics)) + return await self.device.get_settings( + list(characteristics | {CharSetting.TEMP_UNIT}) + ) except CommunicationError as e: _LOGGER.debug("Failed to fetch settings", exc_info=e) diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 6ad5947cb6f..9fada23a987 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -6,10 +6,9 @@ from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum -from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse +from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit from homeassistant.components.number import ( - DEFAULT_MAX_VALUE, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -24,9 +23,17 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import TemperatureConverter from . import IronOSConfigEntry -from .const import MAX_TEMP, MIN_TEMP +from .const import ( + MAX_TEMP, + MAX_TEMP_F, + MIN_BOOST_TEMP, + MIN_BOOST_TEMP_F, + MIN_TEMP, + MIN_TEMP_F, +) from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -38,9 +45,10 @@ class IronOSNumberEntityDescription(NumberEntityDescription): """Describes IronOS number entity.""" value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None] - max_value_fn: Callable[[LiveDataResponse], float | int] | None = None characteristic: CharSetting raw_value_fn: Callable[[float], float | int] | None = None + native_max_value_f: float | None = None + native_min_value_f: float | None = None class PinecilNumber(StrEnum): @@ -74,44 +82,6 @@ def multiply(value: float | None, multiplier: float) -> float | None: PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( - IronOSNumberEntityDescription( - key=PinecilNumber.SETPOINT_TEMP, - translation_key=PinecilNumber.SETPOINT_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda data, _: data.setpoint_temp, - characteristic=CharSetting.SETPOINT_TEMP, - mode=NumberMode.BOX, - native_min_value=MIN_TEMP, - native_step=5, - max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP), - ), - IronOSNumberEntityDescription( - key=PinecilNumber.SLEEP_TEMP, - translation_key=PinecilNumber.SLEEP_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda _, settings: settings.get("sleep_temp"), - characteristic=CharSetting.SLEEP_TEMP, - mode=NumberMode.BOX, - native_min_value=MIN_TEMP, - native_max_value=MAX_TEMP, - native_step=10, - entity_category=EntityCategory.CONFIG, - ), - IronOSNumberEntityDescription( - key=PinecilNumber.BOOST_TEMP, - translation_key=PinecilNumber.BOOST_TEMP, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - device_class=NumberDeviceClass.TEMPERATURE, - value_fn=lambda _, settings: settings.get("boost_temp"), - characteristic=CharSetting.BOOST_TEMP, - mode=NumberMode.BOX, - native_min_value=0, - native_max_value=MAX_TEMP, - native_step=10, - entity_category=EntityCategory.CONFIG, - ), IronOSNumberEntityDescription( key=PinecilNumber.QC_MAX_VOLTAGE, translation_key=PinecilNumber.QC_MAX_VOLTAGE, @@ -296,32 +266,6 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, ), - IronOSNumberEntityDescription( - key=PinecilNumber.TEMP_INCREMENT_SHORT, - translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, - value_fn=(lambda _, settings: settings.get("temp_increment_short")), - characteristic=CharSetting.TEMP_INCREMENT_SHORT, - raw_value_fn=lambda value: value, - mode=NumberMode.BOX, - native_min_value=1, - native_max_value=50, - native_step=1, - entity_category=EntityCategory.CONFIG, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), - IronOSNumberEntityDescription( - key=PinecilNumber.TEMP_INCREMENT_LONG, - translation_key=PinecilNumber.TEMP_INCREMENT_LONG, - value_fn=(lambda _, settings: settings.get("temp_increment_long")), - characteristic=CharSetting.TEMP_INCREMENT_LONG, - raw_value_fn=lambda value: value, - mode=NumberMode.BOX, - native_min_value=5, - native_max_value=90, - native_step=5, - entity_category=EntityCategory.CONFIG, - native_unit_of_measurement=UnitOfTemperature.CELSIUS, - ), ) PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( @@ -341,6 +285,82 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = ( ), ) +""" +The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities. +These entities represent user-defined input values, not measured temperatures, and their +interpretation depends on the device's current unit configuration. Applying a device_class +results in automatic unit conversions, which introduce rounding errors due to the use of integers. +This can prevent the correct value from being set, as the input is modified during synchronization with the device. +""" +PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = ( + IronOSNumberEntityDescription( + key=PinecilNumber.SLEEP_TEMP, + translation_key=PinecilNumber.SLEEP_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda _, settings: settings.get("sleep_temp"), + characteristic=CharSetting.SLEEP_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_min_value_f=MIN_TEMP_F, + native_max_value_f=MAX_TEMP_F, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.BOOST_TEMP, + translation_key=PinecilNumber.BOOST_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda _, settings: settings.get("boost_temp"), + characteristic=CharSetting.BOOST_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_BOOST_TEMP, + native_min_value_f=MIN_BOOST_TEMP_F, + native_max_value=MAX_TEMP, + native_max_value_f=MAX_TEMP_F, + native_step=10, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_SHORT, + translation_key=PinecilNumber.TEMP_INCREMENT_SHORT, + value_fn=(lambda _, settings: settings.get("temp_increment_short")), + characteristic=CharSetting.TEMP_INCREMENT_SHORT, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=1, + native_max_value=50, + native_step=1, + entity_category=EntityCategory.CONFIG, + ), + IronOSNumberEntityDescription( + key=PinecilNumber.TEMP_INCREMENT_LONG, + translation_key=PinecilNumber.TEMP_INCREMENT_LONG, + value_fn=(lambda _, settings: settings.get("temp_increment_long")), + characteristic=CharSetting.TEMP_INCREMENT_LONG, + raw_value_fn=lambda value: value, + mode=NumberMode.BOX, + native_min_value=5, + native_max_value=90, + native_step=5, + entity_category=EntityCategory.CONFIG, + ), +) + +PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription( + key=PinecilNumber.SETPOINT_TEMP, + translation_key=PinecilNumber.SETPOINT_TEMP, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda data, _: data.setpoint_temp, + characteristic=CharSetting.SETPOINT_TEMP, + mode=NumberMode.BOX, + native_min_value=MIN_TEMP, + native_max_value=MAX_TEMP, + native_min_value_f=MIN_TEMP_F, + native_max_value_f=MAX_TEMP_F, + native_step=5, +) + async def async_setup_entry( hass: HomeAssistant, @@ -354,9 +374,18 @@ async def async_setup_entry( if coordinators.live_data.v223_features: descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223 - async_add_entities( + entities = [ IronOSNumberEntity(coordinators, description) for description in descriptions + ] + + entities.extend( + IronOSTemperatureNumberEntity(coordinators, description) + for description in PINECIL_TEMP_NUMBER_DESCRIPTIONS ) + entities.append( + IronOSSetpointNumberEntity(coordinators, PINECIL_SETPOINT_NUMBER_DESCRIPTION) + ) + async_add_entities(entities) class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): @@ -388,15 +417,6 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): self.coordinator.data, self.settings.data ) - @property - def native_max_value(self) -> float: - """Return sensor state.""" - - if self.entity_description.max_value_fn is not None: - return self.entity_description.max_value_fn(self.coordinator.data) - - return self.entity_description.native_max_value or DEFAULT_MAX_VALUE - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -407,3 +427,60 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity): ) ) await self.settings.async_request_refresh() + + +class IronOSTemperatureNumberEntity(IronOSNumberEntity): + """Implementation of a IronOS temperature number entity.""" + + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the sensor, if any.""" + + return ( + UnitOfTemperature.FAHRENHEIT + if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT + else UnitOfTemperature.CELSIUS + ) + + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + + return ( + self.entity_description.native_min_value_f + if self.entity_description.native_min_value_f + and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT + else super().native_min_value + ) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + + return ( + self.entity_description.native_max_value_f + if self.entity_description.native_max_value_f + and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT + else super().native_max_value + ) + + +class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity): + """IronOS setpoint temperature entity.""" + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + + return ( + min( + TemperatureConverter.convert( + float(max_tip_c), + UnitOfTemperature.CELSIUS, + self.native_unit_of_measurement, + ), + super().native_max_value, + ) + if (max_tip_c := self.coordinator.data.max_tip_temp_ability) is not None + else super().native_max_value + ) diff --git a/tests/components/iron_os/snapshots/test_number.ambr b/tests/components/iron_os/snapshots/test_number.ambr index 37d8b1f4819..52fd6bb2ce4 100644 --- a/tests/components/iron_os/snapshots/test_number.ambr +++ b/tests/components/iron_os/snapshots/test_number.ambr @@ -6,7 +6,7 @@ 'area_id': None, 'capabilities': dict({ 'max': 450, - 'min': 0, + 'min': 250, 'mode': , 'step': 10, }), @@ -27,7 +27,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Boost temperature', 'platform': 'iron_os', @@ -42,10 +42,9 @@ # name: test_state[number.pinecil_boost_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Boost temperature', 'max': 450, - 'min': 0, + 'min': 250, 'mode': , 'step': 10, 'unit_of_measurement': , @@ -839,7 +838,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Setpoint temperature', 'platform': 'iron_os', @@ -854,7 +853,6 @@ # name: test_state[number.pinecil_setpoint_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Setpoint temperature', 'max': 450, 'min': 10, @@ -1015,7 +1013,7 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, 'original_name': 'Sleep temperature', 'platform': 'iron_os', @@ -1030,7 +1028,6 @@ # name: test_state[number.pinecil_sleep_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', 'friendly_name': 'Pinecil Sleep temperature', 'max': 450, 'min': 10, diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 9a4ba53f338..3c7be52c577 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -5,10 +5,15 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CharSetting, CommunicationError +from pynecil import CharSetting, CommunicationError, TempUnit import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.iron_os.const import ( + MAX_TEMP_F, + MIN_BOOST_TEMP_F, + MIN_TEMP_F, +) from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, @@ -56,6 +61,47 @@ async def test_state( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +@pytest.mark.parametrize( + ("entity_id", "min_value", "max_value"), + [ + ("number.pinecil_setpoint_temperature", MIN_TEMP_F, MAX_TEMP_F), + ("number.pinecil_boost_temperature", MIN_BOOST_TEMP_F, MAX_TEMP_F), + ("number.pinecil_long_press_temperature_step", 5, 90), + ("number.pinecil_short_press_temperature_step", 1, 50), + ("number.pinecil_sleep_temperature", MIN_TEMP_F, MAX_TEMP_F), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_state_fahrenheit( + hass: HomeAssistant, + config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + mock_pynecil: AsyncMock, + entity_id: str, + min_value: int, + max_value: int, +) -> None: + """Test with temp unit set to fahrenheit.""" + + mock_pynecil.get_settings.return_value["temp_unit"] = TempUnit.FAHRENHEIT + + 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 + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state is not None + + assert state.attributes["min"] == min_value + assert state.attributes["max"] == max_value + + @pytest.mark.parametrize( ("entity_id", "characteristic", "value", "expected_value"), [ From 4d58024d5d13a334a4142a37a608cf72bb7b1326 Mon Sep 17 00:00:00 2001 From: Steffen Rusitschka Date: Mon, 30 Jun 2025 10:52:33 +0200 Subject: [PATCH 0863/1664] Add publish_string_states config to zabbix (#134773) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add include_strings config to zabbix * Remove commented code * Fix ruff formatting * Update homeassistant/components/zabbix/__init__.py Co-authored-by: Abílio Costa * Update homeassistant/components/zabbix/__init__.py Co-authored-by: Abílio Costa * Don't use dict.get, CONF_INCLUDE_STRINGS has a default value and will always be set. Co-authored-by: Erik Montnemery * Convert to string only when include_strings is true Co-authored-by: Erik Montnemery * change to guard * Fix review comments * ruff, mypy, pylint fixes * more ruff, mypy fixes * and another ruff format fix --------- Co-authored-by: Abílio Costa Co-authored-by: Erik Montnemery --- homeassistant/components/zabbix/__init__.py | 58 +++++++++++++-------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 524bac271de..31a09242a71 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -43,6 +43,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) CONF_PUBLISH_STATES_HOST = "publish_states_host" +CONF_PUBLISH_STRING_STATES = "publish_string_states" DEFAULT_SSL = False DEFAULT_PATH = "zabbix" @@ -67,6 +68,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PUBLISH_STATES_HOST): cv.string, + vol.Optional(CONF_PUBLISH_STRING_STATES, default=False): cv.boolean, } ) }, @@ -85,6 +87,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: password = conf.get(CONF_PASSWORD) publish_states_host = conf.get(CONF_PUBLISH_STATES_HOST) + publish_string_states = conf[CONF_PUBLISH_STRING_STATES] entities_filter = convert_include_exclude_filter(conf) @@ -107,6 +110,28 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN] = zapi + def update_metrics( + metrics: list[ItemValue], + item_type: str, + keys: set[str], + key_values: dict[str, float | str], + ): + keys_count = len(keys) + keys.update(key_values) + if len(keys) > keys_count: + discovery = [{"{#KEY}": key} for key in keys] + metric = ItemValue( + publish_states_host, + f"homeassistant.{item_type}s_discovery", + json.dumps(discovery), + ) + metrics.append(metric) + for key, value in key_values.items(): + metric = ItemValue( + publish_states_host, f"homeassistant.{item_type}[{key}]", value + ) + metrics.append(metric) + def event_to_metrics( event: Event, float_keys: set[str], string_keys: set[str] ) -> list[ItemValue] | None: @@ -119,8 +144,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: if not entities_filter(entity_id): return None - floats = {} - strings = {} + floats: dict[str, float | str] = {} + strings: dict[str, float | str] = {} try: _state_as_value = float(state.state) floats[entity_id] = _state_as_value @@ -129,7 +154,8 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: _state_as_value = float(state_helper.state_as_number(state)) floats[entity_id] = _state_as_value except ValueError: - strings[entity_id] = state.state + if publish_string_states: + strings[entity_id] = str(state.state) for key, value in state.attributes.items(): # For each value we try to cast it as float @@ -141,28 +167,18 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: except (ValueError, TypeError): float_value = None if float_value is None or not math.isfinite(float_value): - strings[attribute_id] = str(value) + # Don't store string attributes for now + pass else: floats[attribute_id] = float_value - metrics = [] - float_keys_count = len(float_keys) - float_keys.update(floats) - if len(float_keys) != float_keys_count: - floats_discovery = [{"{#KEY}": float_key} for float_key in float_keys] - metric = ItemValue( - publish_states_host, - "homeassistant.floats_discovery", - json.dumps(floats_discovery), - ) - metrics.append(metric) - for key, value in floats.items(): - metric = ItemValue( - publish_states_host, f"homeassistant.float[{key}]", value - ) - metrics.append(metric) + metrics: list[ItemValue] = [] + update_metrics(metrics, "float", float_keys, floats) - string_keys.update(strings) + if not publish_string_states: + return metrics + + update_metrics(metrics, "string", string_keys, strings) return metrics if publish_states_host: From a6e3da43cabfc43ee3e269e7c48dbce4e458d81c Mon Sep 17 00:00:00 2001 From: Evan Severson <208220+eseverson@users.noreply.github.com> Date: Mon, 30 Jun 2025 02:08:50 -0700 Subject: [PATCH 0864/1664] Fixed pushbullet handling of fields longer than 255 characters (#146993) --- homeassistant/components/pushbullet/sensor.py | 9 +- tests/components/pushbullet/test_sensor.py | 168 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/components/pushbullet/test_sensor.py diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 2dbaa8fc713..ea9a8f198ef 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -116,7 +116,12 @@ class PushBulletNotificationSensor(SensorEntity): attributes into self._state_attributes. """ try: - self._attr_native_value = self.pb_provider.data[self.entity_description.key] + value = self.pb_provider.data[self.entity_description.key] + # Truncate state value to MAX_LENGTH_STATE_STATE while preserving full content in attributes + if isinstance(value, str) and len(value) > MAX_LENGTH_STATE_STATE: + self._attr_native_value = value[: MAX_LENGTH_STATE_STATE - 3] + "..." + else: + self._attr_native_value = value self._attr_extra_state_attributes = self.pb_provider.data except (KeyError, TypeError): pass diff --git a/tests/components/pushbullet/test_sensor.py b/tests/components/pushbullet/test_sensor.py new file mode 100644 index 00000000000..b6ae8c3a211 --- /dev/null +++ b/tests/components/pushbullet/test_sensor.py @@ -0,0 +1,168 @@ +"""Test pushbullet sensor platform.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.pushbullet.const import DOMAIN +from homeassistant.components.pushbullet.sensor import ( + SENSOR_TYPES, + PushBulletNotificationSensor, +) +from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.core import HomeAssistant + +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +def _create_mock_provider() -> Mock: + """Create a mock pushbullet provider for testing.""" + mock_provider = Mock() + mock_provider.pushbullet.user_info = {"iden": "test_user_123"} + return mock_provider + + +def _get_sensor_description(key: str): + """Get sensor description by key.""" + for desc in SENSOR_TYPES: + if desc.key == key: + return desc + raise ValueError(f"Sensor description not found for key: {key}") + + +def _create_test_sensor( + provider: Mock, sensor_key: str +) -> PushBulletNotificationSensor: + """Create a test sensor instance with mocked dependencies.""" + description = _get_sensor_description(sensor_key) + sensor = PushBulletNotificationSensor( + name="Test Pushbullet", pb_provider=provider, description=description + ) + # Mock async_write_ha_state to avoid requiring full HA setup + sensor.async_write_ha_state = Mock() + return sensor + + +@pytest.fixture +async def mock_pushbullet_entry(hass: HomeAssistant, requests_mock_fixture): + """Set up pushbullet integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def test_sensor_truncation_logic() -> None: + """Test sensor truncation logic for body sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test long body truncation + long_body = "a" * (MAX_LENGTH_STATE_STATE + 50) + provider.data = { + "body": long_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("a") + assert sensor._attr_extra_state_attributes["body"] == long_body + + # Test normal length body + normal_body = "This is a normal body" + provider.data = { + "body": normal_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation + assert sensor._attr_native_value == normal_body + assert len(sensor._attr_native_value) < MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == normal_body + + # Test exactly max length + exact_body = "a" * MAX_LENGTH_STATE_STATE + provider.data = { + "body": exact_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation at the limit + assert sensor._attr_native_value == exact_body + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == exact_body + + +def test_sensor_truncation_title_sensor() -> None: + """Test sensor truncation logic on title sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "title") + + # Test long title truncation + long_title = "Title " + "x" * (MAX_LENGTH_STATE_STATE) + provider.data = { + "body": "Test body", + "title": long_title, + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("Title") + assert sensor._attr_extra_state_attributes["title"] == long_title + + +def test_sensor_truncation_non_string_handling() -> None: + """Test that non-string values are handled correctly.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test with None value + provider.data = { + "body": None, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value is None + + # Test with integer value (would be converted to string by Home Assistant) + provider.data = { + "body": 12345, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value == 12345 # Not truncated since it's not a string + + # Test with missing key + provider.data = { + "title": "Test Title", + "type": "note", + } + + # This should not raise an exception + sensor.async_update_callback() From c7b2f236be23d15d081f4378a21f708656cc7e62 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 11:15:12 +0200 Subject: [PATCH 0865/1664] Type Z-Wave JS config entry (#147456) * Type Z-Wave JS config entry * Migrate to data class --- homeassistant/components/zwave_js/__init__.py | 31 +++---- homeassistant/components/zwave_js/api.py | 82 +++++++++++-------- .../components/zwave_js/binary_sensor.py | 17 ++-- homeassistant/components/zwave_js/button.py | 13 ++- homeassistant/components/zwave_js/climate.py | 13 ++- .../components/zwave_js/config_flow.py | 7 +- homeassistant/components/zwave_js/const.py | 2 - homeassistant/components/zwave_js/cover.py | 22 ++--- .../zwave_js/device_automation_helpers.py | 5 +- .../components/zwave_js/diagnostics.py | 15 ++-- homeassistant/components/zwave_js/event.py | 11 ++- homeassistant/components/zwave_js/fan.py | 15 ++-- homeassistant/components/zwave_js/helpers.py | 34 ++++---- .../components/zwave_js/humidifier.py | 11 ++- homeassistant/components/zwave_js/light.py | 13 ++- homeassistant/components/zwave_js/lock.py | 8 +- homeassistant/components/zwave_js/models.py | 27 ++++++ homeassistant/components/zwave_js/number.py | 15 ++-- homeassistant/components/zwave_js/select.py | 19 ++--- homeassistant/components/zwave_js/sensor.py | 22 +++-- homeassistant/components/zwave_js/services.py | 2 +- homeassistant/components/zwave_js/siren.py | 11 ++- homeassistant/components/zwave_js/switch.py | 17 ++-- .../components/zwave_js/triggers/event.py | 4 +- .../zwave_js/triggers/trigger_helpers.py | 6 +- homeassistant/components/zwave_js/update.py | 9 +- 26 files changed, 220 insertions(+), 211 deletions(-) create mode 100644 homeassistant/components/zwave_js/models.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 0b172c20715..982525be778 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -29,7 +29,7 @@ from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.hassio import AddonError, AddonManager, AddonState from homeassistant.components.persistent_notification import async_create -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_DEVICE_ID, ATTR_DOMAIN, @@ -104,7 +104,6 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, - DATA_CLIENT, DOMAIN, DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, @@ -133,10 +132,10 @@ from .helpers import ( get_valueless_base_unique_id, ) from .migrate import async_migrate_discovered_value +from .models import ZwaveJSConfigEntry, ZwaveJSData from .services import async_setup_services CONNECT_TIMEOUT = 10 -DATA_DRIVER_EVENTS = "driver_events" CONFIG_SCHEMA = vol.Schema( { @@ -182,7 +181,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool: """Set up Z-Wave JS from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): await async_ensure_addon_running(hass, entry) @@ -260,10 +259,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Connection to Zwave JS Server initialized") - entry_runtime_data = entry.runtime_data = { - DATA_CLIENT: client, - } - entry_runtime_data[DATA_DRIVER_EVENTS] = driver_events = DriverEvents(hass, entry) + driver_events = DriverEvents(hass, entry) + entry_runtime_data = ZwaveJSData( + client=client, + driver_events=driver_events, + ) + entry.runtime_data = entry_runtime_data driver = client.driver # When the driver is ready we know it's set on the client. @@ -348,7 +349,7 @@ class DriverEvents: driver: Driver - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> None: """Set up the driver events instance.""" self.config_entry = entry self.dev_reg = dr.async_get(hass) @@ -1045,7 +1046,7 @@ class NodeEvents: async def client_listen( hass: HomeAssistant, - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: ZwaveClient, driver_ready: asyncio.Event, ) -> None: @@ -1072,12 +1073,12 @@ async def client_listen( hass.config_entries.async_schedule_reload(entry.entry_id) -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) entry_runtime_data = entry.runtime_data - client: ZwaveClient = entry_runtime_data[DATA_CLIENT] + client = entry_runtime_data.client if client.connected and (driver := client.driver): await async_disable_server_logging_if_needed(hass, entry, driver) @@ -1094,7 +1095,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: ZwaveJSConfigEntry) -> None: """Remove a config entry.""" if not entry.data.get(CONF_INTEGRATION_CREATED_ADDON): return @@ -1116,7 +1117,9 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: LOGGER.error(err) -async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_ensure_addon_running( + hass: HomeAssistant, entry: ZwaveJSConfigEntry +) -> None: """Ensure that Z-Wave JS add-on is installed and running.""" addon_manager = _get_addon_manager(hass) try: diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index a17f13e0d07..0f75d8b4673 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -7,7 +7,7 @@ from collections.abc import Callable, Coroutine from contextlib import suppress import dataclasses from functools import partial, wraps -from typing import Any, Concatenate, Literal, cast +from typing import TYPE_CHECKING, Any, Concatenate, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -70,7 +70,7 @@ from homeassistant.components.websocket_api import ( ERR_UNKNOWN_ERROR, ActiveConnection, ) -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -86,7 +86,6 @@ from .const import ( ATTR_WAIT_FOR_RESULT, CONF_DATA_COLLECTION_OPTED_IN, CONF_INSTALLER_MODE, - DATA_CLIENT, DOMAIN, DRIVER_READY_TIMEOUT, EVENT_DEVICE_ADDED_TO_REGISTRY, @@ -102,6 +101,10 @@ from .helpers import ( get_device_id, ) +if TYPE_CHECKING: + from .models import ZwaveJSConfigEntry + + DATA_UNSUBSCRIBE = "unsubs" # general API constants @@ -254,7 +257,7 @@ async def _async_get_entry( connection: ActiveConnection, msg: dict[str, Any], entry_id: str, -) -> tuple[ConfigEntry, Client, Driver] | tuple[None, None, None]: +) -> tuple[ZwaveJSConfigEntry, Client, Driver] | tuple[None, None, None]: """Get config entry and client from message data.""" entry = hass.config_entries.async_get_entry(entry_id) if entry is None: @@ -269,7 +272,7 @@ async def _async_get_entry( ) return None, None, None - client: Client = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client if client.driver is None: connection.send_error( @@ -284,7 +287,14 @@ async def _async_get_entry( def async_get_entry( orig_func: Callable[ - [HomeAssistant, ActiveConnection, dict[str, Any], ConfigEntry, Client, Driver], + [ + HomeAssistant, + ActiveConnection, + dict[str, Any], + ZwaveJSConfigEntry, + Client, + Driver, + ], Coroutine[Any, Any, None], ], ) -> Callable[ @@ -726,7 +736,7 @@ async def websocket_add_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -903,7 +913,7 @@ async def websocket_cancel_secure_bootstrap_s2( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -926,7 +936,7 @@ async def websocket_subscribe_s2_inclusion( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -979,7 +989,7 @@ async def websocket_grant_security_classes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1007,7 +1017,7 @@ async def websocket_validate_dsk_and_enter_pin( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1077,7 +1087,7 @@ async def websocket_provision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1162,7 +1172,7 @@ async def websocket_unprovision_smart_start_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1212,7 +1222,7 @@ async def websocket_get_provisioning_entries( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1236,7 +1246,7 @@ async def websocket_parse_qr_code_string( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1262,7 +1272,7 @@ async def websocket_try_parse_dsk_from_qr_code_string( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1291,7 +1301,7 @@ async def websocket_lookup_device( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1323,7 +1333,7 @@ async def websocket_supports_feature( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1349,7 +1359,7 @@ async def websocket_stop_inclusion( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1376,7 +1386,7 @@ async def websocket_stop_exclusion( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1404,7 +1414,7 @@ async def websocket_remove_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1692,7 +1702,7 @@ async def websocket_begin_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1719,7 +1729,7 @@ async def websocket_subscribe_rebuild_routes_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -1772,7 +1782,7 @@ async def websocket_stop_rebuilding_routes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2100,7 +2110,7 @@ async def websocket_subscribe_log_updates( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2187,7 +2197,7 @@ async def websocket_update_log_config( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2211,7 +2221,7 @@ async def websocket_get_log_config( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2238,7 +2248,7 @@ async def websocket_update_data_collection_preference( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2273,7 +2283,7 @@ async def websocket_data_collection_status( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2507,7 +2517,7 @@ async def websocket_is_any_ota_firmware_update_in_progress( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2602,7 +2612,7 @@ async def websocket_check_for_config_updates( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2631,7 +2641,7 @@ async def websocket_install_config_update( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2670,7 +2680,7 @@ async def websocket_subscribe_controller_statistics( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -2823,7 +2833,7 @@ async def websocket_hard_reset_controller( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -3000,7 +3010,7 @@ async def websocket_backup_nvm( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: @@ -3062,7 +3072,7 @@ async def websocket_restore_nvm( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any], - entry: ConfigEntry, + entry: ZwaveJSConfigEntry, client: Client, driver: Driver, ) -> None: diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index d70690ace31..5b7fe4f4d7c 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from dataclasses import dataclass -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY from zwave_js_server.const.command_class.notification import ( @@ -18,15 +17,15 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -364,11 +363,11 @@ def is_valid_notification_binary_sensor( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave binary sensor from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: @@ -448,7 +447,7 @@ class ZWaveBooleanBinarySensor(ZWaveBaseEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -476,7 +475,7 @@ class ZWaveNotificationBinarySensor(ZWaveBaseEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, state_key: str, @@ -509,7 +508,7 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, description: PropertyZWaveJSEntityDescription, @@ -533,7 +532,7 @@ class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor): _attr_entity_category = EntityCategory.DIAGNOSTIC def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterBinarySensor entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index f3a1d5af04d..36bca858b50 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -2,32 +2,31 @@ from __future__ import annotations -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave button from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_button(info: ZwaveDiscoveryInfo) -> None: @@ -70,7 +69,7 @@ class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity): """Representation of a ZWave button entity for a boolean value.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize entity.""" super().__init__(config_entry, driver, info) @@ -141,7 +140,7 @@ class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveNotificationIdleButton entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 809d3543fe4..5d3b1f8ef07 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_CURRENT_TEMP_PROPERTY, @@ -31,18 +30,18 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import DynamicCurrentTempClimateDataTemplate from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -96,11 +95,11 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave climate from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_climate(info: ZwaveDiscoveryInfo) -> None: @@ -130,7 +129,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize thermostat.""" super().__init__(config_entry, driver, info) @@ -563,7 +562,7 @@ class DynamicCurrentTempClimate(ZWaveClimate): """Representation of a thermostat that can dynamically use a different Zwave Value for current temp.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize thermostat.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 7e95e274713..3e46fc6bac3 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -27,7 +27,6 @@ from homeassistant.components.hassio import ( ) from homeassistant.config_entries import ( SOURCE_USB, - ConfigEntry, ConfigEntryState, ConfigFlow, ConfigFlowResult, @@ -62,11 +61,11 @@ from .const import ( CONF_S2_UNAUTHENTICATED_KEY, CONF_USB_PATH, CONF_USE_ADDON, - DATA_CLIENT, DOMAIN, DRIVER_READY_TIMEOUT, ) from .helpers import CannotConnect, async_get_version_info +from .models import ZwaveJSConfigEntry _LOGGER = logging.getLogger(__name__) @@ -185,7 +184,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): self.backup_filepath: Path | None = None self.use_addon = False self._migrating = False - self._reconfigure_config_entry: ConfigEntry | None = None + self._reconfigure_config_entry: ZwaveJSConfigEntry | None = None self._usb_discovery = False self._recommended_install = False @@ -1443,7 +1442,7 @@ class ZWaveJSConfigFlow(ConfigFlow, domain=DOMAIN): assert config_entry is not None if config_entry.state != ConfigEntryState.LOADED: raise AbortFlow("Configuration entry is not loaded") - client: Client = config_entry.runtime_data[DATA_CLIENT] + client: Client = config_entry.runtime_data.client assert client.driver is not None return client.driver diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index a99e9fd0113..6dc76ebd05d 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -38,8 +38,6 @@ CONF_USE_ADDON = "use_addon" CONF_DATA_COLLECTION_OPTED_IN = "data_collection_opted_in" DOMAIN = "zwave_js" -DATA_CLIENT = "client" -DATA_OLD_SERVER_LOG_LEVEL = "old_server_log_level" EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" EVENT_VALUE_UPDATED = "value updated" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index dc44f46a3ce..424fe94b8b9 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( CURRENT_VALUE_PROPERTY, TARGET_STATE_PROPERTY, @@ -34,31 +33,26 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - COVER_POSITION_PROPERTY_KEYS, - COVER_TILT_PROPERTY_KEYS, - DATA_CLIENT, - DOMAIN, -) +from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import CoverTiltDataTemplate from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Cover from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_cover(info: ZwaveDiscoveryInfo) -> None: @@ -288,7 +282,7 @@ class ZWaveMultilevelSwitchCover(CoverPositionMixin): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -318,7 +312,7 @@ class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -336,7 +330,7 @@ class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): """Representation of a Z-Wave Window Covering cover device.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize.""" super().__init__(config_entry, driver, info) @@ -438,7 +432,7 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 4eed2a5b50c..27c9ff2bd34 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -2,14 +2,13 @@ from __future__ import annotations -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.value import ConfigurationValue from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN NODE_STATUSES = ["asleep", "awake", "dead", "alive"] @@ -55,5 +54,5 @@ def async_bypass_dynamic_config_validation(hass: HomeAssistant, device_id: str) return True # The driver may not be ready when the config entry is loaded. - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client return client.driver is None diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 5515100b20b..1929341a4be 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -13,13 +13,12 @@ from zwave_js_server.model.value import ValueDataType from zwave_js_server.util.node import dump_node_state from homeassistant.components.diagnostics import REDACTED, async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DATA_CLIENT, USER_AGENT +from .const import USER_AGENT from .helpers import ( ZwaveValueMatcher, get_home_and_node_id_from_device_entry, @@ -27,6 +26,7 @@ from .helpers import ( get_value_id_from_unique_id, value_matches_matcher, ) +from .models import ZwaveJSConfigEntry KEYS_TO_REDACT = {"homeId", "location"} @@ -73,7 +73,10 @@ def redact_node_state(node_state: dict) -> dict: def get_device_entities( - hass: HomeAssistant, node: Node, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, + node: Node, + config_entry: ZwaveJSConfigEntry, + device: dr.DeviceEntry, ) -> list[dict[str, Any]]: """Get entities for a device.""" entity_entries = er.async_entries_for_device( @@ -125,7 +128,7 @@ def get_device_entities( async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" msgs: list[dict] = async_redact_data( @@ -144,10 +147,10 @@ async def async_get_config_entry_diagnostics( async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: dr.DeviceEntry + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, device: dr.DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - client: Client = config_entry.runtime_data[DATA_CLIENT] + client: Client = config_entry.runtime_data.client identifiers = get_home_and_node_id_from_device_entry(device) node_id = identifiers[1] if identifiers else None driver = client.driver diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py index 66959aa9b75..60f0e110108 100644 --- a/homeassistant/components/zwave_js/event.py +++ b/homeassistant/components/zwave_js/event.py @@ -2,30 +2,29 @@ from __future__ import annotations -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value, ValueNotification from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN +from .const import ATTR_VALUE, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Event entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_event(info: ZwaveDiscoveryInfo) -> None: @@ -56,7 +55,7 @@ class ZwaveEventEntity(ZWaveBaseEntity, EventEntity): """Representation of a Z-Wave event entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveEventEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index ae36e0afb42..8e47cbbeb1d 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -5,7 +5,6 @@ from __future__ import annotations import math from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE from zwave_js_server.const.command_class.thermostat import ( @@ -20,7 +19,6 @@ from homeassistant.components.fan import ( FanEntity, FanEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -30,11 +28,12 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import FanValueMapping, FanValueMappingDataTemplate from .entity import ZWaveBaseEntity from .helpers import get_value_of_zwave_value +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -45,11 +44,11 @@ ATTR_FAN_STATE = "fan_state" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Fan from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_fan(info: ZwaveDiscoveryInfo) -> None: @@ -85,7 +84,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): ) def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the fan.""" super().__init__(config_entry, driver, info) @@ -165,7 +164,7 @@ class ValueMappingZwaveFan(ZwaveFan): """A Zwave fan with a value mapping data (e.g., 1-24 is low).""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the fan.""" super().__init__(config_entry, driver, info) @@ -316,7 +315,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): _fan_state: ZwaveValue | None = None def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the thermostat fan.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index bfa093f7db9..5694be5482b 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -10,7 +10,6 @@ from typing import Any, cast import aiohttp import voluptuous as vol -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( LOG_LEVEL_MAP, CommandClass, @@ -30,7 +29,7 @@ from zwave_js_server.model.value import ( from zwave_js_server.version import VersionInfo, get_server_version from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_AREA_ID, ATTR_DEVICE_ID, @@ -51,12 +50,11 @@ from .const import ( ATTR_ENDPOINT, ATTR_PROPERTY, ATTR_PROPERTY_KEY, - DATA_CLIENT, - DATA_OLD_SERVER_LOG_LEVEL, DOMAIN, LIB_LOGGER, LOGGER, ) +from .models import ZwaveJSConfigEntry SERVER_VERSION_TIMEOUT = 10 @@ -143,7 +141,7 @@ async def async_enable_statistics(driver: Driver) -> None: async def async_enable_server_logging_if_needed( - hass: HomeAssistant, entry: ConfigEntry, driver: Driver + hass: HomeAssistant, entry: ZwaveJSConfigEntry, driver: Driver ) -> None: """Enable logging of zwave-js-server in the lib.""" # If lib log level is set to debug, we want to enable server logging. First we @@ -161,15 +159,14 @@ async def async_enable_server_logging_if_needed( if (curr_server_log_level := driver.log_config.level) and ( LOG_LEVEL_MAP[curr_server_log_level] ) > LIB_LOGGER.getEffectiveLevel(): - entry_data = entry.runtime_data - entry_data[DATA_OLD_SERVER_LOG_LEVEL] = curr_server_log_level + entry.runtime_data.old_server_log_level = curr_server_log_level await driver.async_update_log_config(LogConfig(level=LogLevel.DEBUG)) await driver.client.enable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") async def async_disable_server_logging_if_needed( - hass: HomeAssistant, entry: ConfigEntry, driver: Driver + hass: HomeAssistant, entry: ZwaveJSConfigEntry, driver: Driver ) -> None: """Disable logging of zwave-js-server in the lib if still connected to server.""" if ( @@ -180,10 +177,8 @@ async def async_disable_server_logging_if_needed( return LOGGER.info("Disabling zwave_js server logging") if ( - DATA_OLD_SERVER_LOG_LEVEL in entry.runtime_data - and (old_server_log_level := entry.runtime_data.pop(DATA_OLD_SERVER_LOG_LEVEL)) - != driver.log_config.level - ): + old_server_log_level := entry.runtime_data.old_server_log_level + ) is not None and old_server_log_level != driver.log_config.level: LOGGER.info( ( "Server logging is currently set to %s as a result of server logging " @@ -193,6 +188,7 @@ async def async_disable_server_logging_if_needed( old_server_log_level, ) await driver.async_update_log_config(LogConfig(level=old_server_log_level)) + entry.runtime_data.old_server_log_level = None driver.client.disable_server_logging() LOGGER.info("Zwave-js-server logging is enabled") @@ -262,7 +258,7 @@ def async_get_node_from_device_id( # Use device config entry ID's to validate that this is a valid zwave_js device # and to get the client config_entry_ids = device_entry.config_entries - entry = next( + entry: ZwaveJSConfigEntry | None = next( ( entry for entry in hass.config_entries.async_entries(DOMAIN) @@ -277,7 +273,7 @@ def async_get_node_from_device_id( if entry.state != ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client driver = client.driver if driver is None: @@ -310,7 +306,7 @@ async def async_get_provisioning_entry_from_device_id( # Use device config entry ID's to validate that this is a valid zwave_js device # and to get the client config_entry_ids = device_entry.config_entries - entry = next( + entry: ZwaveJSConfigEntry | None = next( ( entry for entry in hass.config_entries.async_entries(DOMAIN) @@ -325,7 +321,7 @@ async def async_get_provisioning_entry_from_device_id( if entry.state != ConfigEntryState.LOADED: raise ValueError(f"Device {device_id} config entry is not loaded") - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client driver = client.driver if driver is None: @@ -393,7 +389,7 @@ def async_get_nodes_from_area_id( for device in dr.async_entries_for_area(dev_reg, area_id) if any( cast( - ConfigEntry, + ZwaveJSConfigEntry, hass.config_entries.async_get_entry(config_entry_id), ).domain == DOMAIN @@ -487,7 +483,7 @@ def async_get_node_status_sensor_entity_id( entry = hass.config_entries.async_get_entry(entry_id) assert entry - client = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client node = async_get_node_from_device_id(hass, device_id, dev_reg) return ent_reg.async_get_entity_id( SENSOR_DOMAIN, @@ -565,7 +561,7 @@ def get_device_info(driver: Driver, node: ZwaveNode) -> DeviceInfo: def get_network_identifier_for_notification( - hass: HomeAssistant, config_entry: ConfigEntry, controller: Controller + hass: HomeAssistant, config_entry: ZwaveJSConfigEntry, controller: Controller ) -> str: """Return the network identifier string for persistent notifications.""" home_id = str(controller.home_id) diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 2b85bd4449f..83f5e507c01 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -5,7 +5,6 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.humidity_control import ( HUMIDITY_CONTROL_SETPOINT_PROPERTY, @@ -23,14 +22,14 @@ from homeassistant.components.humidifier import ( HumidifierEntity, HumidifierEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -69,11 +68,11 @@ DEHUMIDIFIER_ENTITY_DESCRIPTION = ZwaveHumidifierEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave humidifier from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_humidifier(info: ZwaveDiscoveryInfo) -> None: @@ -122,7 +121,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, description: ZwaveHumidifierEntityDescription, diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index f60e129cc77..23ec240e5a7 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import ( TARGET_VALUE_PROPERTY, TRANSITION_DURATION_OPTION, @@ -38,15 +37,15 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -66,11 +65,11 @@ MAX_MIREDS = 370 # 2700K as a safe default async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Light from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_light(info: ZwaveDiscoveryInfo) -> None: @@ -109,7 +108,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): _attr_max_color_temp_kelvin = 6500 # 153 mireds as a safe default def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the light.""" super().__init__(config_entry, driver, info) @@ -539,7 +538,7 @@ class ZwaveColorOnOffLight(ZwaveLight): """ def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the light.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index f609084955c..6e22afd3d2d 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any import voluptuous as vol -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, @@ -20,7 +19,6 @@ from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.util.lock import clear_usercode, set_configuration, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity, LockState -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform @@ -34,7 +32,6 @@ from .const import ( ATTR_LOCK_TIMEOUT, ATTR_OPERATION_TYPE, ATTR_TWIST_ASSIST, - DATA_CLIENT, DOMAIN, LOGGER, SERVICE_CLEAR_LOCK_USERCODE, @@ -43,6 +40,7 @@ from .const import ( ) from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -61,11 +59,11 @@ UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535)) async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave lock from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_lock(info: ZwaveDiscoveryInfo) -> None: diff --git a/homeassistant/components/zwave_js/models.py b/homeassistant/components/zwave_js/models.py new file mode 100644 index 00000000000..63f77871c14 --- /dev/null +++ b/homeassistant/components/zwave_js/models.py @@ -0,0 +1,27 @@ +"""Type definitions for Z-Wave JS integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from zwave_js_server.const import LogLevel + +from homeassistant.config_entries import ConfigEntry + +if TYPE_CHECKING: + from zwave_js_server.client import Client as ZwaveClient + + from . import DriverEvents + + +@dataclass +class ZwaveJSData: + """Data for zwave_js runtime data.""" + + client: ZwaveClient + driver_events: DriverEvents + old_server_log_level: LogLevel | None = None + + +type ZwaveJSConfigEntry = ConfigEntry[ZwaveJSData] diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 2e2d93bbdbe..982966ce3a9 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -5,33 +5,32 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any, cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN +from .const import ATTR_RESERVED_VALUES, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Number entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_number(info: ZwaveDiscoveryInfo) -> None: @@ -62,7 +61,7 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): """Representation of a Z-Wave number entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveNumberEntity entity.""" super().__init__(config_entry, driver, info) @@ -114,7 +113,7 @@ class ZWaveConfigParameterNumberEntity(ZwaveNumberEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterNumber entity.""" super().__init__(config_entry, driver, info) @@ -142,7 +141,7 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): """Representation of a volume number entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveVolumeNumberEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index 8a6ccc57c17..b8c84d02c95 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -4,33 +4,32 @@ from __future__ import annotations from typing import cast -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const.command_class.lock import TARGET_MODE_PROPERTY from zwave_js_server.const.command_class.sound_switch import TONE_ID_PROPERTY, ToneID from zwave_js_server.model.driver import Driver from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Select entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_select(info: ZwaveDiscoveryInfo) -> None: @@ -69,7 +68,7 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveSelectEntity entity.""" super().__init__(config_entry, driver, info) @@ -103,7 +102,7 @@ class ZWaveDoorLockSelectEntity(ZwaveSelectEntity): """Representation of a Z-Wave door lock CC mode select entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveDoorLockSelectEntity entity.""" super().__init__(config_entry, driver, info) @@ -126,7 +125,7 @@ class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterSelect entity.""" super().__init__(config_entry, driver, info) @@ -145,7 +144,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveDefaultToneSelectEntity entity.""" super().__init__(config_entry, driver, info) @@ -194,7 +193,7 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): """Representation of a Z-Wave Multilevel Switch CC select entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveSelectEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 05fa785760b..ac65b9e2749 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -7,7 +7,6 @@ from dataclasses import dataclass from typing import Any import voluptuous as vol -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, @@ -28,7 +27,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, @@ -56,7 +54,6 @@ from .const import ( ATTR_METER_TYPE, ATTR_METER_TYPE_NAME, ATTR_VALUE, - DATA_CLIENT, DOMAIN, ENTITY_DESC_KEY_BATTERY_LEVEL, ENTITY_DESC_KEY_BATTERY_LIST_STATE, @@ -94,6 +91,7 @@ from .discovery_data_template import ( from .entity import ZWaveBaseEntity from .helpers import get_device_info, get_valueless_base_unique_id from .migrate import async_migrate_statistics_sensors +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 @@ -576,11 +574,11 @@ def get_entity_description( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. @@ -717,7 +715,7 @@ class ZwaveSensor(ZWaveBaseEntity, SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -756,7 +754,7 @@ class ZWaveNumericSensor(ZwaveSensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -831,7 +829,7 @@ class ZWaveListSensor(ZwaveSensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -870,7 +868,7 @@ class ZWaveConfigParameterSensor(ZWaveListSensor): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, entity_description: SensorEntityDescription, @@ -906,7 +904,7 @@ class ZWaveNodeStatusSensor(SensorEntity): _attr_translation_key = "node_status" def __init__( - self, config_entry: ConfigEntry, driver: Driver, node: ZwaveNode + self, config_entry: ZwaveJSConfigEntry, driver: Driver, node: ZwaveNode ) -> None: """Initialize a generic Z-Wave device entity.""" self.config_entry = config_entry @@ -968,7 +966,7 @@ class ZWaveControllerStatusSensor(SensorEntity): _attr_has_entity_name = True _attr_translation_key = "controller_status" - def __init__(self, config_entry: ConfigEntry, driver: Driver) -> None: + def __init__(self, config_entry: ZwaveJSConfigEntry, driver: Driver) -> None: """Initialize a generic Z-Wave device entity.""" self.config_entry = config_entry self.controller = driver.controller @@ -1030,7 +1028,7 @@ class ZWaveStatisticsSensor(SensorEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, statistics_src: ZwaveNode | Controller, description: ZWaveJSStatisticsSensorEntityDescription, diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 076e3b6a50d..9420159b806 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -704,7 +704,7 @@ class ZWaveServices: client = first_node.client except StopIteration: data = self._hass.config_entries.async_entries(const.DOMAIN)[0].runtime_data - client = data[const.DATA_CLIENT] + client = data.client assert client.driver first_node = next( node diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index f0526171a70..f63a3bb9144 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const.command_class.sound_switch import ToneID from zwave_js_server.model.driver import Driver @@ -15,25 +14,25 @@ from homeassistant.components.siren import ( SirenEntity, SirenEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave Siren entity from Config Entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_siren(info: ZwaveDiscoveryInfo) -> None: @@ -57,7 +56,7 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): """Representation of a Z-Wave siren entity.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZwaveSirenEntity entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 2ff80d8505e..75e6b31bc50 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY from zwave_js_server.const.command_class.barrier_operator import ( BarrierEventSignalingSubsystemState, @@ -12,26 +11,26 @@ from zwave_js_server.const.command_class.barrier_operator import ( from zwave_js_server.model.driver import Driver from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 0 async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave sensor from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client @callback def async_add_switch(info: ZwaveDiscoveryInfo) -> None: @@ -65,7 +64,7 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): """Representation of a Z-Wave switch.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the switch.""" super().__init__(config_entry, driver, info) @@ -95,7 +94,7 @@ class ZWaveIndicatorSwitch(ZWaveSwitch): """Representation of a Z-Wave Indicator CC switch.""" def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize the switch.""" super().__init__(config_entry, driver, info) @@ -108,7 +107,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): def __init__( self, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo, ) -> None: @@ -164,7 +163,7 @@ class ZWaveConfigParameterSwitch(ZWaveSwitch): _attr_entity_category = EntityCategory.CONFIG def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo ) -> None: """Initialize a ZWaveConfigParameterSwitch entity.""" super().__init__(config_entry, driver, info) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index f74357327e9..8d0ccf60fdf 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -7,7 +7,6 @@ import functools from pydantic.v1 import ValidationError import voluptuous as vol -from zwave_js_server.client import Client from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP @@ -26,7 +25,6 @@ from ..const import ( ATTR_EVENT_SOURCE, ATTR_NODE_ID, ATTR_PARTIAL_DICT_MATCH, - DATA_CLIENT, DOMAIN, ) from ..helpers import ( @@ -219,7 +217,7 @@ async def async_attach_trigger( entry_id = config[ATTR_CONFIG_ENTRY_ID] entry = hass.config_entries.async_get_entry(entry_id) assert entry - client: Client = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client driver = client.driver assert driver drivers.add(driver) diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 1ef9ebaae28..917d207109f 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -1,14 +1,12 @@ """Helpers for Z-Wave JS custom triggers.""" -from zwave_js_server.client import Client as ZwaveClient - from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_CONFIG_ENTRY_ID, DATA_CLIENT, DOMAIN +from ..const import ATTR_CONFIG_ENTRY_ID, DOMAIN @callback @@ -37,7 +35,7 @@ def async_bypass_dynamic_config_validation( return True # The driver may not be ready when the config entry is loaded. - client: ZwaveClient = entry.runtime_data[DATA_CLIENT] + client = entry.runtime_data.client if client.driver is None: return True diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 985c4a86813..4355857f5df 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta from typing import Any, Final from awesomeversion import AwesomeVersion -from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError, FailedZWaveCommand from zwave_js_server.model.driver import Driver @@ -27,7 +26,6 @@ from homeassistant.components.update import ( UpdateEntity, UpdateEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import CoreState, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -36,8 +34,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import ExtraStoredData -from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DATA_CLIENT, DOMAIN, LOGGER +from .const import API_KEY_FIRMWARE_UPDATE_SERVICE, DOMAIN, LOGGER from .helpers import get_device_info, get_valueless_base_unique_id +from .models import ZwaveJSConfigEntry PARALLEL_UPDATES = 1 @@ -76,11 +75,11 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: ZwaveJSConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Z-Wave update entity from config entry.""" - client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT] + client = config_entry.runtime_data.client cnt: Counter = Counter() @callback From 52a99aea0cd0c69a34f3e72e03a3ebb9e32b0de4 Mon Sep 17 00:00:00 2001 From: "Phill (pssc)" Date: Mon, 30 Jun 2025 10:41:22 +0100 Subject: [PATCH 0866/1664] Squeezebox: Fix Allow server device details to merge with players with the same MAC (#133517) * Disambiguate bewtween servers and player to stop them being merged * ruff format * make SqueezeLite players not a service * ruff * Tidy redunant code * config url * revert config url * change to domain server * use default to see how they are mereged with server device * refactor to use defaults so where a player is part of a bigger ie server service device in the same intergration it doesnt replace its information * ruff * make test match the new data * Fix merge * Fix tests * Fix meregd test data * Fix all tests add new test for merged device in reg * Remove info from device_info so its only a lookup * manual merge of server player shared devices * Fix format of merged entires * fixes for testing * Fix test with input from @peteS-UK device knowlonger exits for this test * Fix test now device doesnt exits for tests * Update homeassistant/components/squeezebox/media_player.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Fix Copilots formatting * Apply suggestions from code review --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Erik Montnemery --- .../components/squeezebox/__init__.py | 6 +- homeassistant/components/squeezebox/const.py | 3 +- homeassistant/components/squeezebox/entity.py | 4 -- .../components/squeezebox/media_player.py | 55 +++++++++++++++++-- tests/components/squeezebox/conftest.py | 23 ++++---- .../snapshots/test_media_player.ambr | 45 ++++++++++++++- .../squeezebox/snapshots/test_switch.ambr | 20 +++---- tests/components/squeezebox/test_button.py | 2 +- .../squeezebox/test_media_player.py | 12 ++++ tests/components/squeezebox/test_switch.py | 20 +++---- 10 files changed, 146 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 596a44c498c..8bd0e2fca52 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -39,8 +39,9 @@ from .const import ( DOMAIN, KNOWN_PLAYERS, KNOWN_SERVERS, - MANUFACTURER, + SERVER_MANUFACTURER, SERVER_MODEL, + SERVER_MODEL_ID, SIGNAL_PLAYER_DISCOVERED, SIGNAL_PLAYER_REDISCOVERED, STATUS_API_TIMEOUT, @@ -173,8 +174,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - config_entry_id=entry.entry_id, identifiers={(DOMAIN, lms.uuid)}, name=lms.name, - manufacturer=MANUFACTURER, + manufacturer=SERVER_MANUFACTURER, model=SERVER_MODEL, + model_id=SERVER_MODEL_ID, sw_version=version, entry_type=DeviceEntryType.SERVICE, connections=mac_connect, diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 92eb3736341..9d78605aee1 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -6,10 +6,11 @@ DOMAIN = "squeezebox" DEFAULT_PORT = 9000 KNOWN_PLAYERS = "known_players" KNOWN_SERVERS = "known_servers" -MANUFACTURER = "https://lyrion.org/" PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 +SERVER_MANUFACTURER = "https://lyrion.org/" SERVER_MODEL = "Lyrion Music Server" +SERVER_MODEL_ID = "LMS" STATUS_API_TIMEOUT = 10 STATUS_SENSOR_LASTSCAN = "lastscan" STATUS_SENSOR_NEEDSRESTART = "needsrestart" diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py index 95fd2d60461..f2be716320f 100644 --- a/homeassistant/components/squeezebox/entity.py +++ b/homeassistant/components/squeezebox/entity.py @@ -26,11 +26,7 @@ class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]): self._player = coordinator.player self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, format_mac(self._player.player_id))}, - name=self._player.name, connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))}, - via_device=(DOMAIN, coordinator.server_uuid), - model=self._player.model, - manufacturer=self._player.creator, ) @property diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index b29e19c1e3c..8cf945cd7e9 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -33,11 +33,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, discovery_flow, entity_platform, entity_registry as er, ) -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.start import async_at_start @@ -61,6 +62,9 @@ from .const import ( DOMAIN, KNOWN_PLAYERS, KNOWN_SERVERS, + SERVER_MANUFACTURER, + SERVER_MODEL, + SERVER_MODEL_ID, SIGNAL_PLAYER_DISCOVERED, SQUEEZEBOX_SOURCE_STRINGS, ) @@ -125,9 +129,52 @@ async def async_setup_entry( """Set up the Squeezebox media_player platform from a server config entry.""" # Add media player entities when discovered - async def _player_discovered(player: SqueezeBoxPlayerUpdateCoordinator) -> None: - _LOGGER.debug("Setting up media_player entity for player %s", player) - async_add_entities([SqueezeBoxMediaPlayerEntity(player)]) + async def _player_discovered( + coordinator: SqueezeBoxPlayerUpdateCoordinator, + ) -> None: + player = coordinator.player + _LOGGER.debug("Setting up media_player device and entity for player %s", player) + device_registry = dr.async_get(hass) + server_device = device_registry.async_get_device( + identifiers={(DOMAIN, coordinator.server_uuid)}, + ) + + name = player.name + model = player.model + manufacturer = player.creator + model_id = player.model_type + sw_version = "" + # Why? so we nicely merge with a server and a player linked by a MAC server is not all info lost + if ( + server_device + and (CONNECTION_NETWORK_MAC, format_mac(player.player_id)) + in server_device.connections + ): + _LOGGER.debug("Shared server & player device %s", server_device) + name = server_device.name + sw_version = server_device.sw_version or sw_version + model = SERVER_MODEL + "/" + model if model else SERVER_MODEL + manufacturer = ( + SERVER_MANUFACTURER + " / " + manufacturer + if manufacturer + else SERVER_MANUFACTURER + ) + model_id = SERVER_MODEL_ID + "/" + model_id if model_id else SERVER_MODEL_ID + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, player.player_id)}, + connections={(CONNECTION_NETWORK_MAC, player.player_id)}, + name=name, + model=model, + manufacturer=manufacturer, + model_id=model_id, + hw_version=player.firmware, + sw_version=sw_version, + via_device=(DOMAIN, coordinator.server_uuid), + ) + _LOGGER.debug("Creating / Updating player device %s", device) + async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)]) entry.async_on_unload( async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered) diff --git a/tests/components/squeezebox/conftest.py b/tests/components/squeezebox/conftest.py index a3adf05f5f0..97aca31fa05 100644 --- a/tests/components/squeezebox/conftest.py +++ b/tests/components/squeezebox/conftest.py @@ -30,7 +30,6 @@ from homeassistant.components.squeezebox.const import ( ) from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import format_mac from tests.common import MockConfigEntry @@ -44,7 +43,7 @@ SERVER_UUIDS = [ "12345678-1234-1234-1234-123456789012", "87654321-4321-4321-4321-210987654321", ] -TEST_MAC = ["aa:bb:cc:dd:ee:ff", "ff:ee:dd:cc:bb:aa"] +TEST_MAC = ["aa:bb:cc:dd:ee:ff", "de:ad:be:ef:de:ad", "ff:ee:dd:cc:bb:aa"] TEST_PLAYER_NAME = "Test Player" TEST_SERVER_NAME = "Test Server" TEST_ALARM_ID = "1" @@ -52,14 +51,13 @@ FAKE_VALID_ITEM_ID = "1234" FAKE_INVALID_ITEM_ID = "4321" FAKE_IP = "42.42.42.42" -FAKE_MAC = "deadbeefdead" -FAKE_UUID = "deadbeefdeadbeefbeefdeafbeef42" +FAKE_UUID = "deadbeefdeadbeefbeefdeafbddeef42" FAKE_PORT = 9000 FAKE_VERSION = "42.0" FAKE_QUERY_RESPONSE = { - STATUS_QUERY_UUID: FAKE_UUID, - STATUS_QUERY_MAC: FAKE_MAC, + STATUS_QUERY_UUID: SERVER_UUIDS[0], + STATUS_QUERY_MAC: TEST_MAC[2], STATUS_QUERY_VERSION: FAKE_VERSION, STATUS_SENSOR_RESCAN: 1, STATUS_SENSOR_LASTSCAN: 0, @@ -268,6 +266,7 @@ def player_factory() -> MagicMock: def mock_pysqueezebox_player(uuid: str) -> MagicMock: """Mock a Lyrion Media Server player.""" + assert uuid with patch( "homeassistant.components.squeezebox.Player", autospec=True ) as mock_player: @@ -294,6 +293,8 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock: mock_player.image_url = None mock_player.model = "SqueezeLite" mock_player.creator = "Ralph Irving & Adrian Smith" + mock_player.model_type = None + mock_player.firmware = None mock_player.alarms_enabled = True return mock_player @@ -310,7 +311,7 @@ def lms_factory(player_factory: MagicMock) -> MagicMock: @pytest.fixture def lms(player_factory: MagicMock) -> MagicMock: """Mock a Lyrion Media Server with one mock player attached.""" - return mock_pysqueezebox_server(player_factory, 1, uuid=TEST_MAC[0]) + return mock_pysqueezebox_server(player_factory, 1, uuid=SERVER_UUIDS[0]) def mock_pysqueezebox_server( @@ -323,9 +324,11 @@ def mock_pysqueezebox_server( mock_lms.uuid = uuid mock_lms.name = TEST_SERVER_NAME - mock_lms.async_query = AsyncMock(return_value={"uuid": format_mac(uuid)}) + mock_lms.async_query = AsyncMock( + return_value={"uuid": uuid, "mac": TEST_MAC[2]} + ) mock_lms.async_status = AsyncMock( - return_value={"uuid": format_mac(uuid), "version": FAKE_VERSION} + return_value={"uuid": uuid, "version": FAKE_VERSION} ) return mock_lms @@ -428,6 +431,6 @@ async def configured_players( hass: HomeAssistant, config_entry: MockConfigEntry, lms_factory: MagicMock ) -> list[MagicMock]: """Fixture mocking calls to two pysqueezebox Players from a configured squeezebox.""" - lms = lms_factory(2, uuid=SERVER_UUIDS[0]) + lms = lms_factory(3, uuid=SERVER_UUIDS[0]) await configure_squeezebox_media_player_platform(hass, config_entry, lms) return await lms.async_get_players() diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index 4bb00dea5c6..d86c839019c 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -12,7 +12,7 @@ ), }), 'disabled_by': None, - 'entry_type': , + 'entry_type': None, 'hw_version': None, 'id': , 'identifiers': set({ @@ -32,7 +32,48 @@ 'primary_config_entry': , 'serial_number': None, 'suggested_area': None, - 'sw_version': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- +# name: test_device_registry_server_merged + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + '12345678-1234-1234-1234-123456789012', + ), + tuple( + 'squeezebox', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', + 'model': 'Lyrion Music Server/SqueezeLite', + 'model_id': 'LMS', + 'name': '1.2.3.4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', 'via_device_id': , }) # --- diff --git a/tests/components/squeezebox/snapshots/test_switch.ambr b/tests/components/squeezebox/snapshots/test_switch.ambr index 275fc26baa7..6d53eb38021 100644 --- a/tests/components/squeezebox/snapshots/test_switch.ambr +++ b/tests/components/squeezebox/snapshots/test_switch.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_entity_registry[switch.test_player_alarm_1-entry] +# name: test_entity_registry[switch.none_alarm_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -12,7 +12,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.test_player_alarm_1', + 'entity_id': 'switch.none_alarm_1', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -34,21 +34,21 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[switch.test_player_alarm_1-state] +# name: test_entity_registry[switch.none_alarm_1-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'alarm_id': '1', - 'friendly_name': 'Test Player Alarm (1)', + 'friendly_name': 'Alarm (1)', }), 'context': , - 'entity_id': 'switch.test_player_alarm_1', + 'entity_id': 'switch.none_alarm_1', 'last_changed': , 'last_reported': , 'last_updated': , 'state': 'on', }) # --- -# name: test_entity_registry[switch.test_player_alarms_enabled-entry] +# name: test_entity_registry[switch.none_alarms_enabled-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -61,7 +61,7 @@ 'disabled_by': None, 'domain': 'switch', 'entity_category': , - 'entity_id': 'switch.test_player_alarms_enabled', + 'entity_id': 'switch.none_alarms_enabled', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -83,13 +83,13 @@ 'unit_of_measurement': None, }) # --- -# name: test_entity_registry[switch.test_player_alarms_enabled-state] +# name: test_entity_registry[switch.none_alarms_enabled-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Player Alarms enabled', + 'friendly_name': 'Alarms enabled', }), 'context': , - 'entity_id': 'switch.test_player_alarms_enabled', + 'entity_id': 'switch.none_alarms_enabled', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/squeezebox/test_button.py b/tests/components/squeezebox/test_button.py index 16ced65be61..53c4e9ef626 100644 --- a/tests/components/squeezebox/test_button.py +++ b/tests/components/squeezebox/test_button.py @@ -14,7 +14,7 @@ async def test_squeezebox_press( await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.test_player_preset_1"}, + {ATTR_ENTITY_ID: "button.none_preset_1"}, blocking=True, ) diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index f71a7db23ba..e1f480e33a0 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -94,6 +94,18 @@ async def test_device_registry( assert reg_device == snapshot +async def test_device_registry_server_merged( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_players: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) + assert reg_device is not None + assert reg_device == snapshot + + async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, diff --git a/tests/components/squeezebox/test_switch.py b/tests/components/squeezebox/test_switch.py index e4c8c3b5e4d..2e6e9bafeb0 100644 --- a/tests/components/squeezebox/test_switch.py +++ b/tests/components/squeezebox/test_switch.py @@ -34,13 +34,13 @@ async def test_switch_state( freezer: FrozenDateTimeFactory, ) -> None: """Test the state of the switch.""" - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "on" + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}").state == "on" mock_alarms_player.alarms[0]["enabled"] = False freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "off" + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}").state == "off" async def test_switch_deleted( @@ -49,13 +49,13 @@ async def test_switch_deleted( freezer: FrozenDateTimeFactory, ) -> None: """Test detecting switch deleted.""" - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}").state == "on" + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}").state == "on" mock_alarms_player.alarms = [] freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get(f"switch.test_player_alarm_{TEST_ALARM_ID}") is None + assert hass.states.get(f"switch.none_alarm_{TEST_ALARM_ID}") is None async def test_turn_on( @@ -66,7 +66,7 @@ async def test_turn_on( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: f"switch.test_player_alarm_{TEST_ALARM_ID}"}, + {CONF_ENTITY_ID: f"switch.none_alarm_{TEST_ALARM_ID}"}, blocking=True, ) mock_alarms_player.async_update_alarm.assert_called_once_with( @@ -82,7 +82,7 @@ async def test_turn_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: f"switch.test_player_alarm_{TEST_ALARM_ID}"}, + {CONF_ENTITY_ID: f"switch.none_alarm_{TEST_ALARM_ID}"}, blocking=True, ) mock_alarms_player.async_update_alarm.assert_called_once_with( @@ -97,14 +97,14 @@ async def test_alarms_enabled_state( ) -> None: """Test the alarms enabled switch.""" - assert hass.states.get("switch.test_player_alarms_enabled").state == "on" + assert hass.states.get("switch.none_alarms_enabled").state == "on" mock_alarms_player.alarms_enabled = False freezer.tick(timedelta(seconds=PLAYER_UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("switch.test_player_alarms_enabled").state == "off" + assert hass.states.get("switch.none_alarms_enabled").state == "off" async def test_alarms_enabled_turn_on( @@ -115,7 +115,7 @@ async def test_alarms_enabled_turn_on( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, - {CONF_ENTITY_ID: "switch.test_player_alarms_enabled"}, + {CONF_ENTITY_ID: "switch.none_alarms_enabled"}, blocking=True, ) mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(True) @@ -129,7 +129,7 @@ async def test_alarms_enabled_turn_off( await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, - {CONF_ENTITY_ID: "switch.test_player_alarms_enabled"}, + {CONF_ENTITY_ID: "switch.none_alarms_enabled"}, blocking=True, ) mock_alarms_player.async_set_alarms_enabled.assert_called_once_with(False) From 179e1c2b00a04a6c74eda9242c9b2b4eec97d014 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:53:30 +0200 Subject: [PATCH 0867/1664] Bump github/codeql-action from 3.29.0 to 3.29.1 (#147799) 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 583cfdd211c..2b5dd713b41 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.0 + uses: github/codeql-action/init@v3.29.1 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.0 + uses: github/codeql-action/analyze@v3.29.1 with: category: "/language:python" From e642cd45ae56aa4a6a2c05e3c9a72cdaa30e09dd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 11:56:26 +0200 Subject: [PATCH 0868/1664] Enforce async_load_fixture in async test functions (#145709) --- pylint/plugins/hass_async_load_fixtures.py | 80 ++++++++++++++++++++++ pyproject.toml | 1 + tests/util/test_location.py | 18 +++-- 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 pylint/plugins/hass_async_load_fixtures.py diff --git a/pylint/plugins/hass_async_load_fixtures.py b/pylint/plugins/hass_async_load_fixtures.py new file mode 100644 index 00000000000..b1680f3f280 --- /dev/null +++ b/pylint/plugins/hass_async_load_fixtures.py @@ -0,0 +1,80 @@ +"""Plugin for logger invocations.""" + +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +FUNCTION_NAMES = ( + "load_fixture", + "load_json_array_fixture", + "load_json_object_fixture", +) + + +class HassLoadFixturesChecker(BaseChecker): + """Checker for I/O load fixtures.""" + + name = "hass_async_load_fixtures" + priority = -1 + msgs = { + "W7481": ( + "Test fixture files should be loaded asynchronously", + "hass-async-load-fixtures", + "Used when a test fixture file is loaded synchronously", + ), + } + options = () + + _decorators_queue: list[nodes.Decorators] + _function_queue: list[nodes.FunctionDef | nodes.AsyncFunctionDef] + _in_test_module: bool + + def visit_module(self, node: nodes.Module) -> None: + """Visit a module definition.""" + self._in_test_module = node.name.startswith("tests.") + self._decorators_queue = [] + self._function_queue = [] + + def visit_decorators(self, node: nodes.Decorators) -> None: + """Visit a function definition.""" + self._decorators_queue.append(node) + + def leave_decorators(self, node: nodes.Decorators) -> None: + """Leave a function definition.""" + self._decorators_queue.pop() + + def visit_functiondef(self, node: nodes.FunctionDef) -> None: + """Visit a function definition.""" + self._function_queue.append(node) + + def leave_functiondef(self, node: nodes.FunctionDef) -> None: + """Leave a function definition.""" + self._function_queue.pop() + + visit_asyncfunctiondef = visit_functiondef + leave_asyncfunctiondef = leave_functiondef + + def visit_call(self, node: nodes.Call) -> None: + """Check for sync I/O in load_fixture.""" + if ( + # Ensure we are in a test module + not self._in_test_module + # Ensure we are in an async function context + or not self._function_queue + or not isinstance(self._function_queue[-1], nodes.AsyncFunctionDef) + # Ensure we are not in the decorators + or self._decorators_queue + # Check function name + or not isinstance(node.func, nodes.Name) + or node.func.name not in FUNCTION_NAMES + ): + return + + self.add_message("hass-async-load-fixtures", node=node) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassLoadFixturesChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index d97bf3e1890..7ab0e89bce5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_async_load_fixtures", "hass_decorator", "hass_enforce_class_module", "hass_enforce_sorted_platforms", diff --git a/tests/util/test_location.py b/tests/util/test_location.py index ecb54eeeaa9..61d879f3827 100644 --- a/tests/util/test_location.py +++ b/tests/util/test_location.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util import location as location_util -from tests.common import load_fixture +from tests.common import async_load_fixture from tests.test_util.aiohttp import AiohttpClientMocker # Paris @@ -77,10 +77,14 @@ def test_get_miles() -> None: async def test_detect_location_info_whoami( - aioclient_mock: AiohttpClientMocker, session: aiohttp.ClientSession + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + session: aiohttp.ClientSession, ) -> None: """Test detect location info using services.home-assistant.io/whoami.""" - aioclient_mock.get(location_util.WHOAMI_URL, text=load_fixture("whoami.json")) + aioclient_mock.get( + location_util.WHOAMI_URL, text=await async_load_fixture(hass, "whoami.json") + ) with patch("homeassistant.util.location.HA_VERSION", "1.0"): info = await location_util.async_detect_location_info(session, _test_real=True) @@ -101,10 +105,14 @@ async def test_detect_location_info_whoami( async def test_dev_url( - aioclient_mock: AiohttpClientMocker, session: aiohttp.ClientSession + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + session: aiohttp.ClientSession, ) -> None: """Test usage of dev URL.""" - aioclient_mock.get(location_util.WHOAMI_URL_DEV, text=load_fixture("whoami.json")) + aioclient_mock.get( + location_util.WHOAMI_URL_DEV, text=await async_load_fixture(hass, "whoami.json") + ) with patch("homeassistant.util.location.HA_VERSION", "1.0.dev0"): info = await location_util.async_detect_location_info(session, _test_real=True) From 7fbf25e8625be382196955086f8c87cee1f43e12 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 30 Jun 2025 12:15:52 +0200 Subject: [PATCH 0869/1664] Plugwise: remove outdated fixtures (#147806) --- .../anna_heatpump_heating/all_data.json | 107 ---- .../fixtures/legacy_anna/all_data.json | 69 -- .../fixtures/m_adam_cooling/all_data.json | 213 ------- .../fixtures/m_adam_heating/all_data.json | 212 ------- .../fixtures/m_adam_jip/all_data.json | 380 ----------- .../all_data.json | 594 ------------------ .../m_anna_heatpump_cooling/all_data.json | 107 ---- .../m_anna_heatpump_idle/all_data.json | 107 ---- .../fixtures/p1v4_442_single/all_data.json | 51 -- .../fixtures/p1v4_442_triple/all_data.json | 64 -- .../fixtures/stretch_v31/all_data.json | 143 ----- 11 files changed, 2047 deletions(-) delete mode 100644 tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json delete mode 100644 tests/components/plugwise/fixtures/legacy_anna/all_data.json delete mode 100644 tests/components/plugwise/fixtures/m_adam_cooling/all_data.json delete mode 100644 tests/components/plugwise/fixtures/m_adam_heating/all_data.json delete mode 100644 tests/components/plugwise/fixtures/m_adam_jip/all_data.json delete mode 100644 tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json delete mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json delete mode 100644 tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json delete mode 100644 tests/components/plugwise/fixtures/p1v4_442_single/all_data.json delete mode 100644 tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json delete mode 100644 tests/components/plugwise/fixtures/stretch_v31/all_data.json diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json deleted file mode 100644 index 3a54c3fb9a2..00000000000 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "devices": { - "015ae9ea3f964e668e490fa39da3870b": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.0.15", - "hardware": "AME Smile 2.0 board", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_thermo", - "name": "Smile Anna", - "sensors": { - "outdoor_temperature": 20.2 - }, - "vendor": "Plugwise" - }, - "1cbf783bb11e4a7c8a6843dee3a86927": { - "available": true, - "binary_sensors": { - "compressor_state": true, - "cooling_enabled": false, - "cooling_state": false, - "dhw_state": false, - "flame_state": false, - "heating_state": true, - "secondary_boiler_state": false - }, - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "max_dhw_temperature": { - "lower_bound": 35.0, - "resolution": 0.01, - "setpoint": 53.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 0.0, - "resolution": 1.0, - "setpoint": 60.0, - "upper_bound": 100.0 - }, - "model": "Generic heater/cooler", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 46.3, - "intended_boiler_temperature": 35.0, - "modulation_level": 52, - "outdoor_air_temperature": 3.0, - "return_temperature": 25.1, - "water_pressure": 1.57, - "water_temperature": 29.1 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Techneco" - }, - "3cb70739631c4d17a86b8b12e8a5161b": { - "active_preset": "home", - "available_schedules": ["standaard", "off"], - "climate_mode": "auto", - "control_state": "heating", - "dev_class": "thermostat", - "firmware": "2018-02-08T11:15:53+01:00", - "hardware": "6539-1301-5002", - "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "select_schedule": "standaard", - "sensors": { - "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0, - "illuminance": 86.0, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "temperature": 19.3 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": -0.5, - "upper_bound": 2.0 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 67, - "notifications": {}, - "reboot": true, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/legacy_anna/all_data.json b/tests/components/plugwise/fixtures/legacy_anna/all_data.json deleted file mode 100644 index 9275b82cde9..00000000000 --- a/tests/components/plugwise/fixtures/legacy_anna/all_data.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "devices": { - "0000aaaa0000aaaa0000aaaa0000aa00": { - "dev_class": "gateway", - "firmware": "1.8.22", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mac_address": "01:23:45:67:89:AB", - "model": "Gateway", - "name": "Smile Anna", - "vendor": "Plugwise" - }, - "04e4cbfe7f4340f090f85ec3b9e6a950": { - "binary_sensors": { - "flame_state": true, - "heating_state": true - }, - "dev_class": "heater_central", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "maximum_boiler_temperature": { - "lower_bound": 50.0, - "resolution": 1.0, - "setpoint": 50.0, - "upper_bound": 90.0 - }, - "model": "Generic heater", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 51.2, - "intended_boiler_temperature": 17.0, - "modulation_level": 0.0, - "return_temperature": 21.7, - "water_pressure": 1.2, - "water_temperature": 23.6 - }, - "vendor": "Bosch Thermotechniek B.V." - }, - "0d266432d64443e283b5d708ae98b455": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "heating", - "dev_class": "thermostat", - "firmware": "2017-03-13T11:54:58+01:00", - "hardware": "6539-1301-500", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["away", "vacation", "asleep", "home", "no_frost"], - "sensors": { - "illuminance": 150.8, - "setpoint": 20.5, - "temperature": 20.4 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", - "heater_id": "04e4cbfe7f4340f090f85ec3b9e6a950", - "item_count": 41, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json deleted file mode 100644 index af6d4b83380..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ /dev/null @@ -1,213 +0,0 @@ -{ - "devices": { - "056ee145a816487eaa69243c3280f8bf": { - "available": true, - "binary_sensors": { - "cooling_state": true, - "dhw_state": false, - "flame_state": false, - "heating_state": false - }, - "dev_class": "heater_central", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "maximum_boiler_temperature": { - "lower_bound": 25.0, - "resolution": 0.01, - "setpoint": 50.0, - "upper_bound": 95.0 - }, - "model": "Generic heater", - "name": "OpenTherm", - "sensors": { - "intended_boiler_temperature": 17.5, - "water_temperature": 19.0 - }, - "switches": { - "dhw_cm_switch": false - } - }, - "1772a4ea304041adb83f357b751341ff": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Badkamer", - "sensors": { - "battery": 99, - "setpoint": 18.0, - "temperature": 21.6, - "temperature_difference": -0.2, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C8FF5EE" - }, - "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "available": true, - "dev_class": "thermostat", - "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "ThermoTouch", - "model_id": "143.1", - "name": "Anna", - "sensors": { - "setpoint": 23.5, - "temperature": 25.8 - }, - "vendor": "Plugwise" - }, - "da224107914542988a88561b4452b0f6": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "3.7.8", - "gateway_modes": ["away", "full", "vacation"], - "hardware": "AME Smile 2.0 board", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345679891", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "regulation_modes": [ - "bleeding_hot", - "bleeding_cold", - "off", - "heating", - "cooling" - ], - "select_gateway_mode": "full", - "select_regulation_mode": "cooling", - "sensors": { - "outdoor_temperature": 29.65 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000D5A168D" - }, - "e2f4322d57924fa090fbbc48b3a140dc": { - "available": true, - "binary_sensors": { - "low_battery": true - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-10T02:00:00+02:00", - "hardware": "255", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Lisa", - "model_id": "158-01", - "name": "Lisa Badkamer", - "sensors": { - "battery": 14, - "setpoint": 23.5, - "temperature": 23.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C869B61" - }, - "e8ef2a01ed3b4139a53bf749204fe6b4": { - "dev_class": "switching", - "members": [ - "2568cc4b9c1e401495d4741a5f89bee1", - "29542b2b6a6a4169acecc15c72a599b8" - ], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "f2bf9048bef64cc5b6d5110154e33c81": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "cool", - "control_state": "cooling", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Living room", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", - "sensors": { - "electricity_consumed": 149.9, - "electricity_produced": 0.0, - "temperature": 25.8 - }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 23.5, - "upper_bound": 35.0 - }, - "thermostats": { - "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "f871b8c4d63549319221e294e4f88074": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "auto", - "control_state": "cooling", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Bathroom", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", - "sensors": { - "electricity_consumed": 0.0, - "electricity_produced": 0.0, - "temperature": 23.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 25.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], - "secondary": ["1772a4ea304041adb83f357b751341ff"] - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "da224107914542988a88561b4452b0f6", - "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 89, - "notifications": {}, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json deleted file mode 100644 index bb24faeebfa..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ /dev/null @@ -1,212 +0,0 @@ -{ - "devices": { - "056ee145a816487eaa69243c3280f8bf": { - "available": true, - "binary_sensors": { - "dhw_state": false, - "flame_state": false, - "heating_state": true - }, - "dev_class": "heater_central", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "max_dhw_temperature": { - "lower_bound": 40.0, - "resolution": 0.01, - "setpoint": 60.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 25.0, - "resolution": 0.01, - "setpoint": 50.0, - "upper_bound": 95.0 - }, - "model": "Generic heater", - "name": "OpenTherm", - "sensors": { - "intended_boiler_temperature": 38.1, - "water_temperature": 37.0 - }, - "switches": { - "dhw_cm_switch": false - } - }, - "1772a4ea304041adb83f357b751341ff": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Badkamer", - "sensors": { - "battery": 99, - "setpoint": 18.0, - "temperature": 18.6, - "temperature_difference": -0.2, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C8FF5EE" - }, - "ad4838d7d35c4d6ea796ee12ae5aedf8": { - "available": true, - "dev_class": "thermostat", - "location": "f2bf9048bef64cc5b6d5110154e33c81", - "model": "ThermoTouch", - "model_id": "143.1", - "name": "Anna", - "sensors": { - "setpoint": 20.0, - "temperature": 19.1 - }, - "vendor": "Plugwise" - }, - "da224107914542988a88561b4452b0f6": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "3.7.8", - "gateway_modes": ["away", "full", "vacation"], - "hardware": "AME Smile 2.0 board", - "location": "bc93488efab249e5bc54fd7e175a6f91", - "mac_address": "012345679891", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"], - "select_gateway_mode": "full", - "select_regulation_mode": "heating", - "sensors": { - "outdoor_temperature": -1.25 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000D5A168D" - }, - "e2f4322d57924fa090fbbc48b3a140dc": { - "available": true, - "binary_sensors": { - "low_battery": true - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-10T02:00:00+02:00", - "hardware": "255", - "location": "f871b8c4d63549319221e294e4f88074", - "model": "Lisa", - "model_id": "158-01", - "name": "Lisa Badkamer", - "sensors": { - "battery": 14, - "setpoint": 15.0, - "temperature": 17.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "000D6F000C869B61" - }, - "e8ef2a01ed3b4139a53bf749204fe6b4": { - "dev_class": "switching", - "members": [ - "2568cc4b9c1e401495d4741a5f89bee1", - "29542b2b6a6a4169acecc15c72a599b8" - ], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "f2bf9048bef64cc5b6d5110154e33c81": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "heat", - "control_state": "preheating", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Living room", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "off", - "sensors": { - "electricity_consumed": 149.9, - "electricity_produced": 0.0, - "temperature": 19.1 - }, - "thermostat": { - "lower_bound": 1.0, - "resolution": 0.01, - "setpoint": 20.0, - "upper_bound": 35.0 - }, - "thermostats": { - "primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "f871b8c4d63549319221e294e4f88074": { - "active_preset": "home", - "available_schedules": [ - "Badkamer", - "Test", - "Vakantie", - "Weekschema", - "off" - ], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Bathroom", - "preset_modes": ["no_frost", "asleep", "vacation", "home", "away"], - "select_schedule": "Badkamer", - "sensors": { - "electricity_consumed": 0.0, - "electricity_produced": 0.0, - "temperature": 17.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["e2f4322d57924fa090fbbc48b3a140dc"], - "secondary": ["1772a4ea304041adb83f357b751341ff"] - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "da224107914542988a88561b4452b0f6", - "heater_id": "056ee145a816487eaa69243c3280f8bf", - "item_count": 89, - "notifications": {}, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json b/tests/components/plugwise/fixtures/m_adam_jip/all_data.json deleted file mode 100644 index 1a3ef66c147..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_jip/all_data.json +++ /dev/null @@ -1,380 +0,0 @@ -{ - "devices": { - "06aecb3d00354375924f50c47af36bd2": { - "active_preset": "no_frost", - "climate_mode": "off", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Slaapkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 24.2 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["1346fbd8498d4dbcab7e18d51b771f3d"], - "secondary": ["356b65335e274d769c338223e7af9c33"] - }, - "vendor": "Plugwise" - }, - "13228dab8ce04617af318a2888b3c548": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Woonkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 27.4 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.01, - "setpoint": 9.0, - "upper_bound": 30.0 - }, - "thermostats": { - "primary": ["f61f1a2535f54f52ad006a3d18e459ca"], - "secondary": ["833de10f269c4deab58fb9df69901b4e"] - }, - "vendor": "Plugwise" - }, - "1346fbd8498d4dbcab7e18d51b771f3d": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "06aecb3d00354375924f50c47af36bd2", - "model": "Lisa", - "model_id": "158-01", - "name": "Slaapkamer", - "sensors": { - "battery": 92, - "setpoint": 13.0, - "temperature": 24.2 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03" - }, - "1da4d325838e4ad8aac12177214505c9": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "d58fec52899f4f1c92e4f8fad6d8c48c", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Logeerkamer", - "sensors": { - "setpoint": 13.0, - "temperature": 28.8, - "temperature_difference": 2.0, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "356b65335e274d769c338223e7af9c33": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "06aecb3d00354375924f50c47af36bd2", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Slaapkamer", - "sensors": { - "setpoint": 13.0, - "temperature": 24.2, - "temperature_difference": 1.7, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05" - }, - "457ce8414de24596a2d5e7dbc9c7682f": { - "available": true, - "dev_class": "zz_misc_plug", - "location": "9e4433a9d69f40b3aefd15e74395eaec", - "model": "Aqara Smart Plug", - "model_id": "lumi.plug.maeu01", - "name": "Plug", - "sensors": { - "electricity_consumed_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": false - }, - "vendor": "LUMI", - "zigbee_mac_address": "ABCD012345670A06" - }, - "6f3e9d7084214c21b9dfa46f6eeb8700": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "d27aede973b54be484f6842d1b2802ad", - "model": "Lisa", - "model_id": "158-01", - "name": "Kinderkamer", - "sensors": { - "battery": 79, - "setpoint": 13.0, - "temperature": 30.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "833de10f269c4deab58fb9df69901b4e": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "13228dab8ce04617af318a2888b3c548", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Woonkamer", - "sensors": { - "setpoint": 9.0, - "temperature": 24.0, - "temperature_difference": 1.8, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09" - }, - "a6abc6a129ee499c88a4d420cc413b47": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "d58fec52899f4f1c92e4f8fad6d8c48c", - "model": "Lisa", - "model_id": "158-01", - "name": "Logeerkamer", - "sensors": { - "battery": 80, - "setpoint": 13.0, - "temperature": 30.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "b5c2386c6f6342669e50fe49dd05b188": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "3.2.8", - "gateway_modes": ["away", "full", "vacation"], - "hardware": "AME Smile 2.0 board", - "location": "9e4433a9d69f40b3aefd15e74395eaec", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], - "select_gateway_mode": "full", - "select_regulation_mode": "heating", - "sensors": { - "outdoor_temperature": 24.9 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - }, - "d27aede973b54be484f6842d1b2802ad": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Kinderkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 30.0 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["6f3e9d7084214c21b9dfa46f6eeb8700"], - "secondary": ["d4496250d0e942cfa7aea3476e9070d5"] - }, - "vendor": "Plugwise" - }, - "d4496250d0e942cfa7aea3476e9070d5": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2020-11-04T01:00:00+01:00", - "hardware": "1", - "location": "d27aede973b54be484f6842d1b2802ad", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Tom Kinderkamer", - "sensors": { - "setpoint": 13.0, - "temperature": 28.7, - "temperature_difference": 1.9, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.1, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" - }, - "d58fec52899f4f1c92e4f8fad6d8c48c": { - "active_preset": "home", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Logeerkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 30.0 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9 - }, - "thermostats": { - "primary": ["a6abc6a129ee499c88a4d420cc413b47"], - "secondary": ["1da4d325838e4ad8aac12177214505c9"] - }, - "vendor": "Plugwise" - }, - "e4684553153b44afbef2200885f379dc": { - "available": true, - "binary_sensors": { - "dhw_state": false, - "flame_state": false, - "heating_state": false - }, - "dev_class": "heater_central", - "location": "9e4433a9d69f40b3aefd15e74395eaec", - "max_dhw_temperature": { - "lower_bound": 40.0, - "resolution": 0.01, - "setpoint": 60.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 20.0, - "resolution": 0.01, - "setpoint": 90.0, - "upper_bound": 90.0 - }, - "model": "Generic heater", - "model_id": "10.20", - "name": "OpenTherm", - "sensors": { - "intended_boiler_temperature": 0.0, - "modulation_level": 0.0, - "return_temperature": 37.1, - "water_pressure": 1.4, - "water_temperature": 37.3 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Remeha B.V." - }, - "f61f1a2535f54f52ad006a3d18e459ca": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermometer", - "firmware": "2020-09-01T02:00:00+02:00", - "hardware": "1", - "location": "13228dab8ce04617af318a2888b3c548", - "model": "Jip", - "model_id": "168-01", - "name": "Woonkamer", - "sensors": { - "battery": 100, - "humidity": 56.2, - "setpoint": 9.0, - "temperature": 27.4 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "b5c2386c6f6342669e50fe49dd05b188", - "heater_id": "e4684553153b44afbef2200885f379dc", - "item_count": 244, - "notifications": {}, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json deleted file mode 100644 index 8da184a7a3e..00000000000 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/all_data.json +++ /dev/null @@ -1,594 +0,0 @@ -{ - "devices": { - "02cf28bfec924855854c544690a609ef": { - "available": true, - "dev_class": "vcr_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "NVR", - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A15" - }, - "08963fec7c53423ca5680aa4cb502c63": { - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", - "sensors": { - "temperature": 18.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 14.0, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": [ - "f1fee6043d3642a9b0a65297455f008e", - "680423ff840043738f42cc7f1ff97a36" - ], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "12493538af164a409c6a1c79e38afe1c": { - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Bios", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "off", - "sensors": { - "electricity_consumed": 0.0, - "electricity_produced": 0.0, - "temperature": 16.5 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["df4a4a8169904cdb9c03d61a21f42140"], - "secondary": ["a2c3583e0a6349358998b760cea82d2a"] - }, - "vendor": "Plugwise" - }, - "21f2b542c49845e6bb416884c55778d6": { - "available": true, - "dev_class": "game_console_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "Playstation Smart Plug", - "sensors": { - "electricity_consumed": 84.1, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A12" - }, - "446ac08dd04d4eff8ac57489757b7314": { - "active_preset": "no_frost", - "climate_mode": "heat", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Garage", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "sensors": { - "temperature": 15.6 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 5.5, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["e7693eb9582644e5b865dba8d4447cf1"], - "secondary": [] - }, - "vendor": "Plugwise" - }, - "4a810418d5394b3f82727340b91ba740": { - "available": true, - "dev_class": "router_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "USG Smart Plug", - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A16" - }, - "675416a629f343c495449970e2ca37b5": { - "available": true, - "dev_class": "router_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "Ziggo Modem", - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "680423ff840043738f42cc7f1ff97a36": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Thermostatic Radiator Badkamer 1", - "sensors": { - "battery": 51, - "setpoint": 14.0, - "temperature": 19.1, - "temperature_difference": -0.4, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A17" - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Lisa", - "model_id": "158-01", - "name": "Zone Thermostat Jessie", - "sensors": { - "battery": 37, - "setpoint": 15.0, - "temperature": 17.2 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03" - }, - "78d1126fc4c743db81b61c20e88342a7": { - "available": true, - "dev_class": "central_heating_pump_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Plug", - "model_id": "160-01", - "name": "CV Pomp", - "sensors": { - "electricity_consumed": 35.6, - "electricity_consumed_interval": 7.37, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05" - }, - "82fa13f017d240daa0d0ea1775420f24": { - "active_preset": "asleep", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Jessie", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", - "sensors": { - "temperature": 17.2 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["6a3bf693d05e48e0b460c815a4fdd09d"], - "secondary": ["d3da73bde12a47d5a6b8f9dad971f2ec"] - }, - "vendor": "Plugwise" - }, - "90986d591dcd426cae3ec3e8111ff730": { - "binary_sensors": { - "heating_state": true - }, - "dev_class": "heater_central", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "model": "Unknown", - "name": "OnOff", - "sensors": { - "intended_boiler_temperature": 70.0, - "modulation_level": 1, - "water_temperature": 70.0 - } - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "available": true, - "dev_class": "settop_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "Fibaro HC2", - "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A13" - }, - "a2c3583e0a6349358998b760cea82d2a": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Bios Cv Thermostatic Radiator ", - "sensors": { - "battery": 62, - "setpoint": 13.0, - "temperature": 17.2, - "temperature_difference": -0.2, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09" - }, - "b310b72a0e354bfab43089919b9a88bf": { - "available": true, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Floor kraan", - "sensors": { - "setpoint": 21.5, - "temperature": 26.0, - "temperature_difference": 3.5, - "valve_position": 100 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Lisa", - "model_id": "158-01", - "name": "Zone Lisa WK", - "sensors": { - "battery": 34, - "setpoint": 21.5, - "temperature": 20.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "c50f167537524366a5af7aa3942feb1e": { - "active_preset": "home", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - "off" - ], - "climate_mode": "auto", - "control_state": "heating", - "dev_class": "climate", - "model": "ThermoZone", - "name": "Woonkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", - "sensors": { - "electricity_consumed": 35.6, - "electricity_produced": 0.0, - "temperature": 20.9 - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 21.5, - "upper_bound": 100.0 - }, - "thermostats": { - "primary": ["b59bcebaf94b499ea7d46e4a66fb62d8"], - "secondary": ["b310b72a0e354bfab43089919b9a88bf"] - }, - "vendor": "Plugwise" - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "available": true, - "dev_class": "vcr_plug", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "model_id": "160-01", - "name": "NAS", - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A14" - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "Thermostatic Radiator Jessie", - "sensors": { - "battery": 62, - "setpoint": 15.0, - "temperature": 17.1, - "temperature_difference": 0.1, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A10" - }, - "df4a4a8169904cdb9c03d61a21f42140": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Lisa", - "model_id": "158-01", - "name": "Zone Lisa Bios", - "sensors": { - "battery": 67, - "setpoint": 13.0, - "temperature": 16.5 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A06" - }, - "e7693eb9582644e5b865dba8d4447cf1": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "446ac08dd04d4eff8ac57489757b7314", - "model": "Tom/Floor", - "model_id": "106-03", - "name": "CV Kraan Garage", - "sensors": { - "battery": 68, - "setpoint": 5.5, - "temperature": 15.6, - "temperature_difference": 0.0, - "valve_position": 0.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A11" - }, - "f1fee6043d3642a9b0a65297455f008e": { - "available": true, - "binary_sensors": { - "low_battery": false - }, - "dev_class": "thermostatic_radiator_valve", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Lisa", - "model_id": "158-01", - "name": "Thermostatic Radiator Badkamer 2", - "sensors": { - "battery": 92, - "setpoint": 14.0, - "temperature": 18.9 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08" - }, - "fe799307f1624099878210aa0b9f1475": { - "binary_sensors": { - "plugwise_notification": true - }, - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_open_therm", - "name": "Adam", - "select_regulation_mode": "heating", - "sensors": { - "outdoor_temperature": 7.81 - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - } - }, - "gateway": { - "cooling_present": false, - "gateway_id": "fe799307f1624099878210aa0b9f1475", - "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "item_count": 369, - "notifications": { - "af82e4ccf9c548528166d38e560662a4": { - "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." - } - }, - "reboot": true, - "smile_name": "Adam" - } -} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json deleted file mode 100644 index eaa42facf10..00000000000 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "devices": { - "015ae9ea3f964e668e490fa39da3870b": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.0.15", - "hardware": "AME Smile 2.0 board", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_thermo", - "name": "Smile Anna", - "sensors": { - "outdoor_temperature": 28.2 - }, - "vendor": "Plugwise" - }, - "1cbf783bb11e4a7c8a6843dee3a86927": { - "available": true, - "binary_sensors": { - "compressor_state": true, - "cooling_enabled": true, - "cooling_state": true, - "dhw_state": false, - "flame_state": false, - "heating_state": false, - "secondary_boiler_state": false - }, - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "max_dhw_temperature": { - "lower_bound": 35.0, - "resolution": 0.01, - "setpoint": 53.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 0.0, - "resolution": 1.0, - "setpoint": 60.0, - "upper_bound": 100.0 - }, - "model": "Generic heater/cooler", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 41.5, - "intended_boiler_temperature": 0.0, - "modulation_level": 40, - "outdoor_air_temperature": 28.0, - "return_temperature": 23.8, - "water_pressure": 1.57, - "water_temperature": 22.7 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Techneco" - }, - "3cb70739631c4d17a86b8b12e8a5161b": { - "active_preset": "home", - "available_schedules": ["standaard", "off"], - "climate_mode": "auto", - "control_state": "cooling", - "dev_class": "thermostat", - "firmware": "2018-02-08T11:15:53+01:00", - "hardware": "6539-1301-5002", - "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "select_schedule": "standaard", - "sensors": { - "cooling_activation_outdoor_temperature": 21.0, - "cooling_deactivation_threshold": 4.0, - "illuminance": 86.0, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "temperature": 26.3 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": -0.5, - "upper_bound": 2.0 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 67, - "notifications": {}, - "reboot": true, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json deleted file mode 100644 index 52645b0f317..00000000000 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "devices": { - "015ae9ea3f964e668e490fa39da3870b": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.0.15", - "hardware": "AME Smile 2.0 board", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile_thermo", - "name": "Smile Anna", - "sensors": { - "outdoor_temperature": 28.2 - }, - "vendor": "Plugwise" - }, - "1cbf783bb11e4a7c8a6843dee3a86927": { - "available": true, - "binary_sensors": { - "compressor_state": false, - "cooling_enabled": true, - "cooling_state": false, - "dhw_state": false, - "flame_state": false, - "heating_state": false, - "secondary_boiler_state": false - }, - "dev_class": "heater_central", - "location": "a57efe5f145f498c9be62a9b63626fbf", - "max_dhw_temperature": { - "lower_bound": 35.0, - "resolution": 0.01, - "setpoint": 53.0, - "upper_bound": 60.0 - }, - "maximum_boiler_temperature": { - "lower_bound": 0.0, - "resolution": 1.0, - "setpoint": 60.0, - "upper_bound": 100.0 - }, - "model": "Generic heater/cooler", - "name": "OpenTherm", - "sensors": { - "dhw_temperature": 46.3, - "intended_boiler_temperature": 18.0, - "modulation_level": 0, - "outdoor_air_temperature": 28.2, - "return_temperature": 22.0, - "water_pressure": 1.57, - "water_temperature": 19.1 - }, - "switches": { - "dhw_cm_switch": false - }, - "vendor": "Techneco" - }, - "3cb70739631c4d17a86b8b12e8a5161b": { - "active_preset": "home", - "available_schedules": ["standaard", "off"], - "climate_mode": "auto", - "control_state": "idle", - "dev_class": "thermostat", - "firmware": "2018-02-08T11:15:53+01:00", - "hardware": "6539-1301-5002", - "location": "c784ee9fdab44e1395b8dee7d7a497d5", - "model": "ThermoTouch", - "name": "Anna", - "preset_modes": ["no_frost", "home", "away", "asleep", "vacation"], - "select_schedule": "standaard", - "sensors": { - "cooling_activation_outdoor_temperature": 25.0, - "cooling_deactivation_threshold": 4.0, - "illuminance": 86.0, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "temperature": 23.0 - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": -0.5, - "upper_bound": 2.0 - }, - "thermostat": { - "lower_bound": 4.0, - "resolution": 0.1, - "setpoint_high": 30.0, - "setpoint_low": 20.5, - "upper_bound": 30.0 - }, - "vendor": "Plugwise" - } - }, - "gateway": { - "cooling_present": true, - "gateway_id": "015ae9ea3f964e668e490fa39da3870b", - "heater_id": "1cbf783bb11e4a7c8a6843dee3a86927", - "item_count": 67, - "notifications": {}, - "reboot": true, - "smile_name": "Smile Anna" - } -} diff --git a/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json deleted file mode 100644 index 3ea4bb01be2..00000000000 --- a/tests/components/plugwise/fixtures/p1v4_442_single/all_data.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "devices": { - "a455b61e52394b2db5081ce025a430f3": { - "binary_sensors": { - "plugwise_notification": false - }, - "dev_class": "gateway", - "firmware": "4.4.2", - "hardware": "AME Smile 2.0 board", - "location": "a455b61e52394b2db5081ce025a430f3", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile", - "name": "Smile P1", - "vendor": "Plugwise" - }, - "ba4de7613517478da82dd9b6abea36af": { - "available": true, - "dev_class": "smartmeter", - "location": "a455b61e52394b2db5081ce025a430f3", - "model": "KFM5KAIFA-METER", - "name": "P1", - "sensors": { - "electricity_consumed_off_peak_cumulative": 17643.423, - "electricity_consumed_off_peak_interval": 15, - "electricity_consumed_off_peak_point": 486, - "electricity_consumed_peak_cumulative": 13966.608, - "electricity_consumed_peak_interval": 0, - "electricity_consumed_peak_point": 0, - "electricity_phase_one_consumed": 486, - "electricity_phase_one_produced": 0, - "electricity_produced_off_peak_cumulative": 0.0, - "electricity_produced_off_peak_interval": 0, - "electricity_produced_off_peak_point": 0, - "electricity_produced_peak_cumulative": 0.0, - "electricity_produced_peak_interval": 0, - "electricity_produced_peak_point": 0, - "net_electricity_cumulative": 31610.031, - "net_electricity_point": 486 - }, - "vendor": "SHENZHEN KAIFA TECHNOLOGY \uff08CHENGDU\uff09 CO., LTD." - } - }, - "gateway": { - "gateway_id": "a455b61e52394b2db5081ce025a430f3", - "item_count": 32, - "notifications": {}, - "reboot": true, - "smile_name": "Smile P1" - } -} diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json deleted file mode 100644 index b7476b24a1e..00000000000 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "devices": { - "03e65b16e4b247a29ae0d75a78cb492e": { - "binary_sensors": { - "plugwise_notification": true - }, - "dev_class": "gateway", - "firmware": "4.4.2", - "hardware": "AME Smile 2.0 board", - "location": "03e65b16e4b247a29ae0d75a78cb492e", - "mac_address": "012345670001", - "model": "Gateway", - "model_id": "smile", - "name": "Smile P1", - "vendor": "Plugwise" - }, - "b82b6b3322484f2ea4e25e0bd5f3d61f": { - "available": true, - "dev_class": "smartmeter", - "location": "03e65b16e4b247a29ae0d75a78cb492e", - "model": "XMX5LGF0010453051839", - "name": "P1", - "sensors": { - "electricity_consumed_off_peak_cumulative": 70537.898, - "electricity_consumed_off_peak_interval": 314, - "electricity_consumed_off_peak_point": 5553, - "electricity_consumed_peak_cumulative": 161328.641, - "electricity_consumed_peak_interval": 0, - "electricity_consumed_peak_point": 0, - "electricity_phase_one_consumed": 1763, - "electricity_phase_one_produced": 0, - "electricity_phase_three_consumed": 2080, - "electricity_phase_three_produced": 0, - "electricity_phase_two_consumed": 1703, - "electricity_phase_two_produced": 0, - "electricity_produced_off_peak_cumulative": 0.0, - "electricity_produced_off_peak_interval": 0, - "electricity_produced_off_peak_point": 0, - "electricity_produced_peak_cumulative": 0.0, - "electricity_produced_peak_interval": 0, - "electricity_produced_peak_point": 0, - "gas_consumed_cumulative": 16811.37, - "gas_consumed_interval": 0.06, - "net_electricity_cumulative": 231866.539, - "net_electricity_point": 5553, - "voltage_phase_one": 233.2, - "voltage_phase_three": 234.7, - "voltage_phase_two": 234.4 - }, - "vendor": "XEMEX NV" - } - }, - "gateway": { - "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "item_count": 41, - "notifications": { - "97a04c0c263049b29350a660b4cdd01e": { - "warning": "The Smile P1 is not connected to a smart meter." - } - }, - "reboot": true, - "smile_name": "Smile P1" - } -} diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json deleted file mode 100644 index b1675116bdf..00000000000 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "devices": { - "0000aaaa0000aaaa0000aaaa0000aa00": { - "dev_class": "gateway", - "firmware": "3.1.11", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "mac_address": "01:23:45:67:89:AB", - "model": "Gateway", - "name": "Stretch", - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101" - }, - "059e4d03c7a34d278add5c7a4a781d19": { - "dev_class": "washingmachine", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Wasmachine (52AC1)", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": true, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01" - }, - "5871317346d045bc9f6b987ef25ee638": { - "dev_class": "water_heater_vessel", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4028", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Boiler (1EB31)", - "sensors": { - "electricity_consumed": 1.19, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07" - }, - "aac7b735042c4832ac9ff33aae4f453b": { - "dev_class": "dishwasher", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "6539-0701-4022", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Vaatwasser (2a1ab)", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.71, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02" - }, - "cfe95cf3de1948c0b8955125bf754614": { - "dev_class": "dryer", - "firmware": "2011-06-27T10:52:18+02:00", - "hardware": "0000-0440-0107", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle type F", - "name": "Droger (52559)", - "sensors": { - "electricity_consumed": 0.0, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A04" - }, - "d03738edfcc947f7b8f4573571d90d2d": { - "dev_class": "switching", - "members": [ - "059e4d03c7a34d278add5c7a4a781d19", - "cfe95cf3de1948c0b8955125bf754614" - ], - "model": "Switchgroup", - "name": "Schakel", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "d950b314e9d8499f968e6db8d82ef78c": { - "dev_class": "report", - "members": [ - "059e4d03c7a34d278add5c7a4a781d19", - "5871317346d045bc9f6b987ef25ee638", - "aac7b735042c4832ac9ff33aae4f453b", - "cfe95cf3de1948c0b8955125bf754614", - "e1c884e7dede431dadee09506ec4f859" - ], - "model": "Switchgroup", - "name": "Stroomvreters", - "switches": { - "relay": true - }, - "vendor": "Plugwise" - }, - "e1c884e7dede431dadee09506ec4f859": { - "dev_class": "refrigerator", - "firmware": "2011-06-27T10:47:37+02:00", - "hardware": "6539-0700-7330", - "location": "0000aaaa0000aaaa0000aaaa0000aa00", - "model": "Circle+ type F", - "name": "Koelkast (92C4A)", - "sensors": { - "electricity_consumed": 50.5, - "electricity_consumed_interval": 0.08, - "electricity_produced": 0.0 - }, - "switches": { - "lock": false, - "relay": true - }, - "vendor": "Plugwise", - "zigbee_mac_address": "0123456789AB" - } - }, - "gateway": { - "gateway_id": "0000aaaa0000aaaa0000aaaa0000aa00", - "item_count": 83, - "smile_name": "Stretch" - } -} From ee8830cc77333356b9bb440bdd63cd182f65dc79 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:35:19 -0400 Subject: [PATCH 0870/1664] Person ble_trackers for non-home zones not processed correctly (#138475) Co-authored-by: Erik Montnemery Co-authored-by: Joost Lekkerkerker --- homeassistant/components/person/__init__.py | 3 +- tests/components/person/test_init.py | 75 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 856e07bb2ee..0dd8646b17e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -27,7 +27,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, STATE_HOME, - STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -526,7 +525,7 @@ class Person( latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: latest_non_gps_home = _get_latest(latest_non_gps_home, state) - elif state.state == STATE_NOT_HOME: + else: latest_not_home = _get_latest(latest_not_home, state) if latest_non_gps_home: diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1d6c398c444..c001da86adb 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -244,6 +244,81 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER +async def test_setup_router_ble_trackers( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test router and BLE trackers.""" + # BLE trackers are considered stationary trackers; however unlike a router based tracker + # whose states are home and not_home, a BLE tracker may have the value of any zone that the + # beacon is configured for. + hass.set_state(CoreState.not_running) + user_id = hass_admin_user.id + config = { + DOMAIN: { + "id": "1234", + "name": "tracked person", + "user_id": user_id, + "device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2], + } + } + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get("person.tracked_person") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.states.async_set( + DEVICE_TRACKER, "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER} + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "not_home" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_GPS_ACCURACY) is None + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + # Set the BLE tracker to the "office" zone. + hass.states.async_set( + DEVICE_TRACKER_2, + "office", + { + ATTR_LATITUDE: 12.123456, + ATTR_LONGITUDE: 13.123456, + ATTR_GPS_ACCURACY: 12, + ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + }, + ) + await hass.async_block_till_done() + + # The person should be in the office. + state = hass.states.get("person.tracked_person") + assert state.state == "office" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) == 12.123456 + assert state.attributes.get(ATTR_LONGITUDE) == 13.123456 + assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: From 741a3d5009de323dc5ae29eecc449169c6e17b1f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 Jun 2025 14:11:10 +0200 Subject: [PATCH 0871/1664] Remove backup helper (#143558) * Remove backup helper * Update aws_s3 tests --- homeassistant/bootstrap.py | 5 - homeassistant/components/backup/__init__.py | 27 +++--- .../components/backup/basic_websocket.py | 38 -------- .../components/backup/coordinator.py | 8 +- homeassistant/components/backup/manager.py | 37 ++++++-- homeassistant/components/backup/onboarding.py | 11 ++- homeassistant/components/backup/websocket.py | 26 +++++- homeassistant/components/hassio/backup.py | 4 +- homeassistant/helpers/backup.py | 93 ------------------- tests/components/aws_s3/test_backup.py | 2 - tests/components/azure_storage/test_backup.py | 2 - tests/components/backup/common.py | 2 - .../backup/snapshots/test_websocket.ambr | 17 ---- tests/components/backup/test_backup.py | 4 - tests/components/backup/test_onboarding.py | 7 -- tests/components/backup/test_websocket.py | 25 ----- tests/components/cloud/test_backup.py | 4 +- tests/components/google_drive/test_backup.py | 4 +- tests/components/hassio/test_backup.py | 8 -- tests/components/hassio/test_update.py | 2 - tests/components/hassio/test_websocket_api.py | 2 - tests/components/kitchen_sink/test_backup.py | 4 +- tests/components/onedrive/test_backup.py | 4 +- tests/components/synology_dsm/test_backup.py | 5 +- tests/components/webdav/test_backup.py | 2 - tests/helpers/test_backup.py | 41 -------- 26 files changed, 88 insertions(+), 296 deletions(-) delete mode 100644 homeassistant/components/backup/basic_websocket.py delete mode 100644 homeassistant/helpers/backup.py delete mode 100644 tests/helpers/test_backup.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index f70237645e0..0b86bdb7087 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -75,7 +75,6 @@ from .core_config import async_process_ha_core_config from .exceptions import HomeAssistantError from .helpers import ( area_registry, - backup, category_registry, config_validation as cv, device_registry, @@ -880,10 +879,6 @@ async def _async_set_up_integrations( if "recorder" in all_domains: recorder.async_initialize_recorder(hass) - # Initialize backup - if "backup" in all_domains: - backup.async_initialize_backup(hass) - stages: list[tuple[str, set[str], int | None]] = [ *( (name, domain_group, timeout) diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 973f354060a..f3289d6e744 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -2,9 +2,9 @@ from homeassistant.config_entries import SOURCE_SYSTEM from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, discovery_flow -from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.typing import ConfigType @@ -37,7 +37,6 @@ from .manager import ( IdleEvent, IncorrectPasswordError, ManagerBackup, - ManagerStateEvent, NewBackup, RestoreBackupEvent, RestoreBackupStage, @@ -72,12 +71,12 @@ __all__ = [ "IncorrectPasswordError", "LocalBackupAgent", "ManagerBackup", - "ManagerStateEvent", "NewBackup", "RestoreBackupEvent", "RestoreBackupStage", "RestoreBackupState", "WrittenBackup", + "async_get_manager", "suggested_filename", "suggested_filename_from_name_date", ] @@ -104,13 +103,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: backup_manager = BackupManager(hass, reader_writer) hass.data[DATA_MANAGER] = backup_manager - try: - await backup_manager.async_setup() - except Exception as err: - hass.data[DATA_BACKUP].manager_ready.set_exception(err) - raise - else: - hass.data[DATA_BACKUP].manager_ready.set_result(None) + await backup_manager.async_setup() async_register_websocket_handlers(hass, with_hassio) @@ -143,3 +136,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +@callback +def async_get_manager(hass: HomeAssistant) -> BackupManager: + """Get the backup manager instance. + + Raises HomeAssistantError if the backup integration is not available. + """ + if DATA_MANAGER not in hass.data: + raise HomeAssistantError("Backup integration is not available") + + return hass.data[DATA_MANAGER] diff --git a/homeassistant/components/backup/basic_websocket.py b/homeassistant/components/backup/basic_websocket.py deleted file mode 100644 index 614dc23a927..00000000000 --- a/homeassistant/components/backup/basic_websocket.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Websocket commands for the Backup integration.""" - -from typing import Any - -import voluptuous as vol - -from homeassistant.components import websocket_api -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.backup import async_subscribe_events - -from .const import DATA_MANAGER -from .manager import ManagerStateEvent - - -@callback -def async_register_websocket_handlers(hass: HomeAssistant) -> None: - """Register websocket commands.""" - websocket_api.async_register_command(hass, handle_subscribe_events) - - -@websocket_api.require_admin -@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) -@websocket_api.async_response -async def handle_subscribe_events( - hass: HomeAssistant, - connection: websocket_api.ActiveConnection, - msg: dict[str, Any], -) -> None: - """Subscribe to backup events.""" - - def on_event(event: ManagerStateEvent) -> None: - connection.send_message(websocket_api.event_message(msg["id"], event)) - - if DATA_MANAGER in hass.data: - manager = hass.data[DATA_MANAGER] - on_event(manager.last_event) - connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event) - connection.send_result(msg["id"]) diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py index 3f6146f68d7..1a3429578c2 100644 --- a/homeassistant/components/backup/coordinator.py +++ b/homeassistant/components/backup/coordinator.py @@ -8,10 +8,6 @@ from datetime import datetime from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.backup import ( - async_subscribe_events, - async_subscribe_platform_events, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, LOGGER @@ -56,8 +52,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]): update_interval=None, ) self.unsubscribe: list[Callable[[], None]] = [ - async_subscribe_events(hass, self._on_event), - async_subscribe_platform_events(hass, self._on_event), + backup_manager.async_subscribe_events(self._on_event), + backup_manager.async_subscribe_platform_events(self._on_event), ] self.backup_manager = backup_manager diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 8dbce1b455c..e7fc1262f6d 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -36,7 +36,6 @@ from homeassistant.helpers import ( issue_registry as ir, start, ) -from homeassistant.helpers.backup import DATA_BACKUP from homeassistant.helpers.json import json_bytes from homeassistant.util import dt as dt_util, json as json_util @@ -372,12 +371,10 @@ class BackupManager: # Latest backup event and backup event subscribers self.last_event: ManagerStateEvent = BlockedEvent() self.last_action_event: ManagerStateEvent | None = None - self._backup_event_subscriptions = hass.data[ - DATA_BACKUP - ].backup_event_subscriptions - self._backup_platform_event_subscriptions = hass.data[ - DATA_BACKUP - ].backup_platform_event_subscriptions + self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = [] + self._backup_platform_event_subscriptions: list[ + Callable[[BackupPlatformEvent], None] + ] = [] async def async_setup(self) -> None: """Set up the backup manager.""" @@ -1385,6 +1382,32 @@ class BackupManager: for subscription in self._backup_event_subscriptions: subscription(event) + @callback + def async_subscribe_events( + self, + on_event: Callable[[ManagerStateEvent], None], + ) -> Callable[[], None]: + """Subscribe events.""" + + def remove_subscription() -> None: + self._backup_event_subscriptions.remove(on_event) + + self._backup_event_subscriptions.append(on_event) + return remove_subscription + + @callback + def async_subscribe_platform_events( + self, + on_event: Callable[[BackupPlatformEvent], None], + ) -> Callable[[], None]: + """Subscribe to backup platform events.""" + + def remove_subscription() -> None: + self._backup_platform_event_subscriptions.remove(on_event) + + self._backup_platform_event_subscriptions.append(on_event) + return remove_subscription + def _create_automatic_backup_failed_issue( self, translation_key: str, translation_placeholders: dict[str, str] | None ) -> None: diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py index ad7027c988c..dad0d5e7e35 100644 --- a/homeassistant/components/backup/onboarding.py +++ b/homeassistant/components/backup/onboarding.py @@ -19,9 +19,14 @@ from homeassistant.components.onboarding import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager -from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http +from . import ( + BackupManager, + Folder, + IncorrectPasswordError, + async_get_manager, + http as backup_http, +) if TYPE_CHECKING: from homeassistant.components.onboarding import OnboardingStoreData @@ -54,7 +59,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P]( if self._data["done"]: raise HTTPUnauthorized - manager = await async_get_backup_manager(request.app[KEY_HASS]) + manager = async_get_manager(request.app[KEY_HASS]) return await func(self, manager, request, *args, **kwargs) return with_backup diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py index 080b5bb18a8..3e6b13bfb56 100644 --- a/homeassistant/components/backup/websocket.py +++ b/homeassistant/components/backup/websocket.py @@ -10,7 +10,11 @@ from homeassistant.helpers import config_validation as cv from .config import Day, ScheduleRecurrence from .const import DATA_MANAGER, LOGGER -from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError +from .manager import ( + DecryptOnDowloadNotSupported, + IncorrectPasswordError, + ManagerStateEvent, +) from .models import BackupNotFound, Folder @@ -30,6 +34,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) -> websocket_api.async_register_command(hass, handle_create_with_automatic_settings) websocket_api.async_register_command(hass, handle_delete) websocket_api.async_register_command(hass, handle_restore) + websocket_api.async_register_command(hass, handle_subscribe_events) websocket_api.async_register_command(hass, handle_config_info) websocket_api.async_register_command(hass, handle_config_update) @@ -417,3 +422,22 @@ def handle_config_update( changes.pop("type") manager.config.update(**changes) connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"}) +@websocket_api.async_response +async def handle_subscribe_events( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Subscribe to backup events.""" + + def on_event(event: ManagerStateEvent) -> None: + connection.send_message(websocket_api.event_message(msg["id"], event)) + + manager = hass.data[DATA_MANAGER] + on_event(manager.last_event) + connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event) + connection.send_result(msg["id"]) diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py index 7f7bf077e21..1e9a14be1f2 100644 --- a/homeassistant/components/hassio/backup.py +++ b/homeassistant/components/hassio/backup.py @@ -48,13 +48,13 @@ from homeassistant.components.backup import ( RestoreBackupStage, RestoreBackupState, WrittenBackup, + async_get_manager as async_get_backup_manager, suggested_filename as suggested_backup_filename, suggested_filename_from_name_date, ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -839,7 +839,7 @@ async def backup_addon_before_update( async def backup_core_before_update(hass: HomeAssistant) -> None: """Prepare for updating core.""" - backup_manager = await async_get_backup_manager(hass) + backup_manager = async_get_backup_manager(hass) client = get_supervisor_client(hass) try: diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py deleted file mode 100644 index e445bef4aae..00000000000 --- a/homeassistant/helpers/backup.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Helpers for the backup integration.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import TYPE_CHECKING - -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.hass_dict import HassKey - -if TYPE_CHECKING: - from homeassistant.components.backup import ( - BackupManager, - BackupPlatformEvent, - ManagerStateEvent, - ) - -DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data") -DATA_MANAGER: HassKey[BackupManager] = HassKey("backup") - - -@dataclass(slots=True) -class BackupData: - """Backup data stored in hass.data.""" - - backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field( - default_factory=list - ) - backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = ( - field(default_factory=list) - ) - manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future) - - -@callback -def async_initialize_backup(hass: HomeAssistant) -> None: - """Initialize backup data. - - This creates the BackupData instance stored in hass.data[DATA_BACKUP] and - registers the basic backup websocket API which is used by frontend to subscribe - to backup events. - """ - from homeassistant.components.backup import basic_websocket # noqa: PLC0415 - - hass.data[DATA_BACKUP] = BackupData() - basic_websocket.async_register_websocket_handlers(hass) - - -async def async_get_manager(hass: HomeAssistant) -> BackupManager: - """Get the backup manager instance. - - Raises HomeAssistantError if the backup integration is not available. - """ - if DATA_BACKUP not in hass.data: - raise HomeAssistantError("Backup integration is not available") - - await hass.data[DATA_BACKUP].manager_ready - return hass.data[DATA_MANAGER] - - -@callback -def async_subscribe_events( - hass: HomeAssistant, - on_event: Callable[[ManagerStateEvent], None], -) -> Callable[[], None]: - """Subscribe to backup events.""" - backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions - - def remove_subscription() -> None: - backup_event_subscriptions.remove(on_event) - - backup_event_subscriptions.append(on_event) - return remove_subscription - - -@callback -def async_subscribe_platform_events( - hass: HomeAssistant, - on_event: Callable[[BackupPlatformEvent], None], -) -> Callable[[], None]: - """Subscribe to backup platform events.""" - backup_platform_event_subscriptions = hass.data[ - DATA_BACKUP - ].backup_platform_event_subscriptions - - def remove_subscription() -> None: - backup_platform_event_subscriptions.remove(on_event) - - backup_platform_event_subscriptions.append(on_event) - return remove_subscription diff --git a/tests/components/aws_s3/test_backup.py b/tests/components/aws_s3/test_backup.py index bf5baf2044b..aa8725a01b3 100644 --- a/tests/components/aws_s3/test_backup.py +++ b/tests/components/aws_s3/test_backup.py @@ -23,7 +23,6 @@ from homeassistant.components.aws_s3.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -43,7 +42,6 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/azure_storage/test_backup.py b/tests/components/azure_storage/test_backup.py index 8fb81e7dbc4..d7fb6981878 100644 --- a/tests/components/azure_storage/test_backup.py +++ b/tests/components/azure_storage/test_backup.py @@ -19,7 +19,6 @@ from homeassistant.components.azure_storage.const import ( ) from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -39,7 +38,6 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) await setup_integration(hass, mock_config_entry) diff --git a/tests/components/backup/common.py b/tests/components/backup/common.py index e6c5aab08cc..d9533d2764d 100644 --- a/tests/components/backup/common.py +++ b/tests/components/backup/common.py @@ -19,7 +19,6 @@ from homeassistant.components.backup import ( from homeassistant.components.backup.backup import CoreLocalBackupAgent from homeassistant.components.backup.const import DATA_MANAGER from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import mock_platform @@ -132,7 +131,6 @@ async def setup_backup_integration( ) -> dict[str, Mock]: """Set up the Backup integration.""" backups = backups or {} - async_initialize_backup(hass) with ( patch("homeassistant.components.backup.is_hassio", return_value=with_hassio), patch( diff --git a/tests/components/backup/snapshots/test_websocket.ambr b/tests/components/backup/snapshots/test_websocket.ambr index 1ce16b2c7d3..31e7fa0ee5b 100644 --- a/tests/components/backup/snapshots/test_websocket.ambr +++ b/tests/components/backup/snapshots/test_websocket.ambr @@ -6299,20 +6299,3 @@ 'type': 'event', }) # --- -# name: test_subscribe_event_early - dict({ - 'event': dict({ - 'manager_state': 'idle', - }), - 'id': 1, - 'type': 'event', - }) -# --- -# name: test_subscribe_event_early.1 - dict({ - 'id': 1, - 'result': None, - 'success': True, - 'type': 'result', - }) -# --- diff --git a/tests/components/backup/test_backup.py b/tests/components/backup/test_backup.py index 5a33bf39390..0624839336c 100644 --- a/tests/components/backup/test_backup.py +++ b/tests/components/backup/test_backup.py @@ -14,7 +14,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.backup import DOMAIN, AgentBackup from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .common import ( @@ -64,7 +63,6 @@ async def test_load_backups( side_effect: Exception | None, ) -> None: """Test load backups.""" - async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) @@ -84,7 +82,6 @@ async def test_upload( hass_client: ClientSessionGenerator, ) -> None: """Test upload backup.""" - async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_client() @@ -140,7 +137,6 @@ async def test_delete_backup( unlink_path: Path | None, ) -> None: """Test delete backup.""" - async_initialize_backup(hass) assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() client = await hass_ws_client(hass) diff --git a/tests/components/backup/test_onboarding.py b/tests/components/backup/test_onboarding.py index 51d704b8ba5..c36ec5eb4f7 100644 --- a/tests/components/backup/test_onboarding.py +++ b/tests/components/backup/test_onboarding.py @@ -10,7 +10,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import backup, onboarding from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.common import register_auth_provider @@ -57,7 +56,6 @@ async def test_onboarding_view_after_done( mock_onboarding_storage(hass_storage, {"done": [onboarding.const.STEP_USER]}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -111,7 +109,6 @@ async def test_onboarding_backup_info( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -232,7 +229,6 @@ async def test_onboarding_backup_restore( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -329,7 +325,6 @@ async def test_onboarding_backup_restore_error( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -373,7 +368,6 @@ async def test_onboarding_backup_restore_unexpected_error( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() @@ -399,7 +393,6 @@ async def test_onboarding_backup_upload( mock_onboarding_storage(hass_storage, {"done": []}) assert await async_setup_component(hass, "onboarding", {}) - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/backup/test_websocket.py b/tests/components/backup/test_websocket.py index 34e562ecfd6..02e40cabb33 100644 --- a/tests/components/backup/test_websocket.py +++ b/tests/components/backup/test_websocket.py @@ -30,8 +30,6 @@ from homeassistant.components.backup.manager import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.backup import async_initialize_backup -from homeassistant.setup import async_setup_component from .common import ( LOCAL_AGENT_ID, @@ -4057,29 +4055,6 @@ async def test_subscribe_event( assert await client.receive_json() == snapshot -async def test_subscribe_event_early( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - snapshot: SnapshotAssertion, -) -> None: - """Test subscribe event before backup integration has started.""" - async_initialize_backup(hass) - await setup_backup_integration(hass, with_hassio=False) - - client = await hass_ws_client(hass) - await client.send_json_auto_id({"type": "backup/subscribe_events"}) - assert await client.receive_json() == snapshot - - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - manager = hass.data[DATA_MANAGER] - - manager.async_on_backup_event( - CreateBackupEvent(stage=None, state=CreateBackupState.IN_PROGRESS, reason=None) - ) - assert await client.receive_json() == snapshot - - @pytest.mark.parametrize( ("agent_id", "backup_id", "password"), [ diff --git a/tests/components/cloud/test_backup.py b/tests/components/cloud/test_backup.py index c9e0f37829a..72640ed0a0e 100644 --- a/tests/components/cloud/test_backup.py +++ b/tests/components/cloud/test_backup.py @@ -21,7 +21,6 @@ from homeassistant.components.cloud import DOMAIN from homeassistant.components.cloud.backup import async_register_backup_agents_listener from homeassistant.components.cloud.const import EVENT_CLOUD_EVENT from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReaderChunked @@ -37,8 +36,7 @@ async def setup_integration( cloud: MagicMock, cloud_logged_in: None, ) -> AsyncGenerator[None]: - """Set up cloud and backup integrations.""" - async_initialize_backup(hass) + """Set up cloud integration.""" with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/google_drive/test_backup.py b/tests/components/google_drive/test_backup.py index b8e37d0f3b8..6307a7586d2 100644 --- a/tests/components/google_drive/test_backup.py +++ b/tests/components/google_drive/test_backup.py @@ -17,7 +17,6 @@ from homeassistant.components.backup import ( ) from homeassistant.components.google_drive import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .conftest import CONFIG_ENTRY_TITLE, TEST_AGENT_ID @@ -66,8 +65,7 @@ async def setup_integration( config_entry: MockConfigEntry, mock_api: MagicMock, ) -> None: - """Set up Google Drive and backup integrations.""" - async_initialize_backup(hass) + """Set up Google Drive integration.""" config_entry.add_to_hass(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) mock_api.list_files = AsyncMock( diff --git a/tests/components/hassio/test_backup.py b/tests/components/hassio/test_backup.py index ed1a6e312d3..3bc397b46f9 100644 --- a/tests/components/hassio/test_backup.py +++ b/tests/components/hassio/test_backup.py @@ -49,7 +49,6 @@ from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.backup import RESTORE_JOB_ID_ENV from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .test_init import MOCK_ENVIRON @@ -326,7 +325,6 @@ async def setup_backup_integration( hass: HomeAssistant, hassio_enabled: None, supervisor_client: AsyncMock ) -> None: """Set up Backup integration.""" - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() @@ -466,7 +464,6 @@ async def test_agent_info( client = await hass_ws_client(hass) supervisor_client.mounts.info.return_value = mounts - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await client.send_json_auto_id({"type": "backup/agents/info"}) @@ -1474,7 +1471,6 @@ async def test_reader_writer_create_per_agent_encryption( ) supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE supervisor_client.mounts.info.return_value = mounts - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) for command in commands: @@ -2610,7 +2606,6 @@ async def test_restore_progress_after_restart( supervisor_client.jobs.get_job.return_value = get_job_result - async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2634,7 +2629,6 @@ async def test_restore_progress_after_restart_report_progress( supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE - async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2717,7 +2711,6 @@ async def test_restore_progress_after_restart_unknown_job( supervisor_client.jobs.get_job.side_effect = SupervisorError - async_initialize_backup(hass) with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) @@ -2817,7 +2810,6 @@ async def test_config_load_config_info( hass_storage.update(storage_data) - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 6ecc2b44244..cfc3a923399 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -26,7 +26,6 @@ from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -246,7 +245,6 @@ async def test_update_addon(hass: HomeAssistant, update_addon: AsyncMock) -> Non async def setup_backup_integration(hass: HomeAssistant) -> None: """Set up the backup integration.""" - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/hassio/test_websocket_api.py b/tests/components/hassio/test_websocket_api.py index 8c68e9bf705..1f2a7d34819 100644 --- a/tests/components/hassio/test_websocket_api.py +++ b/tests/components/hassio/test_websocket_api.py @@ -27,7 +27,6 @@ from homeassistant.components.hassio.const import ( ) from homeassistant.const import __version__ as HAVERSION from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component @@ -360,7 +359,6 @@ async def test_update_addon( async def setup_backup_integration(hass: HomeAssistant) -> None: """Set up the backup integration.""" - async_initialize_backup(hass) assert await async_setup_component(hass, "backup", {}) await hass.async_block_till_done() diff --git a/tests/components/kitchen_sink/test_backup.py b/tests/components/kitchen_sink/test_backup.py index 02ad346cd58..598b8681b11 100644 --- a/tests/components/kitchen_sink/test_backup.py +++ b/tests/components/kitchen_sink/test_backup.py @@ -15,7 +15,6 @@ from homeassistant.components.backup import ( from homeassistant.components.kitchen_sink import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import instance_id -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator, WebSocketGenerator @@ -36,8 +35,7 @@ async def backup_only() -> AsyncGenerator[None]: @pytest.fixture(autouse=True) async def setup_integration(hass: HomeAssistant) -> AsyncGenerator[None]: - """Set up Kitchen Sink and backup integrations.""" - async_initialize_backup(hass) + """Set up Kitchen Sink integration.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) diff --git a/tests/components/onedrive/test_backup.py b/tests/components/onedrive/test_backup.py index 4d0abd5a602..40a8def0e39 100644 --- a/tests/components/onedrive/test_backup.py +++ b/tests/components/onedrive/test_backup.py @@ -21,7 +21,6 @@ from homeassistant.components.onedrive.backup import ( from homeassistant.components.onedrive.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from . import setup_integration @@ -36,8 +35,7 @@ async def setup_backup_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, ) -> AsyncGenerator[None]: - """Set up onedrive and backup integrations.""" - async_initialize_backup(hass) + """Set up onedrive integration.""" with ( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), diff --git a/tests/components/synology_dsm/test_backup.py b/tests/components/synology_dsm/test_backup.py index 0a887bbcae3..513b01ef278 100644 --- a/tests/components/synology_dsm/test_backup.py +++ b/tests/components/synology_dsm/test_backup.py @@ -32,7 +32,6 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from homeassistant.util.aiohttp import MockStreamReader, MockStreamReaderChunked @@ -161,8 +160,7 @@ async def setup_dsm_with_filestation( hass: HomeAssistant, mock_dsm_with_filestation: MagicMock, ): - """Mock setup of synology dsm config entry and backup integration.""" - async_initialize_backup(hass) + """Mock setup of synology dsm config entry.""" with ( patch( "homeassistant.components.synology_dsm.common.SynologyDSM", @@ -220,7 +218,6 @@ async def test_agents_not_loaded( ) -> None: """Test backup agent with no loaded config entry.""" with patch("homeassistant.components.backup.is_hassio", return_value=False): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}}) assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/webdav/test_backup.py b/tests/components/webdav/test_backup.py index 65badabe593..9659724e8a9 100644 --- a/tests/components/webdav/test_backup.py +++ b/tests/components/webdav/test_backup.py @@ -13,7 +13,6 @@ from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN, AgentBackup from homeassistant.components.webdav.backup import async_register_backup_agents_listener from homeassistant.components.webdav.const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.backup import async_initialize_backup from homeassistant.setup import async_setup_component from .const import BACKUP_METADATA @@ -31,7 +30,6 @@ async def setup_backup_integration( patch("homeassistant.components.backup.is_hassio", return_value=False), patch("homeassistant.components.backup.store.STORE_DELAY_SAVE", 0), ): - async_initialize_backup(hass) assert await async_setup_component(hass, BACKUP_DOMAIN, {}) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/helpers/test_backup.py b/tests/helpers/test_backup.py deleted file mode 100644 index f6a4f28622e..00000000000 --- a/tests/helpers/test_backup.py +++ /dev/null @@ -1,41 +0,0 @@ -"""The tests for the backup helpers.""" - -import asyncio -from unittest.mock import patch - -import pytest - -from homeassistant.components.backup import DOMAIN as BACKUP_DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import backup as backup_helper -from homeassistant.setup import async_setup_component - - -async def test_async_get_manager(hass: HomeAssistant) -> None: - """Test async_get_manager.""" - backup_helper.async_initialize_backup(hass) - task = asyncio.create_task(backup_helper.async_get_manager(hass)) - assert await async_setup_component(hass, BACKUP_DOMAIN, {}) - await hass.async_block_till_done() - manager = await task - assert manager is hass.data[backup_helper.DATA_MANAGER] - - -async def test_async_get_manager_no_backup(hass: HomeAssistant) -> None: - """Test async_get_manager when the backup integration is not enabled.""" - with pytest.raises(HomeAssistantError, match="Backup integration is not available"): - await backup_helper.async_get_manager(hass) - - -async def test_async_get_manager_backup_failed_setup(hass: HomeAssistant) -> None: - """Test test_async_get_manager when the backup integration can't be set up.""" - backup_helper.async_initialize_backup(hass) - - with patch( - "homeassistant.components.backup.manager.BackupManager.async_setup", - side_effect=Exception("Boom!"), - ): - assert not await async_setup_component(hass, BACKUP_DOMAIN, {}) - with pytest.raises(Exception, match="Boom!"): - await backup_helper.async_get_manager(hass) From ea702294269f9f5267cc384bcf1b2661a9f7e95c Mon Sep 17 00:00:00 2001 From: Jeef Date: Mon, 30 Jun 2025 07:26:17 -0600 Subject: [PATCH 0872/1664] Add Weatherflow Cloud wind support via websocket (#125611) * rebase off of dev * update tests * update tests * addressing PR finally * API to back * adding a return type * need to test * removed teh extra check on available * some changes * ready for re-review * change assertions * remove icon function * update ambr * ruff * update snapshot and push * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * enhnaced tests * better coverage * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * Update homeassistant/components/weatherflow_cloud/coordinator.py Co-authored-by: Erik Montnemery * remove comments --------- Co-authored-by: Erik Montnemery --- .../components/weatherflow_cloud/__init__.py | 93 ++++- .../weatherflow_cloud/config_flow.py | 5 +- .../components/weatherflow_cloud/const.py | 5 +- .../weatherflow_cloud/coordinator.py | 191 ++++++++- .../components/weatherflow_cloud/entity.py | 19 +- .../components/weatherflow_cloud/icons.json | 51 ++- .../components/weatherflow_cloud/sensor.py | 232 ++++++++++- .../components/weatherflow_cloud/strings.json | 39 +- .../components/weatherflow_cloud/weather.py | 16 +- .../components/weatherflow_cloud/conftest.py | 103 +++-- .../snapshots/test_sensor.ambr | 377 +++++++++++++++++- .../snapshots/test_weather.ambr | 2 +- .../weatherflow_cloud/test_coordinators.py | 223 +++++++++++ .../weatherflow_cloud/test_sensor.py | 116 +++++- .../weatherflow_cloud/test_weather.py | 4 +- 15 files changed, 1334 insertions(+), 142 deletions(-) create mode 100644 tests/components/weatherflow_cloud/test_coordinators.py diff --git a/homeassistant/components/weatherflow_cloud/__init__.py b/homeassistant/components/weatherflow_cloud/__init__.py index 94c65b7c0a1..1b3679b9113 100644 --- a/homeassistant/components/weatherflow_cloud/__init__.py +++ b/homeassistant/components/weatherflow_cloud/__init__.py @@ -2,30 +2,107 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +import asyncio +from dataclasses import dataclass -from .const import DOMAIN -from .coordinator import WeatherFlowCloudDataUpdateCoordinator +from weatherflow4py.api import WeatherFlowRestAPI +from weatherflow4py.ws import WeatherFlowWebsocketAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER +from .coordinator import ( + WeatherFlowCloudUpdateCoordinatorREST, + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.WEATHER] +@dataclass +class WeatherFlowCoordinators: + """Data Class for Entry Data.""" + + rest: WeatherFlowCloudUpdateCoordinatorREST + wind: WeatherFlowWindCoordinator + observation: WeatherFlowObservationCoordinator + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up WeatherFlowCloud from a config entry.""" - data_coordinator = WeatherFlowCloudDataUpdateCoordinator(hass, entry) - await data_coordinator.async_config_entry_first_refresh() + LOGGER.debug("Initializing WeatherFlowCloudDataUpdateCoordinatorREST coordinator") - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data_coordinator + rest_api = WeatherFlowRestAPI( + api_token=entry.data[CONF_API_TOKEN], session=async_get_clientsession(hass) + ) + + stations = await rest_api.async_get_stations() + + # Define Rest Coordinator + rest_data_coordinator = WeatherFlowCloudUpdateCoordinatorREST( + hass=hass, config_entry=entry, rest_api=rest_api, stations=stations + ) + + # Initialize the stations + await rest_data_coordinator.async_config_entry_first_refresh() + + # Construct Websocket Coordinators + LOGGER.debug("Initializing websocket coordinators") + websocket_device_ids = rest_data_coordinator.device_ids + + # Build API once + websocket_api = WeatherFlowWebsocketAPI( + access_token=entry.data[CONF_API_TOKEN], device_ids=websocket_device_ids + ) + + websocket_observation_coordinator = WeatherFlowObservationCoordinator( + hass=hass, + config_entry=entry, + rest_api=rest_api, + websocket_api=websocket_api, + stations=stations, + ) + + websocket_wind_coordinator = WeatherFlowWindCoordinator( + hass=hass, + config_entry=entry, + rest_api=rest_api, + websocket_api=websocket_api, + stations=stations, + ) + + # Run setup method + await asyncio.gather( + websocket_wind_coordinator.async_setup(), + websocket_observation_coordinator.async_setup(), + ) + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = WeatherFlowCoordinators( + rest_data_coordinator, + websocket_wind_coordinator, + websocket_observation_coordinator, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Websocket disconnect handler + async def _async_disconnect_websocket() -> None: + await websocket_api.stop_all_listeners() + await websocket_api.close() + + # Register a websocket shutdown handler + entry.async_on_unload(_async_disconnect_websocket) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/weatherflow_cloud/config_flow.py b/homeassistant/components/weatherflow_cloud/config_flow.py index bdd3003e6b6..41ac59b0e4b 100644 --- a/homeassistant/components/weatherflow_cloud/config_flow.py +++ b/homeassistant/components/weatherflow_cloud/config_flow.py @@ -49,10 +49,11 @@ class WeatherFlowCloudConfigFlow(ConfigFlow, domain=DOMAIN): errors = await _validate_api_token(api_token) if not errors: # Update the existing entry and abort + existing_entry = self._get_reauth_entry() return self.async_update_reload_and_abort( - self._get_reauth_entry(), + existing_entry, data={CONF_API_TOKEN: api_token}, - reload_even_if_entry_is_unchanged=False, + reason="reauth_successful", ) return self.async_show_form( diff --git a/homeassistant/components/weatherflow_cloud/const.py b/homeassistant/components/weatherflow_cloud/const.py index 24ae2f3a3cb..084010721af 100644 --- a/homeassistant/components/weatherflow_cloud/const.py +++ b/homeassistant/components/weatherflow_cloud/const.py @@ -5,7 +5,7 @@ import logging DOMAIN = "weatherflow_cloud" LOGGER = logging.getLogger(__package__) -ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest REST Api" +ATTR_ATTRIBUTION = "Weather data delivered by WeatherFlow/Tempest API" MANUFACTURER = "WeatherFlow" STATE_MAP = { @@ -29,3 +29,6 @@ STATE_MAP = { "thunderstorm": "lightning", "windy": "windy", } + +WEBSOCKET_API = "Websocket API" +REST_API = "REST API" diff --git a/homeassistant/components/weatherflow_cloud/coordinator.py b/homeassistant/components/weatherflow_cloud/coordinator.py index b6d2bfd5af2..ed3f8445110 100644 --- a/homeassistant/components/weatherflow_cloud/coordinator.py +++ b/homeassistant/components/weatherflow_cloud/coordinator.py @@ -1,46 +1,207 @@ -"""Data coordinator for WeatherFlow Cloud Data.""" +"""Improved coordinator design with better type safety.""" +from abc import ABC, abstractmethod from datetime import timedelta +from typing import Generic, TypeVar from aiohttp import ClientResponseError from weatherflow4py.api import WeatherFlowRestAPI +from weatherflow4py.models.rest.stations import StationsResponseREST from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from weatherflow4py.models.ws.obs import WebsocketObservation +from weatherflow4py.models.ws.types import EventType +from weatherflow4py.models.ws.websocket_request import ( + ListenStartMessage, + RapidWindListenStartMessage, +) +from weatherflow4py.models.ws.websocket_response import ( + EventDataRapidWind, + ObservationTempestWS, + RapidWindWS, +) +from weatherflow4py.ws import WeatherFlowWebsocketAPI from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.ssl import client_context from .const import DOMAIN, LOGGER +T = TypeVar("T") -class WeatherFlowCloudDataUpdateCoordinator( - DataUpdateCoordinator[dict[int, WeatherFlowDataREST]] -): - """Class to manage fetching REST Based WeatherFlow Forecast data.""" - config_entry: ConfigEntry +class BaseWeatherFlowCoordinator(DataUpdateCoordinator[dict[int, T]], ABC, Generic[T]): + """Base class for WeatherFlow coordinators.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + rest_api: WeatherFlowRestAPI, + stations: StationsResponseREST, + update_interval: timedelta | None = None, + always_update: bool = False, + ) -> None: + """Initialize Coordinator.""" + self._token = rest_api.api_token + self._rest_api = rest_api + self.stations = stations + self.device_to_station_map = stations.device_station_map + self.device_ids = list(stations.device_station_map.keys()) - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Initialize global WeatherFlow forecast data updater.""" - self.weather_api = WeatherFlowRestAPI( - api_token=config_entry.data[CONF_API_TOKEN] - ) super().__init__( hass, LOGGER, config_entry=config_entry, name=DOMAIN, + always_update=always_update, + update_interval=update_interval, + ) + + @abstractmethod + def get_station_name(self, station_id: int) -> str: + """Get station name for the given station ID.""" + + +class WeatherFlowCloudUpdateCoordinatorREST( + BaseWeatherFlowCoordinator[WeatherFlowDataREST] +): + """Class to manage fetching REST Based WeatherFlow Forecast data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + rest_api: WeatherFlowRestAPI, + stations: StationsResponseREST, + ) -> None: + """Initialize global WeatherFlow forecast data updater.""" + super().__init__( + hass, + config_entry, + rest_api, + stations, update_interval=timedelta(seconds=60), + always_update=True, ) async def _async_update_data(self) -> dict[int, WeatherFlowDataREST]: - """Fetch data from WeatherFlow Forecast.""" + """Update rest data.""" try: - async with self.weather_api: - return await self.weather_api.get_all_data() + async with self._rest_api: + return await self._rest_api.get_all_data() except ClientResponseError as err: if err.status == 401: raise ConfigEntryAuthFailed(err) from err raise UpdateFailed(f"Update failed: {err}") from err + + def get_station(self, station_id: int) -> WeatherFlowDataREST: + """Return station for id.""" + return self.data[station_id] + + def get_station_name(self, station_id: int) -> str: + """Return station name for id.""" + return self.data[station_id].station.name + + +class BaseWebsocketCoordinator( + BaseWeatherFlowCoordinator[dict[int, T | None]], ABC, Generic[T] +): + """Base class for websocket coordinators.""" + + _event_type: EventType + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + rest_api: WeatherFlowRestAPI, + websocket_api: WeatherFlowWebsocketAPI, + stations: StationsResponseREST, + ) -> None: + """Initialize Coordinator.""" + super().__init__( + hass=hass, config_entry=config_entry, rest_api=rest_api, stations=stations + ) + + self.websocket_api = websocket_api + + # Configure the websocket data structure + self._ws_data: dict[int, dict[int, T | None]] = { + station: dict.fromkeys(devices) + for station, devices in self.stations.station_device_map.items() + } + + async def async_setup(self) -> None: + """Set up the websocket connection.""" + await self.websocket_api.connect(client_context()) + self.websocket_api.register_callback( + message_type=self._event_type, + callback=self._handle_websocket_message, + ) + + # Subscribe to messages for all devices + for device_id in self.device_ids: + message = self._create_listen_message(device_id) + await self.websocket_api.send_message(message) + + @abstractmethod + def _create_listen_message(self, device_id: int): + """Create the appropriate listen message for this coordinator type.""" + + @abstractmethod + async def _handle_websocket_message(self, data) -> None: + """Handle incoming websocket data.""" + + def get_station(self, station_id: int): + """Return station for id.""" + return self.stations.stations[station_id] + + def get_station_name(self, station_id: int) -> str: + """Return station name for id.""" + return self.stations.station_map[station_id].name or "" + + +class WeatherFlowWindCoordinator(BaseWebsocketCoordinator[EventDataRapidWind]): + """Coordinator specifically for rapid wind data.""" + + _event_type = EventType.RAPID_WIND + + def _create_listen_message(self, device_id: int) -> RapidWindListenStartMessage: + """Create rapid wind listen message.""" + return RapidWindListenStartMessage(device_id=str(device_id)) + + async def _handle_websocket_message(self, data: RapidWindWS) -> None: + """Handle rapid wind websocket data.""" + device_id = data.device_id + station_id = self.device_to_station_map[device_id] + + # Extract the observation data from the RapidWindWS message + self._ws_data[station_id][device_id] = data.ob + self.async_set_updated_data(self._ws_data) + + +class WeatherFlowObservationCoordinator(BaseWebsocketCoordinator[WebsocketObservation]): + """Coordinator specifically for observation data.""" + + _event_type = EventType.OBSERVATION + + def _create_listen_message(self, device_id: int) -> ListenStartMessage: + """Create observation listen message.""" + return ListenStartMessage(device_id=str(device_id)) + + async def _handle_websocket_message(self, data: ObservationTempestWS) -> None: + """Handle observation websocket data.""" + device_id = data.device_id + station_id = self.device_to_station_map[device_id] + + # For observations, the data IS the observation + self._ws_data[station_id][device_id] = data + self.async_set_updated_data(self._ws_data) + + +# Type aliases for better readability +type WeatherFlowWindCallback = WeatherFlowWindCoordinator +type WeatherFlowObservationCallback = WeatherFlowObservationCoordinator diff --git a/homeassistant/components/weatherflow_cloud/entity.py b/homeassistant/components/weatherflow_cloud/entity.py index 46077ab0870..4ac1da92996 100644 --- a/homeassistant/components/weatherflow_cloud/entity.py +++ b/homeassistant/components/weatherflow_cloud/entity.py @@ -1,23 +1,21 @@ -"""Base entity class for WeatherFlow Cloud integration.""" - -from weatherflow4py.models.rest.unified import WeatherFlowDataREST +"""Entity definition.""" from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_ATTRIBUTION, DOMAIN, MANUFACTURER -from .coordinator import WeatherFlowCloudDataUpdateCoordinator +from .coordinator import BaseWeatherFlowCoordinator -class WeatherFlowCloudEntity(CoordinatorEntity[WeatherFlowCloudDataUpdateCoordinator]): - """Base entity class to use for everything.""" +class WeatherFlowCloudEntity[T](CoordinatorEntity[BaseWeatherFlowCoordinator[T]]): + """Base entity class for WeatherFlow Cloud integration.""" _attr_attribution = ATTR_ATTRIBUTION _attr_has_entity_name = True def __init__( self, - coordinator: WeatherFlowCloudDataUpdateCoordinator, + coordinator: BaseWeatherFlowCoordinator[T], station_id: int, ) -> None: """Class initializer.""" @@ -25,14 +23,9 @@ class WeatherFlowCloudEntity(CoordinatorEntity[WeatherFlowCloudDataUpdateCoordin self.station_id = station_id self._attr_device_info = DeviceInfo( - name=self.station.station.name, + name=coordinator.get_station_name(station_id), entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, str(station_id))}, manufacturer=MANUFACTURER, configuration_url=f"https://tempestwx.com/station/{station_id}/grid", ) - - @property - def station(self) -> WeatherFlowDataREST: - """Individual Station data.""" - return self.coordinator.data[self.station_id] diff --git a/homeassistant/components/weatherflow_cloud/icons.json b/homeassistant/components/weatherflow_cloud/icons.json index 19e6ac56821..5b9cd9c6cf4 100644 --- a/homeassistant/components/weatherflow_cloud/icons.json +++ b/homeassistant/components/weatherflow_cloud/icons.json @@ -1,11 +1,17 @@ { "entity": { "sensor": { + "air_density": { + "default": "mdi:format-line-weight" + }, "air_temperature": { "default": "mdi:thermometer" }, - "air_density": { - "default": "mdi:format-line-weight" + "barometric_pressure": { + "default": "mdi:gauge" + }, + "dew_point": { + "default": "mdi:water-percent" }, "feels_like": { "default": "mdi:thermometer" @@ -13,12 +19,6 @@ "heat_index": { "default": "mdi:sun-thermometer" }, - "wet_bulb_temperature": { - "default": "mdi:thermometer-water" - }, - "wet_bulb_globe_temperature": { - "default": "mdi:thermometer-water" - }, "lightning_strike_count": { "default": "mdi:lightning-bolt" }, @@ -34,8 +34,43 @@ "lightning_strike_last_epoch": { "default": "mdi:lightning-bolt" }, + "sea_level_pressure": { + "default": "mdi:gauge" + }, + "wet_bulb_globe_temperature": { + "default": "mdi:thermometer-water" + }, + "wet_bulb_temperature": { + "default": "mdi:thermometer-water" + }, + "wind_avg": { + "default": "mdi:weather-windy" + }, "wind_chill": { "default": "mdi:snowflake-thermometer" + }, + "wind_direction": { + "default": "mdi:compass", + "range": { + "0": "mdi:arrow-up", + "22.5": "mdi:arrow-top-right", + "67.5": "mdi:arrow-right", + "112.5": "mdi:arrow-bottom-right", + "157.5": "mdi:arrow-down", + "202.5": "mdi:arrow-bottom-left", + "247.5": "mdi:arrow-left", + "292.5": "mdi:arrow-top-left", + "337.5": "mdi:arrow-up" + } + }, + "wind_gust": { + "default": "mdi:weather-dust" + }, + "wind_lull": { + "default": "mdi:weather-windy-variant" + }, + "wind_sample_interval": { + "default": "mdi:timer-outline" } } } diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index d2c62b5f281..42357807d17 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -2,11 +2,17 @@ from __future__ import annotations +from abc import ABC from collections.abc import Callable from dataclasses import dataclass -from datetime import UTC, datetime +from datetime import date, datetime +from decimal import Decimal from weatherflow4py.models.rest.observation import Observation +from weatherflow4py.models.ws.websocket_response import ( + EventDataRapidWind, + WebsocketObservation, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,13 +21,22 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfLength, UnitOfPressure, UnitOfTemperature +from homeassistant.const import ( + EntityCategory, + UnitOfLength, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import UTC +from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators from .const import DOMAIN -from .coordinator import WeatherFlowCloudDataUpdateCoordinator +from .coordinator import WeatherFlowObservationCoordinator, WeatherFlowWindCoordinator from .entity import WeatherFlowCloudEntity @@ -34,6 +49,87 @@ class WeatherFlowCloudSensorEntityDescription( value_fn: Callable[[Observation], StateType | datetime] +@dataclass(frozen=True, kw_only=True) +class WeatherFlowCloudSensorEntityDescriptionWebsocketWind( + SensorEntityDescription, +): + """Describes a weatherflow sensor.""" + + value_fn: Callable[[EventDataRapidWind], StateType | datetime] + + +@dataclass(frozen=True, kw_only=True) +class WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + SensorEntityDescription, +): + """Describes a weatherflow sensor.""" + + value_fn: Callable[[WebsocketObservation], StateType | datetime] + + +WEBSOCKET_WIND_SENSORS: tuple[ + WeatherFlowCloudSensorEntityDescriptionWebsocketWind, ... +] = ( + WeatherFlowCloudSensorEntityDescriptionWebsocketWind( + key="wind_speed", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_speed_meters_per_second, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketWind( + key="wind_direction", + device_class=SensorDeviceClass.WIND_DIRECTION, + translation_key="wind_direction", + value_fn=lambda data: data.wind_direction_degrees, + native_unit_of_measurement="°", + ), +) + +WEBSOCKET_OBSERVATION_SENSORS: tuple[ + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation, ... +] = ( + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_lull", + translation_key="wind_lull", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_lull, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_gust", + translation_key="wind_gust", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_gust, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_avg", + translation_key="wind_avg", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.WIND_SPEED, + suggested_display_precision=1, + value_fn=lambda data: data.wind_avg, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, + ), + WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( + key="wind_sample_interval", + translation_key="wind_sample_interval", + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda data: data.wind_sample_interval, + ), +) + + WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = ( # Air Sensors WeatherFlowCloudSensorEntityDescription( @@ -176,35 +272,133 @@ async def async_setup_entry( ) -> None: """Set up WeatherFlow sensors based on a config entry.""" - coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id + coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][entry.entry_id] + rest_coordinator = coordinators.rest + wind_coordinator = coordinators.wind # Now properly typed + observation_coordinator = coordinators.observation # Now properly typed + + entities: list[SensorEntity] = [ + WeatherFlowCloudSensorREST(rest_coordinator, sensor_description, station_id) + for station_id in rest_coordinator.data + for sensor_description in WF_SENSORS ] - async_add_entities( - WeatherFlowCloudSensor(coordinator, sensor_description, station_id) - for station_id in coordinator.data - for sensor_description in WF_SENSORS + entities.extend( + WeatherFlowWebsocketSensorWind( + coordinator=wind_coordinator, + description=sensor_description, + station_id=station_id, + device_id=device_id, + ) + for station_id in wind_coordinator.stations.station_outdoor_device_map + for device_id in wind_coordinator.stations.station_outdoor_device_map[ + station_id + ] + for sensor_description in WEBSOCKET_WIND_SENSORS ) + entities.extend( + WeatherFlowWebsocketSensorObservation( + coordinator=observation_coordinator, + description=sensor_description, + station_id=station_id, + device_id=device_id, + ) + for station_id in observation_coordinator.stations.station_outdoor_device_map + for device_id in observation_coordinator.stations.station_outdoor_device_map[ + station_id + ] + for sensor_description in WEBSOCKET_OBSERVATION_SENSORS + ) + async_add_entities(entities) -class WeatherFlowCloudSensor(WeatherFlowCloudEntity, SensorEntity): - """Implementation of a WeatherFlow sensor.""" - entity_description: WeatherFlowCloudSensorEntityDescription +class WeatherFlowSensorBase(WeatherFlowCloudEntity, SensorEntity, ABC): + """Common base class.""" def __init__( self, - coordinator: WeatherFlowCloudDataUpdateCoordinator, - description: WeatherFlowCloudSensorEntityDescription, + coordinator: ( + WeatherFlowCloudUpdateCoordinatorREST + | WeatherFlowWindCoordinator + | WeatherFlowObservationCoordinator + ), + description: ( + WeatherFlowCloudSensorEntityDescription + | WeatherFlowCloudSensorEntityDescriptionWebsocketWind + | WeatherFlowCloudSensorEntityDescriptionWebsocketObservation + ), station_id: int, + device_id: int | None = None, ) -> None: - """Initialize the sensor.""" - # Initialize the Entity Class + """Initialize a sensor.""" super().__init__(coordinator, station_id) + self.station_id = station_id + self.device_id = device_id self.entity_description = description - self._attr_unique_id = f"{station_id}_{description.key}" + self._attr_unique_id = self._generate_unique_id() + + def _generate_unique_id(self) -> str: + """Generate a unique ID for the sensor.""" + if self.device_id is not None: + return f"{self.station_id}_{self.device_id}_{self.entity_description.key}" + return f"{self.station_id}_{self.entity_description.key}" + + @property + def available(self) -> bool: + """Get if available.""" + + if not super().available: + return False + + if self.device_id is not None: + # Websocket sensors - have Device IDs + return bool( + self.coordinator.data + and self.coordinator.data[self.station_id][self.device_id] is not None + ) + + return True + + +class WeatherFlowWebsocketSensorObservation(WeatherFlowSensorBase): + """Class for Websocket Observations.""" + + entity_description: WeatherFlowCloudSensorEntityDescriptionWebsocketObservation + + @property + def native_value(self) -> StateType | date | datetime | Decimal: + """Return the native value.""" + data = self.coordinator.data[self.station_id][self.device_id] + return self.entity_description.value_fn(data) + + +class WeatherFlowWebsocketSensorWind(WeatherFlowSensorBase): + """Class for wind over websockets.""" + + entity_description: WeatherFlowCloudSensorEntityDescriptionWebsocketWind @property def native_value(self) -> StateType | datetime: - """Return the state of the sensor.""" - return self.entity_description.value_fn(self.station.observation.obs[0]) + """Return the native value.""" + + # This data is often invalid at starutp. + if self.coordinator.data is not None: + data = self.coordinator.data[self.station_id][self.device_id] + return self.entity_description.value_fn(data) + return None + + +class WeatherFlowCloudSensorREST(WeatherFlowSensorBase): + """Class for a REST based sensor.""" + + entity_description: WeatherFlowCloudSensorEntityDescription + + coordinator: WeatherFlowCloudUpdateCoordinatorREST + + @property + def native_value(self) -> StateType | datetime: + """Return the native value.""" + return self.entity_description.value_fn( + self.coordinator.data[self.station_id].observation.obs[0] + ) diff --git a/homeassistant/components/weatherflow_cloud/strings.json b/homeassistant/components/weatherflow_cloud/strings.json index d22c62a030c..6c6e6f122a4 100644 --- a/homeassistant/components/weatherflow_cloud/strings.json +++ b/homeassistant/components/weatherflow_cloud/strings.json @@ -32,13 +32,15 @@ "barometric_pressure": { "name": "Pressure barometric" }, - "sea_level_pressure": { - "name": "Pressure sea level" - }, - "dew_point": { "name": "Dew point" }, + "feels_like": { + "name": "Feels like" + }, + "heat_index": { + "name": "Heat index" + }, "lightning_strike_count": { "name": "Lightning count" }, @@ -54,33 +56,32 @@ "lightning_strike_last_epoch": { "name": "Lightning last strike" }, - + "sea_level_pressure": { + "name": "Pressure sea level" + }, + "wet_bulb_globe_temperature": { + "name": "Wet bulb globe temperature" + }, + "wet_bulb_temperature": { + "name": "Wet bulb temperature" + }, + "wind_avg": { + "name": "Wind speed (avg)" + }, "wind_chill": { "name": "Wind chill" }, "wind_direction": { "name": "Wind direction" }, - "wind_direction_cardinal": { - "name": "Wind direction (cardinal)" - }, "wind_gust": { "name": "Wind gust" }, "wind_lull": { "name": "Wind lull" }, - "feels_like": { - "name": "Feels like" - }, - "heat_index": { - "name": "Heat index" - }, - "wet_bulb_temperature": { - "name": "Wet bulb temperature" - }, - "wet_bulb_globe_temperature": { - "name": "Wet bulb globe temperature" + "wind_sample_interval": { + "name": "Wind sample interval" } } } diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py index 3cb1f477095..1114d84b858 100644 --- a/homeassistant/components/weatherflow_cloud/weather.py +++ b/homeassistant/components/weatherflow_cloud/weather.py @@ -19,8 +19,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from . import WeatherFlowCloudUpdateCoordinatorREST, WeatherFlowCoordinators from .const import DOMAIN, STATE_MAP -from .coordinator import WeatherFlowCloudDataUpdateCoordinator from .entity import WeatherFlowCloudEntity @@ -30,21 +30,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add a weather entity from a config_entry.""" - coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][ - config_entry.entry_id - ] + coordinators: WeatherFlowCoordinators = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WeatherFlowWeather(coordinator, station_id=station_id) - for station_id, data in coordinator.data.items() + WeatherFlowWeatherREST(coordinators.rest, station_id=station_id) + for station_id, data in coordinators.rest.data.items() ] ) -class WeatherFlowWeather( +class WeatherFlowWeatherREST( WeatherFlowCloudEntity, - SingleCoordinatorWeatherEntity[WeatherFlowCloudDataUpdateCoordinator], + SingleCoordinatorWeatherEntity[WeatherFlowCloudUpdateCoordinatorREST], ): """Implementation of a WeatherFlow weather condition.""" @@ -59,7 +57,7 @@ class WeatherFlowWeather( def __init__( self, - coordinator: WeatherFlowCloudDataUpdateCoordinator, + coordinator: WeatherFlowCloudUpdateCoordinatorREST, station_id: int, ) -> None: """Initialise the platform with a data instance and station.""" diff --git a/tests/components/weatherflow_cloud/conftest.py b/tests/components/weatherflow_cloud/conftest.py index 36b42bf24a8..0a2a0bff005 100644 --- a/tests/components/weatherflow_cloud/conftest.py +++ b/tests/components/weatherflow_cloud/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the WeatherflowCloud tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from aiohttp import ClientResponseError import pytest +from weatherflow4py.api import WeatherFlowRestAPI from weatherflow4py.models.rest.forecast import WeatherDataForecastREST from weatherflow4py.models.rest.observation import ObservationStationREST from weatherflow4py.models.rest.stations import StationsResponseREST from weatherflow4py.models.rest.unified import WeatherFlowDataREST +from weatherflow4py.ws import WeatherFlowWebsocketAPI from homeassistant.components.weatherflow_cloud.const import DOMAIN from homeassistant.const import CONF_API_TOKEN @@ -81,35 +83,88 @@ async def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_api(): - """Fixture for Mock WeatherFlowRestAPI.""" - get_stations_response_data = StationsResponseREST.from_json( - load_fixture("stations.json", DOMAIN) - ) - get_forecast_response_data = WeatherDataForecastREST.from_json( - load_fixture("forecast.json", DOMAIN) - ) - get_observation_response_data = ObservationStationREST.from_json( - load_fixture("station_observation.json", DOMAIN) - ) +def mock_rest_api(): + """Mock rest api.""" + fixtures = { + "stations": StationsResponseREST.from_json( + load_fixture("stations.json", DOMAIN) + ), + "forecast": WeatherDataForecastREST.from_json( + load_fixture("forecast.json", DOMAIN) + ), + "observation": ObservationStationREST.from_json( + load_fixture("station_observation.json", DOMAIN) + ), + } + # Create device_station_map + device_station_map = { + device.device_id: station.station_id + for station in fixtures["stations"].stations + for device in station.devices + } + + # Prepare mock data data = { 24432: WeatherFlowDataREST( - weather=get_forecast_response_data, - observation=get_observation_response_data, - station=get_stations_response_data.stations[0], + weather=fixtures["forecast"], + observation=fixtures["observation"], + station=fixtures["stations"].stations[0], device_observations=None, ) } - with patch( - "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI", - autospec=True, - ) as mock_api_class: - # Create an instance of AsyncMock for the API - mock_api = AsyncMock() - mock_api.get_all_data.return_value = data - # Patch the class to return our mock_api instance - mock_api_class.return_value = mock_api + mock_api = AsyncMock(spec=WeatherFlowRestAPI) + mock_api.get_all_data.return_value = data + mock_api.async_get_stations.return_value = fixtures["stations"] + mock_api.device_station_map = device_station_map + mock_api.api_token = MOCK_API_TOKEN + # Apply patches + with ( + patch( + "homeassistant.components.weatherflow_cloud.WeatherFlowRestAPI", + return_value=mock_api, + ) as _, + patch( + "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowRestAPI", + return_value=mock_api, + ) as _, + ): yield mock_api + + +@pytest.fixture +def mock_stations_data(mock_rest_api): + """Mock stations data for coordinator tests.""" + return mock_rest_api.async_get_stations.return_value + + +@pytest.fixture +async def mock_websocket_api(): + """Mock WeatherFlowWebsocketAPI.""" + mock_websocket = AsyncMock() + mock_websocket.send = AsyncMock() + mock_websocket.recv = AsyncMock() + + mock_ws_instance = AsyncMock(spec=WeatherFlowWebsocketAPI) + mock_ws_instance.connect = AsyncMock() + mock_ws_instance.send_message = AsyncMock() + mock_ws_instance.register_callback = MagicMock() + mock_ws_instance.websocket = mock_websocket + + with ( + patch( + "homeassistant.components.weatherflow_cloud.coordinator.WeatherFlowWebsocketAPI", + return_value=mock_ws_instance, + ), + patch( + "homeassistant.components.weatherflow_cloud.WeatherFlowWebsocketAPI", + return_value=mock_ws_instance, + ), + patch( + "weatherflow4py.ws.WeatherFlowWebsocketAPI", return_value=mock_ws_instance + ), + ): + # mock_connect.return_value = mock_websocket + yield mock_ws_instance diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index f9819f39dca..a34d885b77b 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -42,7 +42,7 @@ # name: test_all_entities[sensor.my_home_station_air_density-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Air density', 'state_class': , 'unit_of_measurement': 'kg/m³', @@ -98,7 +98,7 @@ # name: test_all_entities[sensor.my_home_station_dew_point-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Dew point', 'state_class': , @@ -155,7 +155,7 @@ # name: test_all_entities[sensor.my_home_station_feels_like-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Feels like', 'state_class': , @@ -212,7 +212,7 @@ # name: test_all_entities[sensor.my_home_station_heat_index-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Heat index', 'state_class': , @@ -266,7 +266,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_count-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Lightning count', 'state_class': , }), @@ -318,7 +318,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_count_last_1_hr-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Lightning count last 1 hr', 'state_class': , }), @@ -370,7 +370,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_count_last_3_hr-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'friendly_name': 'My Home Station Lightning count last 3 hr', 'state_class': , }), @@ -425,7 +425,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_last_distance-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'distance', 'friendly_name': 'My Home Station Lightning last distance', 'state_class': , @@ -477,7 +477,7 @@ # name: test_all_entities[sensor.my_home_station_lightning_last_strike-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'timestamp', 'friendly_name': 'My Home Station Lightning last strike', }), @@ -535,7 +535,7 @@ # name: test_all_entities[sensor.my_home_station_pressure_barometric-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'atmospheric_pressure', 'friendly_name': 'My Home Station Pressure barometric', 'state_class': , @@ -595,7 +595,7 @@ # name: test_all_entities[sensor.my_home_station_pressure_sea_level-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'atmospheric_pressure', 'friendly_name': 'My Home Station Pressure sea level', 'state_class': , @@ -652,7 +652,7 @@ # name: test_all_entities[sensor.my_home_station_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Temperature', 'state_class': , @@ -709,7 +709,7 @@ # name: test_all_entities[sensor.my_home_station_wet_bulb_globe_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Wet bulb globe temperature', 'state_class': , @@ -766,7 +766,7 @@ # name: test_all_entities[sensor.my_home_station_wet_bulb_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Wet bulb temperature', 'state_class': , @@ -823,7 +823,7 @@ # name: test_all_entities[sensor.my_home_station_wind_chill-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'device_class': 'temperature', 'friendly_name': 'My Home Station Wind chill', 'state_class': , @@ -837,3 +837,350 @@ 'state': '10.5', }) # --- +# name: test_all_entities[sensor.my_home_station_wind_direction-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.my_home_station_wind_direction', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wind direction', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_direction', + 'unique_id': '24432_123456_wind_direction', + 'unit_of_measurement': '°', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_direction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_direction', + 'friendly_name': 'My Home Station Wind direction', + 'unit_of_measurement': '°', + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_direction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_gust-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.my_home_station_wind_gust', + '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': 'Wind gust', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_gust', + 'unique_id': '24432_123456_wind_gust', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_gust-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind gust', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_gust', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_lull-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.my_home_station_wind_lull', + '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': 'Wind lull', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_lull', + 'unique_id': '24432_123456_wind_lull', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_lull-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind lull', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_lull', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_sample_interval-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.my_home_station_wind_sample_interval', + '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': None, + 'original_icon': None, + 'original_name': 'Wind sample interval', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_sample_interval', + 'unique_id': '24432_123456_wind_sample_interval', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_sample_interval-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'friendly_name': 'My Home Station Wind sample interval', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_sample_interval', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_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.my_home_station_wind_speed', + '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': 'Wind speed', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '24432_123456_wind_speed', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind speed', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_speed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed_avg-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.my_home_station_wind_speed_avg', + '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': 'Wind speed (avg)', + 'platform': 'weatherflow_cloud', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wind_avg', + 'unique_id': '24432_123456_wind_avg', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.my_home_station_wind_speed_avg-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'wind_speed', + 'friendly_name': 'My Home Station Wind speed (avg)', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.my_home_station_wind_speed_avg', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr index 867f7874ed3..895333bf269 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_weather.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_weather.ambr @@ -37,7 +37,7 @@ # name: test_weather[weather.my_home_station-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'attribution': 'Weather data delivered by WeatherFlow/Tempest REST Api', + 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', 'dew_point': -13.0, 'friendly_name': 'My Home Station', 'humidity': 27, diff --git a/tests/components/weatherflow_cloud/test_coordinators.py b/tests/components/weatherflow_cloud/test_coordinators.py new file mode 100644 index 00000000000..bb38cfacac8 --- /dev/null +++ b/tests/components/weatherflow_cloud/test_coordinators.py @@ -0,0 +1,223 @@ +"""Tests for the WeatherFlow Cloud coordinators.""" + +from unittest.mock import AsyncMock, Mock + +from aiohttp import ClientResponseError +import pytest +from weatherflow4py.models.ws.types import EventType +from weatherflow4py.models.ws.websocket_request import ( + ListenStartMessage, + RapidWindListenStartMessage, +) +from weatherflow4py.models.ws.websocket_response import ( + EventDataRapidWind, + ObservationTempestWS, + RapidWindWS, +) + +from homeassistant.components.weatherflow_cloud.coordinator import ( + WeatherFlowCloudUpdateCoordinatorREST, + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) +from homeassistant.config_entries import ConfigEntryAuthFailed +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import UpdateFailed + +from tests.common import MockConfigEntry + + +async def test_wind_coordinator_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test wind coordinator setup.""" + + coordinator = WeatherFlowWindCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + await coordinator.async_setup() + + # Verify websocket setup + mock_websocket_api.connect.assert_called_once() + mock_websocket_api.register_callback.assert_called_once_with( + message_type=EventType.RAPID_WIND, + callback=coordinator._handle_websocket_message, + ) + # In the refactored code, send_message is called for each device ID + assert mock_websocket_api.send_message.called + + # Verify at least one message is of the correct type + call_args_list = mock_websocket_api.send_message.call_args_list + assert any( + isinstance(call.args[0], RapidWindListenStartMessage) for call in call_args_list + ) + + +async def test_observation_coordinator_setup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test observation coordinator setup.""" + + coordinator = WeatherFlowObservationCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + await coordinator.async_setup() + + # Verify websocket setup + mock_websocket_api.connect.assert_called_once() + mock_websocket_api.register_callback.assert_called_once_with( + message_type=EventType.OBSERVATION, + callback=coordinator._handle_websocket_message, + ) + # In the refactored code, send_message is called for each device ID + assert mock_websocket_api.send_message.called + + # Verify at least one message is of the correct type + call_args_list = mock_websocket_api.send_message.call_args_list + assert any(isinstance(call.args[0], ListenStartMessage) for call in call_args_list) + + +async def test_wind_coordinator_message_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test wind coordinator message handling.""" + + coordinator = WeatherFlowWindCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + # Create mock wind data + mock_wind_data = Mock(spec=EventDataRapidWind) + mock_message = Mock(spec=RapidWindWS) + + # Use a device ID from the actual mock data + # The first device from the first station in the mock data + device_id = mock_stations_data.stations[0].devices[0].device_id + station_id = mock_stations_data.stations[0].station_id + + mock_message.device_id = device_id + mock_message.ob = mock_wind_data + + # Handle the message + await coordinator._handle_websocket_message(mock_message) + + # Verify data was stored correctly + assert coordinator._ws_data[station_id][device_id] == mock_wind_data + + +async def test_observation_coordinator_message_handling( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test observation coordinator message handling.""" + + coordinator = WeatherFlowObservationCoordinator( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + websocket_api=mock_websocket_api, + stations=mock_stations_data, + ) + + # Create mock observation data + mock_message = Mock(spec=ObservationTempestWS) + + # Use a device ID from the actual mock data + # The first device from the first station in the mock data + device_id = mock_stations_data.stations[0].devices[0].device_id + station_id = mock_stations_data.stations[0].station_id + + mock_message.device_id = device_id + + # Handle the message + await coordinator._handle_websocket_message(mock_message) + + # Verify data was stored correctly (for observations, the message IS the data) + assert coordinator._ws_data[station_id][device_id] == mock_message + + +async def test_rest_coordinator_auth_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test REST coordinator handling of 401 auth error.""" + # Create the coordinator + coordinator = WeatherFlowCloudUpdateCoordinatorREST( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + stations=mock_stations_data, + ) + + # Mock a 401 auth error + mock_rest_api.get_all_data.side_effect = ClientResponseError( + request_info=Mock(), + history=Mock(), + status=401, + message="Unauthorized", + ) + + # Verify the error is properly converted to ConfigEntryAuthFailed + with pytest.raises(ConfigEntryAuthFailed): + await coordinator._async_update_data() + + +async def test_rest_coordinator_other_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_stations_data: Mock, +) -> None: + """Test REST coordinator handling of non-auth errors.""" + # Create the coordinator + coordinator = WeatherFlowCloudUpdateCoordinatorREST( + hass=hass, + config_entry=mock_config_entry, + rest_api=mock_rest_api, + stations=mock_stations_data, + ) + + # Mock a 500 server error + mock_rest_api.get_all_data.side_effect = ClientResponseError( + request_info=Mock(), + history=Mock(), + status=500, + message="Internal Server Error", + ) + + # Verify the error is properly converted to UpdateFailed + with pytest.raises( + UpdateFailed, match="Update failed: 500, message='Internal Server Error'" + ): + await coordinator._async_update_data() diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 59374a80a4b..191f720527f 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -1,13 +1,22 @@ """Tests for the WeatherFlow Cloud sensor platform.""" from datetime import timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch from freezegun.api import FrozenDateTimeFactory +import pytest from syrupy.assertion import SnapshotAssertion from weatherflow4py.models.rest.observation import ObservationStationREST from homeassistant.components.weatherflow_cloud import DOMAIN +from homeassistant.components.weatherflow_cloud.coordinator import ( + WeatherFlowObservationCoordinator, + WeatherFlowWindCoordinator, +) +from homeassistant.components.weatherflow_cloud.sensor import ( + WeatherFlowWebsocketSensorObservation, + WeatherFlowWebsocketSensorWind, +) from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -17,17 +26,19 @@ from . import setup_integration from tests.common import ( MockConfigEntry, async_fire_time_changed, - async_load_fixture, + load_fixture, snapshot_platform, ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_api: AsyncMock, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, ) -> None: """Test all entities.""" with patch( @@ -38,17 +49,19 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities_with_lightning_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_api: AsyncMock, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, freezer: FrozenDateTimeFactory, ) -> None: """Test all entities.""" get_observation_response_data = ObservationStationREST.from_json( - await async_load_fixture(hass, "station_observation_error.json", DOMAIN) + load_fixture("station_observation_error.json", DOMAIN) ) with patch( @@ -62,9 +75,9 @@ async def test_all_entities_with_lightning_error( ) # Update the data in our API - all_data = await mock_api.get_all_data() + all_data = await mock_rest_api.get_all_data() all_data[24432].observation = get_observation_response_data - mock_api.get_all_data.return_value = all_data + mock_rest_api.get_all_data.return_value = all_data # Move time forward freezer.tick(timedelta(minutes=5)) @@ -75,3 +88,92 @@ async def test_all_entities_with_lightning_error( hass.states.get("sensor.my_home_station_lightning_last_strike").state == STATE_UNKNOWN ) + + +async def test_websocket_sensor_observation( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, +) -> None: + """Test the WebsocketSensorObservation class works.""" + # Set up the integration + with patch( + "homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + # Create a mock coordinator with test data + coordinator = MagicMock(spec=WeatherFlowObservationCoordinator) + + # Mock the coordinator data structure + test_station_id = 24432 + test_device_id = 12345 + test_data = { + "temperature": 22.5, + "humidity": 45, + "pressure": 1013.2, + } + + coordinator.data = {test_station_id: {test_device_id: test_data}} + + # Create a sensor entity description + entity_description = MagicMock() + entity_description.value_fn = lambda data: data["temperature"] + + # Create the sensor + sensor = WeatherFlowWebsocketSensorObservation( + coordinator=coordinator, + description=entity_description, + station_id=test_station_id, + device_id=test_device_id, + ) + + # Test that native_value returns the correct value + assert sensor.native_value == 22.5 + + +async def test_websocket_sensor_wind( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_rest_api: AsyncMock, + mock_websocket_api: AsyncMock, +) -> None: + """Test the WebsocketSensorWind class works.""" + # Set up the integration + with patch( + "homeassistant.components.weatherflow_cloud.PLATFORMS", [Platform.SENSOR] + ): + await setup_integration(hass, mock_config_entry) + + # Create a mock coordinator with test data + coordinator = MagicMock(spec=WeatherFlowWindCoordinator) + + # Mock the coordinator data structure + test_station_id = 24432 + test_device_id = 12345 + test_data = { + "wind_speed": 5.2, + "wind_direction": 180, + } + + coordinator.data = {test_station_id: {test_device_id: test_data}} + + # Create a sensor entity description + entity_description = MagicMock() + entity_description.value_fn = lambda data: data["wind_speed"] + + # Create the sensor + sensor = WeatherFlowWebsocketSensorWind( + coordinator=coordinator, + description=entity_description, + station_id=test_station_id, + device_id=test_device_id, + ) + + # Test that native_value returns the correct value + assert sensor.native_value == 5.2 + + # Test with None data (startup condition) + coordinator.data = None + assert sensor.native_value is None diff --git a/tests/components/weatherflow_cloud/test_weather.py b/tests/components/weatherflow_cloud/test_weather.py index 8da67b27060..029cbb11a6e 100644 --- a/tests/components/weatherflow_cloud/test_weather.py +++ b/tests/components/weatherflow_cloud/test_weather.py @@ -18,7 +18,9 @@ async def test_weather( snapshot: SnapshotAssertion, mock_config_entry: MockConfigEntry, entity_registry: er.EntityRegistry, - mock_api: AsyncMock, + mock_rest_api: AsyncMock, + mock_get_stations: AsyncMock, + mock_websocket_api: AsyncMock, ) -> None: """Test all entities.""" with patch( From b52a248def90a21750c36dc96d4dc2a6de291429 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 30 Jun 2025 15:40:10 +0200 Subject: [PATCH 0873/1664] Bump plugwise to v1.7.7 and adapt (#147809) --- homeassistant/components/plugwise/__init__.py | 8 +- homeassistant/components/plugwise/climate.py | 4 +- .../components/plugwise/config_flow.py | 6 +- homeassistant/components/plugwise/entity.py | 2 +- .../components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/conftest.py | 249 ++++++++++-------- .../data.json | 13 + .../plugwise/snapshots/test_diagnostics.ambr | 13 + tests/components/plugwise/test_config_flow.py | 2 +- 11 files changed, 177 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/plugwise/__init__.py b/homeassistant/components/plugwise/__init__.py index e97493a78a7..f71d91d5bd1 100644 --- a/homeassistant/components/plugwise/__init__.py +++ b/homeassistant/components/plugwise/__init__.py @@ -27,10 +27,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) -> config_entry_id=entry.entry_id, identifiers={(DOMAIN, str(coordinator.api.gateway_id))}, manufacturer="Plugwise", - model=coordinator.api.smile_model, - model_id=coordinator.api.smile_model_id, - name=coordinator.api.smile_name, - sw_version=str(coordinator.api.smile_version), + model=coordinator.api.smile.model, + model_id=coordinator.api.smile.model_id, + name=coordinator.api.smile.name, + sw_version=str(coordinator.api.smile.version), ) # required for adding the entity-less P1 Gateway await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 834ff8bce76..71846a04bbd 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -39,7 +39,7 @@ async def async_setup_entry( if not coordinator.new_devices: return - if coordinator.api.smile_name == "Adam": + if coordinator.api.smile.name == "Adam": async_add_entities( PlugwiseClimateEntity(coordinator, device_id) for device_id in coordinator.new_devices @@ -85,7 +85,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE if ( self.coordinator.api.cooling_present - and coordinator.api.smile_name != "Adam" + and coordinator.api.smile.name != "Adam" ): self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE_RANGE diff --git a/homeassistant/components/plugwise/config_flow.py b/homeassistant/components/plugwise/config_flow.py index bf33d4c4a0f..a506969a109 100644 --- a/homeassistant/components/plugwise/config_flow.py +++ b/homeassistant/components/plugwise/config_flow.py @@ -204,11 +204,11 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): api, errors = await verify_connection(self.hass, user_input) if api: await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, + api.smile.hostname or api.gateway_id, raise_on_progress=False, ) self._abort_if_unique_id_configured() - return self.async_create_entry(title=api.smile_name, data=user_input) + return self.async_create_entry(title=api.smile.name, data=user_input) return self.async_show_form( step_id=SOURCE_USER, @@ -236,7 +236,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN): api, errors = await verify_connection(self.hass, full_input) if api: await self.async_set_unique_id( - api.smile_hostname or api.gateway_id, + api.smile.hostname or api.gateway_id, raise_on_progress=False, ) self._abort_if_unique_id_mismatch(reason="not_the_same_smile") diff --git a/homeassistant/components/plugwise/entity.py b/homeassistant/components/plugwise/entity.py index 39838c38fde..41e08a2b012 100644 --- a/homeassistant/components/plugwise/entity.py +++ b/homeassistant/components/plugwise/entity.py @@ -48,7 +48,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]): manufacturer=data.get("vendor"), model=data.get("model"), model_id=data.get("model_id"), - name=coordinator.api.smile_name, + name=coordinator.api.smile.name, sw_version=data.get("firmware"), hw_version=data.get("hardware"), ) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 0cf50326df1..09cec98292a 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.6"], + "requirements": ["plugwise==1.7.7"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3c63782bacc..5bdfb48232f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1689,7 +1689,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.6 +plugwise==1.7.7 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a82c6fad437..3cf7bc78aaa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1427,7 +1427,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==1.7.6 +plugwise==1.7.7 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/conftest.py b/tests/components/plugwise/conftest.py index e0a61106101..bc3de313a86 100644 --- a/tests/components/plugwise/conftest.py +++ b/tests/components/plugwise/conftest.py @@ -7,6 +7,7 @@ import json from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +from munch import Munch from packaging.version import Version import pytest @@ -23,6 +24,14 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +def build_smile(**attrs): + """Build smile Munch from provided attributes.""" + smile = Munch() + for k, v in attrs.items(): + setattr(smile, k, v) + return smile + + def _read_json(environment: str, call: str) -> dict[str, Any]: """Undecode the json data.""" fixture = load_fixture(f"plugwise/{environment}/{call}.json") @@ -106,17 +115,19 @@ def mock_smile_config_flow() -> Generator[MagicMock]: with patch( "homeassistant.components.plugwise.config_flow.Smile", autospec=True, - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.connect.return_value = Version("4.3.2") - smile.smile_hostname = "smile12345" - smile.smile_model = "Test Model" - smile.smile_model_id = "Test Model ID" - smile.smile_name = "Test Smile Name" - smile.smile_version = "4.3.2" + api.connect.return_value = Version("4.3.2") + api.smile = build_smile( + hostname="smile12345", + model="Test Model", + model_id="Test Model ID", + name="Test Smile Name", + version="4.3.2", + ) - yield smile + yield api @pytest.fixture @@ -127,28 +138,30 @@ def mock_smile_adam() -> Generator[MagicMock]: with ( patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock, + ) as api_mock, patch( "homeassistant.components.plugwise.config_flow.Smile", - new=smile_mock, + new=api_mock, ), ): - smile = smile_mock.return_value + api = api_mock.return_value - smile.async_update.return_value = data - smile.cooling_present = False - smile.connect.return_value = Version("3.0.15") - smile.gateway_id = "fe799307f1624099878210aa0b9f1475" - smile.heater_id = "90986d591dcd426cae3ec3e8111ff730" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.smile_type = "thermostat" - smile.smile_version = "3.0.15" + api.async_update.return_value = data + api.cooling_present = False + api.connect.return_value = Version("3.0.15") + api.gateway_id = "fe799307f1624099878210aa0b9f1475" + api.heater_id = "90986d591dcd426cae3ec3e8111ff730" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_open_therm", + name="Adam", + type="thermostat", + version="3.0.15", + ) - yield smile + yield api @pytest.fixture @@ -159,23 +172,25 @@ def mock_smile_adam_heat_cool( data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("3.6.4") - smile.cooling_present = cooling_present - smile.gateway_id = "da224107914542988a88561b4452b0f6" - smile.heater_id = "056ee145a816487eaa69243c3280f8bf" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.smile_type = "thermostat" - smile.smile_version = "3.6.4" + api.async_update.return_value = data + api.connect.return_value = Version("3.6.4") + api.cooling_present = cooling_present + api.gateway_id = "da224107914542988a88561b4452b0f6" + api.heater_id = "056ee145a816487eaa69243c3280f8bf" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_open_therm", + name="Adam", + type="thermostat", + version="3.6.4", + ) - yield smile + yield api @pytest.fixture @@ -185,23 +200,25 @@ def mock_smile_adam_jip() -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("3.2.8") - smile.cooling_present = False - smile.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" - smile.heater_id = "e4684553153b44afbef2200885f379dc" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_open_therm" - smile.smile_name = "Adam" - smile.smile_type = "thermostat" - smile.smile_version = "3.2.8" + api.async_update.return_value = data + api.connect.return_value = Version("3.2.8") + api.cooling_present = False + api.gateway_id = "b5c2386c6f6342669e50fe49dd05b188" + api.heater_id = "e4684553153b44afbef2200885f379dc" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_open_therm", + name="Adam", + type="thermostat", + version="3.2.8", + ) - yield smile + yield api @pytest.fixture @@ -210,23 +227,25 @@ def mock_smile_anna(chosen_env: str, cooling_present: bool) -> Generator[MagicMo data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("4.0.15") - smile.cooling_present = cooling_present - smile.gateway_id = "015ae9ea3f964e668e490fa39da3870b" - smile.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile_thermo" - smile.smile_name = "Smile Anna" - smile.smile_type = "thermostat" - smile.smile_version = "4.0.15" + api.async_update.return_value = data + api.connect.return_value = Version("4.0.15") + api.cooling_present = cooling_present + api.gateway_id = "015ae9ea3f964e668e490fa39da3870b" + api.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927" + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile_thermo", + name="Smile Anna", + type="thermostat", + version="4.0.15", + ) - yield smile + yield api @pytest.fixture @@ -235,22 +254,24 @@ def mock_smile_p1(chosen_env: str, gateway_id: str) -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("4.4.2") - smile.gateway_id = gateway_id - smile.heater_id = None - smile.reboot = True - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = "smile" - smile.smile_name = "Smile P1" - smile.smile_type = "power" - smile.smile_version = "4.4.2" + api.async_update.return_value = data + api.connect.return_value = Version("4.4.2") + api.gateway_id = gateway_id + api.heater_id = None + api.reboot = True + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id="smile", + name="Smile P1", + type="power", + version="4.4.2", + ) - yield smile + yield api @pytest.fixture @@ -260,22 +281,24 @@ def mock_smile_legacy_anna() -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("1.8.22") - smile.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" - smile.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" - smile.reboot = False - smile.smile_hostname = "smile98765" - smile.smile_model = "Gateway" - smile.smile_model_id = None - smile.smile_name = "Smile Anna" - smile.smile_type = "thermostat" - smile.smile_version = "1.8.22" + api.async_update.return_value = data + api.connect.return_value = Version("1.8.22") + api.gateway_id = "0000aaaa0000aaaa0000aaaa0000aa00" + api.heater_id = "04e4cbfe7f4340f090f85ec3b9e6a950" + api.reboot = False + api.smile = build_smile( + hostname="smile98765", + model="Gateway", + model_id=None, + name="Smile Anna", + type="thermostat", + version="1.8.22", + ) - yield smile + yield api @pytest.fixture @@ -285,22 +308,24 @@ def mock_stretch() -> Generator[MagicMock]: data = _read_json(chosen_env, "data") with patch( "homeassistant.components.plugwise.coordinator.Smile", autospec=True - ) as smile_mock: - smile = smile_mock.return_value + ) as api_mock: + api = api_mock.return_value - smile.async_update.return_value = data - smile.connect.return_value = Version("3.1.11") - smile.gateway_id = "259882df3c05415b99c2d962534ce820" - smile.heater_id = None - smile.reboot = False - smile.smile_hostname = "stretch98765" - smile.smile_model = "Gateway" - smile.smile_model_id = None - smile.smile_name = "Stretch" - smile.smile_type = "stretch" - smile.smile_version = "3.1.11" + api.async_update.return_value = data + api.connect.return_value = Version("3.1.11") + api.gateway_id = "259882df3c05415b99c2d962534ce820" + api.heater_id = None + api.reboot = False + api.smile = build_smile( + hostname="stretch98765", + model="Gateway", + model_id=None, + name="Stretch", + type="stretch", + version="3.1.11", + ) - yield smile + yield api @pytest.fixture diff --git a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json index 7c38b1b2197..06459a11798 100644 --- a/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json +++ b/tests/components/plugwise/fixtures/m_adam_multiple_devices_per_zone/data.json @@ -531,6 +531,19 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A11" }, + "e8ef2a01ed3b4139a53bf749204fe6b4": { + "dev_class": "switching", + "members": [ + "02cf28bfec924855854c544690a609ef", + "4a810418d5394b3f82727340b91ba740" + ], + "model": "Switchgroup", + "name": "Test", + "switches": { + "relay": true + }, + "vendor": "Plugwise" + }, "f1fee6043d3642a9b0a65297455f008e": { "available": true, "binary_sensors": { diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index 92ed327b841..4aa367bc116 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -579,6 +579,19 @@ 'vendor': 'Plugwise', 'zigbee_mac_address': 'ABCD012345670A11', }), + 'e8ef2a01ed3b4139a53bf749204fe6b4': dict({ + 'dev_class': 'switching', + 'members': list([ + '02cf28bfec924855854c544690a609ef', + '4a810418d5394b3f82727340b91ba740', + ]), + 'model': 'Switchgroup', + 'name': 'Test', + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + }), 'f1fee6043d3642a9b0a65297455f008e': dict({ 'available': True, 'binary_sensors': dict({ diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 16af7065c49..79a5a366f17 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -478,7 +478,7 @@ async def test_reconfigure_flow_smile_mismatch( mock_config_entry: MockConfigEntry, ) -> None: """Test reconfigure flow aborts on other Smile ID.""" - mock_smile_adam.smile_hostname = TEST_SMILE_HOST + mock_smile_adam.smile.hostname = TEST_SMILE_HOST result = await _start_reconfigure_flow(hass, mock_config_entry, TEST_HOST) From 53936ab0626dda0d22e72eb8b01e9fdc017e7ba8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:01:14 +0200 Subject: [PATCH 0874/1664] Use async_load_fixture in weatherflow_cloud (#147816) --- tests/components/weatherflow_cloud/test_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 191f720527f..dce2b7f8f2e 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -26,7 +26,7 @@ from . import setup_integration from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_fixture, + async_load_fixture, snapshot_platform, ) @@ -61,7 +61,7 @@ async def test_all_entities_with_lightning_error( """Test all entities.""" get_observation_response_data = ObservationStationREST.from_json( - load_fixture("station_observation_error.json", DOMAIN) + await async_load_fixture(hass, "station_observation_error.json", DOMAIN) ) with patch( From 1e3ebd56504ba0102b8ba52599a39d107576b319 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 18:02:42 +0200 Subject: [PATCH 0875/1664] Use correctly formatted MAC in incomfort tests (#147819) --- tests/components/incomfort/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index e3579182b3d..2d9a8273ab6 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -22,13 +22,13 @@ from tests.common import MockConfigEntry DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="rfgateway", ip="192.168.1.12", - macaddress="0004A3DEADFF", + macaddress=dr.format_mac("00:04:A3:DE:AD:FF").replace(":", ""), ) DHCP_SERVICE_INFO_ALT = DhcpServiceInfo( hostname="rfgateway", ip="192.168.1.99", - macaddress="0004A3DEADFF", + macaddress=dr.format_mac("00:04:A3:DE:AD:FF").replace(":", ""), ) From f03af213d41094e57037b615b0879d3051cb4cdd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:50:50 +0200 Subject: [PATCH 0876/1664] Use correctly formatted MAC in lg_thinq tests (#147822) --- tests/components/lg_thinq/test_config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index 7f601cd02c3..a46162723f0 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT @@ -16,7 +17,7 @@ from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="LG_Smart_Dryer2_open", - macaddress="34:E6:E6:11:22:33", + macaddress=dr.format_mac("34:E6:E6:11:22:33").replace(":", ""), ) From 5e3fc858d806215c88a53bd12b9c69120f8ca42e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:52:11 +0200 Subject: [PATCH 0877/1664] Add sensor last online to PlayStation Network integration (#147796) --- .../components/playstation_network/icons.json | 3 ++ .../components/playstation_network/sensor.py | 23 +++++++-- .../playstation_network/strings.json | 3 ++ .../playstation_network/conftest.py | 1 + .../snapshots/test_diagnostics.ambr | 1 + .../snapshots/test_sensor.ambr | 49 +++++++++++++++++++ 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index a05170f78d3..7817a4c8b07 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -26,6 +26,9 @@ }, "online_id": { "default": "mdi:account" + }, + "last_online": { + "default": "mdi:account-clock" } } } diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index b4563b00f25..6af305d3ce7 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -4,16 +4,22 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import ( @@ -29,7 +35,7 @@ PARALLEL_UPDATES = 0 class PlaystationNetworkSensorEntityDescription(SensorEntityDescription): """PlayStation Network sensor description.""" - value_fn: Callable[[PlaystationNetworkData], StateType] + value_fn: Callable[[PlaystationNetworkData], StateType | datetime] entity_picture: str | None = None @@ -43,6 +49,7 @@ class PlaystationNetworkSensor(StrEnum): EARNED_TROPHIES_SILVER = "earned_trophies_silver" EARNED_TROPHIES_BRONZE = "earned_trophies_bronze" ONLINE_ID = "online_id" + LAST_ONLINE = "last_online" SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( @@ -102,6 +109,16 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( translation_key=PlaystationNetworkSensor.ONLINE_ID, value_fn=lambda psn: psn.username, ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.LAST_ONLINE, + translation_key=PlaystationNetworkSensor.LAST_ONLINE, + value_fn=( + lambda psn: dt_util.parse_datetime( + psn.presence["basicPresence"]["lastAvailableDate"] + ) + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), ) @@ -147,7 +164,7 @@ class PlaystationNetworkSensorEntity( ) @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index a26f45d8973..aee4dc0d737 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -78,6 +78,9 @@ }, "online_id": { "name": "Online-ID" + }, + "last_online": { + "name": "Last online" } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 821025dbb9c..431a30ba7f7 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -64,6 +64,7 @@ def mock_user() -> Generator[MagicMock]: "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", } ], + "lastAvailableDate": "2025-06-30T01:42:15.391Z", } } diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 405cee04559..6073b37863e 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ 'titleName': 'STAR WARS Jedi: Survivor™', }), ]), + 'lastAvailableDate': '2025-06-30T01:42:15.391Z', 'primaryPlatformInfo': dict({ 'onlineStatus': 'online', 'platform': 'PS5', diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr index 61030ee0a39..233791c05bd 100644 --- a/tests/components/playstation_network/snapshots/test_sensor.ambr +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -97,6 +97,55 @@ 'state': '11754', }) # --- +# name: test_sensors[sensor.testuser_last_online-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.testuser_last_online', + '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 online', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testuser Last online', + }), + 'context': , + 'entity_id': 'sensor.testuser_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-30T01:42:15+00:00', + }) +# --- # name: test_sensors[sensor.testuser_next_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 2c30a5a14c668e0faea96c1fde38d9fef9e398cb Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 19:53:46 +0200 Subject: [PATCH 0878/1664] Improve exception handling of PlayStation Network (#147792) --- .../playstation_network/coordinator.py | 14 ++- .../playstation_network/test_init.py | 109 ++++++++++++++++++ 2 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 tests/components/playstation_network/test_init.py diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 2581a016feb..69cc95d1d49 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -7,13 +7,13 @@ import logging from psnawp_api.core.psnawp_exceptions import ( PSNAWPAuthenticationError, + PSNAWPClientError, PSNAWPServerError, ) -from psnawp_api.models.user import User from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -28,7 +28,6 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData """Data update coordinator for PSN.""" config_entry: PlaystationNetworkConfigEntry - user: User def __init__( self, @@ -51,12 +50,17 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData """Set up the coordinator.""" try: - self.user = await self.psn.get_user() + await self.psn.get_user() except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error + except (PSNAWPServerError, PSNAWPClientError) as error: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error async def _async_update_data(self) -> PlaystationNetworkData: """Get the latest data from the PSN.""" @@ -67,7 +71,7 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData translation_domain=DOMAIN, translation_key="not_ready", ) from error - except PSNAWPServerError as error: + except (PSNAWPServerError, PSNAWPClientError) as error: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py new file mode 100644 index 00000000000..09fbe4b0de4 --- /dev/null +++ b/tests/components/playstation_network/test_init.py @@ -0,0 +1,109 @@ +"""Tests for PlayStation Network.""" + +from unittest.mock import MagicMock + +from psnawp_api.core import ( + PSNAWPAuthenticationError, + PSNAWPClientError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest + +from homeassistant.components.playstation_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test config entry not ready.""" + + mock_psnawpapi.user.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.SETUP_RETRY + + +async def test_config_entry_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test config entry auth failed setup error.""" + + mock_psnawpapi.user.side_effect = PSNAWPAuthenticationError + 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.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test coordinator data update failed.""" + + mock_psnawpapi.user.return_value.get_presence.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.SETUP_RETRY + + +async def test_coordinator_update_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test coordinator update auth failed setup error.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = ( + PSNAWPAuthenticationError + ) + 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.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id From d8c7ed473bc7543fe71ad20ea800f95c13221cf8 Mon Sep 17 00:00:00 2001 From: rubenbe Date: Mon, 30 Jun 2025 20:11:03 +0200 Subject: [PATCH 0879/1664] Bump xiaomi-ble to 1.1.0 (#147828) Bump xiaomi-ble to 1.1.0 --- homeassistant/components/xiaomi_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 2b87da630a0..2897fbbdb16 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.39.0"] + "requirements": ["xiaomi-ble==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5bdfb48232f..afa52562654 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3126,7 +3126,7 @@ wyoming==1.7.1 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.39.0 +xiaomi-ble==1.1.0 # homeassistant.components.knx xknx==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3cf7bc78aaa..02ed0c64575 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2579,7 +2579,7 @@ wyoming==1.7.1 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.39.0 +xiaomi-ble==1.1.0 # homeassistant.components.knx xknx==3.8.0 From 9961a499eecde0e7b06aecc9f047f01fcb420255 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:11:46 +0200 Subject: [PATCH 0880/1664] Fix sensor displaying unknown when getting readings from heat meters in ista EcoTrend (#147741) --- .../components/ista_ecotrend/util.py | 28 +++++++++---------- tests/components/ista_ecotrend/conftest.py | 14 ++++++++++ .../snapshots/test_diagnostics.ambr | 18 ++++++++++++ .../ista_ecotrend/snapshots/test_util.ambr | 13 +++++++++ tests/components/ista_ecotrend/test_util.py | 5 ++++ 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py index db64dbf85db..5d790a3cf1c 100644 --- a/homeassistant/components/ista_ecotrend/util.py +++ b/homeassistant/components/ista_ecotrend/util.py @@ -108,22 +108,22 @@ def get_statistics( if monthly_consumptions := get_consumptions(data, value_type): return [ { - "value": as_number( - get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get( - "additionalValue" - if value_type == IstaValueType.ENERGY - else "value" - ) - ), + "value": as_number(value), "date": consumptions["date"], } for consumptions in monthly_consumptions - if get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + if ( + value := ( + consumption := get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ) + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + and consumption.get("additionalValue") is not None + else "value" + ) + ) ] return None diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 58977c99b59..7be1302aa4f 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -96,12 +96,16 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", @@ -115,16 +119,21 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "104", + "unit": "Einheiten", "additionalValue": "113,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,1", + "unit": "m³", "additionalValue": "61,1", + "additionalUnit": "kWh", }, { "type": "water", "value": "6,8", + "unit": "m³", }, ], }, @@ -200,16 +209,21 @@ def extend_statistics(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "9000", + "unit": "Einheiten", "additionalValue": "9000,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "9999,0", + "unit": "m³", "additionalValue": "90000,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "9000,0", + "unit": "m³", }, ], }, diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr index c9f5e72ae1f..7395e2f6dc6 100644 --- a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -12,13 +12,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -34,17 +38,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), @@ -103,13 +112,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -125,17 +138,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr index 9069cb617e3..8546b704d3d 100644 --- a/tests/components/ista_ecotrend/snapshots/test_util.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -97,12 +97,22 @@ # --- # name: test_get_statistics[water-energy] list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), ]) # --- # name: test_get_values_by_type[heating] dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }) # --- @@ -114,8 +124,10 @@ # --- # name: test_get_values_by_type[warmwater] dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }) # --- @@ -128,6 +140,7 @@ # name: test_get_values_by_type[water] dict({ 'type': 'water', + 'unit': 'm³', 'value': '5,0', }) # --- diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py index f518a40b4b1..f6840dcd88b 100644 --- a/tests/components/ista_ecotrend/test_util.py +++ b/tests/components/ista_ecotrend/test_util.py @@ -52,16 +52,21 @@ def test_get_values_by_type( { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "5,0", + "unit": "m³", }, ], } From 511b739bf62af1bb54a14cace998e5e6f2190bdc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 20:12:03 +0200 Subject: [PATCH 0881/1664] Use media selector for Assist Satellite actions (#147767) Co-authored-by: Michael Hansen --- .../components/assist_satellite/__init__.py | 30 +++++-- .../components/assist_satellite/services.yaml | 24 ++++-- .../assist_satellite/test_entity.py | 86 +++++++++++++++++++ 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 6bfbdfb33a8..26ce9e75428 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Optional("message"): str, - vol.Optional("media_id"): str, + vol.Optional("media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, } ), cv.has_at_least_one_key("message", "media_id"), @@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( "start_conversation", vol.All( cv.make_entity_service_schema( { vol.Optional("start_message"): str, - vol.Optional("start_media_id"): str, + vol.Optional("start_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("extra_system_prompt"): str, } ), @@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, - vol.Optional("question_media_id"): str, + vol.Optional("question_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("answers"): [ { vol.Required("id"): str, @@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]: raise vol.Invalid("sentences cannot be empty") return value + + +# Validator for media_id fields that accepts both string and media selector format +_media_id_validator = vol.Any( + cv.string, # Plain string format + vol.All( + vol.Schema( + { + vol.Required("media_content_id"): cv.string, + vol.Required("media_content_type"): cv.string, + vol.Remove("metadata"): dict, # Ignore metadata if present + } + ), + # Extract media_content_id from media selector format + lambda x: x["media_content_id"], + ), +) diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 6beb0991861..8433eb6102d 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,7 +14,9 @@ announce: media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -23,7 +25,9 @@ announce: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* start_conversation: target: entity: @@ -40,7 +44,9 @@ start_conversation: start_media_id: required: false selector: - text: + media: + accept: + - audio/* extra_system_prompt: required: false selector: @@ -53,7 +59,9 @@ start_conversation: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* ask_question: fields: entity_id: @@ -72,7 +80,9 @@ ask_question: question_media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -81,7 +91,9 @@ ask_question: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* answers: required: false selector: diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 3473b23bedd..9f14be6c50f 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -235,6 +235,43 @@ async def test_new_pipeline_cancels_pipeline( preannounce_media_id="http://example.com/preannounce.mp3", ), ), + ( + { + "message": "Hello", + "media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + tts_token=None, + media_id_source="media_id", + ), + ), + ( + { + "media_id": { + "media_content_id": "http://example.com/bla.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -610,6 +647,51 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_message": "Hello", + "start_media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + ( + "mock-conversation-id", + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + { + "start_media_id": { + "media_content_id": "http://example.com/given.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -731,6 +813,10 @@ async def test_start_conversation_default_preannounce( ), ( { + "question_media_id": { + "media_content_id": "media-source://tts/cloud?message=What+kind+of+music+would+you+like+to+listen+to%3F&language=en-US&gender=female", + "media_content_type": "provider", + }, "answers": [ { "id": "genre", From 90cbe272a0e540c4b2b393c43eb15a7071f9a7ba Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 30 Jun 2025 20:15:48 +0200 Subject: [PATCH 0882/1664] Wallbox Integration, Reduce API impact by limiting the amount of API calls made (#147618) --- homeassistant/components/wallbox/const.py | 4 + .../components/wallbox/coordinator.py | 67 ++++++++++----- homeassistant/components/wallbox/lock.py | 13 +-- homeassistant/components/wallbox/number.py | 13 +-- tests/components/wallbox/test_lock.py | 82 ++++++++++++------- tests/components/wallbox/test_number.py | 48 +++++++---- tests/components/wallbox/test_select.py | 19 +++++ tests/components/wallbox/test_switch.py | 12 ++- 8 files changed, 170 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 34d17e52275..1059a41db53 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -22,6 +22,8 @@ CHARGER_CURRENT_MODE_KEY = "current_mode" CHARGER_CURRENT_VERSION_KEY = "currentVersion" CHARGER_CURRENCY_KEY = "currency" CHARGER_DATA_KEY = "config_data" +CHARGER_DATA_POST_L1_KEY = "data" +CHARGER_DATA_POST_L2_KEY = "chargerData" CHARGER_DEPOT_PRICE_KEY = "depot_price" CHARGER_ENERGY_PRICE_KEY = "energy_price" CHARGER_FEATURES_KEY = "features" @@ -32,7 +34,9 @@ CHARGER_POWER_BOOST_KEY = "POWER_BOOST" CHARGER_SOFTWARE_KEY = "software" CHARGER_MAX_AVAILABLE_POWER_KEY = "max_available_power" CHARGER_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent" CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" +CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 598bfa7429a..69bf3a3af1c 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,11 +14,13 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, CHARGER_ECO_SMART_KEY, CHARGER_ECO_SMART_MODE_KEY, CHARGER_ECO_SMART_STATUS_KEY, @@ -26,6 +28,7 @@ from .const import ( CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PLAN_KEY, CHARGER_POWER_BOOST_KEY, @@ -192,10 +195,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="too_many_requests" ) from wallbox_connection_error - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error @@ -204,10 +207,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return await self.hass.async_add_executor_job(self._get_data) @_require_authentication - def _set_charging_current(self, charging_current: float) -> None: + def _set_charging_current( + self, charging_current: float + ) -> dict[str, dict[str, dict[str, Any]]]: """Set maximum charging current for Wallbox.""" try: - self._wallbox.setMaxChargingCurrent(self._station, charging_current) + result = self._wallbox.setMaxChargingCurrent( + self._station, charging_current + ) + data = self.data + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_MAX_CHARGING_CURRENT_POST_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -221,16 +233,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( self._set_charging_current, charging_current ) - await self.async_request_refresh() + self.async_set_updated_data(data) @_require_authentication - def _set_icp_current(self, icp_current: float) -> None: + def _set_icp_current(self, icp_current: float) -> dict[str, Any]: """Set maximum icp current for Wallbox.""" try: - self._wallbox.setIcpMaxCurrent(self._station, icp_current) + result = self._wallbox.setIcpMaxCurrent(self._station, icp_current) + data = self.data + data[CHARGER_MAX_ICP_CURRENT_KEY] = result[CHARGER_MAX_ICP_CURRENT_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -244,14 +259,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" - await self.hass.async_add_executor_job(self._set_icp_current, icp_current) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_icp_current, icp_current + ) + self.async_set_updated_data(data) @_require_authentication - def _set_energy_cost(self, energy_cost: float) -> None: + def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: """Set energy cost for Wallbox.""" try: - self._wallbox.setEnergyCost(self._station, energy_cost) + result = self._wallbox.setEnergyCost(self._station, energy_cost) + data = self.data + data[CHARGER_ENERGY_PRICE_KEY] = result[CHARGER_ENERGY_PRICE_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -263,17 +283,24 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_energy_cost, energy_cost + ) + self.async_set_updated_data(data) @_require_authentication - def _set_lock_unlock(self, lock: bool) -> None: + def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: """Set wallbox to locked or unlocked.""" try: if lock: - self._wallbox.lockCharger(self._station) + result = self._wallbox.lockCharger(self._station) else: - self._wallbox.unlockCharger(self._station) + result = self._wallbox.unlockCharger(self._station) + data = self.data + data[CHARGER_LOCKED_UNLOCKED_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_LOCKED_UNLOCKED_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -287,8 +314,8 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + self.async_set_updated_data(data) @_require_authentication def _pause_charger(self, pause: bool) -> None: diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7acc56f67f2..7b5c99340f8 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -16,7 +15,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -34,16 +33,6 @@ async def async_setup_entry( ) -> None: """Create wallbox lock entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to lock, if so, add lock component - try: - await coordinator.async_set_lock_unlock( - coordinator.data[CHARGER_LOCKED_UNLOCKED_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxLock(coordinator, description) for ent in coordinator.data diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 80773478582..e1b044bbdb2 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,6 @@ from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -26,7 +25,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -86,16 +85,6 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user has sufficient rights to change values, if so, add number component: - try: - await coordinator.async_set_charging_current( - coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxNumber(coordinator, entry, description) for ent in coordinator.data diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 5842d708f11..7d95aed7a5d 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -12,10 +12,10 @@ from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_403_error, + http_404_error, http_429_error, setup_integration, - setup_integration_platform_not_ready, - setup_integration_read_only, ) from .const import MOCK_LOCK_ENTITY_ID @@ -38,11 +38,15 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - ), patch( "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 1}}} + ), ), patch( "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 0}}} + ), ), ): await hass.services.async_call( @@ -129,6 +133,52 @@ async def test_wallbox_lock_class_connection_error( new=Mock(side_effect=http_429_error), ), pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", @@ -138,27 +188,3 @@ async def test_wallbox_lock_class_connection_error( }, blocking=True, ) - - -async def test_wallbox_lock_class_authentication_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_read_only(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None - - -async def test_wallbox_lock_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c603ae24106..8067917977d 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -23,7 +23,6 @@ from . import ( http_429_error, setup_integration, setup_integration_bidir, - setup_integration_platform_not_ready, ) from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, @@ -56,7 +55,9 @@ async def test_wallbox_number_class( ), patch( "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + new=Mock( + return_value={"data": {"chargerData": {"maxChargingCurrent": 20}}} + ), ), ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) @@ -259,18 +260,6 @@ async def test_wallbox_number_class_energy_price_auth_error( ) -async def test_wallbox_number_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - - assert state is None - - async def test_wallbox_number_class_icp_energy( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -285,7 +274,7 @@ async def test_wallbox_number_class_icp_energy( ), patch( "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + new=Mock(return_value={"icp_max_current": 20}), ), ): await hass.services.async_call( @@ -328,6 +317,35 @@ async def test_wallbox_number_class_icp_energy_auth_error( ) +async def test_wallbox_number_class_energy_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + async def test_wallbox_number_class_icp_energy_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index f59a8367b41..e46347bfa5a 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -50,6 +50,13 @@ async def test_wallbox_select_solar_charging_class( ) -> None: """Test wallbox select class.""" + if mode == EcoSmartMode.OFF: + response = test_response + elif mode == EcoSmartMode.ECO_MODE: + response = test_response_eco_mode + elif mode == EcoSmartMode.FULL_SOLAR: + response = test_response_full_solar + with ( patch( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", @@ -59,6 +66,10 @@ async def test_wallbox_select_solar_charging_class( "homeassistant.components.wallbox.Wallbox.disableEcoSmart", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=response), + ), ): await setup_integration_select(hass, entry, response) @@ -110,6 +121,10 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -144,6 +159,10 @@ async def test_wallbox_select_too_many_requests_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=http_429_error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index eb983ca44ce..98b87828f74 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -10,7 +10,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, http_404_error, http_429_error, setup_integration +from . import ( + authorisation_response, + http_404_error, + http_429_error, + setup_integration, + test_response, +) from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -40,6 +46,10 @@ async def test_wallbox_switch_class( "homeassistant.components.wallbox.Wallbox.resumeChargingSession", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), ): await hass.services.async_call( "switch", From 88feb5139b6bcb60794de4bb1602347161307202 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 1 Jul 2025 02:16:45 +0800 Subject: [PATCH 0883/1664] Fix Telegram bot proxy URL not initialized when creating a new bot (#147707) --- homeassistant/components/telegram_bot/config_flow.py | 7 ++++++- tests/components/telegram_bot/test_config_flow.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 7b441889b8c..b6480b84f64 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -328,6 +328,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) bot_name = await self._validate_bot( user_input, errors, description_placeholders ) @@ -350,7 +353,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), }, options={ # this value may come from yaml import diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 2af90b9f7ef..659effdda7b 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -117,6 +117,7 @@ async def test_reconfigure_flow_broadcast( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_webhooks_config_entry.data[CONF_PLATFORM] == PLATFORM_BROADCAST + assert mock_webhooks_config_entry.data[CONF_PROXY_URL] == "https://test" async def test_reconfigure_flow_webhooks( From 20f5d85800f548bc9db860762bfb7d6611924555 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:18:22 -0400 Subject: [PATCH 0884/1664] Await firmware installation task when flashing ZBT-1/Yellow firmware (#147824) --- .../firmware_config_flow.py | 66 ++++++++++++++----- .../homeassistant_hardware/strings.json | 3 +- .../homeassistant_sky_connect/strings.json | 6 +- .../homeassistant_yellow/strings.json | 3 +- .../test_config_flow_failures.py | 2 +- 5 files changed, 57 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index a5e35749e1b..3263b091ad5 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -27,6 +27,7 @@ from homeassistant.config_entries import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -67,6 +68,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task | None = None + self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: """Shared translation placeholders.""" @@ -152,8 +154,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None if not self.firmware_install_task: - # We 100% need to install new firmware only if the wrong firmware is - # currently installed + # Keep track of the firmware we're working with, for error messages + self.installing_firmware_name = firmware_name + + # Installing new firmware is only truly required if the wrong type is + # installed: upgrading to the latest release of the current firmware type + # isn't strictly necessary for functionality. firmware_install_required = self._probed_firmware_info is None or ( self._probed_firmware_info.firmware_type != expected_installed_firmware_type @@ -167,7 +173,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): fw_manifest = next( fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) ) - except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + except (StopIteration, TimeoutError, ClientError, ManifestMissing): _LOGGER.warning( "Failed to fetch firmware update manifest", exc_info=True ) @@ -179,13 +185,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) return self.async_show_progress_done(next_step_id=next_step_id) - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) if not firmware_install_required: assert self._probed_firmware_info is not None @@ -205,7 +207,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): try: fw_data = await client.async_fetch_firmware(fw_manifest) - except (TimeoutError, ClientError, ValueError) as err: + except (TimeoutError, ClientError, ValueError): _LOGGER.warning("Failed to fetch firmware update", exc_info=True) # If we cannot download new firmware, we shouldn't block setup @@ -216,13 +218,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): return self.async_show_progress_done(next_step_id=next_step_id) # Otherwise, fail - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( @@ -249,8 +247,40 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): progress_task=self.firmware_install_task, ) + try: + await self.firmware_install_task + except HomeAssistantError: + _LOGGER.exception("Failed to flash firmware") + return self.async_show_progress_done(next_step_id="firmware_install_failed") + return self.async_show_progress_done(next_step_id=next_step_id) + async def async_step_firmware_download_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware download failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + + async def async_step_firmware_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware install failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_install_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index d9c086cb040..da2374de57b 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -37,7 +37,8 @@ "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", - "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.", + "fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index f87a45febe4..13775d1f1eb 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -93,7 +93,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -147,7 +148,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index b43f890b4e3..d0c5e969d11 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -118,7 +118,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 442cf8aea50..0494de1432c 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -339,7 +339,7 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non ) assert pick_thread_progress_result["type"] is FlowResultType.ABORT - assert pick_thread_progress_result["reason"] == "unsupported_firmware" + assert pick_thread_progress_result["reason"] == "fw_install_failed" @pytest.mark.parametrize( From 22a14da19c63cce147b95a4a05f345183f3b2a37 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:21:38 +0200 Subject: [PATCH 0885/1664] Rename service registration method (#146615) --- homeassistant/components/bosch_alarm/__init__.py | 4 ++-- homeassistant/components/bosch_alarm/services.py | 5 +++-- homeassistant/components/dynalite/__init__.py | 4 ++-- homeassistant/components/dynalite/services.py | 2 +- homeassistant/components/guardian/__init__.py | 4 ++-- homeassistant/components/guardian/services.py | 5 +++-- homeassistant/components/heos/__init__.py | 4 ++-- homeassistant/components/heos/services.py | 5 +++-- homeassistant/components/home_connect/__init__.py | 4 ++-- homeassistant/components/home_connect/services.py | 5 +++-- homeassistant/components/knx/__init__.py | 4 ++-- homeassistant/components/knx/services.py | 2 +- homeassistant/components/matrix/__init__.py | 4 ++-- homeassistant/components/matrix/services.py | 5 +++-- homeassistant/components/renault/__init__.py | 4 ++-- homeassistant/components/renault/services.py | 5 +++-- homeassistant/components/velbus/__init__.py | 4 ++-- homeassistant/components/velbus/services.py | 5 +++-- homeassistant/components/zoneminder/__init__.py | 4 ++-- homeassistant/components/zoneminder/services.py | 5 +++-- 20 files changed, 46 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index 7f37476f1bb..c442c921a6b 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import ConfigType from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -from .services import setup_services +from .services import async_setup_services from .types import BoschAlarmConfigEntry CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up bosch alarm services.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index 5d9a5f5645f..acdecbda305 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -9,7 +9,7 @@ from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util @@ -66,7 +66,8 @@ async def async_set_panel_date(call: ServiceCall) -> None: ) from err -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the bosch alarm integration.""" hass.services.async_register( diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 3411882b725..1eb6b4f2e44 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -12,7 +12,7 @@ from .bridge import DynaliteBridge from .const import DOMAIN, LOGGER, PLATFORMS from .convert_config import convert_config from .panel import async_register_dynalite_frontend -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -21,7 +21,7 @@ type DynaliteConfigEntry = ConfigEntry[DynaliteBridge] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - setup_services(hass) + async_setup_services(hass) await async_register_dynalite_frontend(hass) diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py index d0d57a582b4..2621df61853 100644 --- a/homeassistant/components/dynalite/services.py +++ b/homeassistant/components/dynalite/services.py @@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None: @callback -def setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Set up the Dynalite platform.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 65f5525d587..192cb62f5df 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -27,7 +27,7 @@ from .const import ( SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,7 +55,7 @@ class GuardianData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Elexa Guardian component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/guardian/services.py b/homeassistant/components/guardian/services.py index 288c6becbee..927be7c54a5 100644 --- a/homeassistant/components/guardian/services.py +++ b/homeassistant/components/guardian/services.py @@ -122,8 +122,9 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: ) -def setup_services(hass: HomeAssistant) -> None: - """Register the Renault services.""" +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the guardian services.""" for service_name, schema, method in ( ( SERVICE_NAME_PAIR_SENSOR, diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 4df1a2fa0e1..54510540f2a 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from . import services from .const import DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator +from .services import async_setup_services PLATFORMS = [Platform.MEDIA_PLAYER] @@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" - services.register(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 86c6f6d0533..e42e2bf27a2 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -44,7 +44,8 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema( HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register HEOS services.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 01f2acd1851..4a48d1f1ad7 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType from .api import AsyncConfigEntryAuth from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .services import register_actions +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ PLATFORMS = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - register_actions(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index fac1c5fe1a9..09c2f4a967d 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -522,7 +522,8 @@ async def async_service_start_program(call: ServiceCall) -> None: await _async_service_program(call, True) -def register_actions(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register custom actions.""" hass.services.async_register( diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8ad16642e45..470f7891292 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -91,7 +91,7 @@ from .schema import ( TimeSchema, WeatherSchema, ) -from .services import register_knx_services +from .services import async_setup_services from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -138,7 +138,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := config.get(DOMAIN)) is not None: hass.data[_KNX_YAML_CONFIG] = dict(conf) - register_knx_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 7b8c7ec2371..04803e140fd 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) @callback -def register_knx_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Register KNX integration services.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 85f08bb4d87..f523de71f6a 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType, load_json_object from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML -from .services import register_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: config[CONF_COMMANDS], ) - register_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/matrix/services.py b/homeassistant/components/matrix/services.py index edd312348d6..f89a9e7b7fc 100644 --- a/homeassistant/components/matrix/services.py +++ b/homeassistant/components/matrix/services.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -50,7 +50,8 @@ async def _handle_send_message(call: ServiceCall) -> None: await matrix_bot.handle_send_message(call) -def register_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the Matrix bot component.""" hass.services.async_register( diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 48bab1f5c8b..da3769654c4 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type RenaultConfigEntry = ConfigEntry[RenaultHub] @@ -20,7 +20,7 @@ type RenaultConfigEntry = ConfigEntry[RenaultHub] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Renault component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index dfad97ae4ea..df85ad57f66 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -191,7 +191,8 @@ def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: ) -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register the Renault services.""" hass.services.async_register( diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 35c61892964..055fd5e2277 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import setup_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions for the Velbus component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 765c5a0f674..5fccbcaf82e 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -32,7 +32,8 @@ from .const import ( ) -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 241c2729653..27b69a8d62d 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import register_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ex, ) - register_services(hass) + async_setup_services(hass) hass.async_create_task( async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) diff --git a/homeassistant/components/zoneminder/services.py b/homeassistant/components/zoneminder/services.py index 14ce873ec14..53847213c85 100644 --- a/homeassistant/components/zoneminder/services.py +++ b/homeassistant/components/zoneminder/services.py @@ -5,7 +5,7 @@ import logging import voluptuous as vol from homeassistant.const import ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import DOMAIN @@ -32,7 +32,8 @@ def _set_active_state(call: ServiceCall) -> None: ) -def register_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register ZoneMinder services.""" hass.services.async_register( From 217fbb28498b8e58d326e4308c234421aca3477a Mon Sep 17 00:00:00 2001 From: mvn23 Date: Mon, 30 Jun 2025 20:24:13 +0200 Subject: [PATCH 0886/1664] Populate hvac_modes list in opentherm_gw (#142074) --- homeassistant/components/opentherm_gw/climate.py | 13 ++++++++++++- homeassistant/components/opentherm_gw/strings.json | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 68463e764f2..c7e107b1637 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -30,6 +31,7 @@ from .const import ( CONF_SET_PRECISION, DATA_GATEWAYS, DATA_OPENTHERM_GW, + DOMAIN, THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) @@ -75,7 +77,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_hvac_modes = [] + _attr_hvac_modes = [HVACMode.HEAT] _attr_name = None _attr_preset_modes = [] _attr_min_temp = 1 @@ -129,9 +131,11 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): if ch_active and flame_on: self._attr_hvac_action = HVACAction.HEATING self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_modes = [HVACMode.HEAT] elif cooling_active: self._attr_hvac_action = HVACAction.COOLING self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_modes = [HVACMode.COOL] else: self._attr_hvac_action = HVACAction.IDLE @@ -182,6 +186,13 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): return PRESET_AWAY return PRESET_NONE + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="change_hvac_mode_not_supported", + ) + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 8959e0facf9..f3938c81e7e 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -355,6 +355,9 @@ } }, "exceptions": { + "change_hvac_mode_not_supported": { + "message": "Changing HVAC mode is not supported." + }, "invalid_gateway_id": { "message": "Gateway {gw_id} not found or not loaded!" } From be6b624081ffd21494bb218822f7ebee32136c28 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 20:26:52 +0200 Subject: [PATCH 0887/1664] Improve validation for media selector (#147768) --- homeassistant/helpers/selector.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index acb91ddc148..e4277aac98e 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1043,9 +1043,18 @@ class MediaSelector(Selector[MediaSelectorConfig]): """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> dict[str, float]: + def __call__(self, data: Any) -> dict[str, str]: """Validate the passed selection.""" - media: dict[str, float] = self.DATA_SCHEMA(data) + schema = self.DATA_SCHEMA.schema.copy() + + if "accept" in self.config: + # If accept is set, the entity_id field will not be present + schema.pop("entity_id", None) + else: + # If accept is not set, the entity_id field is required + schema[vol.Required("entity_id")] = cv.entity_id_or_uuid + + media: dict[str, str] = self.DATA_SCHEMA(data) return media From 70856bd92acbbeee9adcf0dfe40c33410df7409d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 21:11:51 +0200 Subject: [PATCH 0888/1664] Split OpenAI entity (#147771) --- .../openai_conversation/conversation.py | 307 +---------------- .../components/openai_conversation/entity.py | 314 ++++++++++++++++++ 2 files changed, 322 insertions(+), 299 deletions(-) create mode 100644 homeassistant/components/openai_conversation/entity.py diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e590a72cadb..2446fab638f 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,73 +1,19 @@ """Conversation support for OpenAI.""" -from collections.abc import AsyncGenerator, Callable -import json -from typing import Any, Literal, cast - -import openai -from openai._streaming import AsyncStream -from openai.types.responses import ( - EasyInputMessageParam, - FunctionToolParam, - ResponseCompletedEvent, - ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionToolCallParam, - ResponseIncompleteEvent, - ResponseInputParam, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputMessageParam, - ResponseReasoningItem, - ResponseReasoningItemParam, - ResponseStreamEvent, - ResponseTextDeltaEvent, - ToolParam, - WebSearchToolParam, -) -from openai.types.responses.response_input_param import FunctionCallOutput -from openai.types.responses.web_search_tool_param import UserLocation -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_REASONING_EFFORT, - CONF_TEMPERATURE, - CONF_TOP_P, - CONF_WEB_SEARCH, - CONF_WEB_SEARCH_CITY, - CONF_WEB_SEARCH_CONTEXT_SIZE, - CONF_WEB_SEARCH_COUNTRY, - CONF_WEB_SEARCH_REGION, - CONF_WEB_SEARCH_TIMEZONE, - CONF_WEB_SEARCH_USER_LOCATION, - DOMAIN, - LOGGER, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_REASONING_EFFORT, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_P, - RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, -) +from .const import CONF_PROMPT, DOMAIN +from .entity import OpenAIBaseLLMEntity # Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( @@ -86,152 +32,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> FunctionToolParam: - """Format tool specification.""" - return FunctionToolParam( - type="function", - name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), - description=tool.description, - strict=False, - ) - - -def _convert_content_to_param( - content: conversation.Content, -) -> ResponseInputParam: - """Convert any native chat message for this agent to the native format.""" - messages: ResponseInputParam = [] - if isinstance(content, conversation.ToolResultContent): - return [ - FunctionCallOutput( - type="function_call_output", - call_id=content.tool_call_id, - output=json.dumps(content.tool_result), - ) - ] - - if content.content: - role: Literal["user", "assistant", "system", "developer"] = content.role - if role == "system": - role = "developer" - messages.append( - EasyInputMessageParam(type="message", role=role, content=content.content) - ) - - if isinstance(content, conversation.AssistantContent) and content.tool_calls: - messages.extend( - ResponseFunctionToolCallParam( - type="function_call", - name=tool_call.tool_name, - arguments=json.dumps(tool_call.tool_args), - call_id=tool_call.id, - ) - for tool_call in content.tool_calls - ) - return messages - - -async def _transform_stream( - chat_log: conversation.ChatLog, - result: AsyncStream[ResponseStreamEvent], - messages: ResponseInputParam, -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform an OpenAI delta stream into HA format.""" - async for event in result: - LOGGER.debug("Received event: %s", event) - - if isinstance(event, ResponseOutputItemAddedEvent): - if isinstance(event.item, ResponseOutputMessage): - yield {"role": event.item.role} - elif isinstance(event.item, ResponseFunctionToolCall): - # OpenAI has tool calls as individual events - # while HA puts tool calls inside the assistant message. - # We turn them into individual assistant content for HA - # to ensure that tools are called as soon as possible. - yield {"role": "assistant"} - current_tool_call = event.item - elif isinstance(event, ResponseOutputItemDoneEvent): - item = event.item.model_dump() - item.pop("status", None) - if isinstance(event.item, ResponseReasoningItem): - messages.append(cast(ResponseReasoningItemParam, item)) - elif isinstance(event.item, ResponseOutputMessage): - messages.append(cast(ResponseOutputMessageParam, item)) - elif isinstance(event.item, ResponseFunctionToolCall): - messages.append(cast(ResponseFunctionToolCallParam, item)) - elif isinstance(event, ResponseTextDeltaEvent): - yield {"content": event.delta} - elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): - current_tool_call.arguments += event.delta - elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): - current_tool_call.status = "completed" - yield { - "tool_calls": [ - llm.ToolInput( - id=current_tool_call.call_id, - tool_name=current_tool_call.name, - tool_args=json.loads(current_tool_call.arguments), - ) - ] - } - elif isinstance(event, ResponseCompletedEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - elif isinstance(event, ResponseIncompleteEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - - if ( - event.response.incomplete_details - and event.response.incomplete_details.reason - ): - reason: str = event.response.incomplete_details.reason - else: - reason = "unknown reason" - - if reason == "max_output_tokens": - reason = "max output tokens reached" - elif reason == "content_filter": - reason = "content filter triggered" - - raise HomeAssistantError(f"OpenAI response incomplete: {reason}") - elif isinstance(event, ResponseFailedEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - reason = "unknown reason" - if event.response.error is not None: - reason = event.response.error.message - raise HomeAssistantError(f"OpenAI response failed: {reason}") - elif isinstance(event, ResponseErrorEvent): - raise HomeAssistantError(f"OpenAI response error: {event.message}") - - class OpenAIConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OpenAIBaseLLMEntity, ): """OpenAI conversation agent.""" @@ -239,17 +43,7 @@ class OpenAIConversationEntity( def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - 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)}, - name=subentry.title, - manufacturer="OpenAI", - model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -305,91 +99,6 @@ class OpenAIConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data - - tools: list[ToolParam] | 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 - ] - - if options.get(CONF_WEB_SEARCH): - web_search = WebSearchToolParam( - type="web_search_preview", - search_context_size=options.get( - CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE - ), - ) - if options.get(CONF_WEB_SEARCH_USER_LOCATION): - web_search["user_location"] = UserLocation( - type="approximate", - city=options.get(CONF_WEB_SEARCH_CITY, ""), - region=options.get(CONF_WEB_SEARCH_REGION, ""), - country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), - timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), - ) - if tools is None: - tools = [] - tools.append(web_search) - - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - messages = [ - m - for content in chat_log.content - for m in _convert_content_to_param(content) - ] - - client = self.entry.runtime_data - - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "input": messages, - "max_output_tokens": options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "stream": True, - } - if tools: - model_args["tools"] = tools - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - else: - model_args["store"] = False - - try: - result = await client.responses.create(**model_args) - except openai.RateLimitError as err: - LOGGER.error("Rate limited by OpenAI: %s", err) - raise HomeAssistantError("Rate limited or insufficient funds") from err - except openai.OpenAIError as err: - LOGGER.error("Error talking to OpenAI: %s", err) - raise HomeAssistantError("Error talking to OpenAI") from err - - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) - - if not chat_log.unresponded_tool_results: - break - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py new file mode 100644 index 00000000000..ba7153deb24 --- /dev/null +++ b/homeassistant/components/openai_conversation/entity.py @@ -0,0 +1,314 @@ +"""Base entity for OpenAI.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal, cast + +import openai +from openai._streaming import AsyncStream +from openai.types.responses import ( + EasyInputMessageParam, + FunctionToolParam, + ResponseCompletedEvent, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseIncompleteEvent, + ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputMessageParam, + ResponseReasoningItem, + ResponseReasoningItemParam, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ToolParam, + WebSearchToolParam, +) +from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.web_search_tool_param import UserLocation +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OpenAIConfigEntry +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_REASONING_EFFORT, + CONF_TEMPERATURE, + CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, +) + +# 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 +) -> FunctionToolParam: + """Format tool specification.""" + return FunctionToolParam( + type="function", + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + description=tool.description, + strict=False, + ) + + +def _convert_content_to_param( + content: conversation.Content, +) -> ResponseInputParam: + """Convert any native chat message for this agent to the native format.""" + messages: ResponseInputParam = [] + if isinstance(content, conversation.ToolResultContent): + return [ + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) + ] + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role + if role == "system": + role = "developer" + messages.append( + EasyInputMessageParam(type="message", role=role, content=content.content) + ) + + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + messages.extend( + ResponseFunctionToolCallParam( + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, + ) + for tool_call in content.tool_calls + ) + return messages + + +async def _transform_stream( + chat_log: conversation.ChatLog, + result: AsyncStream[ResponseStreamEvent], + messages: ResponseInputParam, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + async for event in result: + LOGGER.debug("Received event: %s", event) + + if isinstance(event, ResponseOutputItemAddedEvent): + if isinstance(event.item, ResponseOutputMessage): + yield {"role": event.item.role} + elif isinstance(event.item, ResponseFunctionToolCall): + # OpenAI has tool calls as individual events + # while HA puts tool calls inside the assistant message. + # We turn them into individual assistant content for HA + # to ensure that tools are called as soon as possible. + yield {"role": "assistant"} + current_tool_call = event.item + elif isinstance(event, ResponseOutputItemDoneEvent): + item = event.item.model_dump() + item.pop("status", None) + if isinstance(event.item, ResponseReasoningItem): + messages.append(cast(ResponseReasoningItemParam, item)) + elif isinstance(event.item, ResponseOutputMessage): + messages.append(cast(ResponseOutputMessageParam, item)) + elif isinstance(event.item, ResponseFunctionToolCall): + messages.append(cast(ResponseFunctionToolCallParam, item)) + elif isinstance(event, ResponseTextDeltaEvent): + yield {"content": event.delta} + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + current_tool_call.arguments += event.delta + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + current_tool_call.status = "completed" + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call.call_id, + tool_name=current_tool_call.name, + tool_args=json.loads(current_tool_call.arguments), + ) + ] + } + elif isinstance(event, ResponseCompletedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + elif isinstance(event, ResponseIncompleteEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + + if ( + event.response.incomplete_details + and event.response.incomplete_details.reason + ): + reason: str = event.response.incomplete_details.reason + else: + reason = "unknown reason" + + if reason == "max_output_tokens": + reason = "max output tokens reached" + elif reason == "content_filter": + reason = "content filter triggered" + + raise HomeAssistantError(f"OpenAI response incomplete: {reason}") + elif isinstance(event, ResponseFailedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + reason = "unknown reason" + if event.response.error is not None: + reason = event.response.error.message + raise HomeAssistantError(f"OpenAI response failed: {reason}") + elif isinstance(event, ResponseErrorEvent): + raise HomeAssistantError(f"OpenAI response error: {event.message}") + + +class OpenAIBaseLLMEntity(Entity): + """OpenAI conversation agent.""" + + 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)}, + name=subentry.title, + manufacturer="OpenAI", + model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ToolParam] | 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 + ] + + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchToolParam( + type="web_search_preview", + search_context_size=options.get( + CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE + ), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = UserLocation( + type="approximate", + city=options.get(CONF_WEB_SEARCH_CITY, ""), + region=options.get(CONF_WEB_SEARCH_REGION, ""), + country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), + timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + ) + if tools is None: + tools = [] + tools.append(web_search) + + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + messages = [ + m + for content in chat_log.content + for m in _convert_content_to_param(content) + ] + + client = self.entry.runtime_data + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + model_args = { + "model": model, + "input": messages, + "max_output_tokens": options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + "stream": True, + } + if tools: + model_args["tools"] = tools + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + else: + model_args["store"] = False + + try: + result = await client.responses.create(**model_args) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err + except openai.OpenAIError as err: + LOGGER.error("Error talking to OpenAI: %s", err) + raise HomeAssistantError("Error talking to OpenAI") from err + + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, result, messages) + ): + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) + + if not chat_log.unresponded_tool_results: + break From bf74ba990aba4cf0964e13aea0cad233980880d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 21:31:54 +0200 Subject: [PATCH 0889/1664] Split Ollama entity (#147769) --- .../components/ollama/conversation.py | 251 +---------------- homeassistant/components/ollama/entity.py | 258 ++++++++++++++++++ tests/components/ollama/test_conversation.py | 4 +- 3 files changed, 268 insertions(+), 245 deletions(-) create mode 100644 homeassistant/components/ollama/entity.py diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index beedb61f942..ae4de7d48a1 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,41 +2,18 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterator, Callable -import json -import logging -from typing import Any, Literal - -import ollama -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry -from .const import ( - CONF_KEEP_ALIVE, - CONF_MAX_HISTORY, - CONF_MODEL, - CONF_NUM_CTX, - CONF_PROMPT, - CONF_THINK, - DEFAULT_KEEP_ALIVE, - DEFAULT_MAX_HISTORY, - DEFAULT_NUM_CTX, - DOMAIN, -) -from .models import MessageHistory, MessageRole - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_PROMPT, DOMAIN +from .entity import OllamaBaseLLMEntity async def async_setup_entry( @@ -55,129 +32,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> dict[str, Any]: - """Format tool specification.""" - tool_spec = { - "name": tool.name, - "parameters": convert(tool.parameters, custom_serializer=custom_serializer), - } - if tool.description: - tool_spec["description"] = tool.description - return {"type": "function", "function": tool_spec} - - -def _fix_invalid_arguments(value: Any) -> Any: - """Attempt to repair incorrectly formatted json function arguments. - - Small models (for example llama3.1 8B) may produce invalid argument values - which we attempt to repair here. - """ - if not isinstance(value, str): - return value - if (value.startswith("[") and value.endswith("]")) or ( - value.startswith("{") and value.endswith("}") - ): - try: - return json.loads(value) - except json.decoder.JSONDecodeError: - pass - return value - - -def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: - """Rewrite ollama tool arguments. - - This function improves tool use quality by fixing common mistakes made by - small local tool use models. This will repair invalid json arguments and - omit unnecessary arguments with empty values that will fail intent parsing. - """ - return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} - - -def _convert_content( - chat_content: ( - conversation.Content - | conversation.ToolResultContent - | conversation.AssistantContent - ), -) -> ollama.Message: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(chat_content.tool_result), - ) - if isinstance(chat_content, conversation.AssistantContent): - return ollama.Message( - role=MessageRole.ASSISTANT.value, - content=chat_content.content, - tool_calls=[ - ollama.Message.ToolCall( - function=ollama.Message.ToolCall.Function( - name=tool_call.tool_name, - arguments=tool_call.tool_args, - ) - ) - for tool_call in chat_content.tool_calls or () - ], - ) - if isinstance(chat_content, conversation.UserContent): - return ollama.Message( - role=MessageRole.USER.value, - content=chat_content.content, - ) - if isinstance(chat_content, conversation.SystemContent): - return ollama.Message( - role=MessageRole.SYSTEM.value, - content=chat_content.content, - ) - raise TypeError(f"Unexpected content type: {type(chat_content)}") - - -async def _transform_stream( - result: AsyncIterator[ollama.ChatResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - An Ollama streaming response may come in chunks like this: - - response: message=Message(role="assistant", content="Paris") - response: message=Message(role="assistant", content=".") - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - response: message=Message(role="assistant", tool_calls=[...]) - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - - This generator conforms to the chatlog delta stream expectations in that it - yields deltas, then the role only once the response is done. - """ - - new_msg = True - async for response in result: - _LOGGER.debug("Received response: %s", response) - response_message = response["message"] - chunk: conversation.AssistantContentDeltaDict = {} - if new_msg: - new_msg = False - chunk["role"] = "assistant" - if (tool_calls := response_message.get("tool_calls")) is not None: - chunk["tool_calls"] = [ - llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - for tool_call in tool_calls - ] - if (content := response_message.get("content")) is not None: - chunk["content"] = content - if response_message.get("done"): - new_msg = True - yield chunk - - class OllamaConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OllamaBaseLLMEntity, ): """Ollama conversation agent.""" @@ -185,17 +43,7 @@ class OllamaConversationEntity( def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - 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)}, - name=subentry.title, - manufacturer="Ollama", - model=entry.data[CONF_MODEL], - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -255,89 +103,6 @@ class OllamaConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - settings = {**self.entry.data, **self.subentry.data} - - client = self.entry.runtime_data - model = settings[CONF_MODEL] - - tools: list[dict[str, Any]] | 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 - ] - - message_history: MessageHistory = MessageHistory( - [_convert_content(content) for content in chat_log.content] - ) - max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) - self._trim_history(message_history, max_messages) - - # Get response - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - response_generator = await client.chat( - model=model, - # Make a copy of the messages because we mutate the list later - messages=list(message_history.messages), - tools=tools, - stream=True, - # keep_alive requires specifying unit. In this case, seconds - keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", - options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - think=settings.get(CONF_THINK), - ) - except (ollama.RequestError, ollama.ResponseError) as err: - _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - raise HomeAssistantError( - f"Sorry, I had a problem talking to the Ollama server: {err}" - ) from err - - message_history.messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(response_generator) - ) - ] - ) - - if not chat_log.unresponded_tool_results: - break - - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history. - - This sets the max history to allow a configurable size history may take - up in the context window. - - Note that some messages in the history may not be from ollama only, and - may come from other anents, so the assumptions here may not strictly hold, - but generally should be effective. - """ - if max_messages < 1: - # Keep all messages - return - - # Ignore the in progress user message - num_previous_rounds = message_history.num_user_messages - 1 - if num_previous_rounds >= max_messages: - # Trim history but keep system prompt (first message). - # Every other message should be an assistant message, so keep 2x - # message objects. Also keep the last in progress user message - num_keep = 2 * max_messages + 1 - drop_index = len(message_history.messages) - num_keep - message_history.messages = [ - message_history.messages[0], - *message_history.messages[drop_index:], - ] - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py new file mode 100644 index 00000000000..a577bf77573 --- /dev/null +++ b/homeassistant/components/ollama/entity.py @@ -0,0 +1,258 @@ +"""Base entity for the Ollama integration.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Callable +import json +import logging +from typing import Any + +import ollama +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OllamaConfigEntry +from .const import ( + CONF_KEEP_ALIVE, + CONF_MAX_HISTORY, + CONF_MODEL, + CONF_NUM_CTX, + CONF_THINK, + DEFAULT_KEEP_ALIVE, + DEFAULT_MAX_HISTORY, + DEFAULT_NUM_CTX, + DOMAIN, +) +from .models import MessageHistory, MessageRole + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> dict[str, Any]: + """Format tool specification.""" + tool_spec = { + "name": tool.name, + "parameters": convert(tool.parameters, custom_serializer=custom_serializer), + } + if tool.description: + tool_spec["description"] = tool.description + return {"type": "function", "function": tool_spec} + + +def _fix_invalid_arguments(value: Any) -> Any: + """Attempt to repair incorrectly formatted json function arguments. + + Small models (for example llama3.1 8B) may produce invalid argument values + which we attempt to repair here. + """ + if not isinstance(value, str): + return value + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + try: + return json.loads(value) + except json.decoder.JSONDecodeError: + pass + return value + + +def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: + """Rewrite ollama tool arguments. + + This function improves tool use quality by fixing common mistakes made by + small local tool use models. This will repair invalid json arguments and + omit unnecessary arguments with empty values that will fail intent parsing. + """ + return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} + + +def _convert_content( + chat_content: ( + conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent + ), +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncIterator[ollama.ChatResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk + + +class OllamaBaseLLMEntity(Entity): + """Ollama base LLM entity.""" + + 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 + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Ollama", + model=entry.data[CONF_MODEL], + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.subentry.data} + + client = self.entry.runtime_data + model = settings[CONF_MODEL] + + tools: list[dict[str, Any]] | 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 + ] + + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) + max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) + self._trim_history(message_history, max_messages) + + # Get response + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + response_generator = await client.chat( + model=model, + # Make a copy of the messages because we mutate the list later + messages=list(message_history.messages), + tools=tools, + stream=True, + # keep_alive requires specifying unit. In this case, seconds + keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", + options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, + think=settings.get(CONF_THINK), + ) + except (ollama.RequestError, ollama.ResponseError) as err: + _LOGGER.error("Unexpected error talking to Ollama server: %s", err) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err + + message_history.messages.extend( + [ + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(response_generator) + ) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ + if max_messages < 1: + # Keep all messages + return + + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: + # Trim history but keep system prompt (first message). + # Every other message should be an assistant message, so keep 2x + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 + drop_index = len(message_history.messages) - num_keep + message_history.messages = [ + message_history.messages[0], + *message_history.messages[drop_index:], + ] diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index cebb185bd08..d33fffe7152 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -206,7 +206,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -293,7 +293,7 @@ async def test_function_call( ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, From 5a0a1bbbf43af98a7b6e1b35070c5d533a077880 Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Mon, 30 Jun 2025 07:35:19 -0400 Subject: [PATCH 0890/1664] Person ble_trackers for non-home zones not processed correctly (#138475) Co-authored-by: Erik Montnemery Co-authored-by: Joost Lekkerkerker --- homeassistant/components/person/__init__.py | 3 +- tests/components/person/test_init.py | 75 +++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 856e07bb2ee..0dd8646b17e 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -27,7 +27,6 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, SERVICE_RELOAD, STATE_HOME, - STATE_NOT_HOME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -526,7 +525,7 @@ class Person( latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: latest_non_gps_home = _get_latest(latest_non_gps_home, state) - elif state.state == STATE_NOT_HOME: + else: latest_not_home = _get_latest(latest_not_home, state) if latest_non_gps_home: diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 1d6c398c444..c001da86adb 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -244,6 +244,81 @@ async def test_setup_two_trackers( assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER +async def test_setup_router_ble_trackers( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test router and BLE trackers.""" + # BLE trackers are considered stationary trackers; however unlike a router based tracker + # whose states are home and not_home, a BLE tracker may have the value of any zone that the + # beacon is configured for. + hass.set_state(CoreState.not_running) + user_id = hass_admin_user.id + config = { + DOMAIN: { + "id": "1234", + "name": "tracked person", + "user_id": user_id, + "device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2], + } + } + assert await async_setup_component(hass, DOMAIN, config) + + state = hass.states.get("person.tracked_person") + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_SOURCE) is None + assert state.attributes.get(ATTR_USER_ID) == user_id + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + hass.states.async_set( + DEVICE_TRACKER, "not_home", {ATTR_SOURCE_TYPE: SourceType.ROUTER} + ) + await hass.async_block_till_done() + + state = hass.states.get("person.tracked_person") + assert state.state == "not_home" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) is None + assert state.attributes.get(ATTR_LONGITUDE) is None + assert state.attributes.get(ATTR_GPS_ACCURACY) is None + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + # Set the BLE tracker to the "office" zone. + hass.states.async_set( + DEVICE_TRACKER_2, + "office", + { + ATTR_LATITUDE: 12.123456, + ATTR_LONGITUDE: 13.123456, + ATTR_GPS_ACCURACY: 12, + ATTR_SOURCE_TYPE: SourceType.BLUETOOTH_LE, + }, + ) + await hass.async_block_till_done() + + # The person should be in the office. + state = hass.states.get("person.tracked_person") + assert state.state == "office" + assert state.attributes.get(ATTR_ID) == "1234" + assert state.attributes.get(ATTR_LATITUDE) == 12.123456 + assert state.attributes.get(ATTR_LONGITUDE) == 13.123456 + assert state.attributes.get(ATTR_GPS_ACCURACY) == 12 + assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2 + assert state.attributes.get(ATTR_USER_ID) == user_id + assert state.attributes.get(ATTR_DEVICE_TRACKERS) == [ + DEVICE_TRACKER, + DEVICE_TRACKER_2, + ] + + async def test_ignore_unavailable_states( hass: HomeAssistant, hass_admin_user: MockUser ) -> None: From a8b5d1511d9624d925c1089a80c9a98e328e68bd Mon Sep 17 00:00:00 2001 From: mvn23 Date: Mon, 30 Jun 2025 20:24:13 +0200 Subject: [PATCH 0891/1664] Populate hvac_modes list in opentherm_gw (#142074) --- homeassistant/components/opentherm_gw/climate.py | 13 ++++++++++++- homeassistant/components/opentherm_gw/strings.json | 3 +++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 68463e764f2..c7e107b1637 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -21,6 +21,7 @@ from homeassistant.components.climate import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -30,6 +31,7 @@ from .const import ( CONF_SET_PRECISION, DATA_GATEWAYS, DATA_OPENTHERM_GW, + DOMAIN, THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) @@ -75,7 +77,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_hvac_modes = [] + _attr_hvac_modes = [HVACMode.HEAT] _attr_name = None _attr_preset_modes = [] _attr_min_temp = 1 @@ -129,9 +131,11 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): if ch_active and flame_on: self._attr_hvac_action = HVACAction.HEATING self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_modes = [HVACMode.HEAT] elif cooling_active: self._attr_hvac_action = HVACAction.COOLING self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_modes = [HVACMode.COOL] else: self._attr_hvac_action = HVACAction.IDLE @@ -182,6 +186,13 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): return PRESET_AWAY return PRESET_NONE + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="change_hvac_mode_not_supported", + ) + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 8959e0facf9..f3938c81e7e 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -355,6 +355,9 @@ } }, "exceptions": { + "change_hvac_mode_not_supported": { + "message": "Changing HVAC mode is not supported." + }, "invalid_gateway_id": { "message": "Gateway {gw_id} not found or not loaded!" } From 578b43cf61aa448b1b9f82a9e50c0efe64f71c8c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sun, 29 Jun 2025 21:41:50 +0300 Subject: [PATCH 0892/1664] Bump aioshelly to 13.7.1 (#146221) * Bump aioshelly to 13.8.0 * Change version to 13.7.1 --- 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 c6a255b1bbb..1db8dbf55c6 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.0"], + "requirements": ["aioshelly==13.7.1"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 48b8605b346..057dc628f74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.0 +aioshelly==13.7.1 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ba5c97e364..a9be0b2281d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.0 +aioshelly==13.7.1 # homeassistant.components.skybell aioskybell==22.7.0 From adbace95c31fb56612ca86a9c487590f3d5efbe1 Mon Sep 17 00:00:00 2001 From: Evan Severson <208220+eseverson@users.noreply.github.com> Date: Mon, 30 Jun 2025 02:08:50 -0700 Subject: [PATCH 0893/1664] Fixed pushbullet handling of fields longer than 255 characters (#146993) --- homeassistant/components/pushbullet/sensor.py | 9 +- tests/components/pushbullet/test_sensor.py | 168 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 tests/components/pushbullet/test_sensor.py diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py index 2dbaa8fc713..ea9a8f198ef 100644 --- a/homeassistant/components/pushbullet/sensor.py +++ b/homeassistant/components/pushbullet/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -116,7 +116,12 @@ class PushBulletNotificationSensor(SensorEntity): attributes into self._state_attributes. """ try: - self._attr_native_value = self.pb_provider.data[self.entity_description.key] + value = self.pb_provider.data[self.entity_description.key] + # Truncate state value to MAX_LENGTH_STATE_STATE while preserving full content in attributes + if isinstance(value, str) and len(value) > MAX_LENGTH_STATE_STATE: + self._attr_native_value = value[: MAX_LENGTH_STATE_STATE - 3] + "..." + else: + self._attr_native_value = value self._attr_extra_state_attributes = self.pb_provider.data except (KeyError, TypeError): pass diff --git a/tests/components/pushbullet/test_sensor.py b/tests/components/pushbullet/test_sensor.py new file mode 100644 index 00000000000..b6ae8c3a211 --- /dev/null +++ b/tests/components/pushbullet/test_sensor.py @@ -0,0 +1,168 @@ +"""Test pushbullet sensor platform.""" + +from unittest.mock import Mock + +import pytest + +from homeassistant.components.pushbullet.const import DOMAIN +from homeassistant.components.pushbullet.sensor import ( + SENSOR_TYPES, + PushBulletNotificationSensor, +) +from homeassistant.const import MAX_LENGTH_STATE_STATE +from homeassistant.core import HomeAssistant + +from . import MOCK_CONFIG + +from tests.common import MockConfigEntry + + +def _create_mock_provider() -> Mock: + """Create a mock pushbullet provider for testing.""" + mock_provider = Mock() + mock_provider.pushbullet.user_info = {"iden": "test_user_123"} + return mock_provider + + +def _get_sensor_description(key: str): + """Get sensor description by key.""" + for desc in SENSOR_TYPES: + if desc.key == key: + return desc + raise ValueError(f"Sensor description not found for key: {key}") + + +def _create_test_sensor( + provider: Mock, sensor_key: str +) -> PushBulletNotificationSensor: + """Create a test sensor instance with mocked dependencies.""" + description = _get_sensor_description(sensor_key) + sensor = PushBulletNotificationSensor( + name="Test Pushbullet", pb_provider=provider, description=description + ) + # Mock async_write_ha_state to avoid requiring full HA setup + sensor.async_write_ha_state = Mock() + return sensor + + +@pytest.fixture +async def mock_pushbullet_entry(hass: HomeAssistant, requests_mock_fixture): + """Set up pushbullet integration.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return entry + + +def test_sensor_truncation_logic() -> None: + """Test sensor truncation logic for body sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test long body truncation + long_body = "a" * (MAX_LENGTH_STATE_STATE + 50) + provider.data = { + "body": long_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("a") + assert sensor._attr_extra_state_attributes["body"] == long_body + + # Test normal length body + normal_body = "This is a normal body" + provider.data = { + "body": normal_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation + assert sensor._attr_native_value == normal_body + assert len(sensor._attr_native_value) < MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == normal_body + + # Test exactly max length + exact_body = "a" * MAX_LENGTH_STATE_STATE + provider.data = { + "body": exact_body, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + + # Verify no truncation at the limit + assert sensor._attr_native_value == exact_body + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_extra_state_attributes["body"] == exact_body + + +def test_sensor_truncation_title_sensor() -> None: + """Test sensor truncation logic on title sensor.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "title") + + # Test long title truncation + long_title = "Title " + "x" * (MAX_LENGTH_STATE_STATE) + provider.data = { + "body": "Test body", + "title": long_title, + "type": "note", + } + + sensor.async_update_callback() + + # Verify truncation + assert len(sensor._attr_native_value) == MAX_LENGTH_STATE_STATE + assert sensor._attr_native_value.endswith("...") + assert sensor._attr_native_value.startswith("Title") + assert sensor._attr_extra_state_attributes["title"] == long_title + + +def test_sensor_truncation_non_string_handling() -> None: + """Test that non-string values are handled correctly.""" + provider = _create_mock_provider() + sensor = _create_test_sensor(provider, "body") + + # Test with None value + provider.data = { + "body": None, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value is None + + # Test with integer value (would be converted to string by Home Assistant) + provider.data = { + "body": 12345, + "title": "Test Title", + "type": "note", + } + + sensor.async_update_callback() + assert sensor._attr_native_value == 12345 # Not truncated since it's not a string + + # Test with missing key + provider.data = { + "title": "Test Title", + "type": "note", + } + + # This should not raise an exception + sensor.async_update_callback() From 1543726095a19d70275406e6ab3ac14e2a743a40 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 30 Jun 2025 20:15:48 +0200 Subject: [PATCH 0894/1664] Wallbox Integration, Reduce API impact by limiting the amount of API calls made (#147618) --- homeassistant/components/wallbox/const.py | 4 + .../components/wallbox/coordinator.py | 67 ++++++++++----- homeassistant/components/wallbox/lock.py | 13 +-- homeassistant/components/wallbox/number.py | 13 +-- tests/components/wallbox/test_lock.py | 82 ++++++++++++------- tests/components/wallbox/test_number.py | 48 +++++++---- tests/components/wallbox/test_select.py | 19 +++++ tests/components/wallbox/test_switch.py | 12 ++- 8 files changed, 170 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 34d17e52275..1059a41db53 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -22,6 +22,8 @@ CHARGER_CURRENT_MODE_KEY = "current_mode" CHARGER_CURRENT_VERSION_KEY = "currentVersion" CHARGER_CURRENCY_KEY = "currency" CHARGER_DATA_KEY = "config_data" +CHARGER_DATA_POST_L1_KEY = "data" +CHARGER_DATA_POST_L2_KEY = "chargerData" CHARGER_DEPOT_PRICE_KEY = "depot_price" CHARGER_ENERGY_PRICE_KEY = "energy_price" CHARGER_FEATURES_KEY = "features" @@ -32,7 +34,9 @@ CHARGER_POWER_BOOST_KEY = "POWER_BOOST" CHARGER_SOFTWARE_KEY = "software" CHARGER_MAX_AVAILABLE_POWER_KEY = "max_available_power" CHARGER_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent" CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" +CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 598bfa7429a..69bf3a3af1c 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,11 +14,13 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, CHARGER_ECO_SMART_KEY, CHARGER_ECO_SMART_MODE_KEY, CHARGER_ECO_SMART_STATUS_KEY, @@ -26,6 +28,7 @@ from .const import ( CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PLAN_KEY, CHARGER_POWER_BOOST_KEY, @@ -192,10 +195,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="too_many_requests" ) from wallbox_connection_error - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error @@ -204,10 +207,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return await self.hass.async_add_executor_job(self._get_data) @_require_authentication - def _set_charging_current(self, charging_current: float) -> None: + def _set_charging_current( + self, charging_current: float + ) -> dict[str, dict[str, dict[str, Any]]]: """Set maximum charging current for Wallbox.""" try: - self._wallbox.setMaxChargingCurrent(self._station, charging_current) + result = self._wallbox.setMaxChargingCurrent( + self._station, charging_current + ) + data = self.data + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_MAX_CHARGING_CURRENT_POST_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -221,16 +233,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( self._set_charging_current, charging_current ) - await self.async_request_refresh() + self.async_set_updated_data(data) @_require_authentication - def _set_icp_current(self, icp_current: float) -> None: + def _set_icp_current(self, icp_current: float) -> dict[str, Any]: """Set maximum icp current for Wallbox.""" try: - self._wallbox.setIcpMaxCurrent(self._station, icp_current) + result = self._wallbox.setIcpMaxCurrent(self._station, icp_current) + data = self.data + data[CHARGER_MAX_ICP_CURRENT_KEY] = result[CHARGER_MAX_ICP_CURRENT_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -244,14 +259,19 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" - await self.hass.async_add_executor_job(self._set_icp_current, icp_current) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_icp_current, icp_current + ) + self.async_set_updated_data(data) @_require_authentication - def _set_energy_cost(self, energy_cost: float) -> None: + def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: """Set energy cost for Wallbox.""" try: - self._wallbox.setEnergyCost(self._station, energy_cost) + result = self._wallbox.setEnergyCost(self._station, energy_cost) + data = self.data + data[CHARGER_ENERGY_PRICE_KEY] = result[CHARGER_ENERGY_PRICE_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -263,17 +283,24 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_energy_cost, energy_cost + ) + self.async_set_updated_data(data) @_require_authentication - def _set_lock_unlock(self, lock: bool) -> None: + def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: """Set wallbox to locked or unlocked.""" try: if lock: - self._wallbox.lockCharger(self._station) + result = self._wallbox.lockCharger(self._station) else: - self._wallbox.unlockCharger(self._station) + result = self._wallbox.unlockCharger(self._station) + data = self.data + data[CHARGER_LOCKED_UNLOCKED_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_LOCKED_UNLOCKED_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -287,8 +314,8 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + self.async_set_updated_data(data) @_require_authentication def _pause_charger(self, pause: bool) -> None: diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7acc56f67f2..7b5c99340f8 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,6 @@ from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -16,7 +15,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -34,16 +33,6 @@ async def async_setup_entry( ) -> None: """Create wallbox lock entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to lock, if so, add lock component - try: - await coordinator.async_set_lock_unlock( - coordinator.data[CHARGER_LOCKED_UNLOCKED_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxLock(coordinator, description) for ent in coordinator.data diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 80773478582..e1b044bbdb2 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,6 @@ from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -26,7 +25,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -86,16 +85,6 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user has sufficient rights to change values, if so, add number component: - try: - await coordinator.async_set_charging_current( - coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxNumber(coordinator, entry, description) for ent in coordinator.data diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 5842d708f11..7d95aed7a5d 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -12,10 +12,10 @@ from homeassistant.exceptions import HomeAssistantError from . import ( authorisation_response, + http_403_error, + http_404_error, http_429_error, setup_integration, - setup_integration_platform_not_ready, - setup_integration_read_only, ) from .const import MOCK_LOCK_ENTITY_ID @@ -38,11 +38,15 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - ), patch( "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 1}}} + ), ), patch( "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 0}}} + ), ), ): await hass.services.async_call( @@ -129,6 +133,52 @@ async def test_wallbox_lock_class_connection_error( new=Mock(side_effect=http_429_error), ), pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", @@ -138,27 +188,3 @@ async def test_wallbox_lock_class_connection_error( }, blocking=True, ) - - -async def test_wallbox_lock_class_authentication_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_read_only(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None - - -async def test_wallbox_lock_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c603ae24106..8067917977d 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -23,7 +23,6 @@ from . import ( http_429_error, setup_integration, setup_integration_bidir, - setup_integration_platform_not_ready, ) from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, @@ -56,7 +55,9 @@ async def test_wallbox_number_class( ), patch( "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + new=Mock( + return_value={"data": {"chargerData": {"maxChargingCurrent": 20}}} + ), ), ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) @@ -259,18 +260,6 @@ async def test_wallbox_number_class_energy_price_auth_error( ) -async def test_wallbox_number_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - - assert state is None - - async def test_wallbox_number_class_icp_energy( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -285,7 +274,7 @@ async def test_wallbox_number_class_icp_energy( ), patch( "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + new=Mock(return_value={"icp_max_current": 20}), ), ): await hass.services.async_call( @@ -328,6 +317,35 @@ async def test_wallbox_number_class_icp_energy_auth_error( ) +async def test_wallbox_number_class_energy_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + async def test_wallbox_number_class_icp_energy_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index f59a8367b41..e46347bfa5a 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -50,6 +50,13 @@ async def test_wallbox_select_solar_charging_class( ) -> None: """Test wallbox select class.""" + if mode == EcoSmartMode.OFF: + response = test_response + elif mode == EcoSmartMode.ECO_MODE: + response = test_response_eco_mode + elif mode == EcoSmartMode.FULL_SOLAR: + response = test_response_full_solar + with ( patch( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", @@ -59,6 +66,10 @@ async def test_wallbox_select_solar_charging_class( "homeassistant.components.wallbox.Wallbox.disableEcoSmart", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=response), + ), ): await setup_integration_select(hass, entry, response) @@ -110,6 +121,10 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -144,6 +159,10 @@ async def test_wallbox_select_too_many_requests_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=http_429_error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index eb983ca44ce..98b87828f74 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -10,7 +10,13 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, http_404_error, http_429_error, setup_integration +from . import ( + authorisation_response, + http_404_error, + http_429_error, + setup_integration, + test_response, +) from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -40,6 +46,10 @@ async def test_wallbox_switch_class( "homeassistant.components.wallbox.Wallbox.resumeChargingSession", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), ): await hass.services.async_call( "switch", From ae48e3716e80e7a4adc02077ef56c18128cbb90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc=20H=C3=B6rsken?= Date: Sun, 29 Jun 2025 07:33:44 +0200 Subject: [PATCH 0895/1664] Update pywmspro to 0.3.0 to wait for short-lived actions (#147679) Replace action delays with detailed action responses. --- homeassistant/components/wmspro/cover.py | 9 ++------ homeassistant/components/wmspro/light.py | 21 +++++++++++-------- homeassistant/components/wmspro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py index 77dd928bc95..b6f100280ad 100644 --- a/homeassistant/components/wmspro/cover.py +++ b/homeassistant/components/wmspro/cover.py @@ -2,13 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any from wmspro.const import ( WMS_WebControl_pro_API_actionDescription, WMS_WebControl_pro_API_actionType, + WMS_WebControl_pro_API_responseType, ) from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity @@ -18,7 +18,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import WebControlProConfigEntry from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=10) PARALLEL_UPDATES = 1 @@ -61,7 +60,6 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Move the cover to a specific position.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100 - kwargs[ATTR_POSITION]) - await asyncio.sleep(ACTION_DELAY) @property def is_closed(self) -> bool | None: @@ -72,13 +70,11 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): """Open the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=0) - await asyncio.sleep(ACTION_DELAY) async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" action = self._dest.action(self._drive_action_desc) await action(percentage=100) - await asyncio.sleep(ACTION_DELAY) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" @@ -86,8 +82,7 @@ class WebControlProCover(WebControlProGenericEntity, CoverEntity): WMS_WebControl_pro_API_actionDescription.ManualCommand, WMS_WebControl_pro_API_actionType.Stop, ) - await action() - await asyncio.sleep(ACTION_DELAY) + await action(responseType=WMS_WebControl_pro_API_responseType.Detailed) class WebControlProAwning(WebControlProCover): diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py index d828c8a26e8..52d092ed9f0 100644 --- a/homeassistant/components/wmspro/light.py +++ b/homeassistant/components/wmspro/light.py @@ -2,11 +2,13 @@ from __future__ import annotations -import asyncio from datetime import timedelta from typing import Any -from wmspro.const import WMS_WebControl_pro_API_actionDescription +from wmspro.const import ( + WMS_WebControl_pro_API_actionDescription, + WMS_WebControl_pro_API_responseType, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.core import HomeAssistant @@ -17,7 +19,6 @@ from . import WebControlProConfigEntry from .const import BRIGHTNESS_SCALE from .entity import WebControlProGenericEntity -ACTION_DELAY = 0.5 SCAN_INTERVAL = timedelta(seconds=15) PARALLEL_UPDATES = 1 @@ -56,14 +57,16 @@ class WebControlProLight(WebControlProGenericEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=True) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=True, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" action = self._dest.action(WMS_WebControl_pro_API_actionDescription.LightSwitch) - await action(onOffState=False) - await asyncio.sleep(ACTION_DELAY) + await action( + onOffState=False, responseType=WMS_WebControl_pro_API_responseType.Detailed + ) class WebControlProDimmer(WebControlProLight): @@ -90,6 +93,6 @@ class WebControlProDimmer(WebControlProLight): WMS_WebControl_pro_API_actionDescription.LightDimming ) await action( - percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) + percentage=brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]), + responseType=WMS_WebControl_pro_API_responseType.Detailed, ) - await asyncio.sleep(ACTION_DELAY) diff --git a/homeassistant/components/wmspro/manifest.json b/homeassistant/components/wmspro/manifest.json index d4eda3a90a6..9185768165a 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.2.2"] + "requirements": ["pywmspro==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 057dc628f74..58df2b903e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2596,7 +2596,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.0 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a9be0b2281d..afa539b570c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2154,7 +2154,7 @@ pywilight==0.0.74 pywizlight==0.6.3 # homeassistant.components.wmspro -pywmspro==0.2.2 +pywmspro==0.3.0 # homeassistant.components.ws66i pyws66i==1.1 From 6dc464ad73e3a922ea3bb5eeebb35c5041a01d53 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 1 Jul 2025 02:16:45 +0800 Subject: [PATCH 0896/1664] Fix Telegram bot proxy URL not initialized when creating a new bot (#147707) --- homeassistant/components/telegram_bot/config_flow.py | 7 ++++++- tests/components/telegram_bot/test_config_flow.py | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 7b441889b8c..b6480b84f64 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -328,6 +328,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): # validate connection to Telegram API errors: dict[str, str] = {} + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) bot_name = await self._validate_bot( user_input, errors, description_placeholders ) @@ -350,7 +353,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), }, options={ # this value may come from yaml import diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 2af90b9f7ef..659effdda7b 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -117,6 +117,7 @@ async def test_reconfigure_flow_broadcast( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_webhooks_config_entry.data[CONF_PLATFORM] == PLATFORM_BROADCAST + assert mock_webhooks_config_entry.data[CONF_PROXY_URL] == "https://test" async def test_reconfigure_flow_webhooks( From c771f5fe1ecb466bf092c68b73369a66e6677e88 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 Jun 2025 17:24:09 -0500 Subject: [PATCH 0897/1664] Preserve httpx boolean behavior in REST integration after aiohttp conversion (#147738) --- homeassistant/components/rest/data.py | 6 ++++ tests/components/rest/test_data.py | 49 +++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index f20b811a887..731d1ffe9c3 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -110,6 +110,12 @@ class RestData: rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) + # Convert boolean values to lowercase strings for compatibility with aiohttp/yarl + if rendered_params: + for key, value in rendered_params.items(): + if isinstance(value, bool): + rendered_params[key] = str(value).lower() + _LOGGER.debug("Updating from %s", self._resource) # Create request kwargs request_kwargs: dict[str, Any] = { diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 3add886a451..4d6bc000fac 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -442,3 +442,52 @@ async def test_rest_data_timeout_error( "Timeout while fetching data: http://example.com/api" in caplog.text or "Platform rest not ready yet" in caplog.text ) + + +async def test_rest_data_boolean_params_converted_to_strings( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that boolean parameters are converted to lowercase strings.""" + # Mock the request and capture the actual URL + aioclient_mock.get( + "http://example.com/api", + status=200, + json={"status": "ok"}, + headers={"Content-Type": "application/json"}, + ) + + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + "resource": "http://example.com/api", + "method": "GET", + "params": { + "boolTrue": True, + "boolFalse": False, + "stringParam": "test", + "intParam": 123, + }, + "sensor": [ + { + "name": "test_sensor", + "value_template": "{{ value_json.status }}", + } + ], + } + }, + ) + await hass.async_block_till_done() + + # Check that the request was made with boolean values converted to strings + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + + # Check that the URL query parameters have boolean values converted to strings + assert url.query["boolTrue"] == "true" + assert url.query["boolFalse"] == "false" + assert url.query["stringParam"] == "test" + assert url.query["intParam"] == "123" From 3c7c9176d249662588c28423082c395b56a17077 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:11:46 +0200 Subject: [PATCH 0898/1664] Fix sensor displaying unknown when getting readings from heat meters in ista EcoTrend (#147741) --- .../components/ista_ecotrend/util.py | 28 +++++++++---------- tests/components/ista_ecotrend/conftest.py | 14 ++++++++++ .../snapshots/test_diagnostics.ambr | 18 ++++++++++++ .../ista_ecotrend/snapshots/test_util.ambr | 13 +++++++++ tests/components/ista_ecotrend/test_util.py | 5 ++++ 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py index db64dbf85db..5d790a3cf1c 100644 --- a/homeassistant/components/ista_ecotrend/util.py +++ b/homeassistant/components/ista_ecotrend/util.py @@ -108,22 +108,22 @@ def get_statistics( if monthly_consumptions := get_consumptions(data, value_type): return [ { - "value": as_number( - get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get( - "additionalValue" - if value_type == IstaValueType.ENERGY - else "value" - ) - ), + "value": as_number(value), "date": consumptions["date"], } for consumptions in monthly_consumptions - if get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + if ( + value := ( + consumption := get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ) + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + and consumption.get("additionalValue") is not None + else "value" + ) + ) ] return None diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 58977c99b59..7be1302aa4f 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -96,12 +96,16 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", @@ -115,16 +119,21 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "104", + "unit": "Einheiten", "additionalValue": "113,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,1", + "unit": "m³", "additionalValue": "61,1", + "additionalUnit": "kWh", }, { "type": "water", "value": "6,8", + "unit": "m³", }, ], }, @@ -200,16 +209,21 @@ def extend_statistics(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "9000", + "unit": "Einheiten", "additionalValue": "9000,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "9999,0", + "unit": "m³", "additionalValue": "90000,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "9000,0", + "unit": "m³", }, ], }, diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr index c9f5e72ae1f..7395e2f6dc6 100644 --- a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -12,13 +12,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -34,17 +38,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), @@ -103,13 +112,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -125,17 +138,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr index 9536c5336db..6da1a63d7ce 100644 --- a/tests/components/ista_ecotrend/snapshots/test_util.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -85,6 +85,14 @@ # --- # name: test_get_statistics.7 list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), ]) # --- # name: test_get_statistics.8 @@ -101,21 +109,26 @@ # --- # name: test_get_values_by_type dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }) # --- # name: test_get_values_by_type.1 dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }) # --- # name: test_get_values_by_type.2 dict({ 'type': 'water', + 'unit': 'm³', 'value': '5,0', }) # --- diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py index 616abdea8d6..19ec6c886cb 100644 --- a/tests/components/ista_ecotrend/test_util.py +++ b/tests/components/ista_ecotrend/test_util.py @@ -41,16 +41,21 @@ def test_get_values_by_type(snapshot: SnapshotAssertion) -> None: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "5,0", + "unit": "m³", }, ], } From b50e599517e2ebbb2ded80db313b8c71929af43d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 28 Jun 2025 22:56:37 -0700 Subject: [PATCH 0899/1664] Move the async_reload on updates in async_setup_entry in Google Generative AI (#147748) Move the async_reload on updates in async_setup_entry --- .../google_generative_ai_conversation/__init__.py | 9 +++++++++ .../google_generative_ai_conversation/conversation.py | 10 ---------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 5e4ad114adf..e3278eb3cb5 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -207,6 +207,8 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -220,6 +222,13 @@ async def async_unload_entry( return True +async def async_update_options( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index d8eae3f6d0d..0b24e8bbc38 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -61,9 +61,6 @@ class GoogleGenerativeAIConversationEntity( self.hass, "conversation", self.entry.entry_id, self.entity_id ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -103,10 +100,3 @@ class GoogleGenerativeAIConversationEntity( conversation_id=chat_log.conversation_id, continue_conversation=chat_log.continue_conversation, ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) From 62a1c8af1170b1e1d1425baca6b28a04dc558119 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sat, 28 Jun 2025 23:22:04 -0600 Subject: [PATCH 0900/1664] Fix Vesync set_percentage error (#147751) --- homeassistant/components/vesync/fan.py | 42 +++++++++++++++----------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index d9336552744..5b0197606ae 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -165,28 +165,36 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): return attr def set_percentage(self, percentage: int) -> None: - """Set the speed of the device.""" + """Set the speed of the device. + + If percentage is 0, turn off the fan. Otherwise, ensure the fan is on, + set manual mode if needed, and set the speed. + """ + device_type = SKU_TO_BASE_DEVICE[self.device.device_type] + speed_range = SPEED_RANGE[device_type] + if percentage == 0: - success = self.device.turn_off() - if not success: + # Turning off is a special case: do not set speed or mode + if not self.device.turn_off(): raise HomeAssistantError("An error occurred while turning off.") - elif not self.device.is_on: - success = self.device.turn_on() - if not success: + self.schedule_update_ha_state() + return + + # If the fan is off, turn it on first + if not self.device.is_on: + if not self.device.turn_on(): raise HomeAssistantError("An error occurred while turning on.") - success = self.device.manual_mode() - if not success: - raise HomeAssistantError("An error occurred while manual mode.") - success = self.device.change_fan_speed( - math.ceil( - percentage_to_ranged_value( - SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage - ) - ) - ) - if not success: + # Switch to manual mode if not already set + if self.device.mode != VS_FAN_MODE_MANUAL: + if not self.device.manual_mode(): + raise HomeAssistantError("An error occurred while setting manual mode.") + + # Calculate the speed level and set it + speed_level = math.ceil(percentage_to_ranged_value(speed_range, percentage)) + if not self.device.change_fan_speed(speed_level): raise HomeAssistantError("An error occurred while changing fan speed.") + self.schedule_update_ha_state() def set_preset_mode(self, preset_mode: str) -> None: From 328e838351d05bfe47a84c8b0432d82013da6507 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 20:12:03 +0200 Subject: [PATCH 0901/1664] Use media selector for Assist Satellite actions (#147767) Co-authored-by: Michael Hansen --- .../components/assist_satellite/__init__.py | 30 +++++-- .../components/assist_satellite/services.yaml | 24 ++++-- .../assist_satellite/test_entity.py | 86 +++++++++++++++++++ 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 6bfbdfb33a8..26ce9e75428 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Optional("message"): str, - vol.Optional("media_id"): str, + vol.Optional("media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, } ), cv.has_at_least_one_key("message", "media_id"), @@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( "start_conversation", vol.All( cv.make_entity_service_schema( { vol.Optional("start_message"): str, - vol.Optional("start_media_id"): str, + vol.Optional("start_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("extra_system_prompt"): str, } ), @@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, - vol.Optional("question_media_id"): str, + vol.Optional("question_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("answers"): [ { vol.Required("id"): str, @@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]: raise vol.Invalid("sentences cannot be empty") return value + + +# Validator for media_id fields that accepts both string and media selector format +_media_id_validator = vol.Any( + cv.string, # Plain string format + vol.All( + vol.Schema( + { + vol.Required("media_content_id"): cv.string, + vol.Required("media_content_type"): cv.string, + vol.Remove("metadata"): dict, # Ignore metadata if present + } + ), + # Extract media_content_id from media selector format + lambda x: x["media_content_id"], + ), +) diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 6beb0991861..8433eb6102d 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,7 +14,9 @@ announce: media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -23,7 +25,9 @@ announce: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* start_conversation: target: entity: @@ -40,7 +44,9 @@ start_conversation: start_media_id: required: false selector: - text: + media: + accept: + - audio/* extra_system_prompt: required: false selector: @@ -53,7 +59,9 @@ start_conversation: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* ask_question: fields: entity_id: @@ -72,7 +80,9 @@ ask_question: question_media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -81,7 +91,9 @@ ask_question: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* answers: required: false selector: diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 3473b23bedd..9f14be6c50f 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -235,6 +235,43 @@ async def test_new_pipeline_cancels_pipeline( preannounce_media_id="http://example.com/preannounce.mp3", ), ), + ( + { + "message": "Hello", + "media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + tts_token=None, + media_id_source="media_id", + ), + ), + ( + { + "media_id": { + "media_content_id": "http://example.com/bla.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -610,6 +647,51 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_message": "Hello", + "start_media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + ( + "mock-conversation-id", + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + { + "start_media_id": { + "media_content_id": "http://example.com/given.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -731,6 +813,10 @@ async def test_start_conversation_default_preannounce( ), ( { + "question_media_id": { + "media_content_id": "media-source://tts/cloud?message=What+kind+of+music+would+you+like+to+listen+to%3F&language=en-US&gender=female", + "media_content_type": "provider", + }, "answers": [ { "id": "genre", From 1f6d28dcbf9b29b5da43594feddd4247fdcc37f0 Mon Sep 17 00:00:00 2001 From: mkmer <7760516+mkmer@users.noreply.github.com> Date: Sun, 29 Jun 2025 15:22:59 -0400 Subject: [PATCH 0902/1664] Honeywell: Don't use shared session (#147772) --- .../components/honeywell/__init__.py | 22 ++++++------------- .../components/honeywell/config_flow.py | 8 +++++-- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index eb89ba2a681..6c4c7091840 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -9,17 +9,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import ( - async_create_clientsession, - async_get_clientsession, -) +from homeassistant.helpers.aiohttp_client import async_create_clientsession -from .const import ( - _LOGGER, - CONF_COOL_AWAY_TEMPERATURE, - CONF_HEAT_AWAY_TEMPERATURE, - DOMAIN, -) +from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE UPDATE_LOOP_SLEEP_TIME = 5 PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH] @@ -56,11 +48,11 @@ async def async_setup_entry( username = config_entry.data[CONF_USERNAME] password = config_entry.data[CONF_PASSWORD] - if len(hass.config_entries.async_entries(DOMAIN)) > 1: - session = async_create_clientsession(hass) - else: - session = async_get_clientsession(hass) - + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. + session = async_create_clientsession(hass) client = aiosomecomfort.AIOSomeComfort(username, password, session=session) try: await client.login() diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index c7cda500692..15199cdda24 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( CONF_COOL_AWAY_TEMPERATURE, @@ -114,10 +114,14 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): async def is_valid(self, **kwargs) -> bool: """Check if login credentials are valid.""" + # Always create a new session for Honeywell to prevent cookie injection + # issues. Even with response_url handling in aiosomecomfort 0.0.33+, + # cookies can still leak into other integrations when using the shared + # session. See issue #147395. client = aiosomecomfort.AIOSomeComfort( kwargs[CONF_USERNAME], kwargs[CONF_PASSWORD], - session=async_get_clientsession(self.hass), + session=async_create_clientsession(self.hass), ) await client.login() From 66cf9c4ed5737a0f2d73ab97aac215f384ad1a08 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 30 Jun 2025 09:39:02 +0200 Subject: [PATCH 0903/1664] Bump reolink_aio to 0.14.2 (#147797) --- 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 04996689bf7..c422af292b9 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.1"] + "requirements": ["reolink-aio==0.14.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58df2b903e8..dfa6c4ada1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2656,7 +2656,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.2 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index afa539b570c..6360dc34cd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2202,7 +2202,7 @@ renault-api==0.3.1 renson-endura-delta==1.7.2 # homeassistant.components.reolink -reolink-aio==0.14.1 +reolink-aio==0.14.2 # homeassistant.components.rflink rflink==0.0.67 From e8204e5f8e5e1d119e918223489e41b54903e763 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 30 Jun 2025 14:18:22 -0400 Subject: [PATCH 0904/1664] Await firmware installation task when flashing ZBT-1/Yellow firmware (#147824) --- .../firmware_config_flow.py | 66 ++++++++++++++----- .../homeassistant_hardware/strings.json | 3 +- .../homeassistant_sky_connect/strings.json | 6 +- .../homeassistant_yellow/strings.json | 3 +- .../test_config_flow_failures.py | 2 +- 5 files changed, 57 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index a5e35749e1b..3263b091ad5 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -27,6 +27,7 @@ from homeassistant.config_entries import ( ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -67,6 +68,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task | None = None + self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: """Shared translation placeholders.""" @@ -152,8 +154,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): assert self._device is not None if not self.firmware_install_task: - # We 100% need to install new firmware only if the wrong firmware is - # currently installed + # Keep track of the firmware we're working with, for error messages + self.installing_firmware_name = firmware_name + + # Installing new firmware is only truly required if the wrong type is + # installed: upgrading to the latest release of the current firmware type + # isn't strictly necessary for functionality. firmware_install_required = self._probed_firmware_info is None or ( self._probed_firmware_info.firmware_type != expected_installed_firmware_type @@ -167,7 +173,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): fw_manifest = next( fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) ) - except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + except (StopIteration, TimeoutError, ClientError, ManifestMissing): _LOGGER.warning( "Failed to fetch firmware update manifest", exc_info=True ) @@ -179,13 +185,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): ) return self.async_show_progress_done(next_step_id=next_step_id) - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) if not firmware_install_required: assert self._probed_firmware_info is not None @@ -205,7 +207,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): try: fw_data = await client.async_fetch_firmware(fw_manifest) - except (TimeoutError, ClientError, ValueError) as err: + except (TimeoutError, ClientError, ValueError): _LOGGER.warning("Failed to fetch firmware update", exc_info=True) # If we cannot download new firmware, we shouldn't block setup @@ -216,13 +218,9 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): return self.async_show_progress_done(next_step_id=next_step_id) # Otherwise, fail - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( @@ -249,8 +247,40 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): progress_task=self.firmware_install_task, ) + try: + await self.firmware_install_task + except HomeAssistantError: + _LOGGER.exception("Failed to flash firmware") + return self.async_show_progress_done(next_step_id="firmware_install_failed") + return self.async_show_progress_done(next_step_id=next_step_id) + async def async_step_firmware_download_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware download failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + + async def async_step_firmware_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware install failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_install_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index d9c086cb040..da2374de57b 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -37,7 +37,8 @@ "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", - "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.", + "fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index f87a45febe4..13775d1f1eb 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -93,7 +93,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -147,7 +148,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index b43f890b4e3..d0c5e969d11 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -118,7 +118,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 442cf8aea50..0494de1432c 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -339,7 +339,7 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non ) assert pick_thread_progress_result["type"] is FlowResultType.ABORT - assert pick_thread_progress_result["reason"] == "unsupported_firmware" + assert pick_thread_progress_result["reason"] == "fw_install_failed" @pytest.mark.parametrize( From db04c77e62de7206d8f950c944fb0d69a32d6db0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Jun 2025 19:39:34 +0000 Subject: [PATCH 0905/1664] Bump version to 2025.7.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e3b9424292e..55406d605c3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index eb02751785e..519d7789f03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b4" +version = "2025.7.0b5" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 38a7b210521328988c9b3dbe77f93ee5ad38fdd6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 21:47:44 +0200 Subject: [PATCH 0906/1664] Split Anthropic entity (#147770) --- .../components/anthropic/conversation.py | 388 +---------------- homeassistant/components/anthropic/entity.py | 393 ++++++++++++++++++ .../components/anthropic/test_conversation.py | 6 +- 3 files changed, 404 insertions(+), 383 deletions(-) create mode 100644 homeassistant/components/anthropic/entity.py diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index f34d9ed97b6..531d007cf52 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,69 +1,17 @@ """Conversation support for Anthropic.""" -from collections.abc import AsyncGenerator, Callable, Iterable -import json -from typing import Any, Literal, cast - -import anthropic -from anthropic import AsyncStream -from anthropic._types import NOT_GIVEN -from anthropic.types import ( - InputJSONDelta, - MessageDeltaUsage, - MessageParam, - MessageStreamEvent, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - RedactedThinkingBlock, - RedactedThinkingBlockParam, - SignatureDelta, - TextBlock, - TextBlockParam, - TextDelta, - ThinkingBlock, - ThinkingBlockParam, - ThinkingConfigDisabledParam, - ThinkingConfigEnabledParam, - ThinkingDelta, - ToolParam, - ToolResultBlockParam, - ToolUseBlock, - ToolUseBlockParam, - Usage, -) -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_TEMPERATURE, - CONF_THINKING_BUDGET, - DOMAIN, - LOGGER, - MIN_THINKING_BUDGET, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_THINKING_BUDGET, - THINKING_MODELS, -) - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 +from .const import CONF_PROMPT, DOMAIN +from .entity import AnthropicBaseLLMEntity async def async_setup_entry( @@ -82,253 +30,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ToolParam: - """Format tool specification.""" - return ToolParam( - name=tool.name, - description=tool.description or "", - input_schema=convert(tool.parameters, custom_serializer=custom_serializer), - ) - - -def _convert_content( - chat_content: Iterable[conversation.Content], -) -> list[MessageParam]: - """Transform HA chat_log content into Anthropic API format.""" - messages: list[MessageParam] = [] - - for content in chat_content: - if isinstance(content, conversation.ToolResultContent): - tool_result_block = ToolResultBlockParam( - type="tool_result", - tool_use_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if not messages or messages[-1]["role"] != "user": - messages.append( - MessageParam( - role="user", - content=[tool_result_block], - ) - ) - elif isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - tool_result_block, - ] - else: - messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] - elif isinstance(content, conversation.UserContent): - # Combine consequent user messages - if not messages or messages[-1]["role"] != "user": - messages.append( - MessageParam( - role="user", - content=content.content, - ) - ) - elif isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - TextBlockParam(type="text", text=content.content), - ] - else: - messages[-1]["content"].append( # type: ignore[attr-defined] - TextBlockParam(type="text", text=content.content) - ) - elif isinstance(content, conversation.AssistantContent): - # Combine consequent assistant messages - if not messages or messages[-1]["role"] != "assistant": - messages.append( - MessageParam( - role="assistant", - content=[], - ) - ) - - if content.content: - messages[-1]["content"].append( # type: ignore[union-attr] - TextBlockParam(type="text", text=content.content) - ) - if content.tool_calls: - messages[-1]["content"].extend( # type: ignore[union-attr] - [ - ToolUseBlockParam( - type="tool_use", - id=tool_call.id, - name=tool_call.tool_name, - input=tool_call.tool_args, - ) - for tool_call in content.tool_calls - ] - ) - else: - # Note: We don't pass SystemContent here as its passed to the API as the prompt - raise TypeError(f"Unexpected content type: {type(content)}") - - return messages - - -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place - chat_log: conversation.ChatLog, - result: AsyncStream[MessageStreamEvent], - messages: list[MessageParam], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - A typical stream of responses might look something like the following: - - RawMessageStartEvent with no content - - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) - - RawContentBlockDeltaEvent with a ThinkingDelta - - RawContentBlockDeltaEvent with a ThinkingDelta - - RawContentBlockDeltaEvent with a ThinkingDelta - - ... - - RawContentBlockDeltaEvent with a SignatureDelta - - RawContentBlockStopEvent - - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) - - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) - - RawContentBlockStartEvent with an empty TextBlock - - RawContentBlockDeltaEvent with a TextDelta - - RawContentBlockDeltaEvent with a TextDelta - - RawContentBlockDeltaEvent with a TextDelta - - ... - - RawContentBlockStopEvent - - RawContentBlockStartEvent with ToolUseBlock specifying the function name - - RawContentBlockDeltaEvent with a InputJSONDelta - - RawContentBlockDeltaEvent with a InputJSONDelta - - ... - - RawContentBlockStopEvent - - RawMessageDeltaEvent with a stop_reason='tool_use' - - RawMessageStopEvent(type='message_stop') - - Each message could contain multiple blocks of the same type. - """ - if result is None: - raise TypeError("Expected a stream of messages") - - current_message: MessageParam | None = None - current_block: ( - TextBlockParam - | ToolUseBlockParam - | ThinkingBlockParam - | RedactedThinkingBlockParam - | None - ) = None - current_tool_args: str - input_usage: Usage | None = None - - async for response in result: - LOGGER.debug("Received response: %s", response) - - if isinstance(response, RawMessageStartEvent): - if response.message.role != "assistant": - raise ValueError("Unexpected message role") - current_message = MessageParam(role=response.message.role, content=[]) - input_usage = response.message.usage - elif isinstance(response, RawContentBlockStartEvent): - if isinstance(response.content_block, ToolUseBlock): - current_block = ToolUseBlockParam( - type="tool_use", - id=response.content_block.id, - name=response.content_block.name, - input="", - ) - current_tool_args = "" - elif isinstance(response.content_block, TextBlock): - current_block = TextBlockParam( - type="text", text=response.content_block.text - ) - yield {"role": "assistant"} - if response.content_block.text: - yield {"content": response.content_block.text} - elif isinstance(response.content_block, ThinkingBlock): - current_block = ThinkingBlockParam( - type="thinking", - thinking=response.content_block.thinking, - signature=response.content_block.signature, - ) - elif isinstance(response.content_block, RedactedThinkingBlock): - current_block = RedactedThinkingBlockParam( - type="redacted_thinking", data=response.content_block.data - ) - LOGGER.debug( - "Some of Claude’s internal reasoning has been automatically " - "encrypted for safety reasons. This doesn’t affect the quality of " - "responses" - ) - elif isinstance(response, RawContentBlockDeltaEvent): - if current_block is None: - raise ValueError("Unexpected delta without a block") - if isinstance(response.delta, InputJSONDelta): - current_tool_args += response.delta.partial_json - elif isinstance(response.delta, TextDelta): - text_block = cast(TextBlockParam, current_block) - text_block["text"] += response.delta.text - yield {"content": response.delta.text} - elif isinstance(response.delta, ThinkingDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["thinking"] += response.delta.thinking - elif isinstance(response.delta, SignatureDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["signature"] += response.delta.signature - elif isinstance(response, RawContentBlockStopEvent): - if current_block is None: - raise ValueError("Unexpected stop event without a current block") - if current_block["type"] == "tool_use": - # tool block - tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_block["input"] = tool_args - yield { - "tool_calls": [ - llm.ToolInput( - id=current_block["id"], - tool_name=current_block["name"], - tool_args=tool_args, - ) - ] - } - elif current_block["type"] == "thinking": - # thinking block - LOGGER.debug("Thinking: %s", current_block["thinking"]) - - if current_message is None: - raise ValueError("Unexpected stop event without a current message") - current_message["content"].append(current_block) # type: ignore[union-attr] - current_block = None - elif isinstance(response, RawMessageDeltaEvent): - if (usage := response.usage) is not None: - chat_log.async_trace(_create_token_stats(input_usage, usage)) - if response.delta.stop_reason == "refusal": - raise HomeAssistantError("Potential policy violation detected") - elif isinstance(response, RawMessageStopEvent): - if current_message is not None: - messages.append(current_message) - current_message = None - - -def _create_token_stats( - input_usage: Usage | None, response_usage: MessageDeltaUsage -) -> dict[str, Any]: - """Create token stats for conversation agent tracing.""" - input_tokens = 0 - cached_input_tokens = 0 - if input_usage: - input_tokens = input_usage.input_tokens - cached_input_tokens = input_usage.cache_creation_input_tokens or 0 - output_tokens = response_usage.output_tokens - return { - "stats": { - "input_tokens": input_tokens, - "cached_input_tokens": cached_input_tokens, - "output_tokens": output_tokens, - } - } - - class AnthropicConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + AnthropicBaseLLMEntity, ): """Anthropic conversation agent.""" @@ -336,17 +41,7 @@ class AnthropicConversationEntity( def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - 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)}, - name=subentry.title, - manufacturer="Anthropic", - model="Claude", - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -395,73 +90,6 @@ class AnthropicConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data - - tools: list[ToolParam] | 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 - ] - - system = chat_log.content[0] - if not isinstance(system, conversation.SystemContent): - raise TypeError("First message must be a system message") - messages = _convert_content(chat_log.content[1:]) - - client = self.entry.runtime_data - - thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - "system": system.content, - "stream": True, - } - if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: - model_args["thinking"] = ThinkingConfigEnabledParam( - type="enabled", budget_tokens=thinking_budget - ) - else: - model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ) - - try: - stream = await client.messages.create(**model_args) - except anthropic.AnthropicError as err: - raise HomeAssistantError( - f"Sorry, I had a problem talking to Anthropic: {err}" - ) from err - - messages.extend( - _convert_content( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_log, stream, messages), - ) - if not isinstance(content, conversation.AssistantContent) - ] - ) - ) - - if not chat_log.unresponded_tool_results: - break - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py new file mode 100644 index 00000000000..a28c948d28b --- /dev/null +++ b/homeassistant/components/anthropic/entity.py @@ -0,0 +1,393 @@ +"""Base entity for Anthropic.""" + +from collections.abc import AsyncGenerator, Callable, Iterable +import json +from typing import Any, cast + +import anthropic +from anthropic import AsyncStream +from anthropic._types import NOT_GIVEN +from anthropic.types import ( + InputJSONDelta, + MessageDeltaUsage, + MessageParam, + MessageStreamEvent, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageDeltaEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RedactedThinkingBlock, + RedactedThinkingBlockParam, + SignatureDelta, + TextBlock, + TextBlockParam, + TextDelta, + ThinkingBlock, + ThinkingBlockParam, + ThinkingConfigDisabledParam, + ThinkingConfigEnabledParam, + ThinkingDelta, + ToolParam, + ToolResultBlockParam, + ToolUseBlock, + ToolUseBlockParam, + Usage, +) +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import AnthropicConfigEntry +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_TEMPERATURE, + CONF_THINKING_BUDGET, + DOMAIN, + LOGGER, + MIN_THINKING_BUDGET, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + THINKING_MODELS, +) + +# 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 +) -> ToolParam: + """Format tool specification.""" + return ToolParam( + name=tool.name, + description=tool.description or "", + input_schema=convert(tool.parameters, custom_serializer=custom_serializer), + ) + + +def _convert_content( + chat_content: Iterable[conversation.Content], +) -> list[MessageParam]: + """Transform HA chat_log content into Anthropic API format.""" + messages: list[MessageParam] = [] + + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=[tool_result_block], + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + tool_result_block, + ] + else: + messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] + elif isinstance(content, conversation.UserContent): + # Combine consequent user messages + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=content.content, + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + TextBlockParam(type="text", text=content.content), + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + TextBlockParam(type="text", text=content.content) + ) + elif isinstance(content, conversation.AssistantContent): + # Combine consequent assistant messages + if not messages or messages[-1]["role"] != "assistant": + messages.append( + MessageParam( + role="assistant", + content=[], + ) + ) + + if content.content: + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam(type="text", text=content.content) + ) + if content.tool_calls: + messages[-1]["content"].extend( # type: ignore[union-attr] + [ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=tool_call.tool_args, + ) + for tool_call in content.tool_calls + ] + ) + else: + # Note: We don't pass SystemContent here as its passed to the API as the prompt + raise TypeError(f"Unexpected content type: {type(content)}") + + return messages + + +async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place + chat_log: conversation.ChatLog, + result: AsyncStream[MessageStreamEvent], + messages: list[MessageParam], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + A typical stream of responses might look something like the following: + - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - ... + - RawContentBlockDeltaEvent with a SignatureDelta + - RawContentBlockStopEvent + - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) + - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) + - RawContentBlockStartEvent with an empty TextBlock + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - ... + - RawContentBlockStopEvent + - RawContentBlockStartEvent with ToolUseBlock specifying the function name + - RawContentBlockDeltaEvent with a InputJSONDelta + - RawContentBlockDeltaEvent with a InputJSONDelta + - ... + - RawContentBlockStopEvent + - RawMessageDeltaEvent with a stop_reason='tool_use' + - RawMessageStopEvent(type='message_stop') + + Each message could contain multiple blocks of the same type. + """ + if result is None: + raise TypeError("Expected a stream of messages") + + current_message: MessageParam | None = None + current_block: ( + TextBlockParam + | ToolUseBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | None + ) = None + current_tool_args: str + input_usage: Usage | None = None + + async for response in result: + LOGGER.debug("Received response: %s", response) + + if isinstance(response, RawMessageStartEvent): + if response.message.role != "assistant": + raise ValueError("Unexpected message role") + current_message = MessageParam(role=response.message.role, content=[]) + input_usage = response.message.usage + elif isinstance(response, RawContentBlockStartEvent): + if isinstance(response.content_block, ToolUseBlock): + current_block = ToolUseBlockParam( + type="tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" + elif isinstance(response.content_block, TextBlock): + current_block = TextBlockParam( + type="text", text=response.content_block.text + ) + yield {"role": "assistant"} + if response.content_block.text: + yield {"content": response.content_block.text} + elif isinstance(response.content_block, ThinkingBlock): + current_block = ThinkingBlockParam( + type="thinking", + thinking=response.content_block.thinking, + signature=response.content_block.signature, + ) + elif isinstance(response.content_block, RedactedThinkingBlock): + current_block = RedactedThinkingBlockParam( + type="redacted_thinking", data=response.content_block.data + ) + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) + elif isinstance(response, RawContentBlockDeltaEvent): + if current_block is None: + raise ValueError("Unexpected delta without a block") + if isinstance(response.delta, InputJSONDelta): + current_tool_args += response.delta.partial_json + elif isinstance(response.delta, TextDelta): + text_block = cast(TextBlockParam, current_block) + text_block["text"] += response.delta.text + yield {"content": response.delta.text} + elif isinstance(response.delta, ThinkingDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["thinking"] += response.delta.thinking + elif isinstance(response.delta, SignatureDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["signature"] += response.delta.signature + elif isinstance(response, RawContentBlockStopEvent): + if current_block is None: + raise ValueError("Unexpected stop event without a current block") + if current_block["type"] == "tool_use": + # tool block + tool_args = json.loads(current_tool_args) if current_tool_args else {} + current_block["input"] = tool_args + yield { + "tool_calls": [ + llm.ToolInput( + id=current_block["id"], + tool_name=current_block["name"], + tool_args=tool_args, + ) + ] + } + elif current_block["type"] == "thinking": + # thinking block + LOGGER.debug("Thinking: %s", current_block["thinking"]) + + if current_message is None: + raise ValueError("Unexpected stop event without a current message") + current_message["content"].append(current_block) # type: ignore[union-attr] + current_block = None + elif isinstance(response, RawMessageDeltaEvent): + if (usage := response.usage) is not None: + chat_log.async_trace(_create_token_stats(input_usage, usage)) + if response.delta.stop_reason == "refusal": + raise HomeAssistantError("Potential policy violation detected") + elif isinstance(response, RawMessageStopEvent): + if current_message is not None: + messages.append(current_message) + current_message = None + + +def _create_token_stats( + input_usage: Usage | None, response_usage: MessageDeltaUsage +) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } + } + + +class AnthropicBaseLLMEntity(Entity): + """Anthropic base LLM entity.""" + + 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)}, + name=subentry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ToolParam] | 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 + ] + + system = chat_log.content[0] + if not isinstance(system, conversation.SystemContent): + raise TypeError("First message must be a system message") + messages = _convert_content(chat_log.content[1:]) + + client = self.entry.runtime_data + + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "system": system.content, + "stream": True, + } + if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget + ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + + try: + stream = await client.messages.create(**model_args) + except anthropic.AnthropicError as err: + raise HomeAssistantError( + f"Sorry, I had a problem talking to Anthropic: {err}" + ) from err + + messages.extend( + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_log, stream, messages), + ) + if not isinstance(content, conversation.AssistantContent) + ] + ) + ) + + if not chat_log.unresponded_tool_results: + break diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3ae44e552cc..83770e7ee34 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -316,7 +316,7 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") @pytest.mark.parametrize( ("tool_call_json_parts", "expected_call_tool_args"), [ @@ -430,7 +430,7 @@ async def test_function_call( ) -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -760,7 +760,7 @@ async def test_redacted_thinking( assert chat_log.content[2].content == "How can I help you today?" -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") async def test_extended_thinking_tool_call( mock_get_tools, hass: HomeAssistant, From 603e277a5b429e579f5399243f2e35fb4a0946a3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 Jun 2025 21:54:05 +0200 Subject: [PATCH 0907/1664] Add docstring to DhcpServiceInfo MAC address (#147823) Co-authored-by: Franck Nijhof --- homeassistant/helpers/service_info/dhcp.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py index 47479a53a8a..d46c7a59004 100644 --- a/homeassistant/helpers/service_info/dhcp.py +++ b/homeassistant/helpers/service_info/dhcp.py @@ -12,3 +12,9 @@ class DhcpServiceInfo(BaseServiceInfo): ip: str hostname: str macaddress: str + """The MAC address of the device. + + Please note that for historical reason the DHCP service will always format it + as a lowercase string without colons. + eg. "AA:BB:CC:12:34:56" is stored as "aabbcc123456" + """ From 2bdfc8cf5eabcc82c9bc49650b191dcedc757f8d Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 30 Jun 2025 22:08:55 +0200 Subject: [PATCH 0908/1664] Add common states "Empty" and "Full" (#146646) --- homeassistant/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 6e47163e90a..80ced039e46 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -128,9 +128,11 @@ "disabled": "Disabled", "discharging": "Discharging", "disconnected": "Disconnected", + "empty": "Empty", "enabled": "Enabled", "error": "Error", "fault": "Fault", + "full": "Full", "high": "High", "home": "Home", "idle": "Idle", From 84645d0ca64b3cb92a4ea3343058b602ff1fbcee Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Jul 2025 01:59:33 +0200 Subject: [PATCH 0909/1664] Use (new) common states for "Full" and "Empty" in `lg_thinq` (#147833) Use (new) common states for "Full" and "Empty" --- homeassistant/components/lg_thinq/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 38ea7b454ae..65e36a4523e 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -780,10 +780,10 @@ "battery_level": { "name": "Battery", "state": { - "high": "Full", + "high": "[%key:common::state::full%]", "mid": "[%key:common::state::medium%]", "low": "[%key:common::state::low%]", - "warning": "Empty" + "warning": "[%key:common::state::empty%]" } }, "relative_to_start": { From 23c304fc75b3da89e3e2f98a8af89399a4c646d6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Jul 2025 02:13:05 +0200 Subject: [PATCH 0910/1664] Use (new) common state "Full" in `enphase_envoy` (#147834) Use (new) common state "Full" --- homeassistant/components/enphase_envoy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 36319c71bc6..ffe0ccb1271 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -363,7 +363,7 @@ "discharging": "[%key:common::state::discharging%]", "idle": "[%key:common::state::idle%]", "charging": "[%key:common::state::charging%]", - "full": "Full" + "full": "[%key:common::state::full%]" } }, "acb_available_energy": { From 2afe475234cdfe72ec1bc0eb4c40ac9734f7deb2 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Jul 2025 07:12:00 +0200 Subject: [PATCH 0911/1664] Add more mac address prefixes for discovery to PlayStation Network (#147739) --- .../playstation_network/manifest.json | 15 ++++++++++++++ homeassistant/generated/dhcp.py | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index bb7fc7c27ff..590bd73fbf7 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -60,6 +60,21 @@ }, { "macaddress": "D44B5E*" + }, + { + "macaddress": "F8D0AC*" + }, + { + "macaddress": "E86E3A*" + }, + { + "macaddress": "FC0FE6*" + }, + { + "macaddress": "9C37CB*" + }, + { + "macaddress": "84E657*" } ], "documentation": "https://www.home-assistant.io/integrations/playstation_network", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 47072d4c05d..3c1d929b1d8 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -539,6 +539,26 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "playstation_network", "macaddress": "D44B5E*", }, + { + "domain": "playstation_network", + "macaddress": "F8D0AC*", + }, + { + "domain": "playstation_network", + "macaddress": "E86E3A*", + }, + { + "domain": "playstation_network", + "macaddress": "FC0FE6*", + }, + { + "domain": "playstation_network", + "macaddress": "9C37CB*", + }, + { + "domain": "playstation_network", + "macaddress": "84E657*", + }, { "domain": "powerwall", "hostname": "1118431-*", From 9719d2ef2bf9b115cce7a75131882de9ea1b6664 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 1 Jul 2025 08:23:47 +0200 Subject: [PATCH 0912/1664] Start deprecation of battery properties in vacuum (#146401) * Start deprecation of battery properties in vacuum * Small fixes * Fixes * Deprecate battery supported feature --- homeassistant/components/vacuum/__init__.py | 63 ++++++- tests/components/vacuum/test_init.py | 178 ++++++++++++++++++++ 2 files changed, 239 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 83c68fb61b6..11d13431f9d 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -247,6 +247,9 @@ class StateVacuumEntity( _attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0) __vacuum_legacy_state: bool = False + __vacuum_legacy_battery_level: bool = False + __vacuum_legacy_battery_icon: bool = False + __vacuum_legacy_battery_feature: bool = False def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" @@ -255,15 +258,28 @@ class StateVacuumEntity( # Integrations should use the 'activity' property instead of # setting the state directly. cls.__vacuum_legacy_state = True + if any( + method in cls.__dict__ + for method in ("_attr_battery_level", "battery_level") + ): + # Integrations should use a separate battery sensor. + cls.__vacuum_legacy_battery_level = True + if any( + method in cls.__dict__ for method in ("_attr_battery_icon", "battery_icon") + ): + # Integrations should use a separate battery sensor. + cls.__vacuum_legacy_battery_icon = True def __setattr__(self, name: str, value: Any) -> None: """Set attribute. - Deprecation warning if setting '_attr_state' directly - unless already reported. + Deprecation warning if setting state, battery icon or battery level + attributes directly unless already reported. """ if name == "_attr_state": self._report_deprecated_activity_handling() + if name in {"_attr_battery_level", "_attr_battery_icon"}: + self._report_deprecated_battery_properties(name[6:]) return super().__setattr__(name, value) @callback @@ -277,6 +293,10 @@ class StateVacuumEntity( super().add_to_platform_start(hass, platform, parallel_updates) if self.__vacuum_legacy_state: self._report_deprecated_activity_handling() + if self.__vacuum_legacy_battery_level: + self._report_deprecated_battery_properties("battery_level") + if self.__vacuum_legacy_battery_icon: + self._report_deprecated_battery_properties("battery_icon") @callback def _report_deprecated_activity_handling(self) -> None: @@ -295,6 +315,42 @@ class StateVacuumEntity( exclude_integrations={DOMAIN}, ) + @callback + def _report_deprecated_battery_properties(self, property: str) -> None: + """Report on deprecated use of battery properties. + + Integrations should implement a sensor instead. + """ + report_usage( + f"is setting the {property} which has been deprecated." + f" Integration {self.platform.platform_name} should implement a sensor" + " instead with a correct device class and link it to the same device", + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.7", + integration_domain=self.platform.platform_name if self.platform else None, + exclude_integrations={DOMAIN}, + ) + + @callback + def _report_deprecated_battery_feature(self) -> None: + """Report on deprecated use of battery supported features. + + Integrations should remove the battery supported feature when migrating + battery level and icon to a sensor. + """ + report_usage( + f"is setting the battery supported feature which has been deprecated." + f" Integration {self.platform.platform_name} should remove this as part of migrating" + " the battery level and icon to a sensor", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.7", + integration_domain=self.platform.platform_name if self.platform else None, + exclude_integrations={DOMAIN}, + ) + @cached_property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -333,6 +389,9 @@ class StateVacuumEntity( supported_features = self.supported_features if VacuumEntityFeature.BATTERY in supported_features: + if self.__vacuum_legacy_battery_feature is False: + self._report_deprecated_battery_feature() + self.__vacuum_legacy_battery_feature = True data[ATTR_BATTERY_LEVEL] = self.battery_level data[ATTR_BATTERY_ICON] = self.battery_icon diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index b3e5d17c728..77debf634ad 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -435,3 +435,181 @@ async def test_vacuum_deprecated_state_does_not_break_state( state = hass.states.get(entity.entity_id) assert state is not None assert state.state == "cleaning" + + +@pytest.mark.usefixtures("mock_as_custom_component") +async def test_vacuum_log_deprecated_battery_properties( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly using battery properties logs warning.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + @property + def activity(self) -> str: + """Return the state of the entity.""" + return VacuumActivity.CLEANING + + @property + def battery_level(self) -> int: + """Return the battery level of the vacuum.""" + return 50 + + @property + def battery_icon(self) -> str: + """Return the battery icon of the vacuum.""" + return "mdi:battery-50" + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=False, + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." + " Integration test should implement a sensor instead with a correct device class and link it" + " to the same device. This will stop working in Home Assistant 2026.7," + " please report it to the author of the 'test' custom integration" + in caplog.text + ) + assert ( + "Detected that custom integration 'test' is setting the battery_level which has been deprecated." + " Integration test should implement a sensor instead with a correct device class and link it" + " to the same device. This will stop working in Home Assistant 2026.7," + " please report it to the author of the 'test' custom integration" + in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +async def test_vacuum_log_deprecated_battery_properties_using_attr( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly using _attr_battery_* attribute does log issue and raise repair.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + def start(self) -> None: + """Start cleaning.""" + self._attr_battery_level = 50 + self._attr_battery_icon = "mdi:battery-50" + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=False, + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + entity.start() + + assert ( + "Detected that custom integration 'test' is setting the battery_level which has been deprecated." + " Integration test should implement a sensor instead with a correct device class and link it to" + " the same device. This will stop working in Home Assistant 2026.7," + " please report it to the author of the 'test' custom integration" + in caplog.text + ) + assert ( + "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." + " Integration test should implement a sensor instead with a correct device class and link it to" + " the same device. This will stop working in Home Assistant 2026.7," + " please report it to the author of the 'test' custom integration" + in caplog.text + ) + + await async_start(hass, entity.entity_id) + + caplog.clear() + await async_start(hass, entity.entity_id) + # Test we only log once + assert ( + "Detected that custom integration 'test' is setting the battery_level which has been deprecated." + " Integration test should implement a sensor instead with a correct device class and link it to" + " the same device. This will stop working in Home Assistant 2026.7," + " please report it to the author of the 'test' custom integration" + not in caplog.text + ) + assert ( + "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." + " Integration test should implement a sensor instead with a correct device class and link it to" + " the same device. This will stop working in Home Assistant 2026.7," + " please report it to the author of the 'test' custom integration" + not in caplog.text + ) + + +@pytest.mark.usefixtures("mock_as_custom_component") +async def test_vacuum_log_deprecated_battery_supported_feature( + hass: HomeAssistant, + config_flow_fixture: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test incorrectly setting battery supported feature logs warning.""" + + entity = MockVacuum( + name="Testing", + entity_id="vacuum.test", + ) + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + built_in=False, + ) + setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True) + assert await hass.config_entries.async_setup(config_entry.entry_id) + + state = hass.states.get(entity.entity_id) + assert state is not None + + assert ( + "Detected that custom integration 'test' is setting the battery supported feature" + " which has been deprecated. Integration test should remove this as part of migrating" + " the battery level and icon to a sensor. This will stop working in Home Assistant 2026.7" + ", please report it to the author of the 'test' custom integration" + in caplog.text + ) From ddf56f053bf28fe7b034de01e8c4c91860a63f46 Mon Sep 17 00:00:00 2001 From: On Freund Date: Tue, 1 Jul 2025 09:26:04 +0300 Subject: [PATCH 0913/1664] Support device removal in CoolMasterNet integration (#147851) --- .../components/coolmaster/__init__.py | 14 +++++- tests/components/coolmaster/test_init.py | 47 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 5892ef091d9..18a3e943bbc 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -5,8 +5,9 @@ from pycoolmasternet_async import CoolMasterNet from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr -from .const import CONF_SWING_SUPPORT +from .const import CONF_SWING_SUPPORT, DOMAIN from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR] @@ -48,3 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) - async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool: """Unload a Coolmaster config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: CoolmasterConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove a config entry from a device.""" + return not device_entry.identifiers.intersection( + (DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data + ) diff --git a/tests/components/coolmaster/test_init.py b/tests/components/coolmaster/test_init.py index f8ff761517f..cd3693c513c 100644 --- a/tests/components/coolmaster/test_init.py +++ b/tests/components/coolmaster/test_init.py @@ -1,7 +1,12 @@ """The test for the Coolmaster integration.""" +from homeassistant.components.coolmaster.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.typing import WebSocketGenerator async def test_load_entry( @@ -22,3 +27,45 @@ async def test_unload_entry( await hass.config_entries.async_unload(load_int.entry_id) await hass.async_block_till_done() assert load_int.state is ConfigEntryState.NOT_LOADED + + +async def test_registry_cleanup( + hass: HomeAssistant, + load_int: ConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test being able to remove a disconnected device.""" + entry_id = load_int.entry_id + device_registry = dr.async_get(hass) + live_id = "L1.100" + dead_id = "L2.200" + + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + device_registry.async_get_or_create( + config_entry_id=entry_id, + identifiers={(DOMAIN, dead_id)}, + manufacturer="CoolAutomation", + model="CoolMasterNet", + name=dead_id, + sw_version="1.0", + ) + + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3 + + assert await async_setup_component(hass, "config", {}) + client = await hass_ws_client(hass) + # Try to remove "L1.100" - fails since it is live + device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) + assert device is not None + response = await client.remove_device(device.id, entry_id) + assert not response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3 + assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None + + # Try to remove "L2.200" - succeeds since it is dead + device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) + assert device is not None + response = await client.remove_device(device.id, entry_id) + assert response["success"] + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None From 4f7348b8bc0e2070edff24ba1f58dc79a8447a8d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Jul 2025 08:46:58 +0200 Subject: [PATCH 0914/1664] Fix invalid configuration of MQTT device QoS option in subentry flow (#147837) --- homeassistant/components/mqtt/config_flow.py | 9 ++++----- tests/components/mqtt/test_config_flow.py | 6 +++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b022a46cbe7..ee451b5f81d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2771,11 +2771,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - new_device_data, errors = validate_user_input( - user_input, MQTT_DEVICE_PLATFORM_FIELDS - ) - if "mqtt_settings" in user_input: - new_device_data["mqtt_settings"] = user_input["mqtt_settings"] + new_device_data: dict[str, Any] = user_input.copy() + _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + if "advanced_settings" in new_device_data: + new_device_data |= new_device_data.pop("advanced_settings") if not errors: self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 12f77a95c48..9386f1da32c 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -4077,6 +4077,7 @@ async def test_subentry_reconfigure_update_device_properties( "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", + "mqtt_settings": {"qos": 1}, }, ) assert result["type"] is FlowResultType.MENU @@ -4090,12 +4091,15 @@ async def test_subentry_reconfigure_update_device_properties( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - # Check our device was updated + # Check our device and mqtt data was updated correctly device = deepcopy(dict(subentry.data))["device"] assert device["name"] == "Beer notifier" assert "hw_version" not in device assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" + assert device["sw_version"] == "1.1" + assert device["mqtt_settings"]["qos"] == 1 + assert "qos" not in device @pytest.mark.parametrize( From a180cabea9d27add574409b31fa901542149d1bd Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Jul 2025 08:58:31 +0200 Subject: [PATCH 0915/1664] Use (new) common state "Full" in `overkiz` (#147848) Use (new) common state "Full" --- homeassistant/components/overkiz/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index c8f0fae3622..335ae7ba4ef 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -123,7 +123,7 @@ "sensor": { "battery": { "state": { - "full": "Full", + "full": "[%key:common::state::full%]", "low": "[%key:common::state::low%]", "normal": "[%key:common::state::normal%]", "medium": "[%key:common::state::medium%]", From 35f0505c7b5ee123b95b4bd6d3296f885489b4f7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Jul 2025 08:59:55 +0200 Subject: [PATCH 0916/1664] Use (new) common state "Empty" in `whirlpool` (#147847) Use (new) common state "Empty" --- homeassistant/components/whirlpool/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 2a22a2e8e4e..27e5ebe3ea9 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -113,7 +113,7 @@ "name": "Detergent level", "state": { "unknown": "Unknown", - "empty": "Empty", + "empty": "[%key:common::state::empty%]", "25": "25%", "50": "50%", "100": "100%", From 9469c6ad1c76bb1997def55166e37baec441671c Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:16:23 +1200 Subject: [PATCH 0917/1664] Implement suggested_display_precision for ESPHome (#147849) --- homeassistant/components/esphome/sensor.py | 5 +- tests/components/esphome/test_entity.py | 4 +- tests/components/esphome/test_sensor.py | 70 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 5baa092613b..de0f07b94c9 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): # if the string is empty if unit_of_measurement := static_info.unit_of_measurement: self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_suggested_display_precision = static_info.accuracy_decimals self._attr_device_class = try_parse_enum( SensorDeviceClass, static_info.device_class ) @@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property - def native_value(self) -> datetime | str | None: + def native_value(self) -> datetime | int | float | None: """Return the state of the entity.""" if not self._has_state or (state := self._state).missing_state: return None @@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return None if self.device_class is SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(state_float) - return f"{state_float:.{self._static_info.accuracy_decimals}f}" + return state_float class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index c97965a1ba3..ba6a82bbd23 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -375,7 +375,7 @@ async def test_deep_sleep_device( assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(False) await hass.async_block_till_done() @@ -394,7 +394,7 @@ async def test_deep_sleep_device( assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(True) await hass.async_block_till_done() diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 55e228b72be..e520b6ca259 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -13,18 +13,28 @@ from aioesphomeapi import ( TextSensorInfo, TextSensorState, ) +import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, + async_rounded_state, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -441,3 +451,63 @@ async def test_generic_numeric_sensor_empty_string_uom( assert state is not None assert state.state == "123" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement", "state_value", "expected_precision"), + [ + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 23.456, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 0.1, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, -25.789, 1), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1234.56, 0), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1.23456, 3), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 0.123, 3), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 1234.5, 0), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 12.3456, 2), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 230.45, 1), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 3.3, 1), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 15.678, 2), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 0.015, 3), + (SensorDeviceClass.ATMOSPHERIC_PRESSURE, UnitOfPressure.HPA, 1013.25, 1), + (SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, 1.01325, 3), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 45.67, 1), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 4567.0, 0), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 87.654, 1), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 45.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 95.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 100.0, 1), + ], +) +async def test_suggested_display_precision_by_device_class( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + device_class: SensorDeviceClass, + unit_of_measurement: str, + state_value: float, + expected_precision: int, +) -> None: + """Test suggested display precision for different device classes.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + accuracy_decimals=expected_precision, + device_class=device_class.value, + unit_of_measurement=unit_of_measurement, + ) + ] + states = [SensorState(key=1, state=state_value)] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert float( + async_rounded_state(hass, "sensor.test_my_sensor", state) + ) == pytest.approx(round(state_value, expected_precision)) From 5ff698c78da44cfd91d342142f1f309a1fb91b38 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 1 Jul 2025 10:15:45 +0200 Subject: [PATCH 0918/1664] Catch access denied errors in webdav and display proper message (#147093) --- .../components/webdav/config_flow.py | 8 +++- homeassistant/components/webdav/strings.json | 4 +- tests/components/webdav/test_config_flow.py | 7 ++- tests/components/webdav/test_init.py | 46 ++++++++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index e3e46d2575a..95b20761d09 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,11 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import voluptuous as vol import yarl @@ -65,6 +69,8 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" + except AccessDeniedError: + errors["base"] = "access_denied" except MethodNotSupportedError: errors["base"] = "invalid_method" except Exception: diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index ac6418f1239..689b27bbf66 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "access_denied": "The access to the backup path has been denied. Please check the permissions of the backup path.", "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -35,9 +36,6 @@ "cannot_connect": { "message": "Cannot connect to WebDAV server" }, - "cannot_access_or_create_backup_path": { - "message": "Cannot access or create backup path. Please check the path and permissions." - }, "failed_to_migrate_folder": { "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index 9204e6eadab..3ee5c8ae9ad 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import pytest from homeassistant import config_entries @@ -86,6 +90,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (AccessDeniedError("https://webdav.demo"), "access_denied"), (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index 124a644fa93..89f0e703b22 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import WebDavError +from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError, WebDavError import pytest from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN @@ -110,3 +110,47 @@ async def test_migrate_error( 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' in caplog.text ) + + +@pytest.mark.parametrize( + ("error", "expected_message", "expected_state"), + [ + ( + UnauthorizedError("Unauthorized"), + "Invalid username or password", + ConfigEntryState.SETUP_ERROR, + ), + ( + AccessDeniedError("/access_denied"), + "Access denied to /access_denied", + ConfigEntryState.SETUP_ERROR, + ), + ], + ids=["UnauthorizedError", "AccessDeniedError"], +) +async def test_error_during_setup( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, + error: Exception, + expected_message: str, + expected_state: ConfigEntryState, +) -> None: + """Test handling of various errors during setup.""" + webdav_client.check.side_effect = error + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert expected_message in caplog.text + assert config_entry.state is expected_state From 8fc31283b7d94072760296bd193e597305c12e2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:45:17 +0200 Subject: [PATCH 0919/1664] Correct ollama config entry migration (#147858) --- homeassistant/components/ollama/__init__.py | 30 ++++ .../components/ollama/config_flow.py | 1 + tests/components/ollama/test_init.py | 155 ++++++++++++++++++ 3 files changed, 186 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 8890c498e9f..eaddf936e81 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -148,4 +148,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 58b557549e1..03e2b038bab 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -73,6 +73,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 0747578c110..a6cfe4c2de0 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -81,6 +82,7 @@ async def test_migration_from_v1_to_v2( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == TEST_USER_DATA assert mock_config_entry.options == {} @@ -186,6 +188,7 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -273,6 +276,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -295,3 +299,154 @@ async def test_migration_from_v1_to_v2_with_same_urls( assert dev.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_USER_DATA, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=TEST_OPTIONS, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Ollama", + unique_id=None, + ), + ConfigSubentryData( + data=TEST_OPTIONS, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Ollama 2", + unique_id=None, + ), + ], + title="Ollama", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Ollama", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="ollama", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Ollama 2", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == TEST_OPTIONS + assert "Ollama" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.ollama") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.ollama_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 99f7a031d6123f1536e83e350f59b24e908789ef Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:46:13 +0200 Subject: [PATCH 0920/1664] Correct Google generative AI config entry migration (#147856) --- .../__init__.py | 46 ++++ .../config_flow.py | 1 + .../test_init.py | 217 +++++++++++++++++- 3 files changed, 263 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e3278eb3cb5..346d5322b02 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -308,4 +308,50 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_TITLE, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Add TTS subentry which was missing in 2025.7.0b0 + if not any( + subentry.subentry_type == "tts" for subentry in entry.subentries.values() + ): + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) + + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ad90cbcf553..1b1444e81b1 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -92,6 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_api( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 08a94dd151c..9702aae4c9e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( DOMAIN, RECOMMENDED_TTS_OPTIONS, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -473,6 +473,7 @@ async def test_migration_from_v1_to_v2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -618,6 +619,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 2 @@ -716,6 +718,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -784,6 +787,218 @@ async def test_migration_from_v1_to_v2_with_same_keys( } +@pytest.mark.parametrize( + ("device_changes", "extra_subentries", "expected_device_subentries"), + [ + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b0: + # Wrong device registry, no TTS subentry + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: + # Wrong device registry, TTS subentry created + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 + # or later: Correct device registry, TTS subentry created + ( + {}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {"mock_id_1"}}, + ), + ], +) +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_changes: dict[str, str], + extra_subentries: list[ConfigSubentryData], + expected_device_subentries: dict[str, set[str | None]], +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Google Generative AI", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Google Generative AI 2", + unique_id=None, + ), + *extra_subentries, + ], + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Google Generative AI", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device(device_1.id, **device_changes) + assert device_1.config_entries_subentries == expected_device_subentries + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Google Generative AI 2", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + async def test_devices( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From b7999755bd46074fa435267c96d9a65dd2a29574 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:47:06 +0200 Subject: [PATCH 0921/1664] Correct anthropic config entry migration (#147857) --- .../components/anthropic/__init__.py | 30 ++++ .../components/anthropic/config_flow.py | 1 + tests/components/anthropic/test_init.py | 161 ++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 68a46f19031..b25d30fe90e 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -138,4 +138,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_CONVERSATION_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 6a18cb693cd..099eae73d31 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 16240ef8120..be4f41ad4cd 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -12,6 +12,7 @@ from httpx import URL, Request, Response import pytest from homeassistant.components.anthropic.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -113,6 +114,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -224,6 +226,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -317,6 +320,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -339,3 +343,160 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert dev.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Claude", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Claude 2", + unique_id=None, + ), + ], + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Claude", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="claude", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Claude 2", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "Claude" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.claude") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.claude_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 7021fe749543f3c78649972cf1e47020a41727c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:49:07 +0200 Subject: [PATCH 0922/1664] Correct openai conversation config entry migration (#147859) --- .../openai_conversation/__init__.py | 30 ++++ .../openai_conversation/config_flow.py | 1 + .../openai_conversation/test_init.py | 163 +++++++++++++++++- 3 files changed, 193 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 7cac3bb7003..48ca21e05cd 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -361,4 +361,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index a9a444cf3dd..63ebc351ee3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -99,6 +99,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 274d09a9779..d7e8b29cab2 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -18,6 +18,7 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES from homeassistant.components.openai_conversation.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -578,7 +579,7 @@ async def test_migration_from_v1_to_v2( mock_config_entry.entry_id, config_entry=mock_config_entry, device_id=device.id, - suggested_object_id="google_generative_ai_conversation", + suggested_object_id="chatgpt", ) # Run migration @@ -590,6 +591,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -702,6 +704,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -796,6 +799,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -820,6 +824,163 @@ async def test_migration_from_v1_to_v2_with_same_keys( } +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="ChatGPT 2", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="ChatGPT", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="chatgpt", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="ChatGPT 2", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.chatgpt") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.chatgpt_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + @pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) async def test_devices( hass: HomeAssistant, From 573325be97b201c907f8ea2aa12b3b654465809b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:51:49 +0200 Subject: [PATCH 0923/1664] Use correctly formatted MAC in home_connect tests (#147818) --- .../home_connect/test_config_flow.py | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py index 3245f439bef..d6fe70144c0 100644 --- a/tests/components/home_connect/test_config_flow.py +++ b/tests/components/home_connect/test_config_flow.py @@ -29,67 +29,67 @@ DHCP_DISCOVERY = ( DhcpServiceInfo( ip="1.1.1.1", hostname="balay-dishwasher-000000000000000000", - macaddress="C8:D7:78:00:00:00", + macaddress="c8d778000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="BOSCH-ABCDE1234-68A40E000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="BOSCH-ABCDE1234-68A40E000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="bosch-dishwasher-000000000000000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="bosch-dishwasher-000000000000000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="SIEMENS-ABCDE1234-68A40E000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="SIEMENS-ABCDE1234-38B4D3000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="siemens-dishwasher-000000000000000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="siemens-dishwasher-000000000000000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="NEFF-ABCDE1234-68A40E000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="NEFF-ABCDE1234-38B4D3000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="neff-dishwasher-000000000000000000", - macaddress="68:A4:0E:00:00:00", + macaddress="68a40e000000", ), DhcpServiceInfo( ip="1.1.1.1", hostname="neff-dishwasher-000000000000000000", - macaddress="38:B4:D3:00:00:00", + macaddress="38b4d3000000", ), ) @@ -466,7 +466,7 @@ async def test_dhcp_flow_already_setup( DhcpServiceInfo( ip="1.1.1.1", hostname="bosch-cookprocessor-123456789012345678", - macaddress="c8:d7:78:00:00:00", + macaddress="c8d778000000", ), "CookProcessor", ), @@ -474,7 +474,7 @@ async def test_dhcp_flow_already_setup( DhcpServiceInfo( ip="1.1.1.1", hostname="BOSCH-HCS000000-68A40E000000", - macaddress="68:a4:0e:00:00:00", + macaddress="68a40e000000", ), "Hob", ), @@ -507,5 +507,5 @@ async def test_dhcp_flow_complete_device_information( device = device_registry.async_get_device(identifiers={(DOMAIN, appliance.ha_id)}) assert device assert device.connections == { - (dr.CONNECTION_NETWORK_MAC, dhcp_discovery.macaddress) + (dr.CONNECTION_NETWORK_MAC, dr.format_mac(dhcp_discovery.macaddress)) } From 2e12db001d0408879f47814f19bf9986e860458d Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:53:55 +0200 Subject: [PATCH 0924/1664] Fix wrong state in Husqvarna Automower (#146075) --- .../components/husqvarna_automower/const.py | 12 +++++++++++ .../husqvarna_automower/lawn_mower.py | 20 ++++++++++++++----- .../components/husqvarna_automower/sensor.py | 18 ++--------------- .../husqvarna_automower/test_lawn_mower.py | 5 +++++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index 1ea0511d721..d91fea29698 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -1,7 +1,19 @@ """The constants for the Husqvarna Automower integration.""" +from aioautomower.model import MowerStates + DOMAIN = "husqvarna_automower" EXECUTION_TIME_DELAY = 5 NAME = "Husqvarna Automower" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" + +ERROR_STATES = [ + MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, + MowerStates.FATAL_ERROR, + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 5a728265651..daeb4a113b5 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry -from .const import DOMAIN +from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerAvailableEntity, handle_sending_exception @@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): def activity(self) -> LawnMowerActivity: """Return the state of the mower.""" mower_attributes = self.mower_attributes + if mower_attributes.mower.state in ERROR_STATES: + return LawnMowerActivity.ERROR if mower_attributes.mower.state in PAUSED_STATES: return LawnMowerActivity.PAUSED - if (mower_attributes.mower.state == "RESTRICTED") or ( - mower_attributes.mower.activity in DOCKED_ACTIVITIES + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING + if ( + mower_attributes.mower.state is MowerStates.RESTRICTED + or mower_attributes.mower.activity in DOCKED_ACTIVITIES ): return LawnMowerActivity.DOCKED if mower_attributes.mower.state in MowerStates.IN_OPERATION: - if mower_attributes.mower.activity == MowerActivities.GOING_HOME: - return LawnMowerActivity.RETURNING return LawnMowerActivity.MOWING return LawnMowerActivity.ERROR + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return ( + super().available and self.mower_attributes.mower.state != MowerStates.OFF + ) + @property def work_areas(self) -> dict[int, WorkArea] | None: """Return the work areas of the mower.""" diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 5ad8ad91b48..0a059fdd706 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -7,13 +7,7 @@ import logging from operator import attrgetter from typing import TYPE_CHECKING, Any -from aioautomower.model import ( - MowerAttributes, - MowerModes, - MowerStates, - RestrictedReasons, - WorkArea, -) +from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry +from .const import ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerBaseEntity, @@ -166,15 +161,6 @@ ERROR_KEYS = [ "zone_generator_problem", ] -ERROR_STATES = [ - MowerStates.ERROR_AT_POWER_UP, - MowerStates.ERROR, - MowerStates.FATAL_ERROR, - MowerStates.OFF, - MowerStates.STOPPED, - MowerStates.WAIT_POWER_UP, - MowerStates.WAIT_UPDATING, -] ERROR_KEY_LIST = list( dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index c62cf6653c4..bf888779baa 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -42,6 +42,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed MowerStates.IN_OPERATION, LawnMowerActivity.DOCKED, ), + ( + MowerActivities.GOING_HOME, + MowerStates.RESTRICTED, + LawnMowerActivity.RETURNING, + ), ], ) async def test_lawn_mower_states( From 12aef4aae5cdec8b7d8ebce764b94b6b386a504c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:22:48 +0200 Subject: [PATCH 0925/1664] Use correctly formatted MAC in knocki tests (#147821) --- tests/components/knocki/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/knocki/test_config_flow.py b/tests/components/knocki/test_config_flow.py index 4affbd2a197..a82991094b2 100644 --- a/tests/components/knocki/test_config_flow.py +++ b/tests/components/knocki/test_config_flow.py @@ -20,7 +20,7 @@ from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="KNC1-W-00000214", - macaddress="aa:bb:cc:dd:ee:ff", + macaddress="aabbccddeeff", ) From 3f95cb37e6950d6bc1628b134be0ecc77f7f1772 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:23:31 +0200 Subject: [PATCH 0926/1664] Use correctly formatted MAC in sma tests (#147866) --- tests/components/sma/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index c8939ef2d64..29779ec2773 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -28,13 +28,13 @@ from tests.conftest import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="SMA123456", - macaddress="0015BB00abcd", + macaddress="0015bb00abcd", ) DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( ip="1.1.1.1", hostname="SMA123456789", - macaddress="0015BB00abcd", + macaddress="0015bb00abcd", ) From 78aeae577d773bee6889cba27ae817430f9bb847 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 11:24:08 +0200 Subject: [PATCH 0927/1664] Use correctly formatted MAC in roomba tests (#147865) --- tests/components/roomba/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 5b6766f7eb9..d567712dad8 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -77,12 +77,12 @@ DISCOVERY_DEVICES = [ DHCP_DISCOVERY_DEVICES_WITHOUT_MATCHING_IP = [ DhcpServiceInfo( ip="4.4.4.4", - macaddress="50:14:79:DD:EE:FF", + macaddress="501479ddeeff", hostname="irobot-blid", ), DhcpServiceInfo( ip="5.5.5.5", - macaddress="80:A5:89:DD:EE:FF", + macaddress="80a589ddeeff", hostname="roomba-blid", ), ] From 57a8f1e0cc9c5ae8f9902d54ca3653833a3a8449 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:09:00 +0200 Subject: [PATCH 0928/1664] Use correctly formatted MAC in rehlko tests (#147864) --- tests/components/rehlko/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/rehlko/test_config_flow.py b/tests/components/rehlko/test_config_flow.py index 6e3400941ab..661b66e789d 100644 --- a/tests/components/rehlko/test_config_flow.py +++ b/tests/components/rehlko/test_config_flow.py @@ -19,7 +19,7 @@ from tests.common import MockConfigEntry DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="KohlerGen", - macaddress="00146FAABBCC", + macaddress="00146faabbcc", ) From 30a85c40dae7b8e17aac37d2d89be35b194a7f82 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Jul 2025 12:14:46 +0200 Subject: [PATCH 0929/1664] Move async_reload on updates in async_setup_entry in Ollama (#147861) Co-authored-by: Claude --- homeassistant/components/ollama/__init__.py | 8 ++++++++ homeassistant/components/ollama/conversation.py | 12 +----------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index eaddf936e81..f28382d14fc 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -69,6 +69,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bo entry.runtime_data = client await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -79,6 +82,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index ae4de7d48a1..f151f8524a0 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Literal from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -56,9 +56,6 @@ class OllamaConversationEntity( self.hass, "conversation", self.entry.entry_id, self.entity_id ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -102,10 +99,3 @@ class OllamaConversationEntity( conversation_id=chat_log.conversation_id, continue_conversation=chat_log.continue_conversation, ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) From 7fcea17e83f769222bb7b77814ce2e5e3c804f35 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Jul 2025 12:15:28 +0200 Subject: [PATCH 0930/1664] Move async_reload on updates in async_setup_entry in OpenAI Conversation (#147863) Co-authored-by: Claude --- .../components/openai_conversation/__init__.py | 7 +++++++ .../components/openai_conversation/conversation.py | 12 +----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 48ca21e05cd..38c08a1720b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -284,6 +284,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -292,6 +294,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 2446fab638f..1ec17163f69 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -3,7 +3,7 @@ from typing import Literal from homeassistant.components import assist_pipeline, conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -61,9 +61,6 @@ class OpenAIConversationEntity( self.hass, "conversation", self.entry.entry_id, self.entity_id ) conversation.async_set_agent(self.hass, self.entry, self) - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) async def async_will_remove_from_hass(self) -> None: """When entity will be removed from Home Assistant.""" @@ -98,10 +95,3 @@ class OpenAIConversationEntity( conversation_id=chat_log.conversation_id, continue_conversation=chat_log.continue_conversation, ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) From 659cd42739a68e6be52eedaceb0b42a212728e2e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 1 Jul 2025 12:16:00 +0200 Subject: [PATCH 0931/1664] Move async_reload on updates in async_setup_entry in Anthropic (#147862) Co-authored-by: Claude --- homeassistant/components/anthropic/__init__.py | 9 +++++++++ .../components/anthropic/conversation.py | 16 +--------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index b25d30fe90e..e143e4d47c2 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -61,6 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_options)) + return True @@ -69,6 +71,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +async def async_update_options( + hass: HomeAssistant, entry: AnthropicConfigEntry +) -> None: + """Update options.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 531d007cf52..12c7917a30a 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -3,7 +3,7 @@ from typing import Literal from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.helpers import intent @@ -52,13 +52,6 @@ class AnthropicConversationEntity( """Return a list of supported languages.""" return MATCH_ALL - async def async_added_to_hass(self) -> None: - """When entity is added to Home Assistant.""" - await super().async_added_to_hass() - self.entry.async_on_unload( - self.entry.add_update_listener(self._async_entry_update_listener) - ) - async def _async_handle_message( self, user_input: conversation.ConversationInput, @@ -89,10 +82,3 @@ class AnthropicConversationEntity( conversation_id=chat_log.conversation_id, continue_conversation=chat_log.continue_conversation, ) - - async def _async_entry_update_listener( - self, hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle options update.""" - # Reload as we update device info + entity name + supported features - await hass.config_entries.async_reload(entry.entry_id) From 12e2493c42c2dae9c66dfbd8a8785b21a635fcc8 Mon Sep 17 00:00:00 2001 From: Parker Brown <17183625+parkerbxyz@users.noreply.github.com> Date: Tue, 1 Jul 2025 03:18:55 -0700 Subject: [PATCH 0932/1664] Capitalize "version" in Tesla fleet strings (#146501) --- homeassistant/components/tesla_fleet/strings.json | 2 +- tests/components/tesla_fleet/snapshots/test_sensor.ambr | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json index a9b1cfc4845..a5a6cc18411 100644 --- a/homeassistant/components/tesla_fleet/strings.json +++ b/homeassistant/components/tesla_fleet/strings.json @@ -467,7 +467,7 @@ "name": "Tire pressure rear right" }, "version": { - "name": "version" + "name": "Version" }, "vin": { "name": "Vehicle" diff --git a/tests/components/tesla_fleet/snapshots/test_sensor.ambr b/tests/components/tesla_fleet/snapshots/test_sensor.ambr index c251468edc4..f6268627be1 100644 --- a/tests/components/tesla_fleet/snapshots/test_sensor.ambr +++ b/tests/components/tesla_fleet/snapshots/test_sensor.ambr @@ -2356,7 +2356,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'version', + 'original_name': 'Version', 'platform': 'tesla_fleet', 'previous_unique_id': None, 'suggested_object_id': None, @@ -2369,7 +2369,7 @@ # name: test_sensors[sensor.energy_site_version-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', @@ -2382,7 +2382,7 @@ # name: test_sensors[sensor.energy_site_version-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Energy Site version', + 'friendly_name': 'Energy Site Version', }), 'context': , 'entity_id': 'sensor.energy_site_version', From 5a3aa7874d17bdf41fea5509eb4bc20d49cccdc3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:26:10 +0200 Subject: [PATCH 0933/1664] Use correctly formatted MAC in airthings tests (#147817) --- tests/components/airthings/test_config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/airthings/test_config_flow.py b/tests/components/airthings/test_config_flow.py index a96fe33c9d0..ac42eddf769 100644 --- a/tests/components/airthings/test_config_flow.py +++ b/tests/components/airthings/test_config_flow.py @@ -23,17 +23,17 @@ DHCP_SERVICE_INFO = [ DhcpServiceInfo( hostname="airthings-view", ip="192.168.1.100", - macaddress="00:00:00:00:00:00", + macaddress="000000000000", ), DhcpServiceInfo( hostname="airthings-hub", ip="192.168.1.101", - macaddress="D0:14:11:90:00:00", + macaddress="d01411900000", ), DhcpServiceInfo( hostname="airthings-hub", ip="192.168.1.102", - macaddress="70:B3:D5:2A:00:00", + macaddress="70b3d52a0000", ), ] From 61a29db72c3285ffa2b19653d608879a7b949a4b Mon Sep 17 00:00:00 2001 From: Bob Laz Date: Tue, 1 Jul 2025 05:28:13 -0500 Subject: [PATCH 0934/1664] fix state_class for water used today sensor (#147787) --- homeassistant/components/drop_connect/sensor.py | 2 +- tests/components/drop_connect/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index c69e2e12ea0..cc3356cb8e9 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [ native_unit_of_measurement=UnitOfVolume.GALLONS, suggested_display_precision=1, value_fn=lambda device: device.drop_api.water_used_today(), - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), DROPSensorEntityDescription( key=AVERAGE_WATER_USED, diff --git a/tests/components/drop_connect/snapshots/test_sensor.ambr b/tests/components/drop_connect/snapshots/test_sensor.ambr index a5c91dbe3e4..8389f92d8f9 100644 --- a/tests/components/drop_connect/snapshots/test_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_sensor.ambr @@ -356,7 +356,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -372,7 +372,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From 8fa016059d98af8b4bb5b0c6eac12929bc6a3b27 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 1 Jul 2025 12:30:01 +0200 Subject: [PATCH 0935/1664] Bump github/codeql-action from 3.29.1 to 3.29.2 (#147867) 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 2b5dd713b41..8a0af8bd5f9 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.1 + uses: github/codeql-action/init@v3.29.2 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.1 + uses: github/codeql-action/analyze@v3.29.2 with: category: "/language:python" From 5fea4915ef9f3d3122ba4659e8edb53648132a2c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Jul 2025 13:13:12 +0200 Subject: [PATCH 0936/1664] Use (new) common state "Empty" in `litterrobot` (#147835) --- homeassistant/components/litterrobot/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index d9931d71a0d..160f5edb6a0 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -70,7 +70,7 @@ "motor_fault_short": "Motor shorted", "motor_ot_amps": "Motor overtorqued", "motor_disconnected": "Motor disconnected", - "empty": "Empty" + "empty": "[%key:common::state::empty%]" } }, "last_seen": { From c92873bbfffeff01170b83e70db30b778844f427 Mon Sep 17 00:00:00 2001 From: Claudio Ruggeri - CR-Tech <41435902+crug80@users.noreply.github.com> Date: Tue, 1 Jul 2025 13:15:32 +0200 Subject: [PATCH 0937/1664] Change default slave id from 0 to 1 in modbus actions (#142865) * set default slave id in service calls * add test * revert out of scope change --- homeassistant/components/modbus/modbus.py | 4 +- tests/components/modbus/test_init.py | 86 +++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 006ef504590..1304e679347 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -172,7 +172,7 @@ async def async_modbus_setup( async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" - slave = 0 + slave = 1 if ATTR_UNIT in service.data: slave = int(float(service.data[ATTR_UNIT])) @@ -195,7 +195,7 @@ async def async_modbus_setup( async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" - slave = 0 + slave = 1 if ATTR_UNIT in service.data: slave = int(float(service.data[ATTR_UNIT])) if ATTR_SLAVE in service.data: diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 7b76dbc3528..4c0a8bd8f6e 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -1327,3 +1327,89 @@ async def test_check_default_slave( assert mock_modbus.read_holding_registers.mock_calls first_call = mock_modbus.read_holding_registers.mock_calls[0] assert first_call.kwargs["slave"] == expected_slave_value + + +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_NAME: TEST_MODBUS_NAME, + CONF_TYPE: SERIAL, + CONF_BAUDRATE: 9600, + CONF_BYTESIZE: 8, + CONF_METHOD: "rtu", + CONF_PORT: TEST_PORT_SERIAL, + CONF_PARITY: "E", + CONF_STOPBITS: 1, + CONF_SENSORS: [ + { + CONF_NAME: "dummy_noslave", + CONF_ADDRESS: 8888, + } + ], + }, + ], +) +@pytest.mark.parametrize( + "do_write", + [ + { + DATA: ATTR_VALUE, + VALUE: 15, + SERVICE: SERVICE_WRITE_REGISTER, + FUNC: CALL_TYPE_WRITE_REGISTER, + }, + { + DATA: ATTR_STATE, + VALUE: False, + SERVICE: SERVICE_WRITE_COIL, + FUNC: CALL_TYPE_WRITE_COIL, + }, + ], +) +@pytest.mark.parametrize( + "do_return", + [ + {VALUE: ReadResult([0x0001]), DATA: ""}, + {VALUE: ExceptionResponse(0x06), DATA: "Pymodbus:"}, + {VALUE: ModbusException("fail write_"), DATA: "Pymodbus:"}, + ], +) +async def test_pb_service_write_no_slave( + hass: HomeAssistant, + do_write, + do_return, + caplog: pytest.LogCaptureFixture, + mock_modbus_with_pymodbus, +) -> None: + """Run test for service write_register in case of missing slave/unit parameter.""" + + func_name = { + CALL_TYPE_WRITE_COIL: mock_modbus_with_pymodbus.write_coil, + CALL_TYPE_WRITE_REGISTER: mock_modbus_with_pymodbus.write_register, + } + + value_arg_name = { + CALL_TYPE_WRITE_COIL: "value", + CALL_TYPE_WRITE_REGISTER: "value", + } + + data = { + ATTR_HUB: TEST_MODBUS_NAME, + ATTR_ADDRESS: 16, + do_write[DATA]: do_write[VALUE], + } + mock_modbus_with_pymodbus.reset_mock() + caplog.clear() + caplog.set_level(logging.DEBUG) + func_name[do_write[FUNC]].return_value = do_return[VALUE] + await hass.services.async_call(DOMAIN, do_write[SERVICE], data, blocking=True) + assert func_name[do_write[FUNC]].called + assert func_name[do_write[FUNC]].call_args.args == (data[ATTR_ADDRESS],) + assert func_name[do_write[FUNC]].call_args.kwargs == { + "slave": 1, + value_arg_name[do_write[FUNC]]: data[do_write[DATA]], + } + + if do_return[DATA]: + assert any(message.startswith("Pymodbus:") for message in caplog.messages) From 871296dff60f7dfbb133de4aa1b195d8f2bab45a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:13:21 +0200 Subject: [PATCH 0938/1664] Use correctly formatted MAC in lamarzocco tests (#147874) --- tests/components/lamarzocco/conftest.py | 2 +- tests/components/lamarzocco/test_config_flow.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/lamarzocco/conftest.py b/tests/components/lamarzocco/conftest.py index ccfea1243bc..ad1378a6dc1 100644 --- a/tests/components/lamarzocco/conftest.py +++ b/tests/components/lamarzocco/conftest.py @@ -34,7 +34,7 @@ def mock_config_entry( version=3, data=USER_INPUT | { - CONF_ADDRESS: "00:00:00:00:00:00", + CONF_ADDRESS: "000000000000", CONF_TOKEN: "token", }, unique_id=mock_lamarzocco.serial_number, diff --git a/tests/components/lamarzocco/test_config_flow.py b/tests/components/lamarzocco/test_config_flow.py index 38cdc10d8ab..e50707f71af 100644 --- a/tests/components/lamarzocco/test_config_flow.py +++ b/tests/components/lamarzocco/test_config_flow.py @@ -422,7 +422,7 @@ async def test_dhcp_discovery( data=DhcpServiceInfo( ip="192.168.1.42", hostname=mock_lamarzocco.serial_number, - macaddress="aa:bb:cc:dd:ee:ff", + macaddress="aabbccddeeff", ), ) @@ -436,7 +436,7 @@ async def test_dhcp_discovery( assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == { **USER_INPUT, - CONF_ADDRESS: "aa:bb:cc:dd:ee:ff", + CONF_ADDRESS: "aabbccddeeff", CONF_TOKEN: None, } @@ -453,7 +453,7 @@ async def test_dhcp_discovery_abort_on_hostname_changed( data=DhcpServiceInfo( ip="192.168.1.42", hostname="custom_name", - macaddress="00:00:00:00:00:00", + macaddress="000000000000", ), ) assert result["type"] is FlowResultType.ABORT @@ -475,14 +475,14 @@ async def test_dhcp_already_configured_and_update( data=DhcpServiceInfo( ip="192.168.1.42", hostname=mock_lamarzocco.serial_number, - macaddress="aa:bb:cc:dd:ee:ff", + macaddress="aabbccddeeff", ), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert mock_config_entry.data[CONF_ADDRESS] != old_address - assert mock_config_entry.data[CONF_ADDRESS] == "aa:bb:cc:dd:ee:ff" + assert mock_config_entry.data[CONF_ADDRESS] == "aabbccddeeff" async def test_options_flow( From 2cb80e083e0ae1b4016b6a2ea112c2baf9f3920c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 14:33:33 +0200 Subject: [PATCH 0939/1664] Initialize EsphomeEntity._has_state (#147877) --- homeassistant/components/esphome/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 74f73508d83..b9f0125094a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): _static_info: _InfoT _state: _StateT - _has_state: bool + _has_state: bool = False unique_id: str def __init__( From c5873c6dd0197ff51fe51daf714d6dac54b76b28 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:40:12 +0200 Subject: [PATCH 0940/1664] Use correctly formatted MAC in dlink tests (#147871) --- tests/components/dlink/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index 0449f68263c..6998299c76f 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -162,7 +162,7 @@ async def test_dhcp_unique_id_assignment( """Test dhcp initialized flow with no unique id for matching entry.""" dhcp_data = DhcpServiceInfo( ip="2.3.4.5", - macaddress="11:22:33:44:55:66", + macaddress="112233445566", hostname="dsp-w215", ) result = await hass.config_entries.flow.async_init( @@ -177,7 +177,7 @@ async def test_dhcp_unique_id_assignment( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"] == CONF_DATA | {CONF_HOST: "2.3.4.5"} - assert result["result"].unique_id == "11:22:33:44:55:66" + assert result["result"].unique_id == "112233445566" async def test_dhcp_changed_ip( From 4ebffa8d23af39154e390f189d7df54b327ffca7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:40:27 +0200 Subject: [PATCH 0941/1664] Use correctly formatted MAC in palazzetti tests (#147875) --- tests/components/palazzetti/test_config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/palazzetti/test_config_flow.py b/tests/components/palazzetti/test_config_flow.py index 8550f1a3de0..65e1025da70 100644 --- a/tests/components/palazzetti/test_config_flow.py +++ b/tests/components/palazzetti/test_config_flow.py @@ -102,7 +102,7 @@ async def test_dhcp_flow( result = await hass.config_entries.flow.async_init( DOMAIN, data=DhcpServiceInfo( - hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + hostname="connbox1234", ip="192.168.1.1", macaddress="112233445566" ), context={"source": SOURCE_DHCP}, ) @@ -131,7 +131,7 @@ async def test_dhcp_flow_error( result = await hass.config_entries.flow.async_init( DOMAIN, data=DhcpServiceInfo( - hostname="connbox1234", ip="192.168.1.1", macaddress="11:22:33:44:55:66" + hostname="connbox1234", ip="192.168.1.1", macaddress="112233445566" ), context={"source": SOURCE_DHCP}, ) From b47f989c775c9a4e5be47f30f1fc48877be76f48 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:40:41 +0200 Subject: [PATCH 0942/1664] Use correctly formatted MAC in wmspro tests (#147876) --- tests/components/wmspro/test_config_flow.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/components/wmspro/test_config_flow.py b/tests/components/wmspro/test_config_flow.py index dc56d2bf988..c180b213a31 100644 --- a/tests/components/wmspro/test_config_flow.py +++ b/tests/components/wmspro/test_config_flow.py @@ -50,7 +50,7 @@ async def test_config_flow_from_dhcp( ) -> None: """Test we can handle DHCP discovery to create a config entry.""" info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -109,7 +109,7 @@ async def test_config_flow_from_dhcp_add_mac( assert hass.config_entries.async_entries(DOMAIN)[0].unique_id is None info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -126,7 +126,7 @@ async def test_config_flow_from_dhcp_ip_update( ) -> None: """Test we can use DHCP discovery to update IP in a config entry.""" info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -154,7 +154,7 @@ async def test_config_flow_from_dhcp_ip_update( assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "00:11:22:33:44:55" info = DhcpServiceInfo( - ip="5.6.7.8", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="5.6.7.8", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -172,7 +172,7 @@ async def test_config_flow_from_dhcp_no_update( ) -> None: """Test we do not use DHCP discovery to overwrite hostname with IP in config entry.""" info = DhcpServiceInfo( - ip="1.2.3.4", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="1.2.3.4", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info @@ -200,7 +200,7 @@ async def test_config_flow_from_dhcp_no_update( assert hass.config_entries.async_entries(DOMAIN)[0].unique_id == "00:11:22:33:44:55" info = DhcpServiceInfo( - ip="5.6.7.8", hostname="webcontrol", macaddress="00:11:22:33:44:55" + ip="5.6.7.8", hostname="webcontrol", macaddress="001122334455" ) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_DHCP}, data=info From 3f9590b03b71b086ec2a2f7ada689ed42f9e04ae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:41:20 +0200 Subject: [PATCH 0943/1664] Use correctly formatted MAC in gogogate2 tests (#147872) --- tests/components/gogogate2/test_config_flow.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 1e7e48437cd..791b93185d2 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -22,6 +22,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ( ATTR_PROPERTIES_ID, @@ -218,7 +219,9 @@ async def test_discovered_dhcp( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" + ip="1.2.3.4", + macaddress=dr.format_mac(MOCK_MAC_ADDR).replace(":", ""), + hostname="mock_hostname", ), ) assert result["type"] is FlowResultType.FORM @@ -281,7 +284,9 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.2.3.4", macaddress=MOCK_MAC_ADDR, hostname="mock_hostname" + ip="1.2.3.4", + macaddress=dr.format_mac(MOCK_MAC_ADDR).replace(":", ""), + hostname="mock_hostname", ), ) assert result2["type"] is FlowResultType.ABORT @@ -291,7 +296,7 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=DhcpServiceInfo( - ip="1.2.3.4", macaddress="00:00:00:00:00:00", hostname="mock_hostname" + ip="1.2.3.4", macaddress="000000000000", hostname="mock_hostname" ), ) assert result3["type"] is FlowResultType.ABORT From 073a467fb2299cf81e69f2b61a2c8de924009faa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 14:41:31 +0200 Subject: [PATCH 0944/1664] Use correctly formatted MAC in bond tests (#147870) --- tests/components/bond/test_config_flow.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index e5139b253aa..6bb4a4e33de 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -319,7 +319,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ45842", - macaddress=format_mac("3c:6a:2c:1c:8c:80"), + macaddress=format_mac("3c6a2c1c8c80"), ), ) assert result["type"] is FlowResultType.FORM @@ -365,7 +365,7 @@ async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ45842".lower(), - macaddress=format_mac("3c:6a:2c:1c:8c:80"), + macaddress=format_mac("3c6a2c1c8c80"), ), ) assert result["type"] is FlowResultType.ABORT @@ -382,7 +382,7 @@ async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ", - macaddress=format_mac("3c:6a:2c:1c:8c:80"), + macaddress=format_mac("3c6a2c1c8c80"), ), ) assert result["type"] is FlowResultType.FORM From 7deca35172f857ec4ac75df8e418f86ee26c4fbb Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Tue, 1 Jul 2025 16:14:03 +0300 Subject: [PATCH 0945/1664] Add multiple LLM API support for MCP Server (#147785) * Add multiple LLM API support for MCP Server * Update homeassistant/components/mcp_server/config_flow.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * ruff * Update tests/components/mcp_server/conftest.py Co-authored-by: Allen Porter --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Allen Porter --- .../components/mcp_server/config_flow.py | 19 ++++++++--- homeassistant/components/mcp_server/server.py | 2 +- .../components/mcp_server/strings.json | 3 ++ tests/components/mcp_server/conftest.py | 8 +++-- .../components/mcp_server/test_config_flow.py | 33 +++++++++++++++++-- tests/components/mcp_server/test_http.py | 2 +- 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py index e8df68de5e2..e218691975a 100644 --- a/homeassistant/components/mcp_server/config_flow.py +++ b/homeassistant/components/mcp_server/config_flow.py @@ -32,11 +32,18 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + errors: dict[str, str] = {} llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)} if user_input is not None: - return self.async_create_entry( - title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input - ) + if not user_input[CONF_LLM_HASS_API]: + errors[CONF_LLM_HASS_API] = "llm_api_required" + else: + return self.async_create_entry( + title=", ".join( + llm_apis[api_id] for api_id in user_input[CONF_LLM_HASS_API] + ), + data=user_input, + ) return self.async_show_form( step_id="user", @@ -44,7 +51,7 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): { vol.Optional( CONF_LLM_HASS_API, - default=llm.LLM_API_ASSIST, + default=[llm.LLM_API_ASSIST], ): SelectSelector( SelectSelectorConfig( options=[ @@ -53,10 +60,12 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN): value=llm_api_id, ) for llm_api_id, name in llm_apis.items() - ] + ], + multiple=True, ) ), } ), description_placeholders={"more_info_url": MORE_INFO_URL}, + errors=errors, ) diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py index affa4faecd6..953fc1314da 100644 --- a/homeassistant/components/mcp_server/server.py +++ b/homeassistant/components/mcp_server/server.py @@ -42,7 +42,7 @@ def _format_tool( async def create_server( - hass: HomeAssistant, llm_api_id: str, llm_context: llm.LLMContext + hass: HomeAssistant, llm_api_id: str | list[str], llm_context: llm.LLMContext ) -> Server: """Create a new Model Context Protocol Server. diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json index 57f1baf183c..602030475ea 100644 --- a/homeassistant/components/mcp_server/strings.json +++ b/homeassistant/components/mcp_server/strings.json @@ -11,6 +11,9 @@ } } }, + "error": { + "llm_api_required": "At least one LLM API must be configured." + }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } diff --git a/tests/components/mcp_server/conftest.py b/tests/components/mcp_server/conftest.py index b5e25d9fe50..e109a9626d3 100644 --- a/tests/components/mcp_server/conftest.py +++ b/tests/components/mcp_server/conftest.py @@ -23,13 +23,15 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture(name="llm_hass_api") -def llm_hass_api_fixture() -> str: +def llm_hass_api_fixture() -> list[str]: """Fixture for the config entry llm_hass_api.""" - return llm.LLM_API_ASSIST + return [llm.LLM_API_ASSIST] @pytest.fixture(name="config_entry") -def mock_config_entry(hass: HomeAssistant, llm_hass_api: str) -> MockConfigEntry: +def mock_config_entry( + hass: HomeAssistant, llm_hass_api: str | list[str] +) -> MockConfigEntry: """Fixture to load the integration.""" config_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/mcp_server/test_config_flow.py b/tests/components/mcp_server/test_config_flow.py index 3b9f5bee663..52bbc26873c 100644 --- a/tests/components/mcp_server/test_config_flow.py +++ b/tests/components/mcp_server/test_config_flow.py @@ -16,7 +16,7 @@ from homeassistant.data_entry_flow import FlowResultType "params", [ {}, - {CONF_LLM_HASS_API: "assist"}, + {CONF_LLM_HASS_API: ["assist"]}, ], ) async def test_form( @@ -38,4 +38,33 @@ async def test_form( assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Assist" assert len(mock_setup_entry.mock_calls) == 1 - assert result["data"] == {CONF_LLM_HASS_API: "assist"} + assert result["data"] == {CONF_LLM_HASS_API: ["assist"]} + + +@pytest.mark.parametrize( + ("params", "errors"), + [ + ({CONF_LLM_HASS_API: []}, {CONF_LLM_HASS_API: "llm_api_required"}), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + params: dict[str, Any], + errors: dict[str, str], +) -> None: + """Test we get the errors on invalid user input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + params, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == errors diff --git a/tests/components/mcp_server/test_http.py b/tests/components/mcp_server/test_http.py index 61cd1a4dd02..e1c8801f51b 100644 --- a/tests/components/mcp_server/test_http.py +++ b/tests/components/mcp_server/test_http.py @@ -194,7 +194,7 @@ async def test_http_sse_multiple_config_entries( """ config_entry = MockConfigEntry( - domain="mcp_server", data={CONF_LLM_HASS_API: "llm-api-id"} + domain="mcp_server", data={CONF_LLM_HASS_API: ["llm-api-id"]} ) config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) From 651162b8e7cb3832d5f87bbc266ef6196385b03e Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:17:10 +0200 Subject: [PATCH 0946/1664] Fix error in last online sensor of PlayStation integration (#147844) * Fix Last online sensor * set unavailable * available_fn --- .../components/playstation_network/sensor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index 6af305d3ce7..ece2952c0f0 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -37,6 +37,7 @@ class PlaystationNetworkSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[PlaystationNetworkData], StateType | datetime] entity_picture: str | None = None + available_fn: Callable[[PlaystationNetworkData], bool] = lambda _: True class PlaystationNetworkSensor(StrEnum): @@ -117,6 +118,7 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( psn.presence["basicPresence"]["lastAvailableDate"] ) ), + available_fn=lambda psn: "lastAvailableDate" in psn.presence["basicPresence"], device_class=SensorDeviceClass.TIMESTAMP, ), ) @@ -183,3 +185,12 @@ class PlaystationNetworkSensorEntity( ) return super().entity_picture + + @property + def available(self) -> bool: + """Return True if entity is available.""" + + return ( + self.entity_description.available_fn(self.coordinator.data) + and super().available + ) From 6364a9ad98387480b6bcaf7b736719827f66d87a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:31:06 +0200 Subject: [PATCH 0947/1664] Update pillow to 11.3.0 (#147869) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index cb31c7d6314..3522ed00dda 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pydoods"], "quality_scale": "legacy", - "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"] + "requirements": ["pydoods==1.0.2", "Pillow==11.3.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index b5e25c08851..bef0d81d77b 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/generic", "integration_type": "device", "iot_class": "local_push", - "requirements": ["av==13.1.0", "Pillow==11.2.1"] + "requirements": ["av==13.1.0", "Pillow==11.3.0"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index bc01476d509..34013c28a18 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==11.2.1"] + "requirements": ["Pillow==11.3.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 6cab2c39c97..103c410855c 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_push", "loggers": ["matrix_client"], "quality_scale": "legacy", - "requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"] + "requirements": ["matrix-nio==0.25.2", "Pillow==11.3.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index 02074a18b61..af68aa446f5 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", "quality_scale": "legacy", - "requirements": ["Pillow==11.2.1"] + "requirements": ["Pillow==11.3.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index e29e95abc62..70926adb29b 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -6,5 +6,5 @@ "iot_class": "calculated", "loggers": ["pyzbar"], "quality_scale": "legacy", - "requirements": ["Pillow==11.2.1", "pyzbar==0.1.7"] + "requirements": ["Pillow==11.3.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 6107a6057d1..413e9424b15 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["Pillow==11.2.1"] + "requirements": ["Pillow==11.3.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index cee768b6ad0..3e3ee6ef2fa 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -6,5 +6,5 @@ "iot_class": "cloud_polling", "loggers": ["simplehound"], "quality_scale": "legacy", - "requirements": ["Pillow==11.2.1", "simplehound==0.3"] + "requirements": ["Pillow==11.3.0", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index d60e2c5a628..15d96469ee4 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -11,6 +11,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==2.3.0", - "Pillow==11.2.1" + "Pillow==11.3.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 80fccb1bf78..39b8e7fa682 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -48,7 +48,7 @@ mutagen==1.47.0 orjson==3.10.18 packaging>=23.1 paho-mqtt==2.1.0 -Pillow==11.2.1 +Pillow==11.3.0 propcache==0.3.2 psutil-home-assistant==0.0.1 PyJWT==2.10.1 diff --git a/pyproject.toml b/pyproject.toml index 7ab0e89bce5..eb6bdbcef2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ dependencies = [ "PyJWT==2.10.1", # PyJWT has loose dependency. We want the latest one. "cryptography==45.0.3", - "Pillow==11.2.1", + "Pillow==11.3.0", "propcache==0.3.2", "pyOpenSSL==25.1.0", "orjson==3.10.18", diff --git a/requirements.txt b/requirements.txt index 1791d12268b..ce583741763 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,7 +30,7 @@ Jinja2==3.1.6 lru-dict==1.3.0 PyJWT==2.10.1 cryptography==45.0.3 -Pillow==11.2.1 +Pillow==11.3.0 propcache==0.3.2 pyOpenSSL==25.1.0 orjson==3.10.18 diff --git a/requirements_all.txt b/requirements_all.txt index afa52562654..c70d48f4937 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -36,7 +36,7 @@ PSNAWP==3.0.0 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.2.1 +Pillow==11.3.0 # homeassistant.components.plex PlexAPI==4.15.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02ed0c64575..ab25edf64ed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -36,7 +36,7 @@ PSNAWP==3.0.0 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==11.2.1 +Pillow==11.3.0 # homeassistant.components.plex PlexAPI==4.15.16 From 52c86f8a6a5cac1658ace4eb07c8d0d02e229642 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 1 Jul 2025 15:38:04 +0200 Subject: [PATCH 0948/1664] Update frontend to 20250701.0 (#147879) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cf83ce90237..d9b9527c358 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250627.0"] + "requirements": ["home-assistant-frontend==20250701.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39b8e7fa682..1feb0f1339f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c70d48f4937..c144341d16d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab25edf64ed..79f5aa5bd0a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 11c9aa92805481933c22f46dc6f9f458c4e70778 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 1 Jul 2025 15:39:29 +0200 Subject: [PATCH 0949/1664] Bump Nettigo Air Monitor backend library to version 5.0.0 (#147812) --- homeassistant/components/nam/__init__.py | 9 - homeassistant/components/nam/config_flow.py | 65 +++---- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/__init__.py | 5 +- tests/components/nam/test_config_flow.py | 199 +++++++------------- tests/components/nam/test_init.py | 23 +-- 8 files changed, 94 insertions(+), 213 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index d297443c059..03ad5118352 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -44,15 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: translation_key="device_communication_error", translation_placeholders={"device": entry.title}, ) from err - - try: - await nam.async_check_credentials() - except (ApiError, ClientError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="device_communication_error", - translation_placeholders={"device": entry.title}, - ) from err except AuthFailedError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index fa94971e2ef..b90426b66e5 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass import logging from typing import Any @@ -26,15 +25,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN - -@dataclass -class NamConfig: - """NAM device configuration class.""" - - mac_address: str - auth_enabled: bool - - _LOGGER = logging.getLogger(__name__) AUTH_SCHEMA = vol.Schema( @@ -42,29 +32,14 @@ AUTH_SCHEMA = vol.Schema( ) -async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: - """Get device MAC address and auth_enabled property.""" - websession = async_get_clientsession(hass) - - options = ConnectionOptions(host) - nam = await NettigoAirMonitor.create(websession, options) - - mac = await nam.async_get_mac_address() - - return NamConfig(mac, nam.auth_enabled) - - -async def async_check_credentials( +async def async_get_nam( hass: HomeAssistant, host: str, data: dict[str, Any] -) -> None: - """Check if credentials are valid.""" +) -> NettigoAirMonitor: + """Get NAM client.""" websession = async_get_clientsession(hass) - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - nam = await NettigoAirMonitor.create(websession, options) - - await nam.async_check_credentials() + return await NettigoAirMonitor.create(websession, options) class NAMFlowHandler(ConfigFlow, domain=DOMAIN): @@ -72,8 +47,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _config: NamConfig host: str + auth_enabled: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -85,21 +60,20 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self.host = user_input[CONF_HOST] try: - config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + return await self.async_step_credentials() except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - if config.auth_enabled is True: - return await self.async_step_credentials() - return self.async_create_entry( title=self.host, data=user_input, @@ -119,7 +93,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + nam = await async_get_nam(self.hass, self.host, user_input) except AuthFailedError: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, TimeoutError): @@ -128,6 +102,9 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(format_mac(nam.mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + return self.async_create_entry( title=self.host, data={**user_input, CONF_HOST: self.host}, @@ -148,14 +125,16 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self.host}) try: - self._config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + self.auth_enabled = True + return await self.async_step_confirm_discovery() - await self.async_set_unique_id(format_mac(self._config.mac_address)) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + await self.async_set_unique_id(format_mac(nam.mac)) return await self.async_step_confirm_discovery() @@ -171,7 +150,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.host}, ) - if self._config.auth_enabled is True: + if self.auth_enabled is True: return await self.async_step_credentials() self._set_confirm_only() @@ -198,7 +177,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + await async_get_nam(self.hass, self.host, user_input) except ( ApiError, AuthFailedError, @@ -228,11 +207,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - config = await async_get_config(self.hass, user_input[CONF_HOST]) + nam = await async_get_nam(self.hass, user_input[CONF_HOST], {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_mismatch(reason="another_device") return self.async_update_reload_and_abort( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 1c3b9db7a86..4799f657dda 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==4.1.0"], + "requirements": ["nettigo-air-monitor==5.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c144341d16d..ae9e117ccd0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1497,7 +1497,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 79f5aa5bd0a..76528061c7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1283,7 +1283,7 @@ nessclient==1.2.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.nexia nexia==2.10.0 diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index c531d193359..e1063c108e4 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -33,7 +33,10 @@ async def init_integration( update_response = Mock(json=AsyncMock(return_value=nam_data)) with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( "homeassistant.components.nam.NettigoAirMonitor._async_http_request", return_value=update_response, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 80c6e86f420..e3c2397de77 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,7 +1,8 @@ """Define tests for the Nettigo Air Monitor config flow.""" +from collections.abc import Generator from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest @@ -26,11 +27,21 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) VALID_CONFIG = {"host": "10.10.2.3"} VALID_AUTH = {"username": "fake_username", "password": "fake_password"} -DEVICE_CONFIG = {"www_basicauth_enabled": False} -DEVICE_CONFIG_AUTH = {"www_basicauth_enabled": True} -async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_form_create_entry_without_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step without auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -39,18 +50,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -64,7 +66,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: +async def test_form_create_entry_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step with auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -73,18 +77,9 @@ async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=[AuthFailedError("Authorization has failed"), "aa:bb:cc:dd:ee:ff"], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -121,23 +116,17 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: @@ -154,7 +143,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -162,8 +151,8 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" @pytest.mark.parametrize( @@ -178,15 +167,9 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: """Test we handle errors when auth is required.""" exc, base_error = error - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Authorization has failed"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -198,7 +181,7 @@ async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: assert result["step_id"] == "credentials" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=exc, ): result = await hass.config_entries.flow.async_configure( @@ -236,10 +219,6 @@ async def test_form_errors(hass: HomeAssistant, error) -> None: async def test_form_abort(hass: HomeAssistant) -> None: """Test we handle abort after error.""" with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=CannotGetMacError("Cannot get MAC address from device"), @@ -266,15 +245,9 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -288,17 +261,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass: HomeAssistant) -> None: +async def test_zeroconf(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -316,15 +283,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert context["title_placeholders"]["host"] == "10.10.2.3" assert context["confirm_only"] is True - with patch( - "homeassistant.components.nam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "10.10.2.3" @@ -332,17 +292,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: +async def test_zeroconf_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the zeroconf step with auth works.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Auth Error"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -360,18 +316,9 @@ async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} assert context["title_placeholders"]["host"] == "10.10.2.3" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -447,15 +394,9 @@ async def test_reconfigure_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -491,7 +432,7 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -503,15 +444,9 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -546,15 +481,9 @@ async def test_reconfigure_not_the_same_device(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 13bde1432b3..ea61739c008 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -44,27 +44,6 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_config_not_ready_while_checking_credentials(hass: HomeAssistant) -> None: - """Test for setup failure if the connection fails while checking credentials.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="10.10.2.3", - unique_id="aa:bb:cc:dd:ee:ff", - data={"host": "10.10.2.3"}, - ) - entry.add_to_hass(hass) - - with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=ApiError("API Error"), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY - - async def test_config_auth_failed(hass: HomeAssistant) -> None: """Test for setup failure if the auth fails.""" entry = MockConfigEntry( @@ -76,7 +55,7 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=AuthFailedError("Authorization has failed"), ): await hass.config_entries.async_setup(entry.entry_id) From e38eac9415b6d062e31d0d20b353d7d3c998cf63 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 1 Jul 2025 21:42:32 +0800 Subject: [PATCH 0950/1664] Include chat ID in Telegram bot subentry title (#147643) --- homeassistant/components/telegram_bot/config_flow.py | 11 ++++------- tests/components/telegram_bot/test_config_flow.py | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index b6480b84f64..41f26ccd48d 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -237,12 +237,13 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): subentries: list[ConfigSubentryData] = [] allowed_chat_ids: list[int] = import_data[CONF_ALLOWED_CHAT_IDS] + assert self._bot is not None, "Bot should be initialized during import" for chat_id in allowed_chat_ids: chat_name: str = await _async_get_chat_name(self._bot, chat_id) subentry: ConfigSubentryData = ConfigSubentryData( data={CONF_CHAT_ID: chat_id}, subentry_type=CONF_ALLOWED_CHAT_IDS, - title=chat_name, + title=f"{chat_name} ({chat_id})", unique_id=str(chat_id), ) subentries.append(subentry) @@ -380,7 +381,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Shutdown the bot if it exists.""" if self._bot: await self._bot.shutdown() - self._bot = None async def _validate_bot( self, @@ -649,7 +649,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): chat_name = await _async_get_chat_name(bot, chat_id) if chat_name: return self.async_create_entry( - title=chat_name, + title=f"{chat_name} ({chat_id})", data={CONF_CHAT_ID: chat_id}, unique_id=str(chat_id), ) @@ -663,10 +663,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): ) -async def _async_get_chat_name(bot: Bot | None, chat_id: int) -> str: - if not bot: - return str(chat_id) - +async def _async_get_chat_name(bot: Bot, chat_id: int) -> str: try: chat_info: ChatFullInfo = await bot.get_chat(chat_id) return chat_info.effective_name or str(chat_id) diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 659effdda7b..2586761b584 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -383,7 +383,7 @@ async def test_subentry_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert subentry.subentry_type == SUBENTRY_TYPE_ALLOWED_CHAT_IDS - assert subentry.title == "mock title" + assert subentry.title == "mock title (987654321)" assert subentry.unique_id == "987654321" assert subentry.data == {CONF_CHAT_ID: 987654321} From e10b581d4bb0a8855ffb6a794e9d3064bbbaec56 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 1 Jul 2025 15:43:34 +0200 Subject: [PATCH 0951/1664] Fix Meteo france Ciel clair condition mapping (#146965) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/meteo_france/weather.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e2df35f21f3..9b3472e3312 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -6,6 +6,8 @@ import time from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -49,9 +51,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def format_condition(condition: str): +def format_condition(condition: str, force_day: bool = False) -> str: """Return condition from dict CONDITION_MAP.""" - return CONDITION_MAP.get(condition, condition) + mapped_condition = CONDITION_MAP.get(condition, condition) + if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT: + # Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny + return ATTR_CONDITION_SUNNY + return mapped_condition async def async_setup_entry( @@ -212,7 +218,7 @@ class MeteoFranceWeather( forecast["dt"] ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( - forecast["weather12H"]["desc"] + forecast["weather12H"]["desc"], force_day=True ), ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"], ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], From 922720576ab99e6123de7bc71a286a58138878ef Mon Sep 17 00:00:00 2001 From: micha91 Date: Tue, 1 Jul 2025 15:50:04 +0200 Subject: [PATCH 0952/1664] fix: Create new aiohttp session with DummyCookieJar (#147827) --- homeassistant/components/yamaha_musiccast/__init__.py | 5 +++-- homeassistant/components/yamaha_musiccast/config_flow.py | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3e890c8b943..edc124890c5 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging +from aiohttp import DummyCookieJar from aiomusiccast.musiccast_device import MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -52,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = MusicCastDevice( entry.data[CONF_HOST], - async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=DummyCookieJar()), entry.data[CONF_UPNP_DESC], ) coordinator = MusicCastDataUpdateCoordinator(hass, entry, client=client) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index c43e547a71e..b48b5f6e67b 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -6,13 +6,13 @@ import logging from typing import Any from urllib.parse import urlparse -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, DummyCookieJar from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -50,7 +50,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): try: info = await MusicCastDevice.get_device_info( - host, async_get_clientsession(self.hass) + host, async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()) ) except (MusicCastConnectionException, ClientConnectorError): errors["base"] = "cannot_connect" @@ -89,7 +89,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( - discovery_info.ssdp_location, async_get_clientsession(self.hass) + discovery_info.ssdp_location, + async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()), ): return self.async_abort(reason="yxc_control_url_missing") From 510e3977df6b0cae94f782995292a7a7b7277e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20M=C3=A5rtensson?= Date: Tue, 1 Jul 2025 15:57:17 +0200 Subject: [PATCH 0953/1664] Add water_level sensor to Tuya pet fountain cwysj (#146602) Co-authored-by: Norbert Rittel --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/sensor.py | 3 +++ homeassistant/components/tuya/strings.json | 8 ++++++++ 3 files changed, 12 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index a40468fdc8f..922aaab193b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -393,6 +393,7 @@ class DPCode(StrEnum): WATER_RESET = "water_reset" # Resetting of water usage days WATER_SET = "water_set" # Water level WATER_TIME = "water_time" # Water usage duration + WATER_LEVEL = "water_level" WATERSENSOR_STATE = "watersensor_state" WEATHER_DELAY = "weather_delay" WET = "wet" # Humidification diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 912632c074b..bdfc8fe15e7 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -334,6 +334,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + TuyaSensorEntityDescription( + key=DPCode.WATER_LEVEL, translation_key="water_level_state" + ), ), # Air Quality Monitor # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ff67ac19806..a96f805f248 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -617,6 +617,14 @@ "water_level": { "name": "Water level" }, + "water_level_state": { + "name": "Water level", + "state": { + "level_1": "[%key:common::state::low%]", + "level_2": "[%key:common::state::medium%]", + "level_3": "[%key:common::state::full%]" + } + }, "total_watering_time": { "name": "Total watering time" }, From 59bf39f4edd8393e777ff28d92e888ef4c7484b2 Mon Sep 17 00:00:00 2001 From: Jamin Date: Tue, 1 Jul 2025 09:09:51 -0500 Subject: [PATCH 0954/1664] Bump VoIP utils to 0.3.3 (#147880) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 59e54bfefea..0b533795a2c 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.2"] + "requirements": ["voip-utils==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ae9e117ccd0..331e04abaac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3047,7 +3047,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.3 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76528061c7a..7bd9acef543 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2515,7 +2515,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.3 # homeassistant.components.volvooncall volvooncall==0.10.3 From e4bcde7d20d2510fb4ca2d0925e8fbd9180d68eb Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Tue, 1 Jul 2025 10:53:55 +0200 Subject: [PATCH 0955/1664] Fix wrong state in Husqvarna Automower (#146075) --- .../components/husqvarna_automower/const.py | 12 +++++++++++ .../husqvarna_automower/lawn_mower.py | 20 ++++++++++++++----- .../components/husqvarna_automower/sensor.py | 18 ++--------------- .../husqvarna_automower/test_lawn_mower.py | 5 +++++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/const.py b/homeassistant/components/husqvarna_automower/const.py index 1ea0511d721..d91fea29698 100644 --- a/homeassistant/components/husqvarna_automower/const.py +++ b/homeassistant/components/husqvarna_automower/const.py @@ -1,7 +1,19 @@ """The constants for the Husqvarna Automower integration.""" +from aioautomower.model import MowerStates + DOMAIN = "husqvarna_automower" EXECUTION_TIME_DELAY = 5 NAME = "Husqvarna Automower" OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize" OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token" + +ERROR_STATES = [ + MowerStates.ERROR_AT_POWER_UP, + MowerStates.ERROR, + MowerStates.FATAL_ERROR, + MowerStates.OFF, + MowerStates.STOPPED, + MowerStates.WAIT_POWER_UP, + MowerStates.WAIT_UPDATING, +] diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py index 5a728265651..daeb4a113b5 100644 --- a/homeassistant/components/husqvarna_automower/lawn_mower.py +++ b/homeassistant/components/husqvarna_automower/lawn_mower.py @@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AutomowerConfigEntry -from .const import DOMAIN +from .const import DOMAIN, ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import AutomowerAvailableEntity, handle_sending_exception @@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity): def activity(self) -> LawnMowerActivity: """Return the state of the mower.""" mower_attributes = self.mower_attributes + if mower_attributes.mower.state in ERROR_STATES: + return LawnMowerActivity.ERROR if mower_attributes.mower.state in PAUSED_STATES: return LawnMowerActivity.PAUSED - if (mower_attributes.mower.state == "RESTRICTED") or ( - mower_attributes.mower.activity in DOCKED_ACTIVITIES + if mower_attributes.mower.activity == MowerActivities.GOING_HOME: + return LawnMowerActivity.RETURNING + if ( + mower_attributes.mower.state is MowerStates.RESTRICTED + or mower_attributes.mower.activity in DOCKED_ACTIVITIES ): return LawnMowerActivity.DOCKED if mower_attributes.mower.state in MowerStates.IN_OPERATION: - if mower_attributes.mower.activity == MowerActivities.GOING_HOME: - return LawnMowerActivity.RETURNING return LawnMowerActivity.MOWING return LawnMowerActivity.ERROR + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return ( + super().available and self.mower_attributes.mower.state != MowerStates.OFF + ) + @property def work_areas(self) -> dict[int, WorkArea] | None: """Return the work areas of the mower.""" diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 5ad8ad91b48..0a059fdd706 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -7,13 +7,7 @@ import logging from operator import attrgetter from typing import TYPE_CHECKING, Any -from aioautomower.model import ( - MowerAttributes, - MowerModes, - MowerStates, - RestrictedReasons, - WorkArea, -) +from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from . import AutomowerConfigEntry +from .const import ERROR_STATES from .coordinator import AutomowerDataUpdateCoordinator from .entity import ( AutomowerBaseEntity, @@ -166,15 +161,6 @@ ERROR_KEYS = [ "zone_generator_problem", ] -ERROR_STATES = [ - MowerStates.ERROR_AT_POWER_UP, - MowerStates.ERROR, - MowerStates.FATAL_ERROR, - MowerStates.OFF, - MowerStates.STOPPED, - MowerStates.WAIT_POWER_UP, - MowerStates.WAIT_UPDATING, -] ERROR_KEY_LIST = list( dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) diff --git a/tests/components/husqvarna_automower/test_lawn_mower.py b/tests/components/husqvarna_automower/test_lawn_mower.py index c62cf6653c4..bf888779baa 100644 --- a/tests/components/husqvarna_automower/test_lawn_mower.py +++ b/tests/components/husqvarna_automower/test_lawn_mower.py @@ -42,6 +42,11 @@ from tests.common import MockConfigEntry, async_fire_time_changed MowerStates.IN_OPERATION, LawnMowerActivity.DOCKED, ), + ( + MowerActivities.GOING_HOME, + MowerStates.RESTRICTED, + LawnMowerActivity.RETURNING, + ), ], ) async def test_lawn_mower_states( From 08985d783f9d7b5fa1c6c6ffaaebc6477bc94e15 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 1 Jul 2025 15:43:34 +0200 Subject: [PATCH 0956/1664] Fix Meteo france Ciel clair condition mapping (#146965) Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com> --- homeassistant/components/meteo_france/weather.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index e2df35f21f3..9b3472e3312 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -6,6 +6,8 @@ import time from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast from homeassistant.components.weather import ( + ATTR_CONDITION_CLEAR_NIGHT, + ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, ATTR_FORECAST_HUMIDITY, ATTR_FORECAST_NATIVE_PRECIPITATION, @@ -49,9 +51,13 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def format_condition(condition: str): +def format_condition(condition: str, force_day: bool = False) -> str: """Return condition from dict CONDITION_MAP.""" - return CONDITION_MAP.get(condition, condition) + mapped_condition = CONDITION_MAP.get(condition, condition) + if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT: + # Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny + return ATTR_CONDITION_SUNNY + return mapped_condition async def async_setup_entry( @@ -212,7 +218,7 @@ class MeteoFranceWeather( forecast["dt"] ).isoformat(), ATTR_FORECAST_CONDITION: format_condition( - forecast["weather12H"]["desc"] + forecast["weather12H"]["desc"], force_day=True ), ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"], ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], From 414318f3fbd80f141cac298a23eb928c1693bf25 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 1 Jul 2025 10:15:45 +0200 Subject: [PATCH 0957/1664] Catch access denied errors in webdav and display proper message (#147093) --- .../components/webdav/config_flow.py | 8 +++- homeassistant/components/webdav/strings.json | 4 +- tests/components/webdav/test_config_flow.py | 7 ++- tests/components/webdav/test_init.py | 46 ++++++++++++++++++- 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py index e3e46d2575a..95b20761d09 100644 --- a/homeassistant/components/webdav/config_flow.py +++ b/homeassistant/components/webdav/config_flow.py @@ -5,7 +5,11 @@ from __future__ import annotations import logging from typing import Any -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import voluptuous as vol import yarl @@ -65,6 +69,8 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN): result = await client.check() except UnauthorizedError: errors["base"] = "invalid_auth" + except AccessDeniedError: + errors["base"] = "access_denied" except MethodNotSupportedError: errors["base"] = "invalid_method" except Exception: diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json index ac6418f1239..689b27bbf66 100644 --- a/homeassistant/components/webdav/strings.json +++ b/homeassistant/components/webdav/strings.json @@ -21,6 +21,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "access_denied": "The access to the backup path has been denied. Please check the permissions of the backup path.", "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -35,9 +36,6 @@ "cannot_connect": { "message": "Cannot connect to WebDAV server" }, - "cannot_access_or_create_backup_path": { - "message": "Cannot access or create backup path. Please check the path and permissions." - }, "failed_to_migrate_folder": { "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"." } diff --git a/tests/components/webdav/test_config_flow.py b/tests/components/webdav/test_config_flow.py index 9204e6eadab..3ee5c8ae9ad 100644 --- a/tests/components/webdav/test_config_flow.py +++ b/tests/components/webdav/test_config_flow.py @@ -2,7 +2,11 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError +from aiowebdav2.exceptions import ( + AccessDeniedError, + MethodNotSupportedError, + UnauthorizedError, +) import pytest from homeassistant import config_entries @@ -86,6 +90,7 @@ async def test_form_fail(hass: HomeAssistant, webdav_client: AsyncMock) -> None: ("exception", "expected_error"), [ (UnauthorizedError("https://webdav.demo"), "invalid_auth"), + (AccessDeniedError("https://webdav.demo"), "access_denied"), (MethodNotSupportedError("check", "https://webdav.demo"), "invalid_method"), (Exception("Unexpected error"), "unknown"), ], diff --git a/tests/components/webdav/test_init.py b/tests/components/webdav/test_init.py index 124a644fa93..89f0e703b22 100644 --- a/tests/components/webdav/test_init.py +++ b/tests/components/webdav/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aiowebdav2.exceptions import WebDavError +from aiowebdav2.exceptions import AccessDeniedError, UnauthorizedError, WebDavError import pytest from homeassistant.components.webdav.const import CONF_BACKUP_PATH, DOMAIN @@ -110,3 +110,47 @@ async def test_migrate_error( 'Failed to migrate wrong encoded folder "/wrong%20path" to "/wrong path"' in caplog.text ) + + +@pytest.mark.parametrize( + ("error", "expected_message", "expected_state"), + [ + ( + UnauthorizedError("Unauthorized"), + "Invalid username or password", + ConfigEntryState.SETUP_ERROR, + ), + ( + AccessDeniedError("/access_denied"), + "Access denied to /access_denied", + ConfigEntryState.SETUP_ERROR, + ), + ], + ids=["UnauthorizedError", "AccessDeniedError"], +) +async def test_error_during_setup( + hass: HomeAssistant, + webdav_client: AsyncMock, + caplog: pytest.LogCaptureFixture, + error: Exception, + expected_message: str, + expected_state: ConfigEntryState, +) -> None: + """Test handling of various errors during setup.""" + webdav_client.check.side_effect = error + + config_entry = MockConfigEntry( + title="user@webdav.demo", + domain=DOMAIN, + data={ + CONF_URL: "https://webdav.demo", + CONF_USERNAME: "user", + CONF_PASSWORD: "supersecretpassword", + CONF_BACKUP_PATH: "/backups", + }, + entry_id="01JKXV07ASC62D620DGYNG2R8H", + ) + await setup_integration(hass, config_entry) + + assert expected_message in caplog.text + assert config_entry.state is expected_state From c61935fc4157c84bd90bcd5cc3b0d5f9e02ea873 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 1 Jul 2025 21:42:32 +0800 Subject: [PATCH 0958/1664] Include chat ID in Telegram bot subentry title (#147643) --- homeassistant/components/telegram_bot/config_flow.py | 11 ++++------- tests/components/telegram_bot/test_config_flow.py | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index b6480b84f64..41f26ccd48d 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -237,12 +237,13 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): subentries: list[ConfigSubentryData] = [] allowed_chat_ids: list[int] = import_data[CONF_ALLOWED_CHAT_IDS] + assert self._bot is not None, "Bot should be initialized during import" for chat_id in allowed_chat_ids: chat_name: str = await _async_get_chat_name(self._bot, chat_id) subentry: ConfigSubentryData = ConfigSubentryData( data={CONF_CHAT_ID: chat_id}, subentry_type=CONF_ALLOWED_CHAT_IDS, - title=chat_name, + title=f"{chat_name} ({chat_id})", unique_id=str(chat_id), ) subentries.append(subentry) @@ -380,7 +381,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN): """Shutdown the bot if it exists.""" if self._bot: await self._bot.shutdown() - self._bot = None async def _validate_bot( self, @@ -649,7 +649,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): chat_name = await _async_get_chat_name(bot, chat_id) if chat_name: return self.async_create_entry( - title=chat_name, + title=f"{chat_name} ({chat_id})", data={CONF_CHAT_ID: chat_id}, unique_id=str(chat_id), ) @@ -663,10 +663,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow): ) -async def _async_get_chat_name(bot: Bot | None, chat_id: int) -> str: - if not bot: - return str(chat_id) - +async def _async_get_chat_name(bot: Bot, chat_id: int) -> str: try: chat_info: ChatFullInfo = await bot.get_chat(chat_id) return chat_info.effective_name or str(chat_id) diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 659effdda7b..2586761b584 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -383,7 +383,7 @@ async def test_subentry_flow( assert result["type"] is FlowResultType.CREATE_ENTRY assert subentry.subentry_type == SUBENTRY_TYPE_ALLOWED_CHAT_IDS - assert subentry.title == "mock title" + assert subentry.title == "mock title (987654321)" assert subentry.unique_id == "987654321" assert subentry.data == {CONF_CHAT_ID: 987654321} From 47b232db49ca6b5bff895d2ccd0819012a9cf8e1 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Jul 2025 07:12:00 +0200 Subject: [PATCH 0959/1664] Add more mac address prefixes for discovery to PlayStation Network (#147739) --- .../playstation_network/manifest.json | 15 ++++++++++++++ homeassistant/generated/dhcp.py | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/playstation_network/manifest.json b/homeassistant/components/playstation_network/manifest.json index bb7fc7c27ff..590bd73fbf7 100644 --- a/homeassistant/components/playstation_network/manifest.json +++ b/homeassistant/components/playstation_network/manifest.json @@ -60,6 +60,21 @@ }, { "macaddress": "D44B5E*" + }, + { + "macaddress": "F8D0AC*" + }, + { + "macaddress": "E86E3A*" + }, + { + "macaddress": "FC0FE6*" + }, + { + "macaddress": "9C37CB*" + }, + { + "macaddress": "84E657*" } ], "documentation": "https://www.home-assistant.io/integrations/playstation_network", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 47072d4c05d..3c1d929b1d8 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -539,6 +539,26 @@ DHCP: Final[list[dict[str, str | bool]]] = [ "domain": "playstation_network", "macaddress": "D44B5E*", }, + { + "domain": "playstation_network", + "macaddress": "F8D0AC*", + }, + { + "domain": "playstation_network", + "macaddress": "E86E3A*", + }, + { + "domain": "playstation_network", + "macaddress": "FC0FE6*", + }, + { + "domain": "playstation_network", + "macaddress": "9C37CB*", + }, + { + "domain": "playstation_network", + "macaddress": "84E657*", + }, { "domain": "powerwall", "hostname": "1118431-*", From 748cc6386de450ae48dad3319c8ed3bd2c6bcabc Mon Sep 17 00:00:00 2001 From: Bob Laz Date: Tue, 1 Jul 2025 05:28:13 -0500 Subject: [PATCH 0960/1664] fix state_class for water used today sensor (#147787) --- homeassistant/components/drop_connect/sensor.py | 2 +- tests/components/drop_connect/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py index c69e2e12ea0..cc3356cb8e9 100644 --- a/homeassistant/components/drop_connect/sensor.py +++ b/homeassistant/components/drop_connect/sensor.py @@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [ native_unit_of_measurement=UnitOfVolume.GALLONS, suggested_display_precision=1, value_fn=lambda device: device.drop_api.water_used_today(), - state_class=SensorStateClass.TOTAL, + state_class=SensorStateClass.TOTAL_INCREASING, ), DROPSensorEntityDescription( key=AVERAGE_WATER_USED, diff --git a/tests/components/drop_connect/snapshots/test_sensor.ambr b/tests/components/drop_connect/snapshots/test_sensor.ambr index a5c91dbe3e4..8389f92d8f9 100644 --- a/tests/components/drop_connect/snapshots/test_sensor.ambr +++ b/tests/components/drop_connect/snapshots/test_sensor.ambr @@ -356,7 +356,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , @@ -372,7 +372,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Hub DROP-1_C0FFEE Total water used today', - 'state_class': , + 'state_class': , 'unit_of_measurement': , }), 'context': , From f85fc7173f99456df5db018a0dbf085f9862ed51 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 1 Jul 2025 15:39:29 +0200 Subject: [PATCH 0961/1664] Bump Nettigo Air Monitor backend library to version 5.0.0 (#147812) --- homeassistant/components/nam/__init__.py | 9 - homeassistant/components/nam/config_flow.py | 65 +++---- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/__init__.py | 5 +- tests/components/nam/test_config_flow.py | 199 +++++++------------- tests/components/nam/test_init.py | 23 +-- 8 files changed, 94 insertions(+), 213 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index d297443c059..03ad5118352 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -44,15 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool: translation_key="device_communication_error", translation_placeholders={"device": entry.title}, ) from err - - try: - await nam.async_check_credentials() - except (ApiError, ClientError) as err: - raise ConfigEntryNotReady( - translation_domain=DOMAIN, - translation_key="device_communication_error", - translation_placeholders={"device": entry.title}, - ) from err except AuthFailedError as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index fa94971e2ef..b90426b66e5 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass import logging from typing import Any @@ -26,15 +25,6 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN - -@dataclass -class NamConfig: - """NAM device configuration class.""" - - mac_address: str - auth_enabled: bool - - _LOGGER = logging.getLogger(__name__) AUTH_SCHEMA = vol.Schema( @@ -42,29 +32,14 @@ AUTH_SCHEMA = vol.Schema( ) -async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: - """Get device MAC address and auth_enabled property.""" - websession = async_get_clientsession(hass) - - options = ConnectionOptions(host) - nam = await NettigoAirMonitor.create(websession, options) - - mac = await nam.async_get_mac_address() - - return NamConfig(mac, nam.auth_enabled) - - -async def async_check_credentials( +async def async_get_nam( hass: HomeAssistant, host: str, data: dict[str, Any] -) -> None: - """Check if credentials are valid.""" +) -> NettigoAirMonitor: + """Get NAM client.""" websession = async_get_clientsession(hass) - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) - nam = await NettigoAirMonitor.create(websession, options) - - await nam.async_check_credentials() + return await NettigoAirMonitor.create(websession, options) class NAMFlowHandler(ConfigFlow, domain=DOMAIN): @@ -72,8 +47,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - _config: NamConfig host: str + auth_enabled: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -85,21 +60,20 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self.host = user_input[CONF_HOST] try: - config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + return await self.async_step_credentials() except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) - if config.auth_enabled is True: - return await self.async_step_credentials() - return self.async_create_entry( title=self.host, data=user_input, @@ -119,7 +93,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + nam = await async_get_nam(self.hass, self.host, user_input) except AuthFailedError: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, TimeoutError): @@ -128,6 +102,9 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: + await self.async_set_unique_id(format_mac(nam.mac)) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + return self.async_create_entry( title=self.host, data={**user_input, CONF_HOST: self.host}, @@ -148,14 +125,16 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self.host}) try: - self._config = await async_get_config(self.hass, self.host) + nam = await async_get_nam(self.hass, self.host, {}) except (ApiError, ClientConnectorError, TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMacError: return self.async_abort(reason="device_unsupported") + except AuthFailedError: + self.auth_enabled = True + return await self.async_step_confirm_discovery() - await self.async_set_unique_id(format_mac(self._config.mac_address)) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) + await self.async_set_unique_id(format_mac(nam.mac)) return await self.async_step_confirm_discovery() @@ -171,7 +150,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.host}, ) - if self._config.auth_enabled is True: + if self.auth_enabled is True: return await self.async_step_credentials() self._set_confirm_only() @@ -198,7 +177,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_check_credentials(self.hass, self.host, user_input) + await async_get_nam(self.hass, self.host, user_input) except ( ApiError, AuthFailedError, @@ -228,11 +207,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN): if user_input is not None: try: - config = await async_get_config(self.hass, user_input[CONF_HOST]) + nam = await async_get_nam(self.hass, user_input[CONF_HOST], {}) except (ApiError, ClientConnectorError, TimeoutError): errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(format_mac(config.mac_address)) + await self.async_set_unique_id(format_mac(nam.mac)) self._abort_if_unique_id_mismatch(reason="another_device") return self.async_update_reload_and_abort( diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 1c3b9db7a86..4799f657dda 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -7,7 +7,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], - "requirements": ["nettigo-air-monitor==4.1.0"], + "requirements": ["nettigo-air-monitor==5.0.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index dfa6c4ada1e..048c0f161f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1497,7 +1497,7 @@ netdata==1.3.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6360dc34cd2..ab83d9d3586 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1283,7 +1283,7 @@ nessclient==1.2.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==4.1.0 +nettigo-air-monitor==5.0.0 # homeassistant.components.nexia nexia==2.10.0 diff --git a/tests/components/nam/__init__.py b/tests/components/nam/__init__.py index c531d193359..e1063c108e4 100644 --- a/tests/components/nam/__init__.py +++ b/tests/components/nam/__init__.py @@ -33,7 +33,10 @@ async def init_integration( update_response = Mock(json=AsyncMock(return_value=nam_data)) with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), + patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", + ), patch( "homeassistant.components.nam.NettigoAirMonitor._async_http_request", return_value=update_response, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 80c6e86f420..e3c2397de77 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,7 +1,8 @@ """Define tests for the Nettigo Air Monitor config flow.""" +from collections.abc import Generator from ipaddress import ip_address -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError import pytest @@ -26,11 +27,21 @@ DISCOVERY_INFO = ZeroconfServiceInfo( ) VALID_CONFIG = {"host": "10.10.2.3"} VALID_AUTH = {"username": "fake_username", "password": "fake_password"} -DEVICE_CONFIG = {"www_basicauth_enabled": False} -DEVICE_CONFIG_AUTH = {"www_basicauth_enabled": True} -async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.nam.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +async def test_form_create_entry_without_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step without auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -39,18 +50,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -64,7 +66,9 @@ async def test_form_create_entry_without_auth(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: +async def test_form_create_entry_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the user step with auth works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -73,18 +77,9 @@ async def test_form_create_entry_with_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=[AuthFailedError("Authorization has failed"), "aa:bb:cc:dd:ee:ff"], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -121,23 +116,17 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_successful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: @@ -154,7 +143,7 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -162,8 +151,8 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: user_input=VALID_AUTH, ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reauth_unsuccessful" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_unsuccessful" @pytest.mark.parametrize( @@ -178,15 +167,9 @@ async def test_reauth_unsuccessful(hass: HomeAssistant) -> None: async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: """Test we handle errors when auth is required.""" exc, base_error = error - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Authorization has failed"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -198,7 +181,7 @@ async def test_form_with_auth_errors(hass: HomeAssistant, error) -> None: assert result["step_id"] == "credentials" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=exc, ): result = await hass.config_entries.flow.async_configure( @@ -236,10 +219,6 @@ async def test_form_errors(hass: HomeAssistant, error) -> None: async def test_form_abort(hass: HomeAssistant) -> None: """Test we handle abort after error.""" with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=CannotGetMacError("Cannot get MAC address from device"), @@ -266,15 +245,9 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -288,17 +261,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert entry.data["host"] == "1.1.1.1" -async def test_zeroconf(hass: HomeAssistant) -> None: +async def test_zeroconf(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -316,15 +283,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert context["title_placeholders"]["host"] == "10.10.2.3" assert context["confirm_only"] is True - with patch( - "homeassistant.components.nam.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "10.10.2.3" @@ -332,17 +292,13 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: +async def test_zeroconf_with_auth( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: """Test that the zeroconf step with auth works.""" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=AuthFailedError("Auth Error"), - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=AuthFailedError("Auth Error"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -360,18 +316,9 @@ async def test_zeroconf_with_auth(hass: HomeAssistant) -> None: assert result["errors"] == {} assert context["title_placeholders"]["host"] == "10.10.2.3" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), - patch( - "homeassistant.components.nam.async_setup_entry", return_value=True - ) as mock_setup_entry, + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -447,15 +394,9 @@ async def test_reconfigure_successful(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -491,7 +432,7 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_configure( @@ -503,15 +444,9 @@ async def test_reconfigure_not_successful(hass: HomeAssistant) -> None: assert result["step_id"] == "reconfigure" assert result["errors"] == {"base": "cannot_connect"} - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -546,15 +481,9 @@ async def test_reconfigure_not_the_same_device(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reconfigure" - with ( - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - return_value=DEVICE_CONFIG_AUTH, - ), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - return_value="aa:bb:cc:dd:ee:ff", - ), + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 13bde1432b3..ea61739c008 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -44,27 +44,6 @@ async def test_config_not_ready(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_config_not_ready_while_checking_credentials(hass: HomeAssistant) -> None: - """Test for setup failure if the connection fails while checking credentials.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="10.10.2.3", - unique_id="aa:bb:cc:dd:ee:ff", - data={"host": "10.10.2.3"}, - ) - entry.add_to_hass(hass) - - with ( - patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), - patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", - side_effect=ApiError("API Error"), - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.SETUP_RETRY - - async def test_config_auth_failed(hass: HomeAssistant) -> None: """Test for setup failure if the auth fails.""" entry = MockConfigEntry( @@ -76,7 +55,7 @@ async def test_config_auth_failed(hass: HomeAssistant) -> None: entry.add_to_hass(hass) with patch( - "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", side_effect=AuthFailedError("Authorization has failed"), ): await hass.config_entries.async_setup(entry.entry_id) From ff25948e37ca3575d8e389ff9f4200ff18892e67 Mon Sep 17 00:00:00 2001 From: micha91 Date: Tue, 1 Jul 2025 15:50:04 +0200 Subject: [PATCH 0962/1664] fix: Create new aiohttp session with DummyCookieJar (#147827) --- homeassistant/components/yamaha_musiccast/__init__.py | 5 +++-- homeassistant/components/yamaha_musiccast/config_flow.py | 9 +++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index 3e890c8b943..edc124890c5 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -4,13 +4,14 @@ from __future__ import annotations import logging +from aiohttp import DummyCookieJar from aiomusiccast.musiccast_device import MusicCastDevice from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN from .coordinator import MusicCastDataUpdateCoordinator @@ -52,7 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client = MusicCastDevice( entry.data[CONF_HOST], - async_get_clientsession(hass), + async_create_clientsession(hass, cookie_jar=DummyCookieJar()), entry.data[CONF_UPNP_DESC], ) coordinator = MusicCastDataUpdateCoordinator(hass, entry, client=client) diff --git a/homeassistant/components/yamaha_musiccast/config_flow.py b/homeassistant/components/yamaha_musiccast/config_flow.py index c43e547a71e..b48b5f6e67b 100644 --- a/homeassistant/components/yamaha_musiccast/config_flow.py +++ b/homeassistant/components/yamaha_musiccast/config_flow.py @@ -6,13 +6,13 @@ import logging from typing import Any from urllib.parse import urlparse -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, DummyCookieJar from aiomusiccast import MusicCastConnectionException, MusicCastDevice import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_HOST -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -50,7 +50,7 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): try: info = await MusicCastDevice.get_device_info( - host, async_get_clientsession(self.hass) + host, async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()) ) except (MusicCastConnectionException, ClientConnectorError): errors["base"] = "cannot_connect" @@ -89,7 +89,8 @@ class MusicCastFlowHandler(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle ssdp discoveries.""" if not await MusicCastDevice.check_yamaha_ssdp( - discovery_info.ssdp_location, async_get_clientsession(self.hass) + discovery_info.ssdp_location, + async_create_clientsession(self.hass, cookie_jar=DummyCookieJar()), ): return self.async_abort(reason="yxc_control_url_missing") From b25acfe823de2be86d72e8c6d705ef3abe04d002 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 1 Jul 2025 08:46:58 +0200 Subject: [PATCH 0963/1664] Fix invalid configuration of MQTT device QoS option in subentry flow (#147837) --- homeassistant/components/mqtt/config_flow.py | 9 ++++----- tests/components/mqtt/test_config_flow.py | 6 +++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b022a46cbe7..ee451b5f81d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2771,11 +2771,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): reconfig=True, ) if user_input is not None: - new_device_data, errors = validate_user_input( - user_input, MQTT_DEVICE_PLATFORM_FIELDS - ) - if "mqtt_settings" in user_input: - new_device_data["mqtt_settings"] = user_input["mqtt_settings"] + new_device_data: dict[str, Any] = user_input.copy() + _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS) + if "advanced_settings" in new_device_data: + new_device_data |= new_device_data.pop("advanced_settings") if not errors: self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data) if self.source == SOURCE_RECONFIGURE: diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 12f77a95c48..9386f1da32c 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -4077,6 +4077,7 @@ async def test_subentry_reconfigure_update_device_properties( "model": "Beer bottle XL", "model_id": "bn003", "configuration_url": "https://example.com", + "mqtt_settings": {"qos": 1}, }, ) assert result["type"] is FlowResultType.MENU @@ -4090,12 +4091,15 @@ async def test_subentry_reconfigure_update_device_properties( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - # Check our device was updated + # Check our device and mqtt data was updated correctly device = deepcopy(dict(subentry.data))["device"] assert device["name"] == "Beer notifier" assert "hw_version" not in device assert device["model"] == "Beer bottle XL" assert device["model_id"] == "bn003" + assert device["sw_version"] == "1.1" + assert device["mqtt_settings"]["qos"] == 1 + assert "qos" not in device @pytest.mark.parametrize( From 5554e38171d317124bcd85b244ca27e8d086fc45 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 1 Jul 2025 19:16:23 +1200 Subject: [PATCH 0964/1664] Implement suggested_display_precision for ESPHome (#147849) --- homeassistant/components/esphome/sensor.py | 5 +- tests/components/esphome/test_entity.py | 4 +- tests/components/esphome/test_sensor.py | 70 ++++++++++++++++++++++ 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 5baa092613b..de0f07b94c9 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): # if the string is empty if unit_of_measurement := static_info.unit_of_measurement: self._attr_native_unit_of_measurement = unit_of_measurement + self._attr_suggested_display_precision = static_info.accuracy_decimals self._attr_device_class = try_parse_enum( SensorDeviceClass, static_info.device_class ) @@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): self._attr_state_class = _STATE_CLASSES.from_esphome(state_class) @property - def native_value(self) -> datetime | str | None: + def native_value(self) -> datetime | int | float | None: """Return the state of the entity.""" if not self._has_state or (state := self._state).missing_state: return None @@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): return None if self.device_class is SensorDeviceClass.TIMESTAMP: return dt_util.utc_from_timestamp(state_float) - return f"{state_float:.{self._static_info.accuracy_decimals}f}" + return state_float class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity): diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index c97965a1ba3..ba6a82bbd23 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -375,7 +375,7 @@ async def test_deep_sleep_device( assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(False) await hass.async_block_till_done() @@ -394,7 +394,7 @@ async def test_deep_sleep_device( assert state.state == STATE_ON state = hass.states.get("sensor.test_my_sensor") assert state is not None - assert state.state == "123" + assert state.state == "123.0" await mock_device.mock_disconnect(True) await hass.async_block_till_done() diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index 55e228b72be..e520b6ca259 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -13,18 +13,28 @@ from aioesphomeapi import ( TextSensorInfo, TextSensorState, ) +import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, + async_rounded_state, ) from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, + PERCENTAGE, STATE_UNKNOWN, EntityCategory, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfPower, + UnitOfPressure, + UnitOfTemperature, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -441,3 +451,63 @@ async def test_generic_numeric_sensor_empty_string_uom( assert state is not None assert state.state == "123" assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement", "state_value", "expected_precision"), + [ + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 23.456, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 0.1, 1), + (SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, -25.789, 1), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1234.56, 0), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 1.23456, 3), + (SensorDeviceClass.POWER, UnitOfPower.WATT, 0.123, 3), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 1234.5, 0), + (SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 12.3456, 2), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 230.45, 1), + (SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 3.3, 1), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 15.678, 2), + (SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 0.015, 3), + (SensorDeviceClass.ATMOSPHERIC_PRESSURE, UnitOfPressure.HPA, 1013.25, 1), + (SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, 1.01325, 3), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 45.67, 1), + (SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 4567.0, 0), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 87.654, 1), + (SensorDeviceClass.HUMIDITY, PERCENTAGE, 45.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 95.2, 1), + (SensorDeviceClass.BATTERY, PERCENTAGE, 100.0, 1), + ], +) +async def test_suggested_display_precision_by_device_class( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, + device_class: SensorDeviceClass, + unit_of_measurement: str, + state_value: float, + expected_precision: int, +) -> None: + """Test suggested display precision for different device classes.""" + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + accuracy_decimals=expected_precision, + device_class=device_class.value, + unit_of_measurement=unit_of_measurement, + ) + ] + states = [SensorState(key=1, state=state_value)] + await mock_esphome_device( + mock_client=mock_client, + entity_info=entity_info, + states=states, + ) + + state = hass.states.get("sensor.test_my_sensor") + assert state is not None + assert float( + async_rounded_state(hass, "sensor.test_my_sensor", state) + ) == pytest.approx(round(state_value, expected_precision)) From c42fc818bf5283f6c12950d3ff97af8e581edc9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:46:13 +0200 Subject: [PATCH 0965/1664] Correct Google generative AI config entry migration (#147856) --- .../__init__.py | 46 ++++ .../config_flow.py | 1 + .../test_init.py | 217 +++++++++++++++++- 3 files changed, 263 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index e3278eb3cb5..346d5322b02 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -308,4 +308,50 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_TITLE, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Add TTS subentry which was missing in 2025.7.0b0 + if not any( + subentry.subentry_type == "tts" for subentry in entry.subentries.values() + ): + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_TTS_OPTIONS), + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ), + ) + + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ad90cbcf553..1b1444e81b1 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -92,6 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_api( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 08a94dd151c..9702aae4c9e 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( DOMAIN, RECOMMENDED_TTS_OPTIONS, ) -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -473,6 +473,7 @@ async def test_migration_from_v1_to_v2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -618,6 +619,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 2 @@ -716,6 +718,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -784,6 +787,218 @@ async def test_migration_from_v1_to_v2_with_same_keys( } +@pytest.mark.parametrize( + ("device_changes", "extra_subentries", "expected_device_subentries"), + [ + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b0: + # Wrong device registry, no TTS subentry + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1: + # Wrong device registry, TTS subentry created + ( + {"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {None, "mock_id_1"}}, + ), + # Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2 + # or later: Correct device registry, TTS subentry created + ( + {}, + [ + ConfigSubentryData( + data=RECOMMENDED_TTS_OPTIONS, + subentry_id="mock_id_3", + subentry_type="tts", + title=DEFAULT_TTS_NAME, + unique_id=None, + ) + ], + {"mock_entry_id": {"mock_id_1"}}, + ), + ], +) +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + device_changes: dict[str, str], + extra_subentries: list[ConfigSubentryData], + expected_device_subentries: dict[str, set[str | None]], +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Google Generative AI", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Google Generative AI 2", + unique_id=None, + ), + *extra_subentries, + ], + title="Google Generative AI", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Google Generative AI", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device(device_1.id, **device_changes) + assert device_1.config_entries_subentries == expected_device_subentries + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Google Generative AI 2", + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.google_generative_ai_conversation") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get( + "conversation.google_generative_ai_conversation_2" + ) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + async def test_devices( hass: HomeAssistant, mock_config_entry: MockConfigEntry, From 725269ecda9bda1c068bf731cb383a3a23664045 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:47:06 +0200 Subject: [PATCH 0966/1664] Correct anthropic config entry migration (#147857) --- .../components/anthropic/__init__.py | 30 ++++ .../components/anthropic/config_flow.py | 1 + tests/components/anthropic/test_init.py | 161 ++++++++++++++++++ 3 files changed, 192 insertions(+) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index 68a46f19031..b25d30fe90e 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -138,4 +138,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_CONVERSATION_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 6a18cb693cd..099eae73d31 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index 16240ef8120..be4f41ad4cd 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -12,6 +12,7 @@ from httpx import URL, Request, Response import pytest from homeassistant.components.anthropic.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -113,6 +114,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -224,6 +226,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -317,6 +320,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -339,3 +343,160 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert dev.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Claude", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Claude 2", + unique_id=None, + ), + ], + title="Claude", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Claude", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="claude", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Claude 2", + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="claude_2", + ) + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "Claude" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.claude") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.claude_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From 8b2f4f0f86f1c618d452103a44d9bbf3216db8a7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:45:17 +0200 Subject: [PATCH 0967/1664] Correct ollama config entry migration (#147858) --- homeassistant/components/ollama/__init__.py | 30 ++++ .../components/ollama/config_flow.py | 1 + tests/components/ollama/test_init.py | 155 ++++++++++++++++++ 3 files changed, 186 insertions(+) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 8890c498e9f..eaddf936e81 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -148,4 +148,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool: + """Migrate entry.""" + _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + _LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 58b557549e1..03e2b038bab 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -73,6 +73,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 2 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 0747578c110..a6cfe4c2de0 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -81,6 +82,7 @@ async def test_migration_from_v1_to_v2( await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == TEST_USER_DATA assert mock_config_entry.options == {} @@ -186,6 +188,7 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -273,6 +276,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -295,3 +299,154 @@ async def test_migration_from_v1_to_v2_with_same_urls( assert dev.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_USER_DATA, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=TEST_OPTIONS, + subentry_id="mock_id_1", + subentry_type="conversation", + title="Ollama", + unique_id=None, + ), + ConfigSubentryData( + data=TEST_OPTIONS, + subentry_id="mock_id_2", + subentry_type="conversation", + title="Ollama 2", + unique_id=None, + ), + ], + title="Ollama", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="Ollama", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="ollama", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="Ollama 2", + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == TEST_OPTIONS + assert "Ollama" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.ollama") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.ollama_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } From d5d1b620d08a6c7b805db7fde11903b125268b45 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 10:49:07 +0200 Subject: [PATCH 0968/1664] Correct openai conversation config entry migration (#147859) --- .../openai_conversation/__init__.py | 30 ++++ .../openai_conversation/config_flow.py | 1 + .../openai_conversation/test_init.py | 163 +++++++++++++++++- 3 files changed, 193 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 7cac3bb7003..48ca21e05cd 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -361,4 +361,34 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_NAME, options={}, version=2, + minor_version=2, ) + + +async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool: + """Migrate entry.""" + LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) + + if entry.version > 2: + # This means the user has downgraded from a future version + return False + + if entry.version == 2 and entry.minor_version == 1: + # Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1 + device_registry = dr.async_get(hass) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + remove_config_subentry_id=None, + ) + + hass.config_entries.async_update_entry(entry, minor_version=2) + + LOGGER.debug( + "Migration to version %s:%s successful", entry.version, entry.minor_version + ) + + return True diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index a9a444cf3dd..63ebc351ee3 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -99,6 +99,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 + MINOR_VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 274d09a9779..d7e8b29cab2 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -18,6 +18,7 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES from homeassistant.components.openai_conversation.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -578,7 +579,7 @@ async def test_migration_from_v1_to_v2( mock_config_entry.entry_id, config_entry=mock_config_entry, device_id=device.id, - suggested_object_id="google_generative_ai_conversation", + suggested_object_id="chatgpt", ) # Run migration @@ -590,6 +591,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -702,6 +704,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -796,6 +799,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 + assert entry.minor_version == 2 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -820,6 +824,163 @@ async def test_migration_from_v1_to_v2_with_same_keys( } +async def test_migration_from_v2_1_to_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.1 to version 2.2. + + This tests we clean up the broken migration in Home Assistant Core + 2025.7.0b0-2025.7.0b1: + - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) + """ + # Create a v2.1 config entry with 2 subentries, devices and entities + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=1, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ConfigSubentryData( + data=options, + subentry_id="mock_id_2", + subentry_type="conversation", + title="ChatGPT 2", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_1", + identifiers={(DOMAIN, "mock_id_1")}, + name="ChatGPT", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_1 = device_registry.async_update_device( + device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None + ) + assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}} + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_1", + config_entry=mock_config_entry, + config_subentry_id="mock_id_1", + device_id=device_1.id, + suggested_object_id="chatgpt", + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id="mock_id_2", + identifiers={(DOMAIN, "mock_id_2")}, + name="ChatGPT 2", + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + "mock_id_2", + config_entry=mock_config_entry, + config_subentry_id="mock_id_2", + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 2 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + + subentry = conversation_subentries[0] + + entity = entity_registry.async_get("conversation.chatgpt") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_1.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + subentry = conversation_subentries[1] + + entity = entity_registry.async_get("conversation.chatgpt_2") + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == device_2.id + assert device.config_entries == {mock_config_entry.entry_id} + assert device.config_entries_subentries == { + mock_config_entry.entry_id: {subentry.subentry_id} + } + + @pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) async def test_devices( hass: HomeAssistant, From e272ab18859031ace45db53a996826e3abbaeaa9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 14:33:33 +0200 Subject: [PATCH 0969/1664] Initialize EsphomeEntity._has_state (#147877) --- homeassistant/components/esphome/entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index 74f73508d83..b9f0125094a 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): _static_info: _InfoT _state: _StateT - _has_state: bool + _has_state: bool = False unique_id: str def __init__( From 3548ab70fd882d8cf686c7f91b8975d4cc5ca892 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 1 Jul 2025 15:38:04 +0200 Subject: [PATCH 0970/1664] Update frontend to 20250701.0 (#147879) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index cf83ce90237..d9b9527c358 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250627.0"] + "requirements": ["home-assistant-frontend==20250701.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 80fccb1bf78..bf8eb55c506 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 048c0f161f5..ac88cd8f05c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab83d9d3586..2de41577052 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250627.0 +home-assistant-frontend==20250701.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 655f009f07818bdb01d0ae006502cd732b62af2c Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:18:13 +0100 Subject: [PATCH 0971/1664] Fix station name sensor for metoffice (#145500) --- homeassistant/components/metoffice/sensor.py | 15 ++++++++------- tests/components/metoffice/const.py | 12 ++++++++++++ tests/components/metoffice/test_sensor.py | 2 ++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index c6b9f96514b..fc3972eac2a 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -9,6 +9,7 @@ from datapoint.Forecast import Forecast from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, + EntityCategory, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -59,6 +60,7 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( native_attr_name="name", name="Station name", icon="mdi:label-outline", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), MetOfficeSensorEntityDescription( @@ -235,14 +237,13 @@ class MetOfficeCurrentSensor( @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = get_attribute( - self.coordinator.data.now(), self.entity_description.native_attr_name - ) + native_attr = self.entity_description.native_attr_name - if ( - self.entity_description.native_attr_name == "significantWeatherCode" - and value is not None - ): + if native_attr == "name": + return str(self.coordinator.data.name) + + value = get_attribute(self.coordinator.data.now(), native_attr) + if native_attr == "significantWeatherCode" and value is not None: value = CONDITION_MAP.get(value) return value diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index 59061f12ddc..436bc636899 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -40,6 +40,12 @@ KINGSLYNN_SENSOR_RESULTS = { "probability_of_precipitation": "67", "pressure": "998.20", "wind_speed": "22.21", + "wind_direction": "180", + "wind_gust": "40.26", + "feels_like_temperature": "3.4", + "visibility_distance": "7478.00", + "humidity": "97.5", + "station_name": "King's Lynn", } WAVERTREE_SENSOR_RESULTS = { @@ -49,6 +55,12 @@ WAVERTREE_SENSOR_RESULTS = { "probability_of_precipitation": "61", "pressure": "987.50", "wind_speed": "17.60", + "wind_direction": "176", + "wind_gust": "34.52", + "feels_like_temperature": "5.8", + "visibility_distance": "5106.00", + "humidity": "95.13", + "station_name": "Wavertree", } DEVICE_KEY_KINGSLYNN = {(DOMAIN, TEST_COORDINATES_KINGSLYNN)} diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index bd139873073..5ce069a3d09 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -28,6 +28,7 @@ from tests.common import MockConfigEntry, async_load_fixture, get_sensor_display @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_one_sensor_site_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -78,6 +79,7 @@ async def test_one_sensor_site_running( @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_two_sensor_sites_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From c707bf6264425fab94f057de80233ebaadb85bcb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 1 Jul 2025 14:26:59 +0000 Subject: [PATCH 0972/1664] Bump version to 2025.7.0b6 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 55406d605c3..a1187d0994e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 519d7789f03..0842ffb0c1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b5" +version = "2025.7.0b6" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From 23f1e8d1a3c4886342f3b801c3e7c60467c997a7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:55:46 +0200 Subject: [PATCH 0973/1664] Use correctly formatted MAC in elkm1 tests (#147888) --- tests/components/elkm1/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py index 5355013bf94..548f374010e 100644 --- a/tests/components/elkm1/test_config_flow.py +++ b/tests/components/elkm1/test_config_flow.py @@ -1144,7 +1144,7 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: data=DhcpServiceInfo( hostname="any", ip=MOCK_IP_ADDRESS, - macaddress="00:00:00:00:00:00", + macaddress="000000000000", ), ) await hass.async_block_till_done() From 852522219c8b927e354b3ba188cc80a694f8f0ae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 1 Jul 2025 17:56:10 +0200 Subject: [PATCH 0974/1664] Use correctly formatted MAC in bond tests (#147887) --- tests/components/bond/test_config_flow.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 6bb4a4e33de..cc18173b380 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo @@ -319,7 +318,7 @@ async def test_dhcp_discovery(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ45842", - macaddress=format_mac("3c6a2c1c8c80"), + macaddress="3c6a2c1c8c80", ), ) assert result["type"] is FlowResultType.FORM @@ -365,7 +364,7 @@ async def test_dhcp_discovery_already_exists(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ45842".lower(), - macaddress=format_mac("3c6a2c1c8c80"), + macaddress="3c6a2c1c8c80", ), ) assert result["type"] is FlowResultType.ABORT @@ -382,7 +381,7 @@ async def test_dhcp_discovery_short_name(hass: HomeAssistant) -> None: data=DhcpServiceInfo( ip="127.0.0.1", hostname="Bond-KVPRBDJ", - macaddress=format_mac("3c6a2c1c8c80"), + macaddress="3c6a2c1c8c80", ), ) assert result["type"] is FlowResultType.FORM From 60e3b38de1fe86523cd520db63dcc956fdc6b2c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 17:58:15 +0200 Subject: [PATCH 0975/1664] Set Entity._platform_state in arcam_fmj tests (#147889) --- tests/components/arcam_fmj/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/arcam_fmj/conftest.py b/tests/components/arcam_fmj/conftest.py index ca4af1b00a3..31bb41790e5 100644 --- a/tests/components/arcam_fmj/conftest.py +++ b/tests/components/arcam_fmj/conftest.py @@ -11,6 +11,7 @@ from homeassistant.components.arcam_fmj.const import DEFAULT_NAME from homeassistant.components.arcam_fmj.media_player import ArcamFmj from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityPlatformState from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockEntityPlatform @@ -80,6 +81,7 @@ def player_fixture(hass: HomeAssistant, state: State) -> ArcamFmj: player.entity_id = MOCK_ENTITY_ID player.hass = hass player.platform = MockEntityPlatform(hass) + player._platform_state = EntityPlatformState.ADDED player.async_write_ha_state = Mock() return player From 1e6e5ca1b65e9a4751c1481f528fc0d616d0f3a8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 19:32:58 +0200 Subject: [PATCH 0976/1664] Fix broadlink tests (#147890) --- tests/components/broadlink/test_climate.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/components/broadlink/test_climate.py b/tests/components/broadlink/test_climate.py index 6b39d1895b1..fda7fe0cce0 100644 --- a/tests/components/broadlink/test_climate.py +++ b/tests/components/broadlink/test_climate.py @@ -92,7 +92,9 @@ async def test_climate( """Test Broadlink climate.""" device = get_device("Guest room") - mock_setup = await device.setup_entry(hass) + mock_api = device.get_mock_api() + mock_api.get_full_status.return_value = api_return_value + mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_setup.entry.unique_id)} @@ -103,8 +105,6 @@ async def test_climate( climate = climates[0] - mock_setup.api.get_full_status.return_value = api_return_value - await async_update_entity(hass, climate.entity_id) assert mock_setup.api.get_full_status.call_count == 2 state = hass.states.get(climate.entity_id) @@ -122,7 +122,17 @@ async def test_climate_set_temperature_turn_off_turn_on( """Test Broadlink climate.""" device = get_device("Guest room") - mock_setup = await device.setup_entry(hass) + mock_api = device.get_mock_api() + mock_api.get_full_status.return_value = { + "sensor": SensorMode.INNER_SENSOR_CONTROL.value, + "power": 1, + "auto_mode": 0, + "active": 1, + "room_temp": 22, + "thermostat_temp": 23, + "external_temp": 30, + } + mock_setup = await device.setup_entry(hass, mock_api=mock_api) device_entry = device_registry.async_get_device( identifiers={(DOMAIN, mock_setup.entry.unique_id)} From 5e03900e0a4e5a2fa6539f2f81e3fa05d0780c18 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 1 Jul 2025 20:26:26 +0200 Subject: [PATCH 0977/1664] Bump Music Assistant Client to 1.2.3 (#147885) --- .../components/music_assistant/entity.py | 2 +- .../components/music_assistant/manifest.json | 2 +- .../music_assistant/media_browser.py | 8 +------ .../music_assistant/media_player.py | 12 +++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/music_assistant/conftest.py | 1 + .../music_assistant/fixtures/players.json | 21 ++++++++----------- .../components/music_assistant/test_button.py | 2 +- 9 files changed, 22 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index f5b6d92b0cf..21fc072a639 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity): identifiers={(DOMAIN, player_id)}, manufacturer=self.player.device_info.manufacturer or provider.name, model=self.player.device_info.model or self.player.name, - name=self.player.display_name, + name=self.player.name, configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}", ) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 28e8587e90c..e29491e2b21 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.2.0"], + "requirements": ["music-assistant-client==1.2.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 11cbbd3f655..e4724be650a 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -6,11 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from music_assistant_models.enums import MediaType as MASSMediaType -from music_assistant_models.media_items import ( - BrowseFolder, - MediaItemType, - SearchResults, -) +from music_assistant_models.media_items import MediaItemType, SearchResults from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -549,8 +545,6 @@ def _process_search_results( # Add available items to results for item in items: - if TYPE_CHECKING: - assert not isinstance(item, BrowseFolder) if not item.available: continue diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8d4e69bf082..b748aad241c 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -250,8 +250,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): # update generic attributes if player.powered and active_queue is not None: self._attr_state = MediaPlayerState(active_queue.state.value) - if player.powered and player.state is not None: - self._attr_state = MediaPlayerState(player.state.value) + if player.powered and player.playback_state is not None: + self._attr_state = MediaPlayerState(player.playback_state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) # active source and source list (translate to HA source names) @@ -270,12 +270,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_source = active_source_name group_members: list[str] = [] - if player.group_childs: - group_members = player.group_childs + if player.group_members: + group_members = player.group_members elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): - group_members = parent.group_childs + group_members = parent.group_members - # translate MA group_childs to HA group_members as entity id's + # translate MA group_members to HA group_members as entity id's entity_registry = er.async_get(self.hass) group_members_entity_ids: list[str] = [ entity_id diff --git a/requirements_all.txt b/requirements_all.txt index 331e04abaac..4ece4a15236 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bd9acef543..960f07a5c05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1259,7 +1259,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2b397891d6f..5eefccbcda9 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -53,6 +53,7 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.connect = AsyncMock(side_effect=connect) client.start_listening = AsyncMock(side_effect=listen) + client.send_command = AsyncMock(return_value=None) client.server_info = ServerInfoMessage( server_id=MOCK_SERVER_ID, server_version="0.0.0", diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 58ce20da824..5116c97a6ae 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -4,7 +4,6 @@ "player_id": "00:00:00:00:00:01", "provider": "test", "type": "player", - "name": "Test Player 1", "available": true, "powered": false, "device_info": { @@ -23,10 +22,10 @@ ], "elapsed_time": null, "elapsed_time_last_updated": 0, - "state": "idle", + "playback_state": "idle", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "00:00:00:00:00:01", "active_group": null, "current_media": null, @@ -37,7 +36,7 @@ "enabled": true, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "Test Player 1", + "name": "Test Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -75,7 +74,6 @@ "player_id": "00:00:00:00:00:02", "provider": "test", "type": "player", - "name": "Test Player 2", "available": true, "powered": true, "device_info": { @@ -93,10 +91,10 @@ ], "elapsed_time": 0, "elapsed_time_last_updated": 0, - "state": "playing", + "playback_state": "playing", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "spotify", "active_group": null, "current_media": { @@ -117,7 +115,7 @@ "hidden": false, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "My Super Test Player 2", + "name": "My Super Test Player 2", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -139,7 +137,6 @@ "player_id": "test_group_player_1", "provider": "player_group", "type": "group", - "name": "Test Group Player 1", "available": true, "powered": true, "device_info": { @@ -157,10 +154,10 @@ ], "elapsed_time": 0.0, "elapsed_time_last_updated": 1730315437.9904983, - "state": "idle", + "playback_state": "idle", "volume_level": 6, "volume_muted": false, - "group_childs": ["00:00:00:00:00:01", "00:00:00:00:00:02"], + "group_members": ["00:00:00:00:00:01", "00:00:00:00:00:02"], "active_source": "test_group_player_1", "active_group": null, "current_media": { @@ -180,7 +177,7 @@ "enabled": true, "icon": "mdi-speaker-multiple", "group_volume": 6, - "display_name": "Test Group Player 1", + "name": "Test Group Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 5a326b1d8ea..432430b4223 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -75,7 +75,7 @@ async def test_button_press_action( await trigger_subscription_callback( hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id ) - with pytest.raises(HomeAssistantError, match="Player has no active source"): + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, From d6fb860889d9f4a5fccf3e5ae54994f648e7860b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 20:50:38 +0200 Subject: [PATCH 0978/1664] Use entity_registry_enabled_by_default fixture in dsmr_reader tests (#147891) --- .../components/dsmr_reader/test_definitions.py | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/tests/components/dsmr_reader/test_definitions.py b/tests/components/dsmr_reader/test_definitions.py index 86805fb456f..dc6cdc1b41a 100644 --- a/tests/components/dsmr_reader/test_definitions.py +++ b/tests/components/dsmr_reader/test_definitions.py @@ -4,15 +4,13 @@ import pytest from homeassistant.components.dsmr_reader.const import DOMAIN from homeassistant.components.dsmr_reader.definitions import ( - DSMRReaderSensorEntityDescription, dsmr_transform, tariff_transform, ) -from homeassistant.components.dsmr_reader.sensor import DSMRSensor from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, MockEntityPlatform, async_fire_mqtt_message +from tests.common import MockConfigEntry, async_fire_mqtt_message @pytest.mark.parametrize( @@ -71,7 +69,7 @@ async def test_entity_tariff(hass: HomeAssistant) -> None: assert hass.states.get(electricity_tariff).state == "low" -@pytest.mark.usefixtures("mqtt_mock") +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mqtt_mock") async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: """Test the state attribute of DSMRReaderSensorEntityDescription when a dsmr transform is needed.""" config_entry = MockConfigEntry( @@ -85,17 +83,6 @@ async def test_entity_dsmr_transform(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - # Create the entity, since it's not by default - description = DSMRReaderSensorEntityDescription( - key="dsmr/meter-stats/dsmr_version", - name="version_test", - state=dsmr_transform, - ) - sensor = DSMRSensor(description, config_entry) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - await sensor.async_added_to_hass() - # Test dsmr version, if it's a digit async_fire_mqtt_message(hass, "dsmr/meter-stats/dsmr_version", "42") await hass.async_block_till_done() From 926e9261ab09fa8a5ee4024df33b286f30eb90d4 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 1 Jul 2025 20:53:13 +0200 Subject: [PATCH 0979/1664] Add switch to enable/disable boost in IronOS integration (#147831) --- homeassistant/components/iron_os/icons.json | 6 +++ homeassistant/components/iron_os/number.py | 10 ++++ homeassistant/components/iron_os/strings.json | 3 ++ homeassistant/components/iron_os/switch.py | 21 +++++++- .../iron_os/snapshots/test_switch.ambr | 48 +++++++++++++++++++ tests/components/iron_os/test_number.py | 25 +++++++++- tests/components/iron_os/test_switch.py | 43 ++++++++++++++++- 7 files changed, 152 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json index 695b9d16849..039ad61cbf4 100644 --- a/homeassistant/components/iron_os/icons.json +++ b/homeassistant/components/iron_os/icons.json @@ -209,6 +209,12 @@ "state": { "off": "mdi:card-bulleted-off-outline" } + }, + "boost": { + "default": "mdi:thermometer-high", + "state": { + "off": "mdi:thermometer-off" + } } } } diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py index 9fada23a987..71d340148ff 100644 --- a/homeassistant/components/iron_os/number.py +++ b/homeassistant/components/iron_os/number.py @@ -464,6 +464,16 @@ class IronOSTemperatureNumberEntity(IronOSNumberEntity): else super().native_max_value ) + @property + def available(self) -> bool: + """Return True if entity is available.""" + if ( + self.entity_description.key is PinecilNumber.BOOST_TEMP + and self.native_value == 0 + ): + return False + return super().available + class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity): """IronOS setpoint temperature entity.""" diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json index 8a3d9cc5366..18464dc6dd2 100644 --- a/homeassistant/components/iron_os/strings.json +++ b/homeassistant/components/iron_os/strings.json @@ -278,6 +278,9 @@ }, "calibrate_cjc": { "name": "Calibrate CJC" + }, + "boost": { + "name": "Boost" } } }, diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py index 124b670048a..f1f189d83b3 100644 --- a/homeassistant/components/iron_os/switch.py +++ b/homeassistant/components/iron_os/switch.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from enum import StrEnum from typing import Any -from pynecil import CharSetting, SettingsDataResponse +from pynecil import CharSetting, SettingsDataResponse, TempUnit from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.const import EntityCategory @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import IronOSConfigEntry +from .const import MIN_BOOST_TEMP, MIN_BOOST_TEMP_F from .coordinator import IronOSCoordinators from .entity import IronOSBaseEntity @@ -39,6 +40,7 @@ class IronOSSwitch(StrEnum): INVERT_BUTTONS = "invert_buttons" DISPLAY_INVERT = "display_invert" CALIBRATE_CJC = "calibrate_cjc" + BOOST = "boost" SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( @@ -94,6 +96,13 @@ SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = ( entity_registry_enabled_default=False, entity_category=EntityCategory.CONFIG, ), + IronOSSwitchEntityDescription( + key=IronOSSwitch.BOOST, + translation_key=IronOSSwitch.BOOST, + characteristic=CharSetting.BOOST_TEMP, + is_on_fn=lambda x: bool(x.get("boost_temp")), + entity_category=EntityCategory.CONFIG, + ), ) @@ -136,7 +145,15 @@ class IronOSSwitchEntity(IronOSBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.settings.write(self.entity_description.characteristic, True) + if self.entity_description.key is IronOSSwitch.BOOST: + await self.settings.write( + self.entity_description.characteristic, + MIN_BOOST_TEMP_F + if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT + else MIN_BOOST_TEMP, + ) + else: + await self.settings.write(self.entity_description.characteristic, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity on.""" diff --git a/tests/components/iron_os/snapshots/test_switch.ambr b/tests/components/iron_os/snapshots/test_switch.ambr index ff231c4050f..a0591c88fdf 100644 --- a/tests/components/iron_os/snapshots/test_switch.ambr +++ b/tests/components/iron_os/snapshots/test_switch.ambr @@ -47,6 +47,54 @@ 'state': 'on', }) # --- +# name: test_switch_platform[switch.pinecil_boost-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.pinecil_boost', + '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': 'Boost', + 'platform': 'iron_os', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'c0:ff:ee:c0:ff:ee_boost', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_platform[switch.pinecil_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pinecil Boost', + }), + 'context': , + 'entity_id': 'switch.pinecil_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_platform[switch.pinecil_calibrate_cjc-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/iron_os/test_number.py b/tests/components/iron_os/test_number.py index 3c7be52c577..b9c11bf52ef 100644 --- a/tests/components/iron_os/test_number.py +++ b/tests/components/iron_os/test_number.py @@ -20,7 +20,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er @@ -248,3 +248,26 @@ async def test_set_value_exception( target={ATTR_ENTITY_ID: "number.pinecil_setpoint_temperature"}, blocking=True, ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "ble_device") +async def test_boost_temp_unavailable( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test boost temp input is unavailable when off.""" + mock_pynecil.get_settings.return_value["boost_temp"] = 0 + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("number.pinecil_boost_temperature")) + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/iron_os/test_switch.py b/tests/components/iron_os/test_switch.py index d52c3fd333b..0cc60a7dde7 100644 --- a/tests/components/iron_os/test_switch.py +++ b/tests/components/iron_os/test_switch.py @@ -5,7 +5,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory -from pynecil import CharSetting, CommunicationError +from pynecil import CharSetting, CommunicationError, TempUnit import pytest from syrupy.assertion import SnapshotAssertion @@ -110,6 +110,47 @@ async def test_turn_on_off_toggle( mock_pynecil.write.assert_called_once_with(target, value) +@pytest.mark.parametrize( + ("service", "value", "temp_unit"), + [ + (SERVICE_TOGGLE, False, TempUnit.CELSIUS), + (SERVICE_TURN_OFF, False, TempUnit.CELSIUS), + (SERVICE_TURN_ON, 250, TempUnit.CELSIUS), + (SERVICE_TURN_ON, 480, TempUnit.FAHRENHEIT), + ], +) +@pytest.mark.usefixtures("ble_device") +async def test_turn_on_off_toggle_boost( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pynecil: AsyncMock, + freezer: FrozenDateTimeFactory, + service: str, + value: bool, + temp_unit: TempUnit, +) -> None: + """Test the IronOS switch turn on/off, toggle services.""" + mock_pynecil.get_settings.return_value["temp_unit"] = temp_unit + 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 + + freezer.tick(timedelta(seconds=3)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + service, + service_data={ATTR_ENTITY_ID: "switch.pinecil_boost"}, + blocking=True, + ) + assert len(mock_pynecil.write.mock_calls) == 1 + mock_pynecil.write.assert_called_once_with(CharSetting.BOOST_TEMP, value) + + @pytest.mark.parametrize( "service", [SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON], From 058f3b8b6ee1b52ee24aaa7bc3d53401aac9105e Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 1 Jul 2025 21:57:24 +0300 Subject: [PATCH 0980/1664] Add reauth to Alexa Devices config flow (#147773) --- .../components/alexa_devices/config_flow.py | 75 +++++++++++++++++-- .../components/alexa_devices/coordinator.py | 10 ++- .../alexa_devices/quality_scale.yaml | 2 +- .../components/alexa_devices/strings.json | 11 +++ .../alexa_devices/test_config_flow.py | 74 ++++++++++++++++++ 5 files changed, 160 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index 5add7ceb711..961f2760065 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from aioamazondevices.api import AmazonEchoApi @@ -10,11 +11,36 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_CODE, CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import CountrySelector from .const import CONF_LOGIN_DATA, DOMAIN +STEP_REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CODE): cv.string, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]: + """Validate the user input allows us to connect.""" + + api = AmazonEchoApi( + data[CONF_COUNTRY], + data[CONF_USERNAME], + data[CONF_PASSWORD], + ) + + try: + data = await api.login_mode_interactive(data[CONF_CODE]) + finally: + await api.close() + + return data + class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Alexa Devices.""" @@ -25,13 +51,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors = {} if user_input: - client = AmazonEchoApi( - user_input[CONF_COUNTRY], - user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - ) try: - data = await client.login_mode_interactive(user_input[CONF_CODE]) + data = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except CannotAuthenticate: @@ -44,8 +65,6 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): title=user_input[CONF_USERNAME], data=user_input | {CONF_LOGIN_DATA: data}, ) - finally: - await client.close() return self.async_show_form( step_id="user", @@ -61,3 +80,43 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): } ), ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle reauth flow.""" + self.context["title_placeholders"] = {CONF_USERNAME: entry_data[CONF_USERNAME]} + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reauth confirm.""" + errors: dict[str, str] = {} + + reauth_entry = self._get_reauth_entry() + entry_data = reauth_entry.data + + if user_input is not None: + try: + await validate_input(self.hass, {**reauth_entry.data, **user_input}) + except CannotConnect: + errors["base"] = "cannot_connect" + except CannotAuthenticate: + errors["base"] = "invalid_auth" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data={ + CONF_USERNAME: entry_data[CONF_USERNAME], + CONF_PASSWORD: entry_data[CONF_PASSWORD], + CONF_CODE: user_input[CONF_CODE], + }, + ) + + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_USERNAME: entry_data[CONF_USERNAME]}, + data_schema=STEP_REAUTH_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 8e58441d46c..031f52abebf 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -12,10 +12,10 @@ from aioamazondevices.exceptions import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import _LOGGER, CONF_LOGIN_DATA +from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN SCAN_INTERVAL = 30 @@ -55,4 +55,8 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): except (CannotConnect, CannotRetrieveData) as err: raise UpdateFailed(f"Error occurred while updating {self.name}") from err except CannotAuthenticate as err: - raise ConfigEntryError("Could not authenticate") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index afd12ca1df2..4662134efe8 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -34,7 +34,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: status: todo comment: all tests missing diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index d092cfaa2ae..89ab5b7056e 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -22,12 +22,23 @@ "password": "[%key:component::alexa_devices::common::data_description_password%]", "code": "[%key:component::alexa_devices::common::data_description_code%]" } + }, + "reauth_confirm": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "code": "[%key:component::alexa_devices::common::data_code%]" + }, + "data_description": { + "password": "[%key:component::alexa_devices::common::data_description_password%]", + "code": "[%key:component::alexa_devices::common::data_description_code%]" + } } }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "error": { diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 9bf174c5955..57049617986 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -133,3 +133,77 @@ async def test_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_successful( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test starting a reauthentication flow.""" + mock_config_entry.add_to_hass(hass) + 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"], + user_input={ + CONF_PASSWORD: "other_fake_password", + CONF_CODE: "000000", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (CannotConnect, "cannot_connect"), + (CannotAuthenticate, "invalid_auth"), + ], +) +async def test_reauth_not_successful( + hass: HomeAssistant, + mock_amazon_devices_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error: str, +) -> None: + """Test starting a reauthentication flow but no connection found.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_amazon_devices_client.login_mode_interactive.side_effect = side_effect + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "other_fake_password", + CONF_CODE: "000000", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": error} + + mock_amazon_devices_client.login_mode_interactive.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_PASSWORD: "fake_password", + CONF_CODE: "111111", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_PASSWORD] == "fake_password" + assert mock_config_entry.data[CONF_CODE] == "111111" From 639a749a0faa59d777598fa7c021cb3b03284455 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 21:09:48 +0200 Subject: [PATCH 0981/1664] Mock recorder in ista_ecotrend tests (#147893) --- tests/components/ista_ecotrend/test_sensor.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/components/ista_ecotrend/test_sensor.py b/tests/components/ista_ecotrend/test_sensor.py index 82a15872b59..fb1cc63f084 100644 --- a/tests/components/ista_ecotrend/test_sensor.py +++ b/tests/components/ista_ecotrend/test_sensor.py @@ -10,7 +10,9 @@ from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform -@pytest.mark.usefixtures("mock_ista", "entity_registry_enabled_by_default") +@pytest.mark.usefixtures( + "mock_ista", "recorder_mock", "entity_registry_enabled_by_default" +) async def test_setup( hass: HomeAssistant, ista_config_entry: MockConfigEntry, From 78a9cd9201df000aa145d87aa82a7cf4f891b4c5 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 1 Jul 2025 21:43:21 +0200 Subject: [PATCH 0982/1664] Use (new) common state "Empty" for water level in `switchbot` (#147836) --- homeassistant/components/switchbot/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index dbbf98c3945..6077861e1c6 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -118,7 +118,7 @@ "water_level": { "name": "Water level", "state": { - "empty": "Empty", + "empty": "[%key:common::state::empty%]", "low": "[%key:common::state::low%]", "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" From 1195c2ec1004947bddf94be8b62d284db75f2aae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 21:45:08 +0200 Subject: [PATCH 0983/1664] Set Entity._platform_state in core customize test (#147895) --- tests/test_core_config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_core_config.py b/tests/test_core_config.py index bbf7027e7ef..b20503121fc 100644 --- a/tests/test_core_config.py +++ b/tests/test_core_config.py @@ -38,7 +38,7 @@ from homeassistant.core_config import ( async_process_ha_core_config, ) from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityPlatformState from homeassistant.util.unit_system import ( METRIC_SYSTEM, US_CUSTOMARY_SYSTEM, @@ -222,6 +222,7 @@ async def _compute_state(hass: HomeAssistant, config: dict[str, Any]) -> State | entity.entity_id = "test.test" entity.hass = hass entity.platform = MockEntityPlatform(hass) + entity._platform_state = EntityPlatformState.ADDED entity.schedule_update_ha_state() await hass.async_block_till_done() From c71dbd9d4d9bd13ac1ed550fce9023ed68dd3c4b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 21:46:01 +0200 Subject: [PATCH 0984/1664] Set Entity._platform_state in universal tests (#147894) --- tests/components/universal/test_media_player.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index 351e11db512..1418a5b7dac 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -24,6 +24,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, HomeAssistant, callback from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityPlatformState from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component @@ -229,9 +230,11 @@ async def mock_states(hass: HomeAssistant) -> Mock: result = Mock() result.mock_mp_1 = MockMediaPlayer(hass, "mock1") + result.mock_mp_1._platform_state = EntityPlatformState.ADDED result.mock_mp_1.async_schedule_update_ha_state() result.mock_mp_2 = MockMediaPlayer(hass, "mock2") + result.mock_mp_2._platform_state = EntityPlatformState.ADDED result.mock_mp_2.async_schedule_update_ha_state() await hass.async_block_till_done() From 66308a848a02a4ea8070b7a0b84b3920211c9bb8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 1 Jul 2025 21:46:36 +0200 Subject: [PATCH 0985/1664] Set Entity._platform_state in google_assistant tests (#147892) --- .../google_assistant/test_smart_home.py | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 2dba083185d..fc840695081 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -47,6 +47,7 @@ from homeassistant.helpers import ( entity_platform, entity_registry as er, ) +from homeassistant.helpers.entity import EntityPlatformState from homeassistant.setup import async_setup_component from . import BASIC_CONFIG, MockConfig @@ -160,6 +161,7 @@ async def test_sync_message(hass: HomeAssistant, registries) -> None: light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() # This should not show up in the sync request @@ -306,6 +308,7 @@ async def test_sync_in_area(area_on_device, hass: HomeAssistant, registries) -> light.entity_id = entity.entity_id light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() config = MockConfig(should_expose=lambda _: True, entity_config={}) @@ -402,6 +405,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() light2 = DemoLight( @@ -412,6 +416,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light2.entity_id = "light.another_light" light2._attr_device_info = None light2._attr_name = "Another Light" + light2._platform_state = EntityPlatformState.ADDED light2.async_write_ha_state() light3 = DemoLight(None, "Color temp Light", state=True, ct=2500, brightness=200) @@ -420,6 +425,7 @@ async def test_query_message(hass: HomeAssistant) -> None: light3.entity_id = "light.color_temp_light" light3._attr_device_info = None light3._attr_name = "Color temp Light" + light3._platform_state = EntityPlatformState.ADDED light3.async_write_ha_state() events = async_capture_events(hass, EVENT_QUERY_RECEIVED) @@ -909,6 +915,7 @@ async def test_unavailable_state_does_sync(hass: HomeAssistant) -> None: light._available = False light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() events = async_capture_events(hass, EVENT_SYNC_RECEIVED) @@ -994,19 +1001,20 @@ async def test_device_class_switch( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a cover entity syncs to the correct device type.""" - sensor = DemoSwitch( + switch = DemoSwitch( None, - "Demo Sensor", + "Demo switch", state=False, assumed=False, device_class=device_class, ) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "switch.demo_sensor" - sensor._attr_device_info = None - sensor._attr_name = "Demo Sensor" - sensor.async_write_ha_state() + switch.hass = hass + switch.platform = MockEntityPlatform(hass) + switch.entity_id = "switch.demo_switch" + switch._attr_device_info = None + switch._attr_name = "Demo Switch" + switch._platform_state = EntityPlatformState.ADDED + switch.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1024,8 +1032,8 @@ async def test_device_class_switch( "devices": [ { "attributes": {}, - "id": "switch.demo_sensor", - "name": {"name": "Demo Sensor"}, + "id": "switch.demo_switch", + "name": {"name": "Demo Switch"}, "traits": ["action.devices.traits.OnOff"], "type": google_type, "willReportState": False, @@ -1049,15 +1057,16 @@ async def test_device_class_binary_sensor( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a binary entity syncs to the correct device type.""" - sensor = DemoBinarySensor( - None, "Demo Sensor", state=False, device_class=device_class + binary_sensor = DemoBinarySensor( + None, "Demo Binary Sensor", state=False, device_class=device_class ) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "binary_sensor.demo_sensor" - sensor._attr_device_info = None - sensor._attr_name = "Demo Sensor" - sensor.async_write_ha_state() + binary_sensor.hass = hass + binary_sensor.platform = MockEntityPlatform(hass) + binary_sensor.entity_id = "binary_sensor.demo_binary_sensor" + binary_sensor._attr_device_info = None + binary_sensor._attr_name = "Demo Binary Sensor" + binary_sensor._platform_state = EntityPlatformState.ADDED + binary_sensor.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1078,8 +1087,8 @@ async def test_device_class_binary_sensor( "queryOnlyOpenClose": True, "discreteOnlyOpenClose": True, }, - "id": "binary_sensor.demo_sensor", - "name": {"name": "Demo Sensor"}, + "id": "binary_sensor.demo_binary_sensor", + "name": {"name": "Demo Binary Sensor"}, "traits": ["action.devices.traits.OpenClose"], "type": google_type, "willReportState": False, @@ -1106,13 +1115,14 @@ async def test_device_class_cover( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a cover entity syncs to the correct device type.""" - sensor = DemoCover(None, hass, "Demo Sensor", device_class=device_class) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "cover.demo_sensor" - sensor._attr_device_info = None - sensor._attr_name = "Demo Sensor" - sensor.async_write_ha_state() + cover = DemoCover(None, hass, "Demo Cover", device_class=device_class) + cover.hass = hass + cover.platform = MockEntityPlatform(hass) + cover.entity_id = "cover.demo_cover" + cover._attr_device_info = None + cover._attr_name = "Demo Cover" + cover._platform_state = EntityPlatformState.ADDED + cover.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1130,8 +1140,8 @@ async def test_device_class_cover( "devices": [ { "attributes": {"discreteOnlyOpenClose": True}, - "id": "cover.demo_sensor", - "name": {"name": "Demo Sensor"}, + "id": "cover.demo_cover", + "name": {"name": "Demo Cover"}, "traits": [ "action.devices.traits.StartStop", "action.devices.traits.OpenClose", @@ -1157,11 +1167,12 @@ async def test_device_media_player( hass: HomeAssistant, device_class, google_type ) -> None: """Test that a binary entity syncs to the correct device type.""" - sensor = AbstractDemoPlayer("Demo", device_class=device_class) - sensor.hass = hass - sensor.platform = MockEntityPlatform(hass) - sensor.entity_id = "media_player.demo" - sensor.async_write_ha_state() + media_player = AbstractDemoPlayer("Demo", device_class=device_class) + media_player.hass = hass + media_player.platform = MockEntityPlatform(hass) + media_player.entity_id = "media_player.demo" + media_player._platform_state = EntityPlatformState.ADDED + media_player.async_write_ha_state() result = await sh.async_handle_message( hass, @@ -1182,8 +1193,8 @@ async def test_device_media_player( "supportActivityState": True, "supportPlaybackState": True, }, - "id": sensor.entity_id, - "name": {"name": sensor.name}, + "id": media_player.entity_id, + "name": {"name": media_player.name}, "traits": [ "action.devices.traits.OnOff", "action.devices.traits.MediaState", @@ -1455,6 +1466,7 @@ async def test_sync_message_recovery( light.entity_id = "light.demo_light" light._attr_device_info = None light._attr_name = "Demo Light" + light._platform_state = EntityPlatformState.ADDED light.async_write_ha_state() hass.states.async_set( From 60a930554a6716a7f6dc5e8f9463e2d66aa90bf3 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Tue, 1 Jul 2025 15:18:13 +0100 Subject: [PATCH 0986/1664] Fix station name sensor for metoffice (#145500) --- homeassistant/components/metoffice/sensor.py | 15 ++++++++------- tests/components/metoffice/const.py | 12 ++++++++++++ tests/components/metoffice/test_sensor.py | 2 ++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index c6b9f96514b..fc3972eac2a 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -9,6 +9,7 @@ from datapoint.Forecast import Forecast from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, + EntityCategory, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -59,6 +60,7 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = ( native_attr_name="name", name="Station name", icon="mdi:label-outline", + entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), MetOfficeSensorEntityDescription( @@ -235,14 +237,13 @@ class MetOfficeCurrentSensor( @property def native_value(self) -> StateType: """Return the state of the sensor.""" - value = get_attribute( - self.coordinator.data.now(), self.entity_description.native_attr_name - ) + native_attr = self.entity_description.native_attr_name - if ( - self.entity_description.native_attr_name == "significantWeatherCode" - and value is not None - ): + if native_attr == "name": + return str(self.coordinator.data.name) + + value = get_attribute(self.coordinator.data.now(), native_attr) + if native_attr == "significantWeatherCode" and value is not None: value = CONDITION_MAP.get(value) return value diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index 59061f12ddc..436bc636899 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -40,6 +40,12 @@ KINGSLYNN_SENSOR_RESULTS = { "probability_of_precipitation": "67", "pressure": "998.20", "wind_speed": "22.21", + "wind_direction": "180", + "wind_gust": "40.26", + "feels_like_temperature": "3.4", + "visibility_distance": "7478.00", + "humidity": "97.5", + "station_name": "King's Lynn", } WAVERTREE_SENSOR_RESULTS = { @@ -49,6 +55,12 @@ WAVERTREE_SENSOR_RESULTS = { "probability_of_precipitation": "61", "pressure": "987.50", "wind_speed": "17.60", + "wind_direction": "176", + "wind_gust": "34.52", + "feels_like_temperature": "5.8", + "visibility_distance": "5106.00", + "humidity": "95.13", + "station_name": "Wavertree", } DEVICE_KEY_KINGSLYNN = {(DOMAIN, TEST_COORDINATES_KINGSLYNN)} diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index bd139873073..5ce069a3d09 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -28,6 +28,7 @@ from tests.common import MockConfigEntry, async_load_fixture, get_sensor_display @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_one_sensor_site_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -78,6 +79,7 @@ async def test_one_sensor_site_running( @pytest.mark.freeze_time(datetime.datetime(2024, 11, 23, 12, tzinfo=datetime.UTC)) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_two_sensor_sites_running( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From 01e7efc7b4a52a3ce1e96fc43cbd5d08322fb703 Mon Sep 17 00:00:00 2001 From: Jamin Date: Tue, 1 Jul 2025 09:09:51 -0500 Subject: [PATCH 0987/1664] Bump VoIP utils to 0.3.3 (#147880) --- homeassistant/components/voip/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json index 59e54bfefea..0b533795a2c 100644 --- a/homeassistant/components/voip/manifest.json +++ b/homeassistant/components/voip/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["voip_utils"], "quality_scale": "internal", - "requirements": ["voip-utils==0.3.2"] + "requirements": ["voip-utils==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ac88cd8f05c..6de083de86a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3047,7 +3047,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.3 # homeassistant.components.volkszaehler volkszaehler==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2de41577052..c896aad0e08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2515,7 +2515,7 @@ venstarcolortouch==0.19 vilfo-api-client==0.5.0 # homeassistant.components.voip -voip-utils==0.3.2 +voip-utils==0.3.3 # homeassistant.components.volvooncall volvooncall==0.10.3 From 3ed440a3af12d3ac7e9288a48c4f63a7abd6c1f1 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Tue, 1 Jul 2025 20:26:26 +0200 Subject: [PATCH 0988/1664] Bump Music Assistant Client to 1.2.3 (#147885) --- .../components/music_assistant/entity.py | 2 +- .../components/music_assistant/manifest.json | 2 +- .../music_assistant/media_browser.py | 8 +------ .../music_assistant/media_player.py | 12 +++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/music_assistant/conftest.py | 1 + .../music_assistant/fixtures/players.json | 21 ++++++++----------- .../components/music_assistant/test_button.py | 2 +- 9 files changed, 22 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/music_assistant/entity.py b/homeassistant/components/music_assistant/entity.py index f5b6d92b0cf..21fc072a639 100644 --- a/homeassistant/components/music_assistant/entity.py +++ b/homeassistant/components/music_assistant/entity.py @@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity): identifiers={(DOMAIN, player_id)}, manufacturer=self.player.device_info.manufacturer or provider.name, model=self.player.device_info.model or self.player.name, - name=self.player.display_name, + name=self.player.name, configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}", ) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index 28e8587e90c..e29491e2b21 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.2.0"], + "requirements": ["music-assistant-client==1.2.3"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py index 11cbbd3f655..e4724be650a 100644 --- a/homeassistant/components/music_assistant/media_browser.py +++ b/homeassistant/components/music_assistant/media_browser.py @@ -6,11 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, cast from music_assistant_models.enums import MediaType as MASSMediaType -from music_assistant_models.media_items import ( - BrowseFolder, - MediaItemType, - SearchResults, -) +from music_assistant_models.media_items import MediaItemType, SearchResults from homeassistant.components import media_source from homeassistant.components.media_player import ( @@ -549,8 +545,6 @@ def _process_search_results( # Add available items to results for item in items: - if TYPE_CHECKING: - assert not isinstance(item, BrowseFolder) if not item.available: continue diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index 8d4e69bf082..b748aad241c 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -250,8 +250,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): # update generic attributes if player.powered and active_queue is not None: self._attr_state = MediaPlayerState(active_queue.state.value) - if player.powered and player.state is not None: - self._attr_state = MediaPlayerState(player.state.value) + if player.powered and player.playback_state is not None: + self._attr_state = MediaPlayerState(player.playback_state.value) else: self._attr_state = MediaPlayerState(STATE_OFF) # active source and source list (translate to HA source names) @@ -270,12 +270,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): self._attr_source = active_source_name group_members: list[str] = [] - if player.group_childs: - group_members = player.group_childs + if player.group_members: + group_members = player.group_members elif player.synced_to and (parent := self.mass.players.get(player.synced_to)): - group_members = parent.group_childs + group_members = parent.group_members - # translate MA group_childs to HA group_members as entity id's + # translate MA group_members to HA group_members as entity id's entity_registry = er.async_get(self.hass) group_members_entity_ids: list[str] = [ entity_id diff --git a/requirements_all.txt b/requirements_all.txt index 6de083de86a..dc72f39aa08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c896aad0e08..63a979fab9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1259,7 +1259,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.0 +music-assistant-client==1.2.3 # homeassistant.components.tts mutagen==1.47.0 diff --git a/tests/components/music_assistant/conftest.py b/tests/components/music_assistant/conftest.py index 2b397891d6f..5eefccbcda9 100644 --- a/tests/components/music_assistant/conftest.py +++ b/tests/components/music_assistant/conftest.py @@ -53,6 +53,7 @@ async def music_assistant_client_fixture() -> AsyncGenerator[MagicMock]: client.connect = AsyncMock(side_effect=connect) client.start_listening = AsyncMock(side_effect=listen) + client.send_command = AsyncMock(return_value=None) client.server_info = ServerInfoMessage( server_id=MOCK_SERVER_ID, server_version="0.0.0", diff --git a/tests/components/music_assistant/fixtures/players.json b/tests/components/music_assistant/fixtures/players.json index 58ce20da824..5116c97a6ae 100644 --- a/tests/components/music_assistant/fixtures/players.json +++ b/tests/components/music_assistant/fixtures/players.json @@ -4,7 +4,6 @@ "player_id": "00:00:00:00:00:01", "provider": "test", "type": "player", - "name": "Test Player 1", "available": true, "powered": false, "device_info": { @@ -23,10 +22,10 @@ ], "elapsed_time": null, "elapsed_time_last_updated": 0, - "state": "idle", + "playback_state": "idle", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "00:00:00:00:00:01", "active_group": null, "current_media": null, @@ -37,7 +36,7 @@ "enabled": true, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "Test Player 1", + "name": "Test Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -75,7 +74,6 @@ "player_id": "00:00:00:00:00:02", "provider": "test", "type": "player", - "name": "Test Player 2", "available": true, "powered": true, "device_info": { @@ -93,10 +91,10 @@ ], "elapsed_time": 0, "elapsed_time_last_updated": 0, - "state": "playing", + "playback_state": "playing", "volume_level": 20, "volume_muted": false, - "group_childs": [], + "group_members": [], "active_source": "spotify", "active_group": null, "current_media": { @@ -117,7 +115,7 @@ "hidden": false, "icon": "mdi-speaker", "group_volume": 20, - "display_name": "My Super Test Player 2", + "name": "My Super Test Player 2", "power_control": "native", "volume_control": "native", "mute_control": "native", @@ -139,7 +137,6 @@ "player_id": "test_group_player_1", "provider": "player_group", "type": "group", - "name": "Test Group Player 1", "available": true, "powered": true, "device_info": { @@ -157,10 +154,10 @@ ], "elapsed_time": 0.0, "elapsed_time_last_updated": 1730315437.9904983, - "state": "idle", + "playback_state": "idle", "volume_level": 6, "volume_muted": false, - "group_childs": ["00:00:00:00:00:01", "00:00:00:00:00:02"], + "group_members": ["00:00:00:00:00:01", "00:00:00:00:00:02"], "active_source": "test_group_player_1", "active_group": null, "current_media": { @@ -180,7 +177,7 @@ "enabled": true, "icon": "mdi-speaker-multiple", "group_volume": 6, - "display_name": "Test Group Player 1", + "name": "Test Group Player 1", "power_control": "native", "volume_control": "native", "mute_control": "native", diff --git a/tests/components/music_assistant/test_button.py b/tests/components/music_assistant/test_button.py index 5a326b1d8ea..432430b4223 100644 --- a/tests/components/music_assistant/test_button.py +++ b/tests/components/music_assistant/test_button.py @@ -75,7 +75,7 @@ async def test_button_press_action( await trigger_subscription_callback( hass, music_assistant_client, EventType.PLAYER_CONFIG_UPDATED, mass_player_id ) - with pytest.raises(HomeAssistantError, match="Player has no active source"): + with pytest.raises(HomeAssistantError, match="No current item to add to favorites"): await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, From 6104731d53a932f7acd0265e83c488fdbe510251 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 2 Jul 2025 08:09:23 +1200 Subject: [PATCH 0989/1664] Remove codeowner from ESPHome (#147850) --- CODEOWNERS | 4 ++-- homeassistant/components/esphome/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 28deb93492c..74c066a96c9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -452,8 +452,8 @@ build.json @home-assistant/supervisor /tests/components/eq3btsmart/ @eulemitkeule @dbuezas /homeassistant/components/escea/ @lazdavila /tests/components/escea/ @lazdavila -/homeassistant/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco -/tests/components/esphome/ @OttoWinter @jesserockz @kbx81 @bdraco +/homeassistant/components/esphome/ @jesserockz @kbx81 @bdraco +/tests/components/esphome/ @jesserockz @kbx81 @bdraco /homeassistant/components/eufylife_ble/ @bdr99 /tests/components/eufylife_ble/ @bdr99 /homeassistant/components/event/ @home-assistant/core diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 68bc8fe040e..89ffde03a7f 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -2,7 +2,7 @@ "domain": "esphome", "name": "ESPHome", "after_dependencies": ["hassio", "zeroconf", "tag"], - "codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"], + "codeowners": ["@jesserockz", "@kbx81", "@bdraco"], "config_flow": true, "dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"], "dhcp": [ From b2c393db7239cb8254378ac59165375b7acef208 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 1 Jul 2025 20:11:01 +0000 Subject: [PATCH 0990/1664] Bump version to 2025.7.0b7 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index a1187d0994e..397617088c8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0b7" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 0842ffb0c1e..c212e2cdb5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b6" +version = "2025.7.0b7" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From a6146fb5a9007d76978719f9d8c8725268acece5 Mon Sep 17 00:00:00 2001 From: cristianburrini Date: Tue, 1 Jul 2025 22:40:36 +0200 Subject: [PATCH 0991/1664] Increase the number of irrigation zones up to 8 for Tuya enabled controllers. (#147793) --- homeassistant/components/tuya/switch.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index a1d90c6ec2b..b786644fd05 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -440,6 +440,30 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { key=DPCode.SWITCH_2, translation_key="switch_2", ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="switch_3", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="switch_4", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="switch_5", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="switch_6", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_7, + translation_key="switch_7", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_8, + translation_key="switch_8", + ), ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu From 392cde20d9a6ee5e70d07467487f515a923b99fa Mon Sep 17 00:00:00 2001 From: nadimz Date: Tue, 1 Jul 2025 23:03:20 +0200 Subject: [PATCH 0992/1664] Add support for opening state in template lock (#147813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/template/lock.py | 5 +++++ tests/components/template/test_lock.py | 17 +++++++++++++---- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 1ec8b7f7535..4e3f3ed8ccc 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -193,6 +193,11 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Return true if lock is open.""" return self._state == LockState.OPEN + @property + def is_opening(self) -> bool: + """Return true if lock is opening.""" + return self._state == LockState.OPENING + @property def code_format(self) -> str | None: """Regex for code format or None if no code is required.""" diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py index 94b0669acd1..cbee71824ae 100644 --- a/tests/components/template/test_lock.py +++ b/tests/components/template/test_lock.py @@ -307,19 +307,19 @@ async def test_template_state(hass: HomeAssistant) -> None: hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("lock.test_template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.LOCKED hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - state = hass.states.get("lock.test_template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.UNLOCKED hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OPEN) await hass.async_block_till_done() - state = hass.states.get("lock.test_template_lock") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == LockState.OPEN @@ -888,7 +888,16 @@ async def test_actions_with_invalid_regexp_as_codeformat_never_execute( [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], ) @pytest.mark.parametrize( - "test_state", [LockState.UNLOCKING, LockState.LOCKING, LockState.JAMMED] + "test_state", + [ + LockState.LOCKED, + LockState.UNLOCKED, + LockState.OPEN, + LockState.UNLOCKING, + LockState.LOCKING, + LockState.JAMMED, + LockState.OPENING, + ], ) @pytest.mark.usefixtures("setup_state_lock") async def test_lock_state(hass: HomeAssistant, test_state) -> None: From 6842bfae4c0c56c3d76a15485e3709d34dd6a981 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 2 Jul 2025 00:00:25 +0200 Subject: [PATCH 0993/1664] Bump eheimdigital to 1.3.0 (#147908) --- homeassistant/components/eheimdigital/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json index 99f2a0a9c56..dba4b6d563c 100644 --- a/homeassistant/components/eheimdigital/manifest.json +++ b/homeassistant/components/eheimdigital/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["eheimdigital"], "quality_scale": "bronze", - "requirements": ["eheimdigital==1.2.0"], + "requirements": ["eheimdigital==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." } ] diff --git a/requirements_all.txt b/requirements_all.txt index 4ece4a15236..3c57b289030 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -839,7 +839,7 @@ ebusdpy==0.0.17 ecoaliface==0.4.0 # homeassistant.components.eheimdigital -eheimdigital==1.2.0 +eheimdigital==1.3.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 960f07a5c05..a53fd0f868a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -730,7 +730,7 @@ eagle100==0.1.1 easyenergy==2.1.2 # homeassistant.components.eheimdigital -eheimdigital==1.2.0 +eheimdigital==1.3.0 # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 From 2e7113d8816e5927184b27d835394cb7f631cd72 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 2 Jul 2025 04:12:58 +0000 Subject: [PATCH 0994/1664] Swap the Models label for the model name not it's display name, (#147918) Swap display name for name. --- .../google_generative_ai_conversation/config_flow.py | 9 +++++---- .../test_config_flow.py | 4 ---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 1b1444e81b1..ade326cf71b 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -330,13 +330,14 @@ async def google_generative_ai_config_option_schema( api_models = [api_model async for api_model in api_models_pager] models = [ SelectOptionDict( - label=api_model.display_name, + label=api_model.name.lstrip("models/"), value=api_model.name, ) - for api_model in sorted(api_models, key=lambda x: x.display_name or "") + for api_model in sorted( + api_models, key=lambda x: x.name.lstrip("models/") or "" + ) if ( - api_model.display_name - and api_model.name + api_model.name and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index b43c8a42275..a3fa487e1d3 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -43,25 +43,21 @@ from tests.common import MockConfigEntry def get_models_pager(): """Return a generator that yields the models.""" model_25_flash = Mock( - display_name="Gemini 2.5 Flash", supported_actions=["generateContent"], ) model_25_flash.name = "models/gemini-2.5-flash" model_20_flash = Mock( - display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], ) model_20_flash.name = "models/gemini-2.0-flash" model_15_flash = Mock( - display_name="Gemini 1.5 Flash", supported_actions=["generateContent"], ) model_15_flash.name = "models/gemini-1.5-flash-latest" model_15_pro = Mock( - display_name="Gemini 1.5 Pro", supported_actions=["generateContent"], ) model_15_pro.name = "models/gemini-1.5-pro-latest" From bdd2ac9ae4d8dc3a9a77e89dcdd4a9eda5fec813 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Jul 2025 00:34:40 -0500 Subject: [PATCH 0995/1664] Bump bluetooth-data-tools to 1.28.2 (#147920) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f212f4bdc17..33914f3457f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.5.2", - "bluetooth-data-tools==1.28.1", + "bluetooth-data-tools==1.28.2", "dbus-fast==2.43.0", "habluetooth==3.49.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index ba5ca3bdba4..1efe4e05682 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 49daafeca25..3a73c28cdf6 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index f1e1839b735..439e44faad1 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1"] + "requirements": ["bluetooth-data-tools==1.28.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1feb0f1339f..f1906df5bc1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.5.2 -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index 3c57b289030..b4ddebdf0a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -652,7 +652,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a53fd0f868a..e299b9d2b2d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -583,7 +583,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 From 48f9a12cca7b3ccda146ec39a4b4271e61936fbe Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 08:36:41 +0300 Subject: [PATCH 0996/1664] Bump aioamazondevices to 3.2.1 (#147912) --- 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 cdf942e836d..2e74561b755 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": "bronze", - "requirements": ["aioamazondevices==3.1.22"] + "requirements": ["aioamazondevices==3.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4ddebdf0a7..e691f8edba1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.22 +aioamazondevices==3.2.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e299b9d2b2d..134084c6326 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.22 +aioamazondevices==3.2.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 77dcba098463f583f82b8944d020203a8ec51130 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 09:02:53 +0300 Subject: [PATCH 0997/1664] Manager wrong country selection in Alexa Devices (#147914) Co-authored-by: Franck Nijhof --- homeassistant/components/alexa_devices/config_flow.py | 4 +++- homeassistant/components/alexa_devices/strings.json | 1 + tests/components/alexa_devices/test_config_flow.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index 961f2760065..aa9bbb4ae5e 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -6,7 +6,7 @@ from collections.abc import Mapping from typing import Any from aioamazondevices.api import AmazonEchoApi -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,6 +57,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except WrongCountry: + errors["base"] = "wrong_country" else: await self.async_set_unique_id(data["customer_info"]["user_id"]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 89ab5b7056e..03a6cc3de64 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -44,6 +44,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 57049617986..def3a6ec547 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry import pytest from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN @@ -57,6 +57,7 @@ async def test_full_flow( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (WrongCountry, "wrong_country"), ], ) async def test_flow_errors( From 04ae9665447afa3ce327220c4d8d684de69899dc Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 08:36:41 +0300 Subject: [PATCH 0998/1664] Bump aioamazondevices to 3.2.1 (#147912) --- 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 cdf942e836d..2e74561b755 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": "bronze", - "requirements": ["aioamazondevices==3.1.22"] + "requirements": ["aioamazondevices==3.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc72f39aa08..f1ab0674e13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.22 +aioamazondevices==3.2.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 63a979fab9c..545f93b47fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.1.22 +aioamazondevices==3.2.1 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From f838e85a790019cb4b8d60677ed98aab1a556870 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 09:02:53 +0300 Subject: [PATCH 0999/1664] Manager wrong country selection in Alexa Devices (#147914) Co-authored-by: Franck Nijhof --- homeassistant/components/alexa_devices/config_flow.py | 4 +++- homeassistant/components/alexa_devices/strings.json | 1 + tests/components/alexa_devices/test_config_flow.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index 5add7ceb711..20f0888d4ec 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Any from aioamazondevices.api import AmazonEchoApi -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -36,6 +36,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except WrongCountry: + errors["base"] = "wrong_country" else: await self.async_set_unique_id(data["customer_info"]["user_id"]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index d092cfaa2ae..8e11e79418b 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -33,6 +33,7 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index 9bf174c5955..e140bb4ad32 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect +from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry import pytest from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN @@ -57,6 +57,7 @@ async def test_full_flow( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (WrongCountry, "wrong_country"), ], ) async def test_flow_errors( From d4dec6c7a9b09dd7693f33508b220300d638adc9 Mon Sep 17 00:00:00 2001 From: Ivan Lopez Hernandez Date: Wed, 2 Jul 2025 04:12:58 +0000 Subject: [PATCH 1000/1664] Swap the Models label for the model name not it's display name, (#147918) Swap display name for name. --- .../google_generative_ai_conversation/config_flow.py | 9 +++++---- .../test_config_flow.py | 4 ---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 1b1444e81b1..ade326cf71b 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -330,13 +330,14 @@ async def google_generative_ai_config_option_schema( api_models = [api_model async for api_model in api_models_pager] models = [ SelectOptionDict( - label=api_model.display_name, + label=api_model.name.lstrip("models/"), value=api_model.name, ) - for api_model in sorted(api_models, key=lambda x: x.display_name or "") + for api_model in sorted( + api_models, key=lambda x: x.name.lstrip("models/") or "" + ) if ( - api_model.display_name - and api_model.name + api_model.name and ("tts" in api_model.name) == (subentry_type == "tts") and "vision" not in api_model.name and api_model.supported_actions diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index b43c8a42275..a3fa487e1d3 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -43,25 +43,21 @@ from tests.common import MockConfigEntry def get_models_pager(): """Return a generator that yields the models.""" model_25_flash = Mock( - display_name="Gemini 2.5 Flash", supported_actions=["generateContent"], ) model_25_flash.name = "models/gemini-2.5-flash" model_20_flash = Mock( - display_name="Gemini 2.0 Flash", supported_actions=["generateContent"], ) model_20_flash.name = "models/gemini-2.0-flash" model_15_flash = Mock( - display_name="Gemini 1.5 Flash", supported_actions=["generateContent"], ) model_15_flash.name = "models/gemini-1.5-flash-latest" model_15_pro = Mock( - display_name="Gemini 1.5 Pro", supported_actions=["generateContent"], ) model_15_pro.name = "models/gemini-1.5-pro-latest" From fdba791f18a2ee219e43d98ef476ec5ed0109dcb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 2 Jul 2025 00:34:40 -0500 Subject: [PATCH 1001/1664] Bump bluetooth-data-tools to 1.28.2 (#147920) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index f212f4bdc17..33914f3457f 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,7 +19,7 @@ "bleak-retry-connector==3.9.0", "bluetooth-adapters==0.21.4", "bluetooth-auto-recovery==1.5.2", - "bluetooth-data-tools==1.28.1", + "bluetooth-data-tools==1.28.2", "dbus-fast==2.43.0", "habluetooth==3.49.0" ] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index ba5ca3bdba4..1efe4e05682 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 49daafeca25..3a73c28cdf6 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -35,5 +35,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"] + "requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index f1e1839b735..439e44faad1 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.28.1"] + "requirements": ["bluetooth-data-tools==1.28.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf8eb55c506..d81403c2715 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -24,7 +24,7 @@ bleak-retry-connector==3.9.0 bleak==0.22.3 bluetooth-adapters==0.21.4 bluetooth-auto-recovery==1.5.2 -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 certifi>=2021.5.30 ciso8601==2.3.2 diff --git a/requirements_all.txt b/requirements_all.txt index f1ab0674e13..dfaaae375e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -652,7 +652,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 545f93b47fe..e300f2a4df4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -583,7 +583,7 @@ bluetooth-auto-recovery==1.5.2 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.28.1 +bluetooth-data-tools==1.28.2 # homeassistant.components.bond bond-async==0.2.1 From 0e6bbb30c1775be3262aac164b502c74c21bc7c6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Jul 2025 06:04:14 +0000 Subject: [PATCH 1002/1664] Bump version to 2025.7.0b8 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 397617088c8..db378d77902 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b7" +PATCH_VERSION: Final = "0b8" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index c212e2cdb5b..dfc393f69bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b7" +version = "2025.7.0b8" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From afb247c90723b59b3d8f52e499fd8db986817b44 Mon Sep 17 00:00:00 2001 From: Harry Heymann Date: Wed, 2 Jul 2025 02:12:47 -0400 Subject: [PATCH 1003/1664] Bump Python Matter server to 8.0.0 (#147783) --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 48f0bfa2e67..9db0dfc9881 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,6 +7,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==7.0.0"], + "requirements": ["python-matter-server==8.0.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index e691f8edba1..b46799636f2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2462,7 +2462,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==7.0.0 +python-matter-server==8.0.0 # homeassistant.components.melcloud python-melcloud==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 134084c6326..73ae056e8c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2035,7 +2035,7 @@ python-linkplay==0.2.12 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==7.0.0 +python-matter-server==8.0.0 # homeassistant.components.melcloud python-melcloud==0.1.0 From 088c02d38a77ba96e51c4a32915d3ca3db325601 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:09:30 +0200 Subject: [PATCH 1004/1664] Complete tests for eheimdigital (#143337) * Complete tests for eheimdigital * Review * Review * Review * Review * Fix tests --- tests/components/eheimdigital/test_init.py | 15 ++++++- tests/components/eheimdigital/test_light.py | 50 +++++++++++++++++---- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/tests/components/eheimdigital/test_init.py b/tests/components/eheimdigital/test_init.py index c64997ee372..4b282338954 100644 --- a/tests/components/eheimdigital/test_init.py +++ b/tests/components/eheimdigital/test_init.py @@ -2,8 +2,9 @@ from unittest.mock import MagicMock -from eheimdigital.types import EheimDeviceType +from eheimdigital.types import EheimDeviceType, EheimDigitalClientError +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -54,3 +55,15 @@ async def test_remove_device( device_entry.id, mock_config_entry.entry_id ) assert response["success"] + + +async def test_entry_setup_error( + hass: HomeAssistant, + eheimdigital_hub_mock: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test errors on setting up the config entry.""" + + eheimdigital_hub_mock.return_value.connect.side_effect = EheimDigitalClientError() + await init_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/eheimdigital/test_light.py b/tests/components/eheimdigital/test_light.py index c6b2063ec0c..a25fd7cd872 100644 --- a/tests/components/eheimdigital/test_light.py +++ b/tests/components/eheimdigital/test_light.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientError from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl -from eheimdigital.types import EheimDeviceType +from eheimdigital.types import EheimDeviceType, EheimDigitalClientError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -24,6 +24,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.color import value_to_brightness @@ -114,20 +115,34 @@ async def test_dynamic_new_devices( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.usefixtures("eheimdigital_hub_mock") async def test_turn_off( hass: HomeAssistant, mock_config_entry: MockConfigEntry, + eheimdigital_hub_mock: MagicMock, classic_led_ctrl_mock: EheimDigitalClassicLEDControl, ) -> None: """Test turning off the light.""" await init_integration(hass, mock_config_entry) - await mock_config_entry.runtime_data._async_device_found( + await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"]( "00:00:00:00:00:01", EheimDeviceType.VERSION_EHEIM_CLASSIC_LED_CTRL_PLUS_E ) await hass.async_block_till_done() + classic_led_ctrl_mock.hub.send_packet.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1"}, + blocking=True, + ) + + assert exc_info.value.translation_key == "communication_error" + + classic_led_ctrl_mock.hub.send_packet.side_effect = None + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -140,9 +155,9 @@ async def test_turn_off( for call in classic_led_ctrl_mock.hub.mock_calls if call[0] == "send_packet" ] - assert len(calls) == 2 - assert calls[0][1][0].get("title") == "MAN_MODE" - assert calls[1][1][0]["currentValues"][1] == 0 + assert len(calls) == 3 + assert calls[1][1][0].get("title") == "MAN_MODE" + assert calls[2][1][0]["currentValues"][1] == 0 @pytest.mark.parametrize( @@ -169,6 +184,23 @@ async def test_turn_on_brightness( ) await hass.async_block_till_done() + classic_led_ctrl_mock.hub.send_packet.side_effect = EheimDigitalClientError + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "light.mock_classicledcontrol_e_channel_1", + ATTR_BRIGHTNESS: dim_input, + }, + blocking=True, + ) + + assert exc_info.value.translation_key == "communication_error" + + classic_led_ctrl_mock.hub.send_packet.side_effect = None + await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, @@ -184,9 +216,9 @@ async def test_turn_on_brightness( for call in classic_led_ctrl_mock.hub.mock_calls if call[0] == "send_packet" ] - assert len(calls) == 2 - assert calls[0][1][0].get("title") == "MAN_MODE" - assert calls[1][1][0]["currentValues"][1] == expected_dim_value + assert len(calls) == 3 + assert calls[1][1][0].get("title") == "MAN_MODE" + assert calls[2][1][0]["currentValues"][1] == expected_dim_value async def test_turn_on_effect( From 3730a1a3793b36208461a1ec805ff4437ffe9f55 Mon Sep 17 00:00:00 2001 From: John Hess Date: Wed, 2 Jul 2025 01:11:49 -0700 Subject: [PATCH 1005/1664] Bump thermopro-ble to 0.13.1 (#147924) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 29dadfd3d63..6749a53b7b6 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.13.0"] + "requirements": ["thermopro-ble==0.13.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index b46799636f2..f88f29c628d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2925,7 +2925,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.thingspeak thingspeak==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73ae056e8c6..7e4c494cb94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2411,7 +2411,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.lg_thinq thinqconnect==1.0.7 From b2108fdd400248cd79bfe6925301d7f3779225b1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 2 Jul 2025 10:40:16 +0200 Subject: [PATCH 1006/1664] Update Dockerfile.dev to only use uv for Python (#147926) --- Dockerfile.dev | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 5a3f1a2ae64..4c037799567 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,15 +1,7 @@ -FROM mcr.microsoft.com/devcontainers/python:1-3.13 +FROM mcr.microsoft.com/vscode/devcontainers/base:debian SHELL ["/bin/bash", "-o", "pipefail", "-c"] -# Uninstall pre-installed formatting and linting tools -# They would conflict with our pinned versions -RUN \ - pipx uninstall pydocstyle \ - && pipx uninstall pycodestyle \ - && pipx uninstall mypy \ - && pipx uninstall pylint - RUN \ curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - \ && apt-get update \ @@ -32,21 +24,18 @@ RUN \ libxml2 \ git \ cmake \ + autoconf \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Add go2rtc binary COPY --from=ghcr.io/alexxit/go2rtc:latest /usr/local/bin/go2rtc /bin/go2rtc -# Install uv -RUN pip3 install uv - WORKDIR /usr/src -# Setup hass-release -RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ - && uv pip install --system -e hass-release/ \ - && chown -R vscode /usr/src/hass-release/data +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +RUN uv python install 3.13.2 USER vscode ENV VIRTUAL_ENV="/home/vscode/.local/ha-venv" @@ -55,6 +44,10 @@ ENV PATH="$VIRTUAL_ENV/bin:$PATH" WORKDIR /tmp +# Setup hass-release +RUN git clone --depth 1 https://github.com/home-assistant/hass-release ~/hass-release \ + && uv pip install -e ~/hass-release/ + # Install Python dependencies from requirements COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt @@ -65,4 +58,4 @@ RUN uv pip install -r requirements_test.txt WORKDIR /workspaces # Set the default shell to bash instead of sh -ENV SHELL /bin/bash +ENV SHELL=/bin/bash From bee07ad2845879dc4f5e27317300f51675e13e51 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 2 Jul 2025 10:45:07 +0200 Subject: [PATCH 1007/1664] Fix Online ID string in PlayStation Network integration (#147915) --- homeassistant/components/playstation_network/strings.json | 2 +- .../components/playstation_network/snapshots/test_sensor.ambr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index aee4dc0d737..d3a9c986e88 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -77,7 +77,7 @@ "unit_of_measurement": "[%key:component::playstation_network::entity::sensor::earned_trophies_platinum::unit_of_measurement%]" }, "online_id": { - "name": "Online-ID" + "name": "Online ID" }, "last_online": { "name": "Last online" diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr index 233791c05bd..59cd979ed76 100644 --- a/tests/components/playstation_network/snapshots/test_sensor.ambr +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -220,7 +220,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Online-ID', + 'original_name': 'Online ID', 'platform': 'playstation_network', 'previous_unique_id': None, 'suggested_object_id': None, @@ -234,7 +234,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'entity_picture': 'http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png', - 'friendly_name': 'testuser Online-ID', + 'friendly_name': 'testuser Online ID', }), 'context': , 'entity_id': 'sensor.testuser_online_id', From 00dfc04b86b85bac689291d09c7d04b518ccb033 Mon Sep 17 00:00:00 2001 From: Space Date: Wed, 2 Jul 2025 11:45:45 +0200 Subject: [PATCH 1008/1664] Skip processing request body for HTTP HEAD requests (#147899) * Skip processing request body for HTTP HEAD requests * Use aiohttp's must_be_empty_body() to check whether ingress requests should be streamed * Only call must_be_empty_body() once per request * Fix incorrect use of walrus operator --- homeassistant/components/hassio/ingress.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index e673c3a70e9..ca6764cfa34 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -11,6 +11,7 @@ from urllib.parse import quote import aiohttp from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web +from aiohttp.helpers import must_be_empty_body from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from yarl import URL @@ -184,13 +185,16 @@ class HassIOIngress(HomeAssistantView): content_type = "application/octet-stream" # Simple request - if result.status in (204, 304) or ( + if (empty_body := must_be_empty_body(result.method, result.status)) or ( content_length is not UNDEFINED and (content_length_int := int(content_length)) <= MAX_SIMPLE_RESPONSE_SIZE ): # Return Response - body = await result.read() + if empty_body: + body = None + else: + body = await result.read() simple_response = web.Response( headers=headers, status=result.status, From 9c4951261c1537782fdbff1e37fa82ed802bf1f9 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 2 Jul 2025 12:00:48 +0200 Subject: [PATCH 1009/1664] Bump deebot-client to 13.5.0 (#147938) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 97739f698d9..ceb7a1da9de 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f88f29c628d..f3339a810e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e4c494cb94..baa57c6f063 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -671,7 +671,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From ec65066f5e6f15179bb66f7eddebf789b41c1ad0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:09:39 +0200 Subject: [PATCH 1010/1664] Update mypy-dev to 1.17.0a4 (#147939) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 29d2618c69d..a07d531c7f2 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.1 mock-open==1.4.0 -mypy-dev==1.17.0a2 +mypy-dev==1.17.0a4 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From 73e505d48dc97106d17d2419145b09fbe4aa94e0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:11:09 +0200 Subject: [PATCH 1011/1664] Update pytest-xdist to 3.8.0 (#147943) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index a07d531c7f2..67d986394c1 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-sugar==1.0.0 pytest-timeout==2.4.0 pytest-unordered==0.7.0 pytest-picked==0.5.1 -pytest-xdist==3.7.0 +pytest-xdist==3.8.0 pytest==8.4.0 requests-mock==1.12.1 respx==0.22.0 From 6c7da57af2decd64ab9d6900bfe5dad89d2a522f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:14:27 +0200 Subject: [PATCH 1012/1664] Update pytest-cov to 6.2.1 (#147942) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 67d986394c1..3a1c3e31876 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -21,7 +21,7 @@ pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 pytest-asyncio==1.0.0 pytest-aiohttp==1.1.0 -pytest-cov==6.1.1 +pytest-cov==6.2.1 pytest-freezer==0.4.9 pytest-github-actions-annotate-failures==0.3.0 pytest-socket==0.7.0 From 1051f85ac0ec768ff92e15f9b2c9ad9d047a84d2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:20:50 +0200 Subject: [PATCH 1013/1664] Update coverage to 7.9.1 (#147940) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 3a1c3e31876..dd17d704423 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.8.2 +coverage==7.9.1 freezegun==1.5.2 go2rtc-client==0.2.1 license-expression==30.4.1 From bab9ec99768c5a3c15cf60b620fac8ff8f3be403 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 2 Jul 2025 12:47:41 +0200 Subject: [PATCH 1014/1664] Add sensor for online status to PlayStation Network (#147842) --- .../components/playstation_network/helpers.py | 9 +-- .../components/playstation_network/icons.json | 8 +++ .../playstation_network/media_player.py | 2 +- .../components/playstation_network/sensor.py | 8 +++ .../playstation_network/strings.json | 9 +++ .../snapshots/test_diagnostics.ambr | 2 +- .../snapshots/test_sensor.ambr | 62 +++++++++++++++++++ 7 files changed, 91 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 267dc77ff06..9c7dac29a81 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -38,7 +38,7 @@ class PlaystationNetworkData: presence: dict[str, Any] = field(default_factory=dict) username: str = "" account_id: str = "" - available: bool = False + availability: str = "unavailable" active_sessions: dict[PlatformType, SessionData] = field(default_factory=dict) registered_platforms: set[PlatformType] = field(default_factory=set) trophy_summary: TrophySummary | None = None @@ -92,10 +92,7 @@ class PlaystationNetwork: data.username = self.user.online_id data.account_id = self.user.account_id - data.available = ( - data.presence.get("basicPresence", {}).get("availability") - == "availableToPlay" - ) + data.availability = data.presence["basicPresence"]["availability"] session = SessionData() session.platform = PlatformType( @@ -127,8 +124,6 @@ class PlaystationNetwork: if (game_title_info := presence[0] if presence else {}) and game_title_info[ "onlineStatus" ] == "online": - data.available = True - platform = PlatformType(game_title_info["platform"]) if platform is PlatformType.PS4: diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 7817a4c8b07..612427c9a1d 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -29,6 +29,14 @@ }, "last_online": { "default": "mdi:account-clock" + }, + "online_status": { + "default": "mdi:account-badge", + "state": { + "busy": "mdi:account-cancel", + "availabletocommunicate": "mdi:cellphone", + "offline": "mdi:account-off-outline" + } } } } diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index c1320e9b280..3e55e565460 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -107,7 +107,7 @@ class PsnMediaPlayerEntity( """Media Player state getter.""" session = self.coordinator.data.active_sessions.get(self.key) if session and session.status == "online": - if self.coordinator.data.available and session.title_id is not None: + if session.title_id is not None: return MediaPlayerState.PLAYING return MediaPlayerState.ON return MediaPlayerState.OFF diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index ece2952c0f0..305f252f31d 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -51,6 +51,7 @@ class PlaystationNetworkSensor(StrEnum): EARNED_TROPHIES_BRONZE = "earned_trophies_bronze" ONLINE_ID = "online_id" LAST_ONLINE = "last_online" + ONLINE_STATUS = "online_status" SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( @@ -121,6 +122,13 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( available_fn=lambda psn: "lastAvailableDate" in psn.presence["basicPresence"], device_class=SensorDeviceClass.TIMESTAMP, ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.ONLINE_STATUS, + translation_key=PlaystationNetworkSensor.ONLINE_STATUS, + value_fn=lambda psn: psn.availability.lower().replace("unavailable", "offline"), + device_class=SensorDeviceClass.ENUM, + options=["offline", "availabletoplay", "availabletocommunicate", "busy"], + ), ) diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index d3a9c986e88..f68d69417fb 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -81,6 +81,15 @@ }, "last_online": { "name": "Last online" + }, + "online_status": { + "name": "Online status", + "state": { + "offline": "Offline", + "availabletoplay": "Online", + "availabletocommunicate": "Online on PS App", + "busy": "Away" + } } } } diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 6073b37863e..f320eea4b7c 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -13,7 +13,7 @@ 'title_name': 'STAR WARS Jedi: Survivor™', }), }), - 'available': True, + 'availability': 'availableToPlay', 'presence': dict({ 'basicPresence': dict({ 'availability': 'availableToPlay', diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr index 59cd979ed76..a00e3c4ff0a 100644 --- a/tests/components/playstation_network/snapshots/test_sensor.ambr +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -244,6 +244,68 @@ 'state': 'testuser', }) # --- +# name: test_sensors[sensor.testuser_online_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_online_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': 'Online status', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_online_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_online_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'testuser Online status', + 'options': list([ + 'offline', + 'availabletoplay', + 'availabletocommunicate', + 'busy', + ]), + }), + 'context': , + 'entity_id': 'sensor.testuser_online_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'availabletoplay', + }) +# --- # name: test_sensors[sensor.testuser_platinum_trophies-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 7ff90ca49db53e245442ca3e8f3ee7b40bb6156f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 2 Jul 2025 13:06:27 +0200 Subject: [PATCH 1015/1664] Open repair issue when outbound WebSocket is enabled for Shelly non-sleeping RPC device (#147901) --- homeassistant/components/shelly/__init__.py | 9 +- homeassistant/components/shelly/const.py | 3 + homeassistant/components/shelly/repairs.py | 91 +++++++++++++++++++- homeassistant/components/shelly/strings.json | 14 +++ tests/components/shelly/test_repairs.py | 82 ++++++++++++++++++ 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 75fedf9b16d..0467b93a7c8 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,7 +56,10 @@ from .coordinator import ( ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) -from .repairs import async_manage_ble_scanner_firmware_unsupported_issue +from .repairs import ( + async_manage_ble_scanner_firmware_unsupported_issue, + async_manage_outbound_websocket_incorrectly_enabled_issue, +) from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, @@ -327,6 +330,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) hass, entry, ) + async_manage_outbound_websocket_incorrectly_enabled_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 7462766e2d4..60fc5b03d13 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -237,6 +237,9 @@ NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" +OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = ( + "outbound_websocket_incorrectly_enabled_{unique}" +) GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index c39f619fc6c..e1b15f04417 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -20,9 +20,11 @@ from .const import ( BLE_SCANNER_MIN_FIRMWARE, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from .coordinator import ShellyConfigEntry +from .utils import get_rpc_ws_url @callback @@ -65,7 +67,46 @@ def async_manage_ble_scanner_firmware_unsupported_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) -class BleScannerFirmwareUpdateFlow(RepairsFlow): +@callback +def async_manage_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the Outbound WebSocket incorrectly enabled issue.""" + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format( + unique=entry.unique_id + ) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if ( + (ws_config := device.config.get("ws")) + and ws_config["enable"] + and ws_config["server"] == get_rpc_ws_url(hass) + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="outbound_websocket_incorrectly_enabled", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class ShellyRpcRepairsFlow(RepairsFlow): """Handler for an issue fixing flow.""" def __init__(self, device: RpcDevice) -> None: @@ -83,7 +124,7 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - return await self.async_step_update_firmware() + return await self._async_step_confirm() issue_registry = ir.async_get(self.hass) description_placeholders = None @@ -96,6 +137,18 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): description_placeholders=description_placeholders, ) + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + raise NotImplementedError + + +class BleScannerFirmwareUpdateFlow(ShellyRpcRepairsFlow): + """Handler for BLE Scanner Firmware Update flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_update_firmware() + async def async_step_update_firmware( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: @@ -110,6 +163,29 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): return self.async_create_entry(title="", data={}) +class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow): + """Handler for Disable Outbound WebSocket flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_disable_outbound_websocket() + + async def async_step_disable_outbound_websocket( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + try: + result = await self._device.ws_setconfig( + False, self._device.config["ws"]["server"] + ) + if result["restart_required"]: + await self._device.trigger_reboot() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -124,4 +200,11 @@ async def async_create_fix_flow( assert entry is not None device = entry.runtime_data.rpc.device - return BleScannerFirmwareUpdateFlow(device) + + if "ble_scanner_firmware_unsupported" in issue_id: + return BleScannerFirmwareUpdateFlow(device) + + if "outbound_websocket_incorrectly_enabled" in issue_id: + return DisableOutboundWebSocketFlow(device) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 28f3a993462..c1d520a59f1 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -288,6 +288,20 @@ "unsupported_firmware": { "title": "Unsupported firmware for device {device_name}", "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." + }, + "outbound_websocket_incorrectly_enabled": { + "title": "Outbound WebSocket is enabled for {device_name}", + "fix_flow": { + "step": { + "confirm": { + "title": "Outbound WebSocket is enabled for {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is a non-sleeping device and Outbound WebSocket should be disabled in its configuration.\n\nSelect **Submit** button to disable Outbound WebSocket." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } } } } diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index f68d2f82f1b..8dfd59c49ba 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -9,6 +9,7 @@ from homeassistant.components.shelly.const import ( BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from homeassistant.core import HomeAssistant @@ -129,3 +130,84 @@ async def test_unsupported_firmware_issue_exc( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test repair issues handling for the outbound WebSocket incorrectly enabled.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.ws_setconfig.call_count == 1 + assert mock_rpc_device.ws_setconfig.call_args[0] == (False, ws_url) + assert mock_rpc_device.trigger_reboot.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_outbound_websocket_incorrectly_enabled_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, +) -> None: + """Test repair issues handling when ws_setconfig ends with an exception.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.ws_setconfig.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.ws_setconfig.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 From 73251fbb1caf333605a3b2adc97b10f0d8fc66d0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 2 Jul 2025 13:26:47 +0200 Subject: [PATCH 1016/1664] Handle additional errors in Nord Pool (#147937) --- .../components/nordpool/coordinator.py | 3 ++ tests/components/nordpool/test_coordinator.py | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index a6cfd40c323..d2edb81b9e6 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING +import aiohttp from pynordpool import ( Currency, DeliveryPeriodData, @@ -91,6 +92,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): except ( NordPoolResponseError, NordPoolError, + TimeoutError, + aiohttp.ClientError, ) as error: LOGGER.debug("Connection error: %s", error) self.async_set_update_error(error) diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 71c4644ea95..c2d18c4702a 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import patch +import aiohttp from freezegun.api import FrozenDateTimeFactory from pynordpool import ( NordPoolAuthenticationError, @@ -90,6 +91,36 @@ async def test_coordinator( assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=aiohttp.ClientError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=TimeoutError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -109,4 +140,4 @@ async def test_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81645" + assert state.state == "1.81983" From cb8e076703d807e771e8db5af7daf110078563f7 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 2 Jul 2025 07:39:19 -0400 Subject: [PATCH 1017/1664] Fix missing device_class and state_class on compensation entities (#146115) Co-authored-by: Robert Resch --- .../components/compensation/__init__.py | 24 +- .../components/compensation/sensor.py | 82 ++-- tests/components/compensation/test_sensor.py | 453 +++++++++++------- 3 files changed, 361 insertions(+), 198 deletions(-) diff --git a/homeassistant/components/compensation/__init__.py b/homeassistant/components/compensation/__init__.py index e83339d2c18..96e1cdac3d7 100644 --- a/homeassistant/components/compensation/__init__.py +++ b/homeassistant/components/compensation/__init__.py @@ -6,11 +6,18 @@ from operator import itemgetter import numpy as np import voluptuous as vol -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, +) from homeassistant.const import ( CONF_ATTRIBUTE, + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, + CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, @@ -50,20 +57,23 @@ def datapoints_greater_than_degree(value: dict) -> dict: COMPENSATION_SCHEMA = vol.Schema( { - vol.Required(CONF_SOURCE): cv.entity_id, + vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Required(CONF_DATAPOINTS): [ vol.ExactSequence([vol.Coerce(float), vol.Coerce(float)]) ], - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_ATTRIBUTE): cv.string, - vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean, - vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean, - vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): vol.All( vol.Coerce(int), vol.Range(min=1, max=7), ), + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_LOWER_LIMIT, default=False): cv.boolean, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): cv.positive_int, + vol.Required(CONF_SOURCE): cv.entity_id, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_UPPER_LIMIT, default=False): cv.boolean, } ) diff --git a/homeassistant/components/compensation/sensor.py b/homeassistant/components/compensation/sensor.py index 95695932540..de025089647 100644 --- a/homeassistant/components/compensation/sensor.py +++ b/homeassistant/components/compensation/sensor.py @@ -7,15 +7,23 @@ from typing import Any import numpy as np -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + CONF_STATE_CLASS, + SensorEntity, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, CONF_ATTRIBUTE, + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, + CONF_NAME, CONF_SOURCE, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import ( @@ -59,24 +67,13 @@ async def async_setup_platform( source: str = conf[CONF_SOURCE] attribute: str | None = conf.get(CONF_ATTRIBUTE) - name = f"{DEFAULT_NAME} {source}" - if attribute is not None: - name = f"{name} {attribute}" + if not (name := conf.get(CONF_NAME)): + name = f"{DEFAULT_NAME} {source}" + if attribute is not None: + name = f"{name} {attribute}" async_add_entities( - [ - CompensationSensor( - conf.get(CONF_UNIQUE_ID), - name, - source, - attribute, - conf[CONF_PRECISION], - conf[CONF_POLYNOMIAL], - conf.get(CONF_UNIT_OF_MEASUREMENT), - conf[CONF_MINIMUM], - conf[CONF_MAXIMUM], - ) - ] + [CompensationSensor(conf.get(CONF_UNIQUE_ID), name, source, attribute, conf)] ) @@ -91,23 +88,27 @@ class CompensationSensor(SensorEntity): name: str, source: str, attribute: str | None, - precision: int, - polynomial: np.poly1d, - unit_of_measurement: str | None, - minimum: tuple[float, float] | None, - maximum: tuple[float, float] | None, + config: dict[str, Any], ) -> None: """Initialize the Compensation sensor.""" + + self._attr_name = name self._source_entity_id = source - self._precision = precision self._source_attribute = attribute - self._attr_native_unit_of_measurement = unit_of_measurement + + self._precision = config[CONF_PRECISION] + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + + polynomial: np.poly1d = config[CONF_POLYNOMIAL] self._poly = polynomial self._coefficients = polynomial.coefficients.tolist() + self._attr_unique_id = unique_id - self._attr_name = name - self._minimum = minimum - self._maximum = maximum + self._minimum = config[CONF_MINIMUM] + self._maximum = config[CONF_MAXIMUM] + + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) async def async_added_to_hass(self) -> None: """Handle added to Hass.""" @@ -137,13 +138,40 @@ class CompensationSensor(SensorEntity): """Handle sensor state changes.""" new_state: State | None if (new_state := event.data["new_state"]) is None: + _LOGGER.warning( + "While updating compensation %s, the new_state is None", self.name + ) + self._attr_native_value = None + self.async_write_ha_state() return + if new_state.state == STATE_UNKNOWN: + self._attr_native_value = None + self.async_write_ha_state() + return + + if new_state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return + + self._attr_available = True + if self.native_unit_of_measurement is None and self._source_attribute is None: self._attr_native_unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT ) + if self._attr_device_class is None and ( + device_class := new_state.attributes.get(ATTR_DEVICE_CLASS) + ): + self._attr_device_class = device_class + + if self._attr_state_class is None and ( + state_class := new_state.attributes.get(ATTR_STATE_CLASS) + ): + self._attr_state_class = state_class + if self._source_attribute: value = new_state.attributes.get(self._source_attribute) else: diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 877a4f972a9..182db0de54f 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -1,174 +1,232 @@ """The tests for the integration sensor platform.""" +from typing import Any +from unittest.mock import patch + import pytest +from homeassistant import config as hass_config from homeassistant.components.compensation.const import CONF_PRECISION, DOMAIN from homeassistant.components.compensation.sensor import ATTR_COEFFICIENTS -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ( + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, EVENT_STATE_CHANGED, + SERVICE_RELOAD, + STATE_UNAVAILABLE, STATE_UNKNOWN, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import assert_setup_component, get_fixture_path -async def test_linear_state(hass: HomeAssistant) -> None: +TEST_OBJECT_ID = "test_compensation" +TEST_ENTITY_ID = "sensor.test_compensation" +TEST_SOURCE = "sensor.uncompensated" + +TEST_BASE_CONFIG = { + "source": TEST_SOURCE, + "data_points": [ + [1.0, 2.0], + [2.0, 3.0], + ], + "precision": 2, +} +TEST_CONFIG = { + "name": TEST_OBJECT_ID, + "unit_of_measurement": "a", + **TEST_BASE_CONFIG, +} + + +async def async_setup_compensation(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Do setup of a compensation integration sensor.""" + with assert_setup_component(1, DOMAIN): + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test": config}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.fixture +async def setup_compensation(hass: HomeAssistant, config: dict[str, Any]) -> None: + """Do setup of a compensation integration sensor.""" + await async_setup_compensation(hass, config) + + +@pytest.fixture +async def setup_compensation_with_limits( + hass: HomeAssistant, + config: dict[str, Any], + upper: bool, + lower: bool, +): + """Do setup of a compensation integration sensor with extra config.""" + await async_setup_compensation( + hass, + { + **config, + "lower_limit": lower, + "upper_limit": upper, + }, + ) + + +@pytest.fixture +async def caplog_setup_text(caplog: pytest.LogCaptureFixture) -> str: + """Return setup log of integration.""" + return caplog.text + + +@pytest.mark.parametrize("config", [TEST_CONFIG]) +@pytest.mark.usefixtures("setup_compensation") +async def test_linear_state(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test compensation sensor state.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "data_points": [ - [1.0, 2.0], - [2.0, 3.0], - ], - "precision": 2, - "unit_of_measurement": "a", - } - } - } - expected_entity_id = "sensor.compensation_sensor_uncompensated" - - assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, SENSOR_DOMAIN, config) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["test"]["source"] - hass.states.async_set(entity_id, 4, {}) + hass.states.async_set(TEST_SOURCE, 4, {}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 5.0 + assert round(float(state.state), config[CONF_PRECISION]) == 5.0 assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "a" coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] assert coefs == [1.0, 1.0] - hass.states.async_set(entity_id, "foo", {}) + hass.states.async_set(TEST_SOURCE, "foo", {}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN -async def test_linear_state_from_attribute(hass: HomeAssistant) -> None: - """Test compensation sensor state that pulls from attribute.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "attribute": "value", - "data_points": [ - [1.0, 2.0], - [2.0, 3.0], - ], - "precision": 2, - } - } - } - expected_entity_id = "sensor.compensation_sensor_uncompensated_value" - - assert await async_setup_component(hass, DOMAIN, config) - assert await async_setup_component(hass, SENSOR_DOMAIN, config) +@pytest.mark.parametrize("config", [{"name": TEST_OBJECT_ID, **TEST_BASE_CONFIG}]) +@pytest.mark.usefixtures("setup_compensation") +async def test_attributes_come_from_source(hass: HomeAssistant) -> None: + """Test compensation sensor state.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.states.async_set( + TEST_SOURCE, + 4, + { + ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, + ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + }, + ) await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == "5.0" + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + + +@pytest.mark.parametrize("config", [{"attribute": "value", **TEST_CONFIG}]) +@pytest.mark.usefixtures("setup_compensation") +async def test_linear_state_from_attribute( + hass: HomeAssistant, config: dict[str, Any] +) -> None: + """Test compensation sensor state that pulls from attribute.""" hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - entity_id = config[DOMAIN]["test"]["source"] - hass.states.async_set(entity_id, 3, {"value": 4}) + hass.states.async_set(TEST_SOURCE, 3, {"value": 4}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 5.0 + assert round(float(state.state), config[CONF_PRECISION]) == 5.0 coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)] assert coefs == [1.0, 1.0] - hass.states.async_set(entity_id, 3, {"value": "bar"}) + hass.states.async_set(TEST_SOURCE, 3, {"value": "bar"}) await hass.async_block_till_done() - state = hass.states.get(expected_entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state is not None assert state.state == STATE_UNKNOWN -async def test_quadratic_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "config", + [ + { + "name": TEST_OBJECT_ID, + "source": TEST_SOURCE, + "data_points": [ + [50, 3.3], + [50, 2.8], + [50, 2.9], + [70, 2.3], + [70, 2.6], + [70, 2.1], + [80, 2.5], + [80, 2.9], + [80, 2.4], + [90, 3.0], + [90, 3.1], + [90, 2.8], + [100, 3.3], + [100, 3.5], + [100, 3.0], + ], + "degree": 2, + "precision": 3, + }, + ], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_quadratic_state(hass: HomeAssistant, config: dict[str, Any]) -> None: """Test 3 degree polynominial compensation sensor.""" - config = { - "compensation": { - "test": { - "source": "sensor.temperature", - "data_points": [ - [50, 3.3], - [50, 2.8], - [50, 2.9], - [70, 2.3], - [70, 2.6], - [70, 2.1], - [80, 2.5], - [80, 2.9], - [80, 2.4], - [90, 3.0], - [90, 3.1], - [90, 2.8], - [100, 3.3], - [100, 3.5], - [100, 3.0], - ], - "degree": 2, - "precision": 3, - } - } - } - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() + hass.states.async_set(TEST_SOURCE, 43.2, {}) await hass.async_block_till_done() - entity_id = config[DOMAIN]["test"]["source"] - hass.states.async_set(entity_id, 43.2, {}) - await hass.async_block_till_done() - - state = hass.states.get("sensor.compensation_sensor_temperature") + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert round(float(state.state), config[DOMAIN]["test"][CONF_PRECISION]) == 3.327 + assert round(float(state.state), config[CONF_PRECISION]) == 3.327 -async def test_numpy_errors( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: +@pytest.mark.parametrize( + "config", + [ + { + "source": TEST_SOURCE, + "data_points": [ + [0.0, 1.0], + [0.0, 1.0], + ], + }, + ], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_numpy_errors(hass: HomeAssistant, caplog_setup_text) -> None: """Tests bad polyfits.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "data_points": [ - [0.0, 1.0], - [0.0, 1.0], - ], - }, - } - } - await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - assert "invalid value encountered in divide" in caplog.text + assert "invalid value encountered in divide" in caplog_setup_text async def test_datapoints_greater_than_degree( @@ -178,7 +236,7 @@ async def test_datapoints_greater_than_degree( config = { "compensation": { "test": { - "source": "sensor.uncompensated", + "source": TEST_SOURCE, "data_points": [ [1.0, 2.0], [2.0, 3.0], @@ -195,35 +253,13 @@ async def test_datapoints_greater_than_degree( assert "data_points must have at least 3 data_points" in caplog.text +@pytest.mark.parametrize("config", [TEST_CONFIG]) +@pytest.mark.usefixtures("setup_compensation") async def test_new_state_is_none(hass: HomeAssistant) -> None: """Tests catch for empty new states.""" - config = { - "compensation": { - "test": { - "source": "sensor.uncompensated", - "data_points": [ - [1.0, 2.0], - [2.0, 3.0], - ], - "precision": 2, - "unit_of_measurement": "a", - } - } - } - expected_entity_id = "sensor.compensation_sensor_uncompensated" - - await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - last_changed = hass.states.get(expected_entity_id).last_changed - - hass.bus.async_fire( - EVENT_STATE_CHANGED, event_data={"entity_id": "sensor.uncompensated"} - ) - - assert last_changed == hass.states.get(expected_entity_id).last_changed + last_changed = hass.states.get(TEST_ENTITY_ID).last_changed + hass.bus.async_fire(EVENT_STATE_CHANGED, event_data={"entity_id": TEST_SOURCE}) + assert last_changed == hass.states.get(TEST_ENTITY_ID).last_changed @pytest.mark.parametrize( @@ -234,40 +270,129 @@ async def test_new_state_is_none(hass: HomeAssistant) -> None: (True, True), ], ) +@pytest.mark.parametrize( + "config", + [ + { + "name": TEST_OBJECT_ID, + "source": TEST_SOURCE, + "data_points": [ + [1.0, 0.0], + [3.0, 2.0], + [2.0, 1.0], + ], + "precision": 2, + "unit_of_measurement": "a", + }, + ], +) +@pytest.mark.usefixtures("setup_compensation_with_limits") async def test_limits(hass: HomeAssistant, lower: bool, upper: bool) -> None: """Test compensation sensor state.""" - source = "sensor.test" - config = { - "compensation": { - "test": { - "source": source, - "data_points": [ - [1.0, 0.0], - [3.0, 2.0], - [2.0, 1.0], - ], - "precision": 2, - "lower_limit": lower, - "upper_limit": upper, - "unit_of_measurement": "a", - } - } - } - await async_setup_component(hass, DOMAIN, config) + hass.states.async_set(TEST_SOURCE, 0, {}) await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - entity_id = "sensor.compensation_sensor_test" - - hass.states.async_set(source, 0, {}) - await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) value = 0.0 if lower else -1.0 assert float(state.state) == value - hass.states.async_set(source, 5, {}) + hass.states.async_set(TEST_SOURCE, 5, {}) await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) value = 2.0 if upper else 4.0 assert float(state.state) == value + + +@pytest.mark.parametrize( + ("config", "expected"), + [ + (TEST_BASE_CONFIG, "sensor.compensation_sensor_uncompensated"), + ( + {"attribute": "value", **TEST_BASE_CONFIG}, + "sensor.compensation_sensor_uncompensated_value", + ), + ], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_default_name(hass: HomeAssistant, expected: str) -> None: + """Test default configuration name.""" + assert hass.states.get(expected) is not None + + +@pytest.mark.parametrize("config", [TEST_CONFIG]) +@pytest.mark.parametrize( + ("source_state", "expected"), + [(STATE_UNKNOWN, STATE_UNKNOWN), (STATE_UNAVAILABLE, STATE_UNAVAILABLE)], +) +@pytest.mark.usefixtures("setup_compensation") +async def test_non_numerical_states_from_source_entity( + hass: HomeAssistant, config: dict[str, Any], source_state: str, expected: str +) -> None: + """Test non-numerical states from source entity.""" + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + hass.states.async_set(TEST_SOURCE, source_state) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + + hass.states.async_set(TEST_SOURCE, 4) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert round(float(state.state), config[CONF_PRECISION]) == 5.0 + + hass.states.async_set(TEST_SOURCE, source_state) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == expected + + +async def test_source_state_none(hass: HomeAssistant) -> None: + """Test is source sensor state is null and sets state to STATE_UNKNOWN.""" + config = { + "sensor": [ + { + "platform": "template", + "sensors": { + "uncompensated": { + "value_template": "{{ states.sensor.test_state.state }}" + } + }, + }, + ] + } + await async_setup_component(hass, "sensor", config) + await async_setup_compensation(hass, TEST_CONFIG) + + hass.states.async_set("sensor.test_state", 4) + + await hass.async_block_till_done() + state = hass.states.get(TEST_SOURCE) + assert state.state == "4" + + await hass.async_block_till_done() + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == "5.0" + + # Force Template Reload + yaml_path = get_fixture_path("sensor_configuration.yaml", "template") + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "template", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Template state gets to None + state = hass.states.get(TEST_SOURCE) + assert state is None + + # Filter sensor ignores None state setting state to STATE_UNKNOWN + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN From f77e6cc8fc972a17679ff531d6644b94bca510e3 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 2 Jul 2025 13:41:06 +0200 Subject: [PATCH 1018/1664] Add missing exception translations to LCN (#147723) --- homeassistant/components/lcn/__init__.py | 6 ++- homeassistant/components/lcn/helpers.py | 11 ++++- .../components/lcn/quality_scale.yaml | 2 +- homeassistant/components/lcn/services.py | 10 +++-- homeassistant/components/lcn/strings.json | 18 ++++++-- tests/components/lcn/test_services.py | 44 ++++++++++++++++++- tests/components/lcn/test_websocket.py | 10 +++++ 7 files changed, 88 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index 43438fa64dd..77d1bb4e709 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -104,7 +104,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: LcnConfigEntry) - ) as ex: await lcn_connection.async_close() raise ConfigEntryNotReady( - f"Unable to connect to {config_entry.title}: {ex}" + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={ + "config_entry_title": config_entry.title, + }, ) from ex _LOGGER.info('LCN connected to "%s"', config_entry.title) diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py index 515f64b6e31..4937b5dbca7 100644 --- a/homeassistant/components/lcn/helpers.py +++ b/homeassistant/components/lcn/helpers.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_SWITCHES, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType @@ -100,7 +101,11 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str: return cast(str, domain_data["setpoint"]) if domain_name == "scene": return f"{domain_data['register']}{domain_data['scene']}" - raise ValueError("Unknown domain") + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_domain", + translation_placeholders={CONF_DOMAIN: domain_name}, + ) def generate_unique_id( @@ -304,6 +309,8 @@ def get_device_config( def is_states_string(states_string: str) -> list[str]: """Validate the given states string and return states list.""" if len(states_string) != 8: - raise ValueError("Invalid length of states string") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="invalid_length_of_states_string" + ) states = {"1": "ON", "0": "OFF", "T": "TOGGLE", "-": "NOCHANGE"} return [states[state_string] for state_string in states_string] diff --git a/homeassistant/components/lcn/quality_scale.yaml b/homeassistant/components/lcn/quality_scale.yaml index 26be4d210ba..35d76a2ebdc 100644 --- a/homeassistant/components/lcn/quality_scale.yaml +++ b/homeassistant/components/lcn/quality_scale.yaml @@ -19,7 +19,7 @@ rules: test-before-setup: done unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: status: exempt diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index 15d60639a1c..8a172ccac2e 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -330,8 +330,9 @@ class SendKeys(LcnServiceCall): if (delay_time := service.data[CONF_TIME]) != 0: hit = pypck.lcn_defs.SendKeyCommand.HIT if pypck.lcn_defs.SendKeyCommand[service.data[CONF_STATE]] != hit: - raise ValueError( - "Only hit command is allowed when sending deferred keys." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_send_keys_action", ) delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT]) await device_connection.send_keys_hit_deferred(keys, delay_time, delay_unit) @@ -368,8 +369,9 @@ class LockKeys(LcnServiceCall): if (delay_time := service.data[CONF_TIME]) != 0: if table_id != 0: - raise ValueError( - "Only table A is allowed when locking keys for a specific time." + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_lock_keys_table", ) delay_unit = pypck.lcn_defs.TimeUnit.parse(service.data[CONF_TIME_UNIT]) await device_connection.lock_keys_tab_a_temporary( diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 9d806bce104..4e4ca7e0dcd 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -414,11 +414,23 @@ } }, "exceptions": { - "invalid_address": { - "message": "LCN device for given address has not been configured." + "cannot_connect": { + "message": "Unable to connect to {config_entry_title}." }, "invalid_device_id": { - "message": "LCN device for given device ID has not been configured." + "message": "LCN device for given device ID {device_id} has not been configured." + }, + "invalid_domain": { + "message": "Invalid domain {domain}." + }, + "invalid_send_keys_action": { + "message": "Invalid state for sending keys. Only 'hit' allowed for deferred sending." + }, + "invalid_lock_keys_table": { + "message": "Invalid table for locking keys. Only table A allowed when locking for a specific time." + }, + "invalid_length_of_states_string": { + "message": "Invalid length of states string. Expected 8 characters." } } } diff --git a/tests/components/lcn/test_services.py b/tests/components/lcn/test_services.py index cdc8e9671c0..46ede8959ff 100644 --- a/tests/components/lcn/test_services.py +++ b/tests/components/lcn/test_services.py @@ -30,6 +30,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component from .conftest import ( @@ -134,6 +135,23 @@ async def test_service_relays( control_relays.assert_awaited_with(relay_states) + # wrong states string + with ( + patch.object(MockModuleConnection, "control_relays") as control_relays, + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + LcnService.RELAYS, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_STATE: "0011TT--00", + }, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_length_of_states_string" + async def test_service_led( hass: HomeAssistant, @@ -328,7 +346,7 @@ async def test_service_send_keys_hit_deferred( patch.object( MockModuleConnection, "send_keys_hit_deferred" ) as send_keys_hit_deferred, - pytest.raises(ValueError), + pytest.raises(ServiceValidationError) as exc_info, ): await hass.services.async_call( DOMAIN, @@ -342,6 +360,8 @@ async def test_service_send_keys_hit_deferred( }, blocking=True, ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_send_keys_action" async def test_service_lock_keys( @@ -369,6 +389,24 @@ async def test_service_lock_keys( lock_keys.assert_awaited_with(0, lock_states) + # wrong states string + with ( + patch.object(MockModuleConnection, "lock_keys") as lock_keys, + pytest.raises(HomeAssistantError) as exc_info, + ): + await hass.services.async_call( + DOMAIN, + LcnService.LOCK_KEYS, + { + CONF_DEVICE_ID: get_device(hass, entry, (0, 7, False)).id, + CONF_TABLE: "a", + CONF_STATE: "0011TT--00", + }, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_length_of_states_string" + async def test_service_lock_keys_tab_a_temporary( hass: HomeAssistant, @@ -406,7 +444,7 @@ async def test_service_lock_keys_tab_a_temporary( patch.object( MockModuleConnection, "lock_keys_tab_a_temporary" ) as lock_keys_tab_a_temporary, - pytest.raises(ValueError), + pytest.raises(ServiceValidationError) as exc_info, ): await hass.services.async_call( DOMAIN, @@ -420,6 +458,8 @@ async def test_service_lock_keys_tab_a_temporary( }, blocking=True, ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "invalid_lock_keys_table" async def test_service_dyn_text( diff --git a/tests/components/lcn/test_websocket.py b/tests/components/lcn/test_websocket.py index 02bf6b4c546..75d8a605bfb 100644 --- a/tests/components/lcn/test_websocket.py +++ b/tests/components/lcn/test_websocket.py @@ -192,6 +192,16 @@ async def test_lcn_entities_add_command( assert entity_config in entry.data[CONF_ENTITIES] + # invalid domain + await client.send_json_auto_id( + {**ENTITIES_ADD_PAYLOAD, "entry_id": entry.entry_id, CONF_DOMAIN: "invalid"} + ) + + res = await client.receive_json() + assert not res["success"] + assert res["error"]["code"] == "home_assistant_error" + assert res["error"]["translation_key"] == "invalid_domain" + async def test_lcn_entities_delete_command( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, entry: MockConfigEntry From bbe03dcab7ef998fe503dff2001a07f29dcd1432 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 2 Jul 2025 04:46:40 -0700 Subject: [PATCH 1019/1664] Add missing Opower tests (#147934) --- tests/components/opower/conftest.py | 79 ++++++ .../opower/snapshots/test_coordinator.ambr | 177 +++++++++++++ tests/components/opower/test_coordinator.py | 236 ++++++++++++++++++ tests/components/opower/test_init.py | 116 +++++++++ tests/components/opower/test_sensor.py | 60 +++++ 5 files changed, 668 insertions(+) create mode 100644 tests/components/opower/snapshots/test_coordinator.ambr create mode 100644 tests/components/opower/test_coordinator.py create mode 100644 tests/components/opower/test_init.py create mode 100644 tests/components/opower/test_sensor.py diff --git a/tests/components/opower/conftest.py b/tests/components/opower/conftest.py index 12d1a0dcdce..ea1fc5e1e37 100644 --- a/tests/components/opower/conftest.py +++ b/tests/components/opower/conftest.py @@ -1,5 +1,11 @@ """Fixtures for the Opower integration tests.""" +from collections.abc import Generator +from datetime import date +from unittest.mock import AsyncMock, Mock, patch + +from opower import Account, Forecast, MeterType, ReadResolution, UnitOfMeasure +from opower.utilities.pge import PGE import pytest from homeassistant.components.opower.const import DOMAIN @@ -22,3 +28,76 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: ) config_entry.add_to_hass(hass) return config_entry + + +@pytest.fixture +def mock_opower_api() -> Generator[AsyncMock]: + """Mock Opower API.""" + with patch( + "homeassistant.components.opower.coordinator.Opower", autospec=True + ) as mock_api: + api = mock_api.return_value + api.utility = PGE + + api.async_get_accounts.return_value = [ + Account( + customer=Mock(), + uuid="111111-uuid", + utility_account_id="111111", + id="111111", + meter_type=MeterType.ELEC, + read_resolution=ReadResolution.HOUR, + ), + Account( + customer=Mock(), + uuid="222222-uuid", + utility_account_id="222222", + id="222222", + meter_type=MeterType.GAS, + read_resolution=ReadResolution.DAY, + ), + ] + api.async_get_forecast.return_value = [ + Forecast( + account=Account( + customer=Mock(), + uuid="111111-uuid", + utility_account_id="111111", + id="111111", + meter_type=MeterType.ELEC, + read_resolution=ReadResolution.HOUR, + ), + usage_to_date=100, + cost_to_date=20.0, + forecasted_usage=200, + forecasted_cost=40.0, + typical_usage=180, + typical_cost=36.0, + unit_of_measure=UnitOfMeasure.KWH, + start_date=date(2023, 1, 1), + end_date=date(2023, 1, 31), + current_date=date(2023, 1, 15), + ), + Forecast( + account=Account( + customer=Mock(), + uuid="222222-uuid", + utility_account_id="222222", + id="222222", + meter_type=MeterType.GAS, + read_resolution=ReadResolution.DAY, + ), + usage_to_date=50, + cost_to_date=15.0, + forecasted_usage=100, + forecasted_cost=30.0, + typical_usage=90, + typical_cost=27.0, + unit_of_measure=UnitOfMeasure.CCF, + start_date=date(2023, 1, 1), + end_date=date(2023, 1, 31), + current_date=date(2023, 1, 15), + ), + ] + api.async_get_cost_reads.return_value = [] + yield api diff --git a/tests/components/opower/snapshots/test_coordinator.ambr b/tests/components/opower/snapshots/test_coordinator.ambr new file mode 100644 index 00000000000..afa93c5bcf4 --- /dev/null +++ b/tests/components/opower/snapshots/test_coordinator.ambr @@ -0,0 +1,177 @@ +# serializer version: 1 +# name: test_coordinator_first_run + defaultdict({ + 'opower:pge_elec_111111_energy_compensation': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.1, + 'sum': 0.1, + }), + ]), + 'opower:pge_elec_111111_energy_consumption': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 1.5, + 'sum': 1.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 1.5, + }), + ]), + 'opower:pge_elec_111111_energy_cost': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.5, + 'sum': 0.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 0.5, + }), + ]), + 'opower:pge_elec_111111_energy_return': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.5, + 'sum': 0.5, + }), + ]), + }) +# --- +# name: test_coordinator_migration + defaultdict({ + 'opower:pge_elec_111111_energy_consumption': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 1.5, + 'sum': 1.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 1.5, + }), + ]), + 'opower:pge_elec_111111_energy_return': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.5, + 'sum': 0.5, + }), + ]), + }) +# --- +# name: test_coordinator_subsequent_run + defaultdict({ + 'opower:pge_elec_111111_energy_compensation': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.1, + 'sum': 0.1, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 0.0, + 'sum': 0.1, + }), + ]), + 'opower:pge_elec_111111_energy_consumption': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 1.5, + 'sum': 1.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 1.5, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 2.0, + 'sum': 3.5, + }), + ]), + 'opower:pge_elec_111111_energy_cost': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.5, + 'sum': 0.5, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.0, + 'sum': 0.5, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 0.7, + 'sum': 1.2, + }), + ]), + 'opower:pge_elec_111111_energy_return': list([ + dict({ + 'end': 1672592400.0, + 'start': 1672588800.0, + 'state': 0.0, + 'sum': 0.0, + }), + dict({ + 'end': 1672596000.0, + 'start': 1672592400.0, + 'state': 0.5, + 'sum': 0.5, + }), + dict({ + 'end': 1672599600.0, + 'start': 1672596000.0, + 'state': 0.0, + 'sum': 0.5, + }), + ]), + }) +# --- diff --git a/tests/components/opower/test_coordinator.py b/tests/components/opower/test_coordinator.py new file mode 100644 index 00000000000..5f55fd481ba --- /dev/null +++ b/tests/components/opower/test_coordinator.py @@ -0,0 +1,236 @@ +"""Tests for the Opower coordinator.""" + +from datetime import datetime +from unittest.mock import AsyncMock + +from opower import CostRead +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.opower.coordinator import OpowerCoordinator +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.models import StatisticData, StatisticMetaData +from homeassistant.components.recorder.statistics import ( + async_add_external_statistics, + get_last_statistics, + statistics_during_period, +) +from homeassistant.const import UnitOfEnergy +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry +from tests.components.recorder.common import async_wait_recording_done + + +async def test_coordinator_first_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator on its first run with no existing statistics.""" + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=-0.5, # Grid return + provided_cost=-0.1, # Compensation + ), + ] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + + await async_wait_recording_done(hass) + + # Check stats for electric account '111111' + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + "opower:pge_elec_111111_energy_cost", + "opower:pge_elec_111111_energy_compensation", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + +async def test_coordinator_subsequent_run( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the coordinator correctly updates statistics on subsequent runs.""" + # First run + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=-0.5, + provided_cost=-0.1, + ), + ] + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Second run with updated data for one hour and new data for the next hour + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), # Updated data + end_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), + consumption=-1.0, # Was -0.5 + provided_cost=-0.2, # Was -0.1 + ), + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 10)), # New data + end_time=dt_util.as_utc(datetime(2023, 1, 1, 11)), + consumption=2.0, + provided_cost=0.7, + ), + ] + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Check all stats + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + "opower:pge_elec_111111_energy_cost", + "opower:pge_elec_111111_energy_compensation", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + +async def test_coordinator_subsequent_run_no_energy_data( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test the coordinator handles no recent usage/cost data.""" + # First run + mock_opower_api.async_get_cost_reads.return_value = [ + CostRead( + start_time=dt_util.as_utc(datetime(2023, 1, 1, 8)), + end_time=dt_util.as_utc(datetime(2023, 1, 1, 9)), + consumption=1.5, + provided_cost=0.5, + ), + ] + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Second run with no data + mock_opower_api.async_get_cost_reads.return_value = [] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + + assert "No recent usage/cost data. Skipping update" in caplog.text + + # Verify no new stats were added by checking the sum remains 1.5 + statistic_id = "opower:pge_elec_111111_energy_consumption" + stats = await hass.async_add_executor_job( + get_last_statistics, hass, 1, statistic_id, True, {"sum"} + ) + assert stats[statistic_id][0]["sum"] == 1.5 + + +async def test_coordinator_migration( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + snapshot: SnapshotAssertion, +) -> None: + """Test the one-time migration for return-to-grid statistics.""" + # Setup: Create old-style consumption data with negative values + statistic_id = "opower:pge_elec_111111_energy_consumption" + metadata = StatisticMetaData( + has_sum=True, + name="Opower pge elec 111111 consumption", + source=DOMAIN, + statistic_id=statistic_id, + unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + ) + statistics_to_add = [ + StatisticData( + start=dt_util.as_utc(datetime(2023, 1, 1, 8)), + state=1.5, + sum=1.5, + ), + StatisticData( + start=dt_util.as_utc(datetime(2023, 1, 1, 9)), + state=-0.5, # This should be migrated + sum=1.0, + ), + ] + async_add_external_statistics(hass, metadata, statistics_to_add) + await async_wait_recording_done(hass) + + # When the coordinator runs, it should trigger the migration + # Don't need new cost reads for this test + mock_opower_api.async_get_cost_reads.return_value = [] + + coordinator = OpowerCoordinator(hass, mock_config_entry) + await coordinator._async_update_data() + await async_wait_recording_done(hass) + + # Check that the stats have been migrated + stats = await hass.async_add_executor_job( + statistics_during_period, + hass, + dt_util.utc_from_timestamp(0), + None, + { + "opower:pge_elec_111111_energy_consumption", + "opower:pge_elec_111111_energy_return", + }, + "hour", + None, + {"state", "sum"}, + ) + assert stats == snapshot + + # Check that an issue was created + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "return_to_grid_migration_111111") + assert issue is not None + assert issue.severity == ir.IssueSeverity.WARNING diff --git a/tests/components/opower/test_init.py b/tests/components/opower/test_init.py new file mode 100644 index 00000000000..042dd42b0cf --- /dev/null +++ b/tests/components/opower/test_init.py @@ -0,0 +1,116 @@ +"""Tests for the Opower integration.""" + +from unittest.mock import AsyncMock + +from opower.exceptions import ApiException, CannotConnect, InvalidAuth +import pytest + +from homeassistant.components.opower.const import DOMAIN +from homeassistant.components.recorder import Recorder +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_unload_entry( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test successful setup and unload of a config entry.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_opower_api.async_login.assert_awaited_once() + mock_opower_api.async_get_forecast.assert_awaited_once() + mock_opower_api.async_get_accounts.assert_awaited_once() + + 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 + assert not hass.data.get(DOMAIN) + + +@pytest.mark.parametrize( + ("login_side_effect", "expected_state"), + [ + ( + CannotConnect(), + ConfigEntryState.SETUP_RETRY, + ), + ( + InvalidAuth(), + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_login_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, + login_side_effect: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test for login error.""" + mock_opower_api.async_login.side_effect = login_side_effect + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +async def test_get_forecast_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test for API error when getting forecast.""" + mock_opower_api.async_get_forecast.side_effect = ApiException( + message="forecast error", url="" + ) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_accounts_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test for API error when getting accounts.""" + mock_opower_api.async_get_accounts.side_effect = ApiException( + message="accounts error", url="" + ) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_get_cost_reads_error( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test for API error when getting cost reads.""" + mock_opower_api.async_get_cost_reads.side_effect = ApiException( + message="cost reads error", url="" + ) + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/opower/test_sensor.py b/tests/components/opower/test_sensor.py new file mode 100644 index 00000000000..91ffb271b2b --- /dev/null +++ b/tests/components/opower/test_sensor.py @@ -0,0 +1,60 @@ +"""Tests for the Opower sensor platform.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.recorder import Recorder +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfEnergy, UnitOfVolume +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_sensors( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_opower_api: AsyncMock, +) -> None: + """Test the creation and values of Opower sensors.""" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + # Check electric sensors + entry = entity_registry.async_get("sensor.current_bill_electric_usage_to_date") + assert entry + assert entry.unique_id == "pge_111111_elec_usage_to_date" + state = hass.states.get("sensor.current_bill_electric_usage_to_date") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR + assert state.state == "100" + + entry = entity_registry.async_get("sensor.current_bill_electric_cost_to_date") + assert entry + assert entry.unique_id == "pge_111111_elec_cost_to_date" + state = hass.states.get("sensor.current_bill_electric_cost_to_date") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" + assert state.state == "20.0" + + # Check gas sensors + entry = entity_registry.async_get("sensor.current_bill_gas_usage_to_date") + assert entry + assert entry.unique_id == "pge_222222_gas_usage_to_date" + state = hass.states.get("sensor.current_bill_gas_usage_to_date") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS + # Convert 50 CCF to m³ + assert float(state.state) == pytest.approx(50 * 2.83168, abs=1e-3) + + entry = entity_registry.async_get("sensor.current_bill_gas_cost_to_date") + assert entry + assert entry.unique_id == "pge_222222_gas_cost_to_date" + state = hass.states.get("sensor.current_bill_gas_cost_to_date") + assert state + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" + assert state.state == "15.0" From a7002e3a24c0583103fdefcb17dad86c3d181294 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:02:18 +0200 Subject: [PATCH 1020/1664] Update pytest to 8.4.1 (#147951) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index dd17d704423..4b2b7ec4909 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -30,7 +30,7 @@ pytest-timeout==2.4.0 pytest-unordered==0.7.0 pytest-picked==0.5.1 pytest-xdist==3.8.0 -pytest==8.4.0 +pytest==8.4.1 requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 From f10fcde6d8e6bb8d06273153229f921df7bc17aa Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 2 Jul 2025 14:07:47 +0200 Subject: [PATCH 1021/1664] Remove the deprecated interface paramater for velbus (#147868) --- homeassistant/components/velbus/const.py | 1 - homeassistant/components/velbus/services.py | 127 ++++++------------ homeassistant/components/velbus/services.yaml | 20 --- homeassistant/components/velbus/strings.json | 16 --- tests/components/velbus/test_services.py | 46 ------- 5 files changed, 39 insertions(+), 171 deletions(-) diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py index f42e449bdcc..7223e83ddf4 100644 --- a/homeassistant/components/velbus/const.py +++ b/homeassistant/components/velbus/const.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( DOMAIN: Final = "velbus" CONF_CONFIG_ENTRY: Final = "config_entry" -CONF_INTERFACE: Final = "interface" CONF_MEMO_TEXT: Final = "memo_text" CONF_TLS: Final = "tls" diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 5fccbcaf82e..34d074c2dec 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -14,7 +14,6 @@ from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.storage import STORAGE_DIR if TYPE_CHECKING: @@ -22,7 +21,6 @@ if TYPE_CHECKING: from .const import ( CONF_CONFIG_ENTRY, - CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, SERVICE_CLEAR_CACHE, @@ -49,18 +47,6 @@ def async_setup_services(hass: HomeAssistant) -> None: """Get the config entry for this service call.""" if CONF_CONFIG_ENTRY in call.data: entry_id = call.data[CONF_CONFIG_ENTRY] - elif CONF_INTERFACE in call.data: - # Deprecated in 2025.2, to remove in 2025.8 - async_create_issue( - hass, - DOMAIN, - "deprecated_interface_parameter", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_interface_parameter", - ) - entry_id = call.data[CONF_INTERFACE] if not (entry := hass.config_entries.async_get_entry(entry_id)): raise ServiceValidationError( translation_domain=DOMAIN, @@ -118,21 +104,14 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SCAN, scan, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ) - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } ), ) @@ -140,21 +119,14 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SYNC, syn_clock, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ) - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ) + } ), ) @@ -162,29 +134,18 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_SET_MEMO_TEXT, set_memo_text, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Required(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Required(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + vol.Optional(CONF_MEMO_TEXT, default=""): cv.template, + } ), ) @@ -192,26 +153,16 @@ def async_setup_services(hass: HomeAssistant) -> None: DOMAIN, SERVICE_CLEAR_CACHE, clear_cache, - vol.Any( - vol.Schema( - { - vol.Required(CONF_INTERFACE): vol.All(cv.string, check_entry_id), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } - ), - vol.Schema( - { - vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( - { - "integration": DOMAIN, - } - ), - vol.Optional(CONF_ADDRESS): vol.All( - vol.Coerce(int), vol.Range(min=0, max=255) - ), - } - ), + vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY): selector.ConfigEntrySelector( + { + "integration": DOMAIN, + } + ), + vol.Optional(CONF_ADDRESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=255) + ), + } ), ) diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml index 39886913692..2e649c60289 100644 --- a/homeassistant/components/velbus/services.yaml +++ b/homeassistant/components/velbus/services.yaml @@ -1,10 +1,5 @@ sync_clock: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: @@ -12,11 +7,6 @@ sync_clock: scan: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: @@ -24,11 +14,6 @@ scan: clear_cache: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: @@ -42,11 +27,6 @@ clear_cache: set_memo_text: fields: - interface: - example: "192.168.1.5:27015" - default: "" - selector: - text: config_entry: selector: config_entry: diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json index 4ef7ccf62c2..82bcf5cdd5d 100644 --- a/homeassistant/components/velbus/strings.json +++ b/homeassistant/components/velbus/strings.json @@ -60,10 +60,6 @@ "name": "Sync clock", "description": "Syncs the clock of the Velbus modules to the Home Assistant clock, this is the same as the 'sync clock' from VelbusLink.", "fields": { - "interface": { - "name": "Interface", - "description": "The Velbus interface to send the command to, this will be the same value as used during configuration." - }, "config_entry": { "name": "Config entry", "description": "The config entry of the Velbus integration" @@ -74,10 +70,6 @@ "name": "Scan", "description": "Scans the Velbus modules, this will be needed if you see unknown module warnings in the logs, or when you added new modules.", "fields": { - "interface": { - "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", - "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" - }, "config_entry": { "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" @@ -88,10 +80,6 @@ "name": "Clear cache", "description": "Clears the Velbus cache and then starts a new scan.", "fields": { - "interface": { - "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", - "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" - }, "config_entry": { "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" @@ -106,10 +94,6 @@ "name": "Set memo text", "description": "Sets the memo text to the display of modules like VMBGPO, VMBGPOD. Be sure the pages of the modules are configured to display the memo text.", "fields": { - "interface": { - "name": "[%key:component::velbus::services::sync_clock::fields::interface::name%]", - "description": "[%key:component::velbus::services::sync_clock::fields::interface::description%]" - }, "config_entry": { "name": "[%key:component::velbus::services::sync_clock::fields::config_entry::name%]", "description": "[%key:component::velbus::services::sync_clock::fields::config_entry::description%]" diff --git a/tests/components/velbus/test_services.py b/tests/components/velbus/test_services.py index 94ba91e6dc3..afcd79be7de 100644 --- a/tests/components/velbus/test_services.py +++ b/tests/components/velbus/test_services.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.components.velbus.const import ( CONF_CONFIG_ENTRY, - CONF_INTERFACE, CONF_MEMO_TEXT, DOMAIN, SERVICE_CLEAR_CACHE, @@ -18,57 +17,12 @@ from homeassistant.components.velbus.const import ( from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers import issue_registry as ir from . import init_integration from tests.common import MockConfigEntry -async def test_global_services_with_interface( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test services directed at the bus with an interface parameter.""" - await init_integration(hass, config_entry) - - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN, - {CONF_INTERFACE: config_entry.data["port"]}, - blocking=True, - ) - config_entry.runtime_data.controller.scan.assert_called_once_with() - assert issue_registry.async_get_issue(DOMAIN, "deprecated_interface_parameter") - - await hass.services.async_call( - DOMAIN, - SERVICE_SYNC, - {CONF_INTERFACE: config_entry.data["port"]}, - blocking=True, - ) - config_entry.runtime_data.controller.sync_clock.assert_called_once_with() - - # Test invalid interface - with pytest.raises(vol.error.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN, - {CONF_INTERFACE: "nonexistent"}, - blocking=True, - ) - - # Test missing interface - with pytest.raises(vol.error.MultipleInvalid): - await hass.services.async_call( - DOMAIN, - SERVICE_SCAN, - {}, - blocking=True, - ) - - async def test_global_survices_with_config_entry( hass: HomeAssistant, config_entry: MockConfigEntry, From ff76017ba6c0e76c9648f3046d91fb4842c6d908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 2 Jul 2025 12:12:26 +0000 Subject: [PATCH 1022/1664] Simplify unnecessary re match.groups()[0] calls (#147909) --- homeassistant/components/sonos/favorites.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- pylint/plugins/hass_inheritance.py | 2 +- script/hassfest/translations.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index f8b3dbbe492..8824c56a762 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -75,7 +75,7 @@ class SonosFavorites(SonosHouseholdCoordinator): if not (match := re.search(r"FV:2,(\d+)", container_ids)): return - container_id = int(match.groups()[0]) + container_id = int(match.group(1)) event_id = int(event_id.split(",")[-1]) async with self.cache_update_lock: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 32a053527f6..82118209e65 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -3241,7 +3241,7 @@ def _get_module_platform(module_name: str) -> str | None: # Or `homeassistant.components..` return None - platform = module_match.groups()[0] + platform = module_match.group(1) return platform.lstrip(".") if platform else "__init__" diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py index e386986fa23..cc2a40d4a4a 100644 --- a/pylint/plugins/hass_inheritance.py +++ b/pylint/plugins/hass_inheritance.py @@ -18,7 +18,7 @@ def _get_module_platform(module_name: str) -> str | None: # Or `homeassistant.components..` return None - platform = module_match.groups()[0] + platform = module_match.group(1) return platform.lstrip(".") if platform else "__init__" diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 913f7df2e7a..93fd212b981 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -98,7 +98,7 @@ def find_references( continue if match := re.match(RE_REFERENCE, value): - found.append({"source": f"{prefix}::{key}", "ref": match.groups()[0]}) + found.append({"source": f"{prefix}::{key}", "ref": match.group(1)}) def removed_title_validator( @@ -570,7 +570,7 @@ def validate_translation_file( "translations", "Lokalise supports only one level of references: " f'"{reference["source"]}" should point to directly ' - f'to "{match.groups()[0]}"', + f'to "{match.group(1)}"', ) From 57a98240bdf06ea6d6351e590aa97ee331a43961 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Jul 2025 14:26:19 +0200 Subject: [PATCH 1023/1664] Update frontend to 20250702.0 (#147952) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d9b9527c358..bfd868a5334 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250701.0"] + "requirements": ["home-assistant-frontend==20250702.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f1906df5bc1..769e8d9162e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f3339a810e0..5c0f9af19dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index baa57c6f063..95b19175fae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From b7496be61fe252b521ae828d3b82c743635874c7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 15:27:51 +0300 Subject: [PATCH 1024/1664] Bump aioamazondevices to 3.2.2 (#147953) --- 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 2e74561b755..7c23edd92ce 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": "bronze", - "requirements": ["aioamazondevices==3.2.1"] + "requirements": ["aioamazondevices==3.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5c0f9af19dc..4aa32680fad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.1 +aioamazondevices==3.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95b19175fae..135623f78ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.1 +aioamazondevices==3.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 3d27c0ce52718d028bc5687d28f274751973d51b Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 2 Jul 2025 14:48:21 +0200 Subject: [PATCH 1025/1664] SMA add DHCP strictness (#145753) * Add DHCP strictness (needs beta check) * Update to check on CONF_MAC * Update to check on CONF_HOST * Update hostname * Polish it a bit * Update to CONF_HOST, again * Add split * Add CONF_MAC add upon detection * epenet feedback * epenet round II --- homeassistant/components/sma/config_flow.py | 31 ++++++++++++++++++- tests/components/sma/test_config_flow.py | 33 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index f43c851d04a..e08b9ade9fc 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -184,7 +184,36 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): self._data[CONF_HOST] = discovery_info.ip self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) - await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + _LOGGER.debug( + "DHCP discovery detected SMA device: %s, IP: %s, MAC: %s", + self._discovery_data[CONF_NAME], + self._discovery_data[CONF_HOST], + self._discovery_data[CONF_MAC], + ) + + existing_entries_with_host = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.data.get(CONF_HOST) == self._data[CONF_HOST] + and not entry.data.get(CONF_MAC) + ] + + # If we have an existing entry with the same host but no MAC address, + # we update the entry with the MAC address and reload it. + if existing_entries_with_host: + entry = existing_entries_with_host[0] + self.async_update_reload_and_abort( + entry, data_updates={CONF_MAC: self._data[CONF_MAC]} + ) + + # Finally, check if the hostname (which represents the SMA serial number) is unique + serial_number = discovery_info.hostname.lower() + # Example hostname: sma12345678-01 + # Remove 'sma' prefix and strip everything after the dash (including the dash) + if serial_number.startswith("sma"): + serial_number = serial_number.removeprefix("sma") + serial_number = serial_number.split("-", 1)[0] + await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured() return await self.async_step_discovery_confirm() diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index 29779ec2773..b2e488318a5 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -11,8 +11,10 @@ import pytest from homeassistant.components.sma.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( @@ -37,6 +39,12 @@ DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( macaddress="0015bb00abcd", ) +DHCP_DISCOVERY_DUPLICATE_001 = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789-001", + macaddress="0015bb00abcd", +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock @@ -154,6 +162,31 @@ async def test_dhcp_already_configured( assert result["reason"] == "already_configured" +async def test_dhcp_already_configured_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by DHCP when already configured and MAC is added.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert CONF_MAC not in mock_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY_DUPLICATE_001, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert mock_config_entry.data.get(CONF_MAC) == format_mac( + DHCP_DISCOVERY_DUPLICATE_001.macaddress + ) + + @pytest.mark.parametrize( ("exception", "error"), [ From 7447cf329b272f1727910880aebadc2a54d47e3a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:57:46 +0200 Subject: [PATCH 1026/1664] UnifiProtect Change log level from debug to error for connection exceptions in ProtectFlowHandler (#147730) --- homeassistant/components/unifiprotect/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index a3833b355d7..9f7f4bccd7f 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -272,7 +272,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" except ClientError as ex: - _LOGGER.debug(ex) + _LOGGER.error(ex) errors["base"] = "cannot_connect" else: if nvr_data.version < MIN_REQUIRED_PROTECT_V: From 943fb9948bdfbc0e38488c4d28bf37bd59d9e0d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 2 Jul 2025 14:57:53 +0200 Subject: [PATCH 1027/1664] Adjust logic related to entity platform state (#147882) * Adjust logic related to entity platform state * Break up hard to read if-statement * Add and improve tests --- homeassistant/helpers/entity.py | 53 ++++---- tests/helpers/test_entity.py | 222 +++++++++++++++++++++++++++++++- 2 files changed, 251 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 39629d07494..352a77af837 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -215,16 +215,19 @@ class StateInfo(TypedDict): class EntityPlatformState(Enum): """The platform state of an entity.""" - # Not Added: Not yet added to a platform, polling updates - # are written to the state machine. + # Not Added: Not yet added to a platform, states are not written to the + # state machine. NOT_ADDED = auto() - # Added: Added to a platform, polling updates - # are written to the state machine. + # Adding: Preparing for adding to a platform, states are not written to the + # state machine. + ADDING = auto() + + # Added: Added to a platform, states are written to the state machine. ADDED = auto() - # Removed: Removed from a platform, polling updates - # are not written to the state machine. + # Removed: Removed from a platform, states are not written to the + # state machine. REMOVED = auto() @@ -1122,21 +1125,24 @@ class Entity( @callback def _async_write_ha_state(self) -> None: """Write the state to the state machine.""" - if self._platform_state is EntityPlatformState.REMOVED: - # Polling returned after the entity has already been removed - return - - if (entry := self.registry_entry) and entry.disabled_by: - if not self._disabled_reported: - self._disabled_reported = True - _LOGGER.warning( - ( - "Entity %s is incorrectly being triggered for updates while it" - " is disabled. This is a bug in the %s integration" - ), - self.entity_id, - self.platform.platform_name, - ) + # The check for self.platform guards against integrations not using an + # EntityComponent (which has not been allowed since HA Core 2024.1) + if not self.platform: + if self._platform_state is EntityPlatformState.REMOVED: + # Don't write state if the entity is not added to the platform. + return + elif self._platform_state is not EntityPlatformState.ADDED: + if (entry := self.registry_entry) and entry.disabled_by: + if not self._disabled_reported: + self._disabled_reported = True + _LOGGER.warning( + ( + "Entity %s is incorrectly being triggered for updates while it" + " is disabled. This is a bug in the %s integration" + ), + self.entity_id, + self.platform.platform_name, + ) return state_calculate_start = timer() @@ -1145,7 +1151,7 @@ class Entity( ) time_now = timer() - if entry: + if entry := self.registry_entry: # Make sure capabilities in the entity registry are up to date. Capabilities # include capability attributes, device class and supported features supported_features = supported_features or 0 @@ -1346,7 +1352,7 @@ class Entity( self.hass = hass self.platform = platform self.parallel_updates = parallel_updates - self._platform_state = EntityPlatformState.ADDED + self._platform_state = EntityPlatformState.ADDING def _call_on_remove_callbacks(self) -> None: """Call callbacks registered by async_on_remove.""" @@ -1370,6 +1376,7 @@ class Entity( """Finish adding an entity to a platform.""" await self.async_internal_added_to_hass() await self.async_added_to_hass() + self._platform_state = EntityPlatformState.ADDED self.async_write_ha_state() @final diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 706f1a1a806..24205870779 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -32,7 +32,7 @@ from homeassistant.core import ( ReleaseChannel, callback, ) -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -584,10 +584,13 @@ async def test_async_remove_no_platform(hass: HomeAssistant) -> None: ent = entity.Entity() ent.hass = hass ent.entity_id = "test.test" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED ent.async_write_ha_state() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED assert len(hass.states.async_entity_ids()) == 1 await ent.async_remove() assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: @@ -597,10 +600,13 @@ async def test_async_remove_runs_callbacks(hass: HomeAssistant) -> None: platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.entity_id = "test.test" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) + assert ent._platform_state == entity.EntityPlatformState.ADDED ent.async_on_remove(lambda: result.append(1)) await ent.async_remove() assert len(result) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_async_remove_ignores_in_flight_polling(hass: HomeAssistant) -> None: @@ -647,10 +653,12 @@ async def test_async_remove_twice(hass: HomeAssistant) -> None: await ent.async_remove() assert len(result) == 1 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED await ent.async_remove() assert len(result) == 1 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_set_context(hass: HomeAssistant) -> None: @@ -774,6 +782,7 @@ async def test_warn_slow_write_state( mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" mock_entity.platform = MagicMock(platform_name="hue") + mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): mock_entity.async_write_ha_state() @@ -801,6 +810,7 @@ async def test_warn_slow_write_state_custom_component( mock_entity.hass = hass mock_entity.entity_id = "comp_test.test_entity" mock_entity.platform = MagicMock(platform_name="hue") + mock_entity._platform_state = entity.EntityPlatformState.ADDED with patch("homeassistant.helpers.entity.timer", side_effect=[0, 10]): mock_entity.async_write_ha_state() @@ -1781,9 +1791,12 @@ async def test_reuse_entity_object_after_abort( platform = MockEntityPlatform(hass, domain="test") ent = entity.Entity() ent.entity_id = "invalid" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) + assert ent._platform_state == entity.EntityPlatformState.REMOVED assert "Invalid entity ID: invalid" in caplog.text await platform.async_add_entities([ent]) + assert ent._platform_state == entity.EntityPlatformState.REMOVED assert ( "Entity 'invalid' cannot be added a second time to an entity platform" in caplog.text @@ -1800,17 +1813,21 @@ async def test_reuse_entity_object_after_entity_registry_remove( platform = MockEntityPlatform(hass, domain="test", platform_name="test") ent = entity.Entity() ent._attr_unique_id = "5678" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) assert ent.registry_entry is entry assert len(hass.states.async_entity_ids()) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entity_registry.async_remove(entry.entity_id) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED await platform.async_add_entities([ent]) assert "Entity 'test.test_5678' cannot be added a second time" in caplog.text assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_reuse_entity_object_after_entity_registry_disabled( @@ -1823,19 +1840,23 @@ async def test_reuse_entity_object_after_entity_registry_disabled( platform = MockEntityPlatform(hass, domain="test", platform_name="test") ent = entity.Entity() ent._attr_unique_id = "5678" + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) assert ent.registry_entry is entry assert len(hass.states.async_entity_ids()) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entity_registry.async_update_entity( entry.entity_id, disabled_by=er.RegistryEntryDisabler.USER ) await hass.async_block_till_done() assert len(hass.states.async_entity_ids()) == 0 + assert ent._platform_state == entity.EntityPlatformState.REMOVED await platform.async_add_entities([ent]) assert len(hass.states.async_entity_ids()) == 0 assert "Entity 'test.test_5678' cannot be added a second time" in caplog.text + assert ent._platform_state == entity.EntityPlatformState.REMOVED async def test_change_entity_id( @@ -1865,9 +1886,11 @@ async def test_change_entity_id( platform = MockEntityPlatform(hass, domain="test") ent = MockEntity() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED await platform.async_add_entities([ent]) assert hass.states.get("test.test").state == STATE_UNKNOWN assert len(ent.added_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entry = entity_registry.async_update_entity( entry.entity_id, new_entity_id="test.test2" @@ -1877,6 +1900,7 @@ async def test_change_entity_id( assert len(result) == 1 assert len(ent.added_calls) == 2 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.ADDED entity_registry.async_update_entity(entry.entity_id, new_entity_id="test.test3") await hass.async_block_till_done() @@ -1884,6 +1908,7 @@ async def test_change_entity_id( assert len(result) == 2 assert len(ent.added_calls) == 3 assert len(ent.remove_calls) == 2 + assert ent._platform_state == entity.EntityPlatformState.ADDED def test_entity_description_as_dataclass(snapshot: SnapshotAssertion) -> None: @@ -2524,6 +2549,7 @@ async def test_remove_entity_registry( assert len(result) == 1 assert len(ent.added_calls) == 1 assert len(ent.remove_calls) == 1 + assert ent._platform_state == entity.EntityPlatformState.REMOVED assert hass.states.get("test.test") is None @@ -2628,6 +2654,7 @@ async def test_async_write_ha_state_thread_safety_always( ent.entity_id = "test.any" ent.hass = hass ent.platform = MockEntityPlatform(hass, domain="test") + ent._platform_state = entity.EntityPlatformState.ADDED ent.async_write_ha_state() assert hass.states.get(ent.entity_id) @@ -2641,3 +2668,196 @@ async def test_async_write_ha_state_thread_safety_always( ): await hass.async_add_executor_job(ent2.async_write_ha_state) assert not hass.states.get(ent2.entity_id) + + +async def test_platform_state( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test platform state.""" + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + async def async_added_to_hass(self): + # The attempt to write when in state ADDING should be ignored + assert self._platform_state == entity.EntityPlatformState.ADDING + self._attr_state = "added_to_hass" + self.async_write_ha_state() + assert hass.states.get("test.test") is None + + async def async_will_remove_from_hass(self): + # The attempt to write when in state REMOVED should be ignored + assert self._platform_state == entity.EntityPlatformState.REMOVED + assert hass.states.get("test.test").state == "added_to_hass" + self._attr_state = "will_remove_from_hass" + self.async_write_ha_state() + assert hass.states.get("test.test").state == "added_to_hass" + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == "added_to_hass" + assert ent._platform_state == entity.EntityPlatformState.ADDED + + entry = entity_registry.async_remove(entry.entity_id) + await hass.async_block_till_done() + + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert hass.states.get("test.test") is None + + +async def test_platform_state_fail_to_add( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test platform state when raising from async_added_to_hass.""" + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + async def async_added_to_hass(self): + raise ValueError("Failed to add entity") + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity() + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test") is None + assert ent._platform_state == entity.EntityPlatformState.ADDING + + entry = entity_registry.async_remove(entry.entity_id) + await hass.async_block_till_done() + + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert hass.states.get("test.test") is None + + +async def test_platform_state_write_from_init( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test platform state when an entity attempts to write from init.""" + + class MockEntity(entity.Entity): + def __init__(self, hass: HomeAssistant) -> None: + self.hass = hass + # The attempt to write when in state NOT_ADDED is prevented because + # the entity has no entity_id set + self._attr_state = "init" + with pytest.raises(NoEntitySpecifiedError): + self.async_write_ha_state() + assert len(hass.states.async_all()) == 0 + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity(hass) + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.unnamed_device").state == "init" + assert ent._platform_state == entity.EntityPlatformState.ADDED + + assert len(hass.states.async_all()) == 1 + + assert "Platform test_platform does not generate unique IDs." not in caplog.text + assert "Entity id already exists" not in caplog.text + + +async def test_platform_state_write_from_init_entity_id( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test platform state when an entity attempts to write from init. + + The outcome of this test is a bit illogical, when we no longer allow + entities without platforms, attempts to write when state is NOT_ADDED + will be blocked. + """ + + class MockEntity(entity.Entity): + def __init__(self, hass: HomeAssistant) -> None: + self.entity_id = "test.test" + self.hass = hass + # The attempt to write when in state NOT_ADDED is not prevented because + # the platform is not yet set + assert self._platform_state == entity.EntityPlatformState.NOT_ADDED + self._attr_state = "init" + self.async_write_ha_state() + assert hass.states.get("test.test").state == "init" + + async def async_added_to_hass(self): + raise NotImplementedError("Should not be called") + + async def async_will_remove_from_hass(self): + raise NotImplementedError("Should not be called") + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity(hass) + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == "init" + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert len(hass.states.async_all()) == 1 + + # The early attempt to write is interpreted as a state collision + assert "Platform test_platform does not generate unique IDs." not in caplog.text + assert "Entity id already exists - ignoring: test.test" in caplog.text + + +async def test_platform_state_write_from_init_unique_id( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test platform state when an entity attempts to write from init. + + The outcome of this test is a bit illogical, when we no longer allow + entities without platforms, attempts to write when state is NOT_ADDED + will be blocked. + """ + + entry = entity_registry.async_get_or_create( + "test", "test_platform", "5678", suggested_object_id="test" + ) + assert entry.entity_id == "test.test" + + class MockEntity(entity.Entity): + _attr_unique_id = "5678" + + def __init__(self, hass: HomeAssistant) -> None: + self.entity_id = "test.test" + self.hass = hass + # The attempt to write when in state NOT_ADDED is not prevented because + # the platform is not yet set + assert self._platform_state == entity.EntityPlatformState.NOT_ADDED + self._attr_state = "init" + self.async_write_ha_state() + assert hass.states.get("test.test").state == "init" + + async def async_added_to_hass(self): + raise NotImplementedError("Should not be called") + + async def async_will_remove_from_hass(self): + raise NotImplementedError("Should not be called") + + platform = MockEntityPlatform(hass, domain="test") + ent = MockEntity(hass) + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + await platform.async_add_entities([ent]) + assert hass.states.get("test.test").state == "init" + assert ent._platform_state == entity.EntityPlatformState.REMOVED + + assert len(hass.states.async_all()) == 1 + + # The early attempt to write is interpreted as a unique ID collision + assert "Platform test_platform does not generate unique IDs." in caplog.text + assert "Entity id already exists - ignoring: test.test" not in caplog.text From f50ef79c72aa44cbb49dc3fec717dc8cdf404dbd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 2 Jul 2025 15:20:42 +0200 Subject: [PATCH 1028/1664] Ollama: Migrate pick model to subentry (#147944) --- homeassistant/components/ollama/__init__.py | 34 +- .../components/ollama/config_flow.py | 299 ++++++------ homeassistant/components/ollama/entity.py | 5 +- homeassistant/components/ollama/strings.json | 22 +- tests/components/ollama/__init__.py | 2 +- tests/components/ollama/conftest.py | 13 +- tests/components/ollama/test_config_flow.py | 448 ++++++++++++------ tests/components/ollama/test_conversation.py | 38 +- tests/components/ollama/test_init.py | 127 +++-- 9 files changed, 655 insertions(+), 333 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index f28382d14fc..6fe4720d13f 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +from types import MappingProxyType import httpx import ollama @@ -100,8 +101,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: for entry in entries: use_existing = False + # Create subentry with model from entry.data and options from entry.options + subentry_data = entry.options.copy() + subentry_data[CONF_MODEL] = entry.data[CONF_MODEL] + subentry = ConfigSubentry( - data=entry.options, + data=MappingProxyType(subentry_data), subentry_type="conversation", title=entry.title, unique_id=None, @@ -154,9 +159,11 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, + # Update parent entry to only keep URL, remove model + data={CONF_URL: entry.data[CONF_URL]}, options={}, - version=2, - minor_version=2, + version=3, + minor_version=1, ) @@ -164,7 +171,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> """Migrate entry.""" _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: + if entry.version > 3: # This means the user has downgraded from a future version return False @@ -182,6 +189,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Update subentries to include the model + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + updated_data = dict(subentry.data) + updated_data[CONF_MODEL] = entry.data[CONF_MODEL] + + hass.config_entries.async_update_subentry( + entry, subentry, data=MappingProxyType(updated_data) + ) + + # Update main entry to remove model and bump version + hass.config_entries.async_update_entry( + entry, + data={CONF_URL: entry.data[CONF_URL]}, + version=3, + minor_version=1, + ) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 03e2b038bab..49eb12a5c23 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import llm +from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( BooleanSelector, NumberSelector, @@ -38,6 +38,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.util.ssl import get_default_context +from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, @@ -72,43 +73,43 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" - VERSION = 2 - MINOR_VERSION = 2 + VERSION = 3 + MINOR_VERSION = 1 def __init__(self) -> None: """Initialize config flow.""" self.url: str | None = None - self.model: str | None = None - self.client: ollama.AsyncClient | None = None - self.download_task: asyncio.Task | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - user_input = user_input or {} - self.url = user_input.get(CONF_URL, self.url) - self.model = user_input.get(CONF_MODEL, self.model) - - if self.url is None: + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, last_step=False + step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) errors = {} + url = user_input[CONF_URL] - self._async_abort_entries_match({CONF_URL: self.url}) + self._async_abort_entries_match({CONF_URL: url}) try: - self.client = ollama.AsyncClient( - host=self.url, verify=get_default_context() + url = cv.url(url) + except vol.Invalid: + errors["base"] = "invalid_url" + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - async with asyncio.timeout(DEFAULT_TIMEOUT): - response = await self.client.list() - downloaded_models: set[str] = { - model_info["model"] for model_info in response.get("models", []) - } + try: + client = ollama.AsyncClient(host=url, verify=get_default_context()) + async with asyncio.timeout(DEFAULT_TIMEOUT): + await client.list() except (TimeoutError, httpx.ConnectError): errors["base"] = "cannot_connect" except Exception: @@ -117,10 +118,69 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): if errors: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - if self.model is None: + return self.async_create_entry( + title=url, + data={CONF_URL: url}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} + + +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self._name: str | None = None + self._model: str | None = None + self.download_task: asyncio.Task | None = None + self._config_data: dict[str, Any] | None = None + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + @property + def _client(self) -> ollama.AsyncClient: + """Return the Ollama client.""" + entry: OllamaConfigEntry = self._get_entry() + return entry.runtime_data + + async def async_step_set_options( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle model selection and configuration step.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is None: + # Get available models from Ollama server + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + except (TimeoutError, httpx.ConnectError, httpx.HTTPError): + _LOGGER.exception("Failed to get models from Ollama server") + return self.async_abort(reason="cannot_connect") + # Show models that have been downloaded first, followed by all known # models (only latest tags). models_to_list = [ @@ -131,52 +191,69 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): for m in sorted(MODEL_NAMES) if m not in downloaded_models ] - model_step_schema = vol.Schema( - { - vol.Required( - CONF_MODEL, description={"suggested_value": DEFAULT_MODEL} - ): SelectSelector( - SelectSelectorConfig(options=models_to_list, custom_value=True) - ), - } - ) + + if self._is_new: + options = {} + else: + options = self._get_reconfigure_subentry().data.copy() return self.async_show_form( - step_id="user", - data_schema=model_step_schema, + step_id="set_options", + data_schema=vol.Schema( + ollama_config_option_schema( + self.hass, self._is_new, options, models_to_list + ) + ), ) - if self.model not in downloaded_models: - # Ollama server needs to download model first - return await self.async_step_download() + self._model = user_input[CONF_MODEL] + if self._is_new: + self._name = user_input.pop(CONF_NAME) - return self.async_create_entry( - title=self.url, - data={CONF_URL: self.url, CONF_MODEL: self.model}, - subentries=[ - { - "subentry_type": "conversation", - "data": {}, - "title": _get_title(self.model), - "unique_id": None, - } - ], + # Check if model needs to be downloaded + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + currently_downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + + if self._model not in currently_downloaded_models: + # Store the user input to use after download + self._config_data = user_input + # Ollama server needs to download model first + return await self.async_step_download() + except Exception: + _LOGGER.exception("Failed to check model availability") + return self.async_abort(reason="cannot_connect") + + # Model is already downloaded, create/update the entry + if self._is_new: + return self.async_create_entry( + title=self._name, + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, ) async def async_step_download( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step to wait for Ollama server to download a model.""" - assert self.model is not None - assert self.client is not None + assert self._model is not None if self.download_task is None: # Tell Ollama server to pull the model. # The task will block until the model and metadata are fully # downloaded. self.download_task = self.hass.async_create_background_task( - self.client.pull(self.model), - f"Downloading {self.model}", + self._client.pull(self._model), + f"Downloading {self._model}", ) if self.download_task.done(): @@ -192,80 +269,28 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): progress_task=self.download_task, ) - async def async_step_finish( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step after model downloading has succeeded.""" - assert self.url is not None - assert self.model is not None - - return self.async_create_entry( - title=_get_title(self.model), - data={CONF_URL: self.url, CONF_MODEL: self.model}, - subentries=[ - { - "subentry_type": "conversation", - "data": {}, - "title": _get_title(self.model), - "unique_id": None, - } - ], - ) - async def async_step_failed( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step after model downloading has failed.""" return self.async_abort(reason="download_failed") - @classmethod - @callback - def async_get_supported_subentry_types( - cls, config_entry: ConfigEntry - ) -> dict[str, type[ConfigSubentryFlow]]: - """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} - - -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" - - @property - def _is_new(self) -> bool: - """Return if this is a new subentry.""" - return self.source == "user" - - async def async_step_set_options( + async def async_step_finish( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Set conversation options.""" - # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: - return self.async_abort(reason="entry_not_loaded") + """Step after model downloading has succeeded.""" + assert self._config_data is not None - errors: dict[str, str] = {} - - if user_input is None: - if self._is_new: - options = {} - else: - options = self._get_reconfigure_subentry().data.copy() - - elif self._is_new: + # Model download completed, create/update the entry with stored config + if self._is_new: return self.async_create_entry( - title=user_input.pop(CONF_NAME), - data=user_input, + title=self._name, + data=self._config_data, ) - else: - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=user_input, - ) - - schema = ollama_config_option_schema(self.hass, self._is_new, options) - return self.async_show_form( - step_id="set_options", data_schema=vol.Schema(schema), errors=errors + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=self._config_data, ) async_step_user = async_step_set_options @@ -273,19 +298,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): def ollama_config_option_schema( - hass: HomeAssistant, is_new: bool, options: Mapping[str, Any] + hass: HomeAssistant, + is_new: bool, + options: Mapping[str, Any], + models_to_list: list[SelectOptionDict], ) -> dict: """Ollama options schema.""" - hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label=api.name, - value=api.id, - ) - for api in llm.async_get_apis(hass) - ] - if is_new: - schema: dict[vol.Required | vol.Optional, Any] = { + schema: dict = { vol.Required(CONF_NAME, default="Ollama Conversation"): str, } else: @@ -293,6 +313,12 @@ def ollama_config_option_schema( schema.update( { + vol.Required( + CONF_MODEL, + description={"suggested_value": options.get(CONF_MODEL, DEFAULT_MODEL)}, + ): SelectSelector( + SelectSelectorConfig(options=models_to_list, custom_value=True) + ), vol.Optional( CONF_PROMPT, description={ @@ -304,7 +330,18 @@ def ollama_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ], + multiple=True, + ) + ), vol.Optional( CONF_NUM_CTX, description={ @@ -350,11 +387,3 @@ def ollama_config_option_schema( ) return schema - - -def _get_title(model: str) -> str: - """Get title for config entry.""" - if model.endswith(":latest"): - model = model.split(":", maxsplit=1)[0] - - return model diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index a577bf77573..7b63b1dff00 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -166,11 +166,14 @@ class OllamaBaseLLMEntity(Entity): self.subentry = subentry self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id + + model, _, version = subentry.data[CONF_MODEL].partition(":") self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Ollama", - model=entry.data[CONF_MODEL], + model=model, + sw_version=version or "latest", entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 74a5eaff454..bb08e4a4684 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -3,24 +3,17 @@ "step": { "user": { "data": { - "url": "[%key:common::config_flow::data::url%]", - "model": "Model" + "url": "[%key:common::config_flow::data::url%]" } - }, - "download": { - "title": "Downloading model" } }, "abort": { - "download_failed": "Model downloading failed", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "invalid_url": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } }, "config_subentries": { @@ -33,6 +26,7 @@ "step": { "set_options": { "data": { + "model": "Model", "name": "[%key:common::config_flow::data::name%]", "prompt": "Instructions", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", @@ -47,11 +41,19 @@ "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." } + }, + "download": { + "title": "Downloading model" } }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "entry_not_loaded": "Cannot add things while the configuration is disabled." + "entry_not_loaded": "Failed to add agent. The configuration is disabled.", + "download_failed": "Model downloading failed", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } } } diff --git a/tests/components/ollama/__init__.py b/tests/components/ollama/__init__.py index 6ad77bb2217..92db3b13304 100644 --- a/tests/components/ollama/__init__.py +++ b/tests/components/ollama/__init__.py @@ -5,10 +5,10 @@ from homeassistant.helpers import llm TEST_USER_DATA = { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: "test model", } TEST_OPTIONS = { ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, ollama.CONF_MAX_HISTORY: 2, + ollama.CONF_MODEL: "test_model:latest", } diff --git a/tests/components/ollama/conftest.py b/tests/components/ollama/conftest.py index c99f586a5d4..552e7dee20a 100644 --- a/tests/components/ollama/conftest.py +++ b/tests/components/ollama/conftest.py @@ -30,10 +30,11 @@ def mock_config_entry( entry = MockConfigEntry( domain=ollama.DOMAIN, data=TEST_USER_DATA, - version=2, + version=3, + minor_version=1, subentries_data=[ { - "data": mock_config_entry_options, + "data": {**TEST_OPTIONS, **mock_config_entry_options}, "subentry_type": "conversation", "title": "Ollama Conversation", "unique_id": None, @@ -49,10 +50,14 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" + subentry = next(iter(mock_config_entry.subentries.values())) hass.config_entries.async_update_subentry( mock_config_entry, - next(iter(mock_config_entry.subentries.values())), - data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, + subentry, + data={ + **subentry.data, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + }, ) return mock_config_entry diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 4b78df9bce2..7372a460c95 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import ollama +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,7 +18,7 @@ TEST_MODEL = "test_model:latest" async def test_form(hass: HomeAssistant) -> None: - """Test flow when the model is already downloaded.""" + """Test flow when configuring URL only.""" # Pretend we already set up a config entry. hass.config.components.add(ollama.DOMAIN) MockConfigEntry( @@ -34,7 +35,6 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # test model is already "downloaded" return_value={"models": [{"model": TEST_MODEL}]}, ), patch( @@ -42,24 +42,17 @@ async def test_form(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - # Step 1: URL result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} ) await hass.async_block_till_done() - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"] == { + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, } + # No subentries created by default + assert len(result2.get("subentries", [])) == 0 assert len(mock_setup_entry.mock_calls) == 1 @@ -94,98 +87,6 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_need_download(hass: HomeAssistant) -> None: - """Test flow when a model needs to be downloaded.""" - # Pretend we already set up a config entry. - hass.config.components.add(ollama.DOMAIN) - MockConfigEntry( - domain=ollama.DOMAIN, - state=config_entries.ConfigEntryState.LOADED, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - pull_ready = asyncio.Event() - pull_called = asyncio.Event() - pull_model: str | None = None - - async def pull(self, model: str, *args, **kwargs) -> None: - nonlocal pull_model - - async with asyncio.timeout(1): - await pull_ready.wait() - - pull_model = model - pull_called.set() - - with ( - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # No models are downloaded - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - pull, - ), - patch( - "homeassistant.components.ollama.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - # Step 1: URL - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} - ) - await hass.async_block_till_done() - - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - # Step 3: download - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - ) - await hass.async_block_till_done() - - # Run again without the task finishing. - # We should still be downloading. - assert result4["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - await hass.async_block_till_done() - assert result4["type"] is FlowResultType.SHOW_PROGRESS - - # Signal fake pull method to complete - pull_ready.set() - async with asyncio.timeout(1): - await pull_called.wait() - - assert pull_model == TEST_MODEL - - # Step 4: finish - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["data"] == { - ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_subentry_options( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: @@ -193,34 +94,84 @@ async def test_subentry_options( subentry = next(iter(mock_config_entry.subentries.values())) # Test reconfiguration - options_flow = await mock_config_entry.start_subentry_reconfigure_flow( - hass, subentry.subentry_id - ) + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) - assert options_flow["type"] is FlowResultType.FORM - assert options_flow["step_id"] == "set_options" + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["step_id"] == "set_options" - options = await hass.config_entries.subentries.async_configure( - options_flow["flow_id"], - { - ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, - ollama.CONF_THINK: True, - }, - ) + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 100, + ollama.CONF_NUM_CTX: 32768, + ollama.CONF_THINK: True, + }, + ) await hass.async_block_till_done() assert options["type"] is FlowResultType.ABORT assert options["reason"] == "reconfigure_successful" assert subentry.data == { + ollama.CONF_MODEL: TEST_MODEL, ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, + ollama.CONF_MAX_HISTORY: 100.0, + ollama.CONF_NUM_CTX: 32768.0, ollama.CONF_THINK: True, } +async def test_creating_new_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating a new conversation subentry includes name field.""" + # Start a new subentry flow + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with name field + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component, @@ -237,6 +188,125 @@ async def test_creating_conversation_subentry_not_loaded( assert result["reason"] == "entry_not_loaded" +async def test_subentry_need_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model needs to be downloaded.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop 1 iteration + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM, new_flow + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", # not cached + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + +async def test_subentry_download_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model download fails.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + await asyncio.sleep(0) # yield + + raise RuntimeError("Download failed") + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model that needs downloading but will fail + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + # Should show progress flow result for download + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + # Wait for download task to complete (with error) + await hass.async_block_till_done() + + # Submit the progress flow - should get failure + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "download_failed" + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -262,40 +332,132 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["errors"] == {"base": error} -async def test_download_error(hass: HomeAssistant) -> None: - """Test we handle errors while downloading a model.""" +async def test_form_invalid_url(hass: HomeAssistant) -> None: + """Test we handle invalid URL.""" result = await hass.config_entries.flow.async_init( ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - async def _delayed_runtime_error(*args, **kwargs): - await asyncio.sleep(0) - raise RuntimeError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {ollama.CONF_URL: "not-a-valid-url"} + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_url"} + + +async def test_subentry_connection_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when connection to Ollama server fails.""" + with patch( + "ollama.AsyncClient.list", + side_effect=ConnectError("Connection failed"), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.ABORT + assert new_flow["reason"] == "cannot_connect" + + +async def test_subentry_model_check_exception( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when checking model availability throws exception.""" + with patch( + "ollama.AsyncClient.list", + side_effect=[ + {"models": [{"model": TEST_MODEL}]}, # First call succeeds + RuntimeError("Failed to check models"), # Second call fails + ], + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model, should fail when checking availability + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "new_model:latest", + CONF_NAME: "Test Conversation", + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_subentry_reconfigure_with_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry when model needs to be downloaded.""" + subentry = next(iter(mock_config_entry.subentries.values())) + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop with ( patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - _delayed_runtime_error, + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, ), + patch("ollama.AsyncClient.pull", delayed_pull), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} + reconfigure_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "set_options" + + # Reconfigure with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75, + ollama.CONF_NUM_CTX: 8192, + ollama.CONF_THINK: True, + }, ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) - await hass.async_block_till_done() + # Finish download + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], {} + ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "download_failed" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert subentry.data == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75.0, + ollama.CONF_NUM_CTX: 8192.0, + ollama.CONF_THINK: True, + } diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index d33fffe7152..f7e50d61e2c 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -15,7 +15,12 @@ from homeassistant.components.conversation import trace from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent, + llm, +) from tests.common import MockConfigEntry @@ -68,7 +73,7 @@ async def test_chat( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -128,7 +133,7 @@ async def test_chat_stream( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -158,6 +163,7 @@ async def test_template_variables( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." ), + ollama.CONF_MODEL: "test_model:latest", }, ) with ( @@ -524,7 +530,9 @@ async def test_message_history_unlimited( ): subentry = next(iter(mock_config_entry.subentries.values())) hass.config_entries.async_update_subentry( - mock_config_entry, subentry, data={ollama.CONF_MAX_HISTORY: 0} + mock_config_entry, + subentry, + data={**subentry.data, ollama.CONF_MAX_HISTORY: 0}, ) for i in range(100): result = await conversation.async_converse( @@ -573,6 +581,7 @@ async def test_template_error( mock_config_entry, subentry, data={ + **subentry.data, "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -593,6 +602,8 @@ async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test OllamaConversationEntity.""" agent = conversation.get_agent_manager(hass).async_get_agent( @@ -604,6 +615,24 @@ async def test_conversation_agent( assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + entity_entry = entity_registry.async_get("conversation.ollama_conversation") + 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 + + assert device_entry.identifiers == {(ollama.DOMAIN, subentry.subentry_id)} + assert device_entry.name == subentry.title + assert device_entry.manufacturer == "Ollama" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + + model, _, version = subentry.data[ollama.CONF_MODEL].partition(":") + assert device_entry.model == model + assert device_entry.sw_version == version + async def test_conversation_agent_with_assist( hass: HomeAssistant, @@ -679,6 +708,7 @@ async def test_reasoning_filter( mock_config_entry, subentry, data={ + **subentry.data, ollama.CONF_THINK: think, }, ) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index a6cfe4c2de0..c7cd78fca9a 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -9,13 +9,26 @@ from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er, llm from homeassistant.setup import async_setup_component -from . import TEST_OPTIONS, TEST_USER_DATA +from . import TEST_OPTIONS from tests.common import MockConfigEntry +V1_TEST_USER_DATA = { + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", +} + +V1_TEST_OPTIONS = { + ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + ollama.CONF_MAX_HISTORY: 2, +} + +V21_TEST_USER_DATA = V1_TEST_USER_DATA +V21_TEST_OPTIONS = V1_TEST_OPTIONS + @pytest.mark.parametrize( ("side_effect", "error"), @@ -41,17 +54,17 @@ async def test_init_error( assert error in caplog.text -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2.""" + """Test migration from version 1.""" # Create a v1 config entry with conversation options and an entity mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=TEST_USER_DATA, - options=TEST_OPTIONS, + data=V1_TEST_USER_DATA, + options=V1_TEST_OPTIONS, version=1, title="llama-3.2-8b", ) @@ -81,9 +94,10 @@ async def test_migration_from_v1_to_v2( ): await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 - assert mock_config_entry.data == TEST_USER_DATA + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 1 + # After migration, parent entry should only have URL + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} assert len(mock_config_entry.subentries) == 1 @@ -92,7 +106,9 @@ async def test_migration_from_v1_to_v2( assert subentry.unique_id is None assert subentry.title == "llama-3.2-8b" assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should now include the model from the original options + expected_subentry_data = TEST_OPTIONS.copy() + assert subentry.data == expected_subentry_data migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None @@ -117,17 +133,17 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_urls( +async def test_migration_from_v1_with_multiple_urls( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with different URLs.""" + """Test migration from version 1 with different URLs.""" # Create two v1 config entries with different URLs mock_config_entry = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 1", ) @@ -135,7 +151,7 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( mock_config_entry_2 = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11435", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 2", ) @@ -187,13 +203,16 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( assert len(entries) == 2 for idx, entry in enumerate(entries): - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data assert subentry.title == f"Ollama {idx + 1}" dev = device_registry.async_get_device( @@ -204,17 +223,17 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} -async def test_migration_from_v1_to_v2_with_same_urls( +async def test_migration_from_v1_with_same_urls( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with same URLs consolidates entries.""" + """Test migration from version 1 with same URLs consolidates entries.""" # Create two v1 config entries with the same URL mock_config_entry = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama", ) @@ -222,7 +241,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( mock_config_entry_2 = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, # Same URL - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 2", ) @@ -275,8 +294,8 @@ async def test_migration_from_v1_to_v2_with_same_urls( assert len(entries) == 1 entry = entries[0] - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -288,7 +307,10 @@ async def test_migration_from_v1_to_v2_with_same_urls( for subentry in subentries: assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data # Check devices were migrated correctly dev = device_registry.async_get_device( @@ -301,12 +323,12 @@ async def test_migration_from_v1_to_v2_with_same_urls( } -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core 2025.7.0b0-2025.7.0b1: @@ -315,20 +337,20 @@ async def test_migration_from_v2_1_to_v2_2( # Create a v2.1 config entry with 2 subentries, devices and entities mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=TEST_USER_DATA, + data=V21_TEST_USER_DATA, entry_id="mock_entry_id", version=2, minor_version=1, subentries_data=[ ConfigSubentryData( - data=TEST_OPTIONS, + data=V21_TEST_OPTIONS, subentry_id="mock_id_1", subentry_type="conversation", title="Ollama", unique_id=None, ), ConfigSubentryData( - data=TEST_OPTIONS, + data=V21_TEST_OPTIONS, subentry_id="mock_id_2", subentry_type="conversation", title="Ollama 2", @@ -392,8 +414,8 @@ async def test_migration_from_v2_1_to_v2_2( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert entry.title == "Ollama" assert len(entry.subentries) == 2 @@ -405,6 +427,7 @@ async def test_migration_from_v2_1_to_v2_2( assert len(conversation_subentries) == 2 for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" + # Since TEST_USER_DATA no longer has a model, subentry data should be TEST_OPTIONS assert subentry.data == TEST_OPTIONS assert "Ollama" in subentry.title @@ -450,3 +473,45 @@ async def test_migration_from_v2_1_to_v2_2( assert device.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + subentry_data = ConfigSubentryData( + data=V21_TEST_USER_DATA, + subentry_type="conversation", + title="Test Conversation", + unique_id=None, + ) + + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", # Model still in main data + }, + version=2, + minor_version=2, + subentries_data=[subentry_data], + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Check migration to v3.1 + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 1 + + # Check that model was moved from main data to subentry + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.data == { + **V21_TEST_USER_DATA, + ollama.CONF_MODEL: "test_model:latest", + } From d6da686ffef516ea11222ea834c8166635d698d7 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:23:08 +0200 Subject: [PATCH 1029/1664] Z-Wave JS: rename controller to adapter according to term decision (#147955) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/zwave_js/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index b7f9b180624..7445182e5f6 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -15,12 +15,12 @@ "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "migration_low_sdk_version": "The SDK version of the old controller is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old controller to another controller.\n\nCheck the documentation on the manufacturer support pages of the old controller, if it's possible to upgrade the firmware of the old controller to a version that is build with SDK version {ok_sdk_version} or higher.", + "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "reset_failed": "Failed to reset controller.", + "reset_failed": "Failed to reset adapter.", "usb_ports_failed": "Failed to get USB devices." }, "error": { @@ -114,19 +114,19 @@ }, "reconfigure": { "title": "Migrate or re-configure", - "description": "Are you migrating to a new controller or re-configuring the current controller?", + "description": "Are you migrating to a new adapter or re-configuring the current adapter?", "menu_options": { - "intent_migrate": "Migrate to a new controller", - "intent_reconfigure": "Re-configure the current controller" + "intent_migrate": "Migrate to a new adapter", + "intent_reconfigure": "Re-configure the current adapter" } }, "instruct_unplug": { - "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." + "title": "Unplug your old adapter", + "description": "Backup saved to \"{file_path}\"\n\nYour old adapter has not been reset. You should now unplug it to prevent it from interfering with the new adapter.\n\nPlease make sure your new adapter is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", - "description": "Your Z-Wave network could not be restored to the new controller. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", + "description": "Your Z-Wave network could not be restored to the new adapter. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", "submit": "Try again" }, "choose_serial_port": { @@ -289,12 +289,12 @@ "fix_flow": { "step": { "confirm": { - "description": "A Z-Wave controller of model {controller_model} with a different ID ({new_unique_id}) than the previously connected controller ({old_unique_id}) was connected to the {config_entry_title} configuration entry.\n\nReasons for a different controller ID could be:\n\n1. The controller was factory reset, with a 3rd party application.\n2. A controller Non Volatile Memory (NVM) backup was restored to the controller, with a 3rd party application.\n3. A different controller was connected to this configuration entry.\n\nIf a different controller was connected, you should instead set up a new configuration entry for the new controller.\n\nIf you are sure that the current controller is the correct controller you can confirm this by pressing Submit, and the configuration entry will remember the new controller ID.", - "title": "An unknown controller was detected" + "description": "A Z-Wave adapter of model {controller_model} was connected to the {config_entry_title} configuration entry. This adapter has a different ID ({new_unique_id}) than the previously connected adapter ({old_unique_id}).\n\nReasons for a different adapter ID could be:\n\n1. The adapter was factory reset using a 3rd party application.\n2. A backup of the adapter's non-volatile memory was restored to the adapter using a 3rd party application.\n3. A different adapter was connected to this configuration entry.\n\nIf a different adapter was connected, you should instead set up a new configuration entry for the new adapter.\n\nIf you are sure that the current adapter is the correct adapter, confirm by pressing Submit. The configuration entry will remember the new adapter ID.", + "title": "An unknown adapter was detected" } } }, - "title": "An unknown controller was detected" + "title": "An unknown adapter was detected" } }, "services": { From b677ce6c9069e8ee38d2c807b5e736ecb84692fc Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 2 Jul 2025 14:48:21 +0200 Subject: [PATCH 1030/1664] SMA add DHCP strictness (#145753) * Add DHCP strictness (needs beta check) * Update to check on CONF_MAC * Update to check on CONF_HOST * Update hostname * Polish it a bit * Update to CONF_HOST, again * Add split * Add CONF_MAC add upon detection * epenet feedback * epenet round II --- homeassistant/components/sma/config_flow.py | 31 ++++++++++++++++++- tests/components/sma/test_config_flow.py | 33 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index f43c851d04a..e08b9ade9fc 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -184,7 +184,36 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN): self._data[CONF_HOST] = discovery_info.ip self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC]) - await self.async_set_unique_id(discovery_info.hostname.replace("SMA", "")) + _LOGGER.debug( + "DHCP discovery detected SMA device: %s, IP: %s, MAC: %s", + self._discovery_data[CONF_NAME], + self._discovery_data[CONF_HOST], + self._discovery_data[CONF_MAC], + ) + + existing_entries_with_host = [ + entry + for entry in self._async_current_entries(include_ignore=False) + if entry.data.get(CONF_HOST) == self._data[CONF_HOST] + and not entry.data.get(CONF_MAC) + ] + + # If we have an existing entry with the same host but no MAC address, + # we update the entry with the MAC address and reload it. + if existing_entries_with_host: + entry = existing_entries_with_host[0] + self.async_update_reload_and_abort( + entry, data_updates={CONF_MAC: self._data[CONF_MAC]} + ) + + # Finally, check if the hostname (which represents the SMA serial number) is unique + serial_number = discovery_info.hostname.lower() + # Example hostname: sma12345678-01 + # Remove 'sma' prefix and strip everything after the dash (including the dash) + if serial_number.startswith("sma"): + serial_number = serial_number.removeprefix("sma") + serial_number = serial_number.split("-", 1)[0] + await self.async_set_unique_id(serial_number) self._abort_if_unique_id_configured() return await self.async_step_discovery_confirm() diff --git a/tests/components/sma/test_config_flow.py b/tests/components/sma/test_config_flow.py index c8939ef2d64..dd7ff73574d 100644 --- a/tests/components/sma/test_config_flow.py +++ b/tests/components/sma/test_config_flow.py @@ -11,8 +11,10 @@ import pytest from homeassistant.components.sma.const import DOMAIN from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER +from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from . import ( @@ -37,6 +39,12 @@ DHCP_DISCOVERY_DUPLICATE = DhcpServiceInfo( macaddress="0015BB00abcd", ) +DHCP_DISCOVERY_DUPLICATE_001 = DhcpServiceInfo( + ip="1.1.1.1", + hostname="SMA123456789-001", + macaddress="0015bb00abcd", +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_sma_client: AsyncMock @@ -154,6 +162,31 @@ async def test_dhcp_already_configured( assert result["reason"] == "already_configured" +async def test_dhcp_already_configured_duplicate( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test starting a flow by DHCP when already configured and MAC is added.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert CONF_MAC not in mock_config_entry.data + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DHCP_DISCOVERY_DUPLICATE_001, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert mock_config_entry.data.get(CONF_MAC) == format_mac( + DHCP_DISCOVERY_DUPLICATE_001.macaddress + ) + + @pytest.mark.parametrize( ("exception", "error"), [ From b8c19f23f31be9f38af651e2665f95fd79299b84 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 2 Jul 2025 14:57:46 +0200 Subject: [PATCH 1031/1664] UnifiProtect Change log level from debug to error for connection exceptions in ProtectFlowHandler (#147730) --- homeassistant/components/unifiprotect/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 22af2fb135d..8e7dcaede5b 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -274,7 +274,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" except ClientError as ex: - _LOGGER.debug(ex) + _LOGGER.error(ex) errors["base"] = "cannot_connect" else: if nvr_data.version < MIN_REQUIRED_PROTECT_V: From fa1bed184948381007f763bae1803c17e692916b Mon Sep 17 00:00:00 2001 From: Space Date: Wed, 2 Jul 2025 11:45:45 +0200 Subject: [PATCH 1032/1664] Skip processing request body for HTTP HEAD requests (#147899) * Skip processing request body for HTTP HEAD requests * Use aiohttp's must_be_empty_body() to check whether ingress requests should be streamed * Only call must_be_empty_body() once per request * Fix incorrect use of walrus operator --- homeassistant/components/hassio/ingress.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index e673c3a70e9..ca6764cfa34 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -11,6 +11,7 @@ from urllib.parse import quote import aiohttp from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web +from aiohttp.helpers import must_be_empty_body from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest from multidict import CIMultiDict from yarl import URL @@ -184,13 +185,16 @@ class HassIOIngress(HomeAssistantView): content_type = "application/octet-stream" # Simple request - if result.status in (204, 304) or ( + if (empty_body := must_be_empty_body(result.method, result.status)) or ( content_length is not UNDEFINED and (content_length_int := int(content_length)) <= MAX_SIMPLE_RESPONSE_SIZE ): # Return Response - body = await result.read() + if empty_body: + body = None + else: + body = await result.read() simple_response = web.Response( headers=headers, status=result.status, From 2f27d55495f8292108f8e09dd09abc6f4bb09a5d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 2 Jul 2025 13:06:27 +0200 Subject: [PATCH 1033/1664] Open repair issue when outbound WebSocket is enabled for Shelly non-sleeping RPC device (#147901) --- homeassistant/components/shelly/__init__.py | 9 +- homeassistant/components/shelly/const.py | 3 + homeassistant/components/shelly/repairs.py | 91 +++++++++++++++++++- homeassistant/components/shelly/strings.json | 14 +++ tests/components/shelly/test_repairs.py | 82 ++++++++++++++++++ 5 files changed, 194 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 75fedf9b16d..0467b93a7c8 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -56,7 +56,10 @@ from .coordinator import ( ShellyRpcCoordinator, ShellyRpcPollingCoordinator, ) -from .repairs import async_manage_ble_scanner_firmware_unsupported_issue +from .repairs import ( + async_manage_ble_scanner_firmware_unsupported_issue, + async_manage_outbound_websocket_incorrectly_enabled_issue, +) from .utils import ( async_create_issue_unsupported_firmware, get_coap_context, @@ -327,6 +330,10 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) hass, entry, ) + async_manage_outbound_websocket_incorrectly_enabled_issue( + hass, + entry, + ) elif ( sleep_period is None or device_entry is None diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 7462766e2d4..60fc5b03d13 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -237,6 +237,9 @@ NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" FIRMWARE_UNSUPPORTED_ISSUE_ID = "firmware_unsupported_{unique}" BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID = "ble_scanner_firmware_unsupported_{unique}" +OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = ( + "outbound_websocket_incorrectly_enabled_{unique}" +) GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index c39f619fc6c..e1b15f04417 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -11,7 +11,7 @@ from awesomeversion import AwesomeVersion import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import issue_registry as ir @@ -20,9 +20,11 @@ from .const import ( BLE_SCANNER_MIN_FIRMWARE, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from .coordinator import ShellyConfigEntry +from .utils import get_rpc_ws_url @callback @@ -65,7 +67,46 @@ def async_manage_ble_scanner_firmware_unsupported_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) -class BleScannerFirmwareUpdateFlow(RepairsFlow): +@callback +def async_manage_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the Outbound WebSocket incorrectly enabled issue.""" + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format( + unique=entry.unique_id + ) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if ( + (ws_config := device.config.get("ws")) + and ws_config["enable"] + and ws_config["server"] == get_rpc_ws_url(hass) + ): + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="outbound_websocket_incorrectly_enabled", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + +class ShellyRpcRepairsFlow(RepairsFlow): """Handler for an issue fixing flow.""" def __init__(self, device: RpcDevice) -> None: @@ -83,7 +124,7 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - return await self.async_step_update_firmware() + return await self._async_step_confirm() issue_registry = ir.async_get(self.hass) description_placeholders = None @@ -96,6 +137,18 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): description_placeholders=description_placeholders, ) + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + raise NotImplementedError + + +class BleScannerFirmwareUpdateFlow(ShellyRpcRepairsFlow): + """Handler for BLE Scanner Firmware Update flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_update_firmware() + async def async_step_update_firmware( self, user_input: dict[str, str] | None = None ) -> data_entry_flow.FlowResult: @@ -110,6 +163,29 @@ class BleScannerFirmwareUpdateFlow(RepairsFlow): return self.async_create_entry(title="", data={}) +class DisableOutboundWebSocketFlow(ShellyRpcRepairsFlow): + """Handler for Disable Outbound WebSocket flow.""" + + async def _async_step_confirm(self) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + return await self.async_step_disable_outbound_websocket() + + async def async_step_disable_outbound_websocket( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + try: + result = await self._device.ws_setconfig( + False, self._device.config["ws"]["server"] + ) + if result["restart_required"]: + await self._device.trigger_reboot() + except (DeviceConnectionError, RpcCallError): + return self.async_abort(reason="cannot_connect") + + return self.async_create_entry(title="", data={}) + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: @@ -124,4 +200,11 @@ async def async_create_fix_flow( assert entry is not None device = entry.runtime_data.rpc.device - return BleScannerFirmwareUpdateFlow(device) + + if "ble_scanner_firmware_unsupported" in issue_id: + return BleScannerFirmwareUpdateFlow(device) + + if "outbound_websocket_incorrectly_enabled" in issue_id: + return DisableOutboundWebSocketFlow(device) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 28f3a993462..c1d520a59f1 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -288,6 +288,20 @@ "unsupported_firmware": { "title": "Unsupported firmware for device {device_name}", "description": "Your Shelly device {device_name} with IP address {ip_address} is running an unsupported firmware. Please update the firmware.\n\nIf the device does not offer an update, check internet connectivity (gateway, DNS, time) and restart the device." + }, + "outbound_websocket_incorrectly_enabled": { + "title": "Outbound WebSocket is enabled for {device_name}", + "fix_flow": { + "step": { + "confirm": { + "title": "Outbound WebSocket is enabled for {device_name}", + "description": "Your Shelly device {device_name} with IP address {ip_address} is a non-sleeping device and Outbound WebSocket should be disabled in its configuration.\n\nSelect **Submit** button to disable Outbound WebSocket." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } } } } diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index f68d2f82f1b..8dfd59c49ba 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -9,6 +9,7 @@ from homeassistant.components.shelly.const import ( BLE_SCANNER_FIRMWARE_UNSUPPORTED_ISSUE_ID, CONF_BLE_SCANNER_MODE, DOMAIN, + OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, BLEScannerMode, ) from homeassistant.core import HomeAssistant @@ -129,3 +130,84 @@ async def test_unsupported_firmware_issue_exc( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_outbound_websocket_incorrectly_enabled_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test repair issues handling for the outbound WebSocket incorrectly enabled.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.ws_setconfig.call_count == 1 + assert mock_rpc_device.ws_setconfig.call_args[0] == (False, ws_url) + assert mock_rpc_device.trigger_reboot.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 + + +@pytest.mark.parametrize( + "exception", [DeviceConnectionError, RpcCallError(999, "Unknown error")] +) +async def test_outbound_websocket_incorrectly_enabled_issue_exc( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, + monkeypatch: pytest.MonkeyPatch, + exception: Exception, +) -> None: + """Test repair issues handling when ws_setconfig ends with an exception.""" + ws_url = "ws://10.10.10.10:8123/api/shelly/ws" + monkeypatch.setitem( + mock_rpc_device.config, "ws", {"enable": True, "server": ws_url} + ) + + issue_id = OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2) + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + mock_rpc_device.ws_setconfig.side_effect = exception + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "abort" + assert result["reason"] == "cannot_connect" + assert mock_rpc_device.ws_setconfig.call_count == 1 + + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 From eb351e65053343126d5370418166eef1fcb7fd53 Mon Sep 17 00:00:00 2001 From: John Hess Date: Wed, 2 Jul 2025 01:11:49 -0700 Subject: [PATCH 1034/1664] Bump thermopro-ble to 0.13.1 (#147924) --- homeassistant/components/thermopro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/thermopro/manifest.json b/homeassistant/components/thermopro/manifest.json index 29dadfd3d63..6749a53b7b6 100644 --- a/homeassistant/components/thermopro/manifest.json +++ b/homeassistant/components/thermopro/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/thermopro", "iot_class": "local_push", - "requirements": ["thermopro-ble==0.13.0"] + "requirements": ["thermopro-ble==0.13.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index dfaaae375e2..4cfc51b222d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2925,7 +2925,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.thingspeak thingspeak==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e300f2a4df4..82ae255b791 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2411,7 +2411,7 @@ tessie-api==0.1.1 thermobeacon-ble==0.10.0 # homeassistant.components.thermopro -thermopro-ble==0.13.0 +thermopro-ble==0.13.1 # homeassistant.components.lg_thinq thinqconnect==1.0.7 From b816f1a408b2e91434a62229cb18722f6d7f9d90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 2 Jul 2025 13:26:47 +0200 Subject: [PATCH 1035/1664] Handle additional errors in Nord Pool (#147937) --- .../components/nordpool/coordinator.py | 3 ++ tests/components/nordpool/test_coordinator.py | 33 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index a6cfd40c323..d2edb81b9e6 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -6,6 +6,7 @@ from collections.abc import Callable from datetime import datetime, timedelta from typing import TYPE_CHECKING +import aiohttp from pynordpool import ( Currency, DeliveryPeriodData, @@ -91,6 +92,8 @@ class NordPoolDataUpdateCoordinator(DataUpdateCoordinator[DeliveryPeriodsData]): except ( NordPoolResponseError, NordPoolError, + TimeoutError, + aiohttp.ClientError, ) as error: LOGGER.debug("Connection error: %s", error) self.async_set_update_error(error) diff --git a/tests/components/nordpool/test_coordinator.py b/tests/components/nordpool/test_coordinator.py index 71c4644ea95..c2d18c4702a 100644 --- a/tests/components/nordpool/test_coordinator.py +++ b/tests/components/nordpool/test_coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import patch +import aiohttp from freezegun.api import FrozenDateTimeFactory from pynordpool import ( NordPoolAuthenticationError, @@ -90,6 +91,36 @@ async def test_coordinator( assert state.state == STATE_UNAVAILABLE assert "Empty response" in caplog.text + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=aiohttp.ClientError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + + with ( + patch( + "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", + side_effect=TimeoutError("error"), + ) as mock_data, + ): + assert "Response error" not in caplog.text + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + assert mock_data.call_count == 1 + state = hass.states.get("sensor.nord_pool_se3_current_price") + assert state.state == STATE_UNAVAILABLE + assert "error" in caplog.text + with ( patch( "homeassistant.components.nordpool.coordinator.NordPoolClient.async_get_delivery_period", @@ -109,4 +140,4 @@ async def test_coordinator( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.nord_pool_se3_current_price") - assert state.state == "1.81645" + assert state.state == "1.81983" From 1fdf15229231b656b710139cf302e7944500366b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 2 Jul 2025 12:00:48 +0200 Subject: [PATCH 1036/1664] Bump deebot-client to 13.5.0 (#147938) --- homeassistant/components/ecovacs/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json index 97739f698d9..ceb7a1da9de 100644 --- a/homeassistant/components/ecovacs/manifest.json +++ b/homeassistant/components/ecovacs/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/ecovacs", "iot_class": "cloud_push", "loggers": ["sleekxmppfs", "sucks", "deebot_client"], - "requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"] + "requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4cfc51b222d..c3d981a9e51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ decora-wifi==1.4 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82ae255b791..7e8db3086d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -671,7 +671,7 @@ debugpy==1.8.14 # decora==0.6 # homeassistant.components.ecovacs -deebot-client==13.4.0 +deebot-client==13.5.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 116c74587287b221a7e7946e3bb1ee1edf4dddbc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 Jun 2025 21:31:54 +0200 Subject: [PATCH 1037/1664] Split Ollama entity (#147769) --- .../components/ollama/conversation.py | 251 +---------------- homeassistant/components/ollama/entity.py | 258 ++++++++++++++++++ tests/components/ollama/test_conversation.py | 4 +- 3 files changed, 268 insertions(+), 245 deletions(-) create mode 100644 homeassistant/components/ollama/entity.py diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index beedb61f942..ae4de7d48a1 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,41 +2,18 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterator, Callable -import json -import logging -from typing import Any, Literal - -import ollama -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry -from .const import ( - CONF_KEEP_ALIVE, - CONF_MAX_HISTORY, - CONF_MODEL, - CONF_NUM_CTX, - CONF_PROMPT, - CONF_THINK, - DEFAULT_KEEP_ALIVE, - DEFAULT_MAX_HISTORY, - DEFAULT_NUM_CTX, - DOMAIN, -) -from .models import MessageHistory, MessageRole - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_PROMPT, DOMAIN +from .entity import OllamaBaseLLMEntity async def async_setup_entry( @@ -55,129 +32,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> dict[str, Any]: - """Format tool specification.""" - tool_spec = { - "name": tool.name, - "parameters": convert(tool.parameters, custom_serializer=custom_serializer), - } - if tool.description: - tool_spec["description"] = tool.description - return {"type": "function", "function": tool_spec} - - -def _fix_invalid_arguments(value: Any) -> Any: - """Attempt to repair incorrectly formatted json function arguments. - - Small models (for example llama3.1 8B) may produce invalid argument values - which we attempt to repair here. - """ - if not isinstance(value, str): - return value - if (value.startswith("[") and value.endswith("]")) or ( - value.startswith("{") and value.endswith("}") - ): - try: - return json.loads(value) - except json.decoder.JSONDecodeError: - pass - return value - - -def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: - """Rewrite ollama tool arguments. - - This function improves tool use quality by fixing common mistakes made by - small local tool use models. This will repair invalid json arguments and - omit unnecessary arguments with empty values that will fail intent parsing. - """ - return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} - - -def _convert_content( - chat_content: ( - conversation.Content - | conversation.ToolResultContent - | conversation.AssistantContent - ), -) -> ollama.Message: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(chat_content.tool_result), - ) - if isinstance(chat_content, conversation.AssistantContent): - return ollama.Message( - role=MessageRole.ASSISTANT.value, - content=chat_content.content, - tool_calls=[ - ollama.Message.ToolCall( - function=ollama.Message.ToolCall.Function( - name=tool_call.tool_name, - arguments=tool_call.tool_args, - ) - ) - for tool_call in chat_content.tool_calls or () - ], - ) - if isinstance(chat_content, conversation.UserContent): - return ollama.Message( - role=MessageRole.USER.value, - content=chat_content.content, - ) - if isinstance(chat_content, conversation.SystemContent): - return ollama.Message( - role=MessageRole.SYSTEM.value, - content=chat_content.content, - ) - raise TypeError(f"Unexpected content type: {type(chat_content)}") - - -async def _transform_stream( - result: AsyncIterator[ollama.ChatResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - An Ollama streaming response may come in chunks like this: - - response: message=Message(role="assistant", content="Paris") - response: message=Message(role="assistant", content=".") - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - response: message=Message(role="assistant", tool_calls=[...]) - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - - This generator conforms to the chatlog delta stream expectations in that it - yields deltas, then the role only once the response is done. - """ - - new_msg = True - async for response in result: - _LOGGER.debug("Received response: %s", response) - response_message = response["message"] - chunk: conversation.AssistantContentDeltaDict = {} - if new_msg: - new_msg = False - chunk["role"] = "assistant" - if (tool_calls := response_message.get("tool_calls")) is not None: - chunk["tool_calls"] = [ - llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - for tool_call in tool_calls - ] - if (content := response_message.get("content")) is not None: - chunk["content"] = content - if response_message.get("done"): - new_msg = True - yield chunk - - class OllamaConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OllamaBaseLLMEntity, ): """Ollama conversation agent.""" @@ -185,17 +43,7 @@ class OllamaConversationEntity( def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - 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)}, - name=subentry.title, - manufacturer="Ollama", - model=entry.data[CONF_MODEL], - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -255,89 +103,6 @@ class OllamaConversationEntity( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - settings = {**self.entry.data, **self.subentry.data} - - client = self.entry.runtime_data - model = settings[CONF_MODEL] - - tools: list[dict[str, Any]] | 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 - ] - - message_history: MessageHistory = MessageHistory( - [_convert_content(content) for content in chat_log.content] - ) - max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) - self._trim_history(message_history, max_messages) - - # Get response - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - response_generator = await client.chat( - model=model, - # Make a copy of the messages because we mutate the list later - messages=list(message_history.messages), - tools=tools, - stream=True, - # keep_alive requires specifying unit. In this case, seconds - keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", - options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - think=settings.get(CONF_THINK), - ) - except (ollama.RequestError, ollama.ResponseError) as err: - _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - raise HomeAssistantError( - f"Sorry, I had a problem talking to the Ollama server: {err}" - ) from err - - message_history.messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(response_generator) - ) - ] - ) - - if not chat_log.unresponded_tool_results: - break - - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history. - - This sets the max history to allow a configurable size history may take - up in the context window. - - Note that some messages in the history may not be from ollama only, and - may come from other anents, so the assumptions here may not strictly hold, - but generally should be effective. - """ - if max_messages < 1: - # Keep all messages - return - - # Ignore the in progress user message - num_previous_rounds = message_history.num_user_messages - 1 - if num_previous_rounds >= max_messages: - # Trim history but keep system prompt (first message). - # Every other message should be an assistant message, so keep 2x - # message objects. Also keep the last in progress user message - num_keep = 2 * max_messages + 1 - drop_index = len(message_history.messages) - num_keep - message_history.messages = [ - message_history.messages[0], - *message_history.messages[drop_index:], - ] - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py new file mode 100644 index 00000000000..a577bf77573 --- /dev/null +++ b/homeassistant/components/ollama/entity.py @@ -0,0 +1,258 @@ +"""Base entity for the Ollama integration.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Callable +import json +import logging +from typing import Any + +import ollama +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OllamaConfigEntry +from .const import ( + CONF_KEEP_ALIVE, + CONF_MAX_HISTORY, + CONF_MODEL, + CONF_NUM_CTX, + CONF_THINK, + DEFAULT_KEEP_ALIVE, + DEFAULT_MAX_HISTORY, + DEFAULT_NUM_CTX, + DOMAIN, +) +from .models import MessageHistory, MessageRole + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> dict[str, Any]: + """Format tool specification.""" + tool_spec = { + "name": tool.name, + "parameters": convert(tool.parameters, custom_serializer=custom_serializer), + } + if tool.description: + tool_spec["description"] = tool.description + return {"type": "function", "function": tool_spec} + + +def _fix_invalid_arguments(value: Any) -> Any: + """Attempt to repair incorrectly formatted json function arguments. + + Small models (for example llama3.1 8B) may produce invalid argument values + which we attempt to repair here. + """ + if not isinstance(value, str): + return value + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + try: + return json.loads(value) + except json.decoder.JSONDecodeError: + pass + return value + + +def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: + """Rewrite ollama tool arguments. + + This function improves tool use quality by fixing common mistakes made by + small local tool use models. This will repair invalid json arguments and + omit unnecessary arguments with empty values that will fail intent parsing. + """ + return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} + + +def _convert_content( + chat_content: ( + conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent + ), +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncIterator[ollama.ChatResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk + + +class OllamaBaseLLMEntity(Entity): + """Ollama base LLM entity.""" + + 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 + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Ollama", + model=entry.data[CONF_MODEL], + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.subentry.data} + + client = self.entry.runtime_data + model = settings[CONF_MODEL] + + tools: list[dict[str, Any]] | 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 + ] + + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) + max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) + self._trim_history(message_history, max_messages) + + # Get response + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + response_generator = await client.chat( + model=model, + # Make a copy of the messages because we mutate the list later + messages=list(message_history.messages), + tools=tools, + stream=True, + # keep_alive requires specifying unit. In this case, seconds + keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", + options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, + think=settings.get(CONF_THINK), + ) + except (ollama.RequestError, ollama.ResponseError) as err: + _LOGGER.error("Unexpected error talking to Ollama server: %s", err) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err + + message_history.messages.extend( + [ + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(response_generator) + ) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ + if max_messages < 1: + # Keep all messages + return + + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: + # Trim history but keep system prompt (first message). + # Every other message should be an assistant message, so keep 2x + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 + drop_index = len(message_history.messages) - num_keep + message_history.messages = [ + message_history.messages[0], + *message_history.messages[drop_index:], + ] diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index cebb185bd08..d33fffe7152 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -206,7 +206,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -293,7 +293,7 @@ async def test_function_call( ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, From ec5e543c09f5fdda90e2f11e866aba48b94b7260 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 2 Jul 2025 15:20:42 +0200 Subject: [PATCH 1038/1664] Ollama: Migrate pick model to subentry (#147944) --- homeassistant/components/ollama/__init__.py | 34 +- .../components/ollama/config_flow.py | 299 ++++++------ homeassistant/components/ollama/entity.py | 5 +- homeassistant/components/ollama/strings.json | 22 +- tests/components/ollama/__init__.py | 2 +- tests/components/ollama/conftest.py | 13 +- tests/components/ollama/test_config_flow.py | 448 ++++++++++++------ tests/components/ollama/test_conversation.py | 38 +- tests/components/ollama/test_init.py | 127 +++-- 9 files changed, 655 insertions(+), 333 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index eaddf936e81..0f187da1476 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import logging +from types import MappingProxyType import httpx import ollama @@ -92,8 +93,12 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: for entry in entries: use_existing = False + # Create subentry with model from entry.data and options from entry.options + subentry_data = entry.options.copy() + subentry_data[CONF_MODEL] = entry.data[CONF_MODEL] + subentry = ConfigSubentry( - data=entry.options, + data=MappingProxyType(subentry_data), subentry_type="conversation", title=entry.title, unique_id=None, @@ -146,9 +151,11 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, + # Update parent entry to only keep URL, remove model + data={CONF_URL: entry.data[CONF_URL]}, options={}, - version=2, - minor_version=2, + version=3, + minor_version=1, ) @@ -156,7 +163,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> """Migrate entry.""" _LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version) - if entry.version > 2: + if entry.version > 3: # This means the user has downgraded from a future version return False @@ -174,6 +181,25 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Update subentries to include the model + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + updated_data = dict(subentry.data) + updated_data[CONF_MODEL] = entry.data[CONF_MODEL] + + hass.config_entries.async_update_subentry( + entry, subentry, data=MappingProxyType(updated_data) + ) + + # Update main entry to remove model and bump version + hass.config_entries.async_update_entry( + entry, + data={CONF_URL: entry.data[CONF_URL]}, + version=3, + minor_version=1, + ) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 03e2b038bab..49eb12a5c23 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -22,7 +22,7 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import llm +from homeassistant.helpers import config_validation as cv, llm from homeassistant.helpers.selector import ( BooleanSelector, NumberSelector, @@ -38,6 +38,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.util.ssl import get_default_context +from . import OllamaConfigEntry from .const import ( CONF_KEEP_ALIVE, CONF_MAX_HISTORY, @@ -72,43 +73,43 @@ STEP_USER_DATA_SCHEMA = vol.Schema( class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" - VERSION = 2 - MINOR_VERSION = 2 + VERSION = 3 + MINOR_VERSION = 1 def __init__(self) -> None: """Initialize config flow.""" self.url: str | None = None - self.model: str | None = None - self.client: ollama.AsyncClient | None = None - self.download_task: asyncio.Task | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - user_input = user_input or {} - self.url = user_input.get(CONF_URL, self.url) - self.model = user_input.get(CONF_MODEL, self.model) - - if self.url is None: + if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, last_step=False + step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) errors = {} + url = user_input[CONF_URL] - self._async_abort_entries_match({CONF_URL: self.url}) + self._async_abort_entries_match({CONF_URL: url}) try: - self.client = ollama.AsyncClient( - host=self.url, verify=get_default_context() + url = cv.url(url) + except vol.Invalid: + errors["base"] = "invalid_url" + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - async with asyncio.timeout(DEFAULT_TIMEOUT): - response = await self.client.list() - downloaded_models: set[str] = { - model_info["model"] for model_info in response.get("models", []) - } + try: + client = ollama.AsyncClient(host=url, verify=get_default_context()) + async with asyncio.timeout(DEFAULT_TIMEOUT): + await client.list() except (TimeoutError, httpx.ConnectError): errors["base"] = "cannot_connect" except Exception: @@ -117,10 +118,69 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): if errors: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) - if self.model is None: + return self.async_create_entry( + title=url, + data={CONF_URL: url}, + ) + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this integration.""" + return {"conversation": ConversationSubentryFlowHandler} + + +class ConversationSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing conversation subentries.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + super().__init__() + self._name: str | None = None + self._model: str | None = None + self.download_task: asyncio.Task | None = None + self._config_data: dict[str, Any] | None = None + + @property + def _is_new(self) -> bool: + """Return if this is a new subentry.""" + return self.source == "user" + + @property + def _client(self) -> ollama.AsyncClient: + """Return the Ollama client.""" + entry: OllamaConfigEntry = self._get_entry() + return entry.runtime_data + + async def async_step_set_options( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Handle model selection and configuration step.""" + if self._get_entry().state != ConfigEntryState.LOADED: + return self.async_abort(reason="entry_not_loaded") + + if user_input is None: + # Get available models from Ollama server + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + except (TimeoutError, httpx.ConnectError, httpx.HTTPError): + _LOGGER.exception("Failed to get models from Ollama server") + return self.async_abort(reason="cannot_connect") + # Show models that have been downloaded first, followed by all known # models (only latest tags). models_to_list = [ @@ -131,52 +191,69 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): for m in sorted(MODEL_NAMES) if m not in downloaded_models ] - model_step_schema = vol.Schema( - { - vol.Required( - CONF_MODEL, description={"suggested_value": DEFAULT_MODEL} - ): SelectSelector( - SelectSelectorConfig(options=models_to_list, custom_value=True) - ), - } - ) + + if self._is_new: + options = {} + else: + options = self._get_reconfigure_subentry().data.copy() return self.async_show_form( - step_id="user", - data_schema=model_step_schema, + step_id="set_options", + data_schema=vol.Schema( + ollama_config_option_schema( + self.hass, self._is_new, options, models_to_list + ) + ), ) - if self.model not in downloaded_models: - # Ollama server needs to download model first - return await self.async_step_download() + self._model = user_input[CONF_MODEL] + if self._is_new: + self._name = user_input.pop(CONF_NAME) - return self.async_create_entry( - title=self.url, - data={CONF_URL: self.url, CONF_MODEL: self.model}, - subentries=[ - { - "subentry_type": "conversation", - "data": {}, - "title": _get_title(self.model), - "unique_id": None, - } - ], + # Check if model needs to be downloaded + try: + async with asyncio.timeout(DEFAULT_TIMEOUT): + response = await self._client.list() + + currently_downloaded_models: set[str] = { + model_info["model"] for model_info in response.get("models", []) + } + + if self._model not in currently_downloaded_models: + # Store the user input to use after download + self._config_data = user_input + # Ollama server needs to download model first + return await self.async_step_download() + except Exception: + _LOGGER.exception("Failed to check model availability") + return self.async_abort(reason="cannot_connect") + + # Model is already downloaded, create/update the entry + if self._is_new: + return self.async_create_entry( + title=self._name, + data=user_input, + ) + + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=user_input, ) async def async_step_download( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step to wait for Ollama server to download a model.""" - assert self.model is not None - assert self.client is not None + assert self._model is not None if self.download_task is None: # Tell Ollama server to pull the model. # The task will block until the model and metadata are fully # downloaded. self.download_task = self.hass.async_create_background_task( - self.client.pull(self.model), - f"Downloading {self.model}", + self._client.pull(self._model), + f"Downloading {self._model}", ) if self.download_task.done(): @@ -192,80 +269,28 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): progress_task=self.download_task, ) - async def async_step_finish( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Step after model downloading has succeeded.""" - assert self.url is not None - assert self.model is not None - - return self.async_create_entry( - title=_get_title(self.model), - data={CONF_URL: self.url, CONF_MODEL: self.model}, - subentries=[ - { - "subentry_type": "conversation", - "data": {}, - "title": _get_title(self.model), - "unique_id": None, - } - ], - ) - async def async_step_failed( self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: + ) -> SubentryFlowResult: """Step after model downloading has failed.""" return self.async_abort(reason="download_failed") - @classmethod - @callback - def async_get_supported_subentry_types( - cls, config_entry: ConfigEntry - ) -> dict[str, type[ConfigSubentryFlow]]: - """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} - - -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" - - @property - def _is_new(self) -> bool: - """Return if this is a new subentry.""" - return self.source == "user" - - async def async_step_set_options( + async def async_step_finish( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: - """Set conversation options.""" - # abort if entry is not loaded - if self._get_entry().state != ConfigEntryState.LOADED: - return self.async_abort(reason="entry_not_loaded") + """Step after model downloading has succeeded.""" + assert self._config_data is not None - errors: dict[str, str] = {} - - if user_input is None: - if self._is_new: - options = {} - else: - options = self._get_reconfigure_subentry().data.copy() - - elif self._is_new: + # Model download completed, create/update the entry with stored config + if self._is_new: return self.async_create_entry( - title=user_input.pop(CONF_NAME), - data=user_input, + title=self._name, + data=self._config_data, ) - else: - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=user_input, - ) - - schema = ollama_config_option_schema(self.hass, self._is_new, options) - return self.async_show_form( - step_id="set_options", data_schema=vol.Schema(schema), errors=errors + return self.async_update_and_abort( + self._get_entry(), + self._get_reconfigure_subentry(), + data=self._config_data, ) async_step_user = async_step_set_options @@ -273,19 +298,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): def ollama_config_option_schema( - hass: HomeAssistant, is_new: bool, options: Mapping[str, Any] + hass: HomeAssistant, + is_new: bool, + options: Mapping[str, Any], + models_to_list: list[SelectOptionDict], ) -> dict: """Ollama options schema.""" - hass_apis: list[SelectOptionDict] = [ - SelectOptionDict( - label=api.name, - value=api.id, - ) - for api in llm.async_get_apis(hass) - ] - if is_new: - schema: dict[vol.Required | vol.Optional, Any] = { + schema: dict = { vol.Required(CONF_NAME, default="Ollama Conversation"): str, } else: @@ -293,6 +313,12 @@ def ollama_config_option_schema( schema.update( { + vol.Required( + CONF_MODEL, + description={"suggested_value": options.get(CONF_MODEL, DEFAULT_MODEL)}, + ): SelectSelector( + SelectSelectorConfig(options=models_to_list, custom_value=True) + ), vol.Optional( CONF_PROMPT, description={ @@ -304,7 +330,18 @@ def ollama_config_option_schema( vol.Optional( CONF_LLM_HASS_API, description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)), + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ], + multiple=True, + ) + ), vol.Optional( CONF_NUM_CTX, description={ @@ -350,11 +387,3 @@ def ollama_config_option_schema( ) return schema - - -def _get_title(model: str) -> str: - """Get title for config entry.""" - if model.endswith(":latest"): - model = model.split(":", maxsplit=1)[0] - - return model diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index a577bf77573..7b63b1dff00 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -166,11 +166,14 @@ class OllamaBaseLLMEntity(Entity): self.subentry = subentry self._attr_name = subentry.title self._attr_unique_id = subentry.subentry_id + + model, _, version = subentry.data[CONF_MODEL].partition(":") self._attr_device_info = dr.DeviceInfo( identifiers={(DOMAIN, subentry.subentry_id)}, name=subentry.title, manufacturer="Ollama", - model=entry.data[CONF_MODEL], + model=model, + sw_version=version or "latest", entry_type=dr.DeviceEntryType.SERVICE, ) diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 74a5eaff454..bb08e4a4684 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -3,24 +3,17 @@ "step": { "user": { "data": { - "url": "[%key:common::config_flow::data::url%]", - "model": "Model" + "url": "[%key:common::config_flow::data::url%]" } - }, - "download": { - "title": "Downloading model" } }, "abort": { - "download_failed": "Model downloading failed", "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "invalid_url": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "progress": { - "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } }, "config_subentries": { @@ -33,6 +26,7 @@ "step": { "set_options": { "data": { + "model": "Model", "name": "[%key:common::config_flow::data::name%]", "prompt": "Instructions", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", @@ -47,11 +41,19 @@ "num_ctx": "Maximum number of text tokens the model can process. Lower to reduce Ollama RAM, or increase for a large number of exposed entities.", "think": "If enabled, the LLM will think before responding. This can improve response quality but may increase latency." } + }, + "download": { + "title": "Downloading model" } }, "abort": { "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "entry_not_loaded": "Cannot add things while the configuration is disabled." + "entry_not_loaded": "Failed to add agent. The configuration is disabled.", + "download_failed": "Model downloading failed", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } } } diff --git a/tests/components/ollama/__init__.py b/tests/components/ollama/__init__.py index 6ad77bb2217..92db3b13304 100644 --- a/tests/components/ollama/__init__.py +++ b/tests/components/ollama/__init__.py @@ -5,10 +5,10 @@ from homeassistant.helpers import llm TEST_USER_DATA = { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: "test model", } TEST_OPTIONS = { ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, ollama.CONF_MAX_HISTORY: 2, + ollama.CONF_MODEL: "test_model:latest", } diff --git a/tests/components/ollama/conftest.py b/tests/components/ollama/conftest.py index c99f586a5d4..552e7dee20a 100644 --- a/tests/components/ollama/conftest.py +++ b/tests/components/ollama/conftest.py @@ -30,10 +30,11 @@ def mock_config_entry( entry = MockConfigEntry( domain=ollama.DOMAIN, data=TEST_USER_DATA, - version=2, + version=3, + minor_version=1, subentries_data=[ { - "data": mock_config_entry_options, + "data": {**TEST_OPTIONS, **mock_config_entry_options}, "subentry_type": "conversation", "title": "Ollama Conversation", "unique_id": None, @@ -49,10 +50,14 @@ def mock_config_entry_with_assist( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> MockConfigEntry: """Mock a config entry with assist.""" + subentry = next(iter(mock_config_entry.subentries.values())) hass.config_entries.async_update_subentry( mock_config_entry, - next(iter(mock_config_entry.subentries.values())), - data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}, + subentry, + data={ + **subentry.data, + CONF_LLM_HASS_API: llm.LLM_API_ASSIST, + }, ) return mock_config_entry diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 4b78df9bce2..7372a460c95 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -8,6 +8,7 @@ import pytest from homeassistant import config_entries from homeassistant.components import ollama +from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -17,7 +18,7 @@ TEST_MODEL = "test_model:latest" async def test_form(hass: HomeAssistant) -> None: - """Test flow when the model is already downloaded.""" + """Test flow when configuring URL only.""" # Pretend we already set up a config entry. hass.config.components.add(ollama.DOMAIN) MockConfigEntry( @@ -34,7 +35,6 @@ async def test_form(hass: HomeAssistant) -> None: with ( patch( "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # test model is already "downloaded" return_value={"models": [{"model": TEST_MODEL}]}, ), patch( @@ -42,24 +42,17 @@ async def test_form(hass: HomeAssistant) -> None: return_value=True, ) as mock_setup_entry, ): - # Step 1: URL result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} ) await hass.async_block_till_done() - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["data"] == { + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["data"] == { ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, } + # No subentries created by default + assert len(result2.get("subentries", [])) == 0 assert len(mock_setup_entry.mock_calls) == 1 @@ -94,98 +87,6 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_form_need_download(hass: HomeAssistant) -> None: - """Test flow when a model needs to be downloaded.""" - # Pretend we already set up a config entry. - hass.config.components.add(ollama.DOMAIN) - MockConfigEntry( - domain=ollama.DOMAIN, - state=config_entries.ConfigEntryState.LOADED, - ).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] is None - - pull_ready = asyncio.Event() - pull_called = asyncio.Event() - pull_model: str | None = None - - async def pull(self, model: str, *args, **kwargs) -> None: - nonlocal pull_model - - async with asyncio.timeout(1): - await pull_ready.wait() - - pull_model = model - pull_called.set() - - with ( - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - # No models are downloaded - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - pull, - ), - patch( - "homeassistant.components.ollama.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - # Step 1: URL - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} - ) - await hass.async_block_till_done() - - # Step 2: model - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} - ) - await hass.async_block_till_done() - - # Step 3: download - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], - ) - await hass.async_block_till_done() - - # Run again without the task finishing. - # We should still be downloading. - assert result4["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - await hass.async_block_till_done() - assert result4["type"] is FlowResultType.SHOW_PROGRESS - - # Signal fake pull method to complete - pull_ready.set() - async with asyncio.timeout(1): - await pull_called.wait() - - assert pull_model == TEST_MODEL - - # Step 4: finish - result5 = await hass.config_entries.flow.async_configure( - result4["flow_id"], - ) - - assert result5["type"] is FlowResultType.CREATE_ENTRY - assert result5["data"] == { - ollama.CONF_URL: "http://localhost:11434", - ollama.CONF_MODEL: TEST_MODEL, - } - assert len(mock_setup_entry.mock_calls) == 1 - - async def test_subentry_options( hass: HomeAssistant, mock_config_entry, mock_init_component ) -> None: @@ -193,34 +94,84 @@ async def test_subentry_options( subentry = next(iter(mock_config_entry.subentries.values())) # Test reconfiguration - options_flow = await mock_config_entry.start_subentry_reconfigure_flow( - hass, subentry.subentry_id - ) + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + options_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id + ) - assert options_flow["type"] is FlowResultType.FORM - assert options_flow["step_id"] == "set_options" + assert options_flow["type"] is FlowResultType.FORM + assert options_flow["step_id"] == "set_options" - options = await hass.config_entries.subentries.async_configure( - options_flow["flow_id"], - { - ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, - ollama.CONF_THINK: True, - }, - ) + options = await hass.config_entries.subentries.async_configure( + options_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 100, + ollama.CONF_NUM_CTX: 32768, + ollama.CONF_THINK: True, + }, + ) await hass.async_block_till_done() assert options["type"] is FlowResultType.ABORT assert options["reason"] == "reconfigure_successful" assert subentry.data == { + ollama.CONF_MODEL: TEST_MODEL, ollama.CONF_PROMPT: "test prompt", - ollama.CONF_MAX_HISTORY: 100, - ollama.CONF_NUM_CTX: 32768, + ollama.CONF_MAX_HISTORY: 100.0, + ollama.CONF_NUM_CTX: 32768.0, ollama.CONF_THINK: True, } +async def test_creating_new_conversation_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating a new conversation subentry includes name field.""" + # Start a new subentry flow + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with name field + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: TEST_MODEL, + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: TEST_MODEL, + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component, @@ -237,6 +188,125 @@ async def test_creating_conversation_subentry_not_loaded( assert result["reason"] == "entry_not_loaded" +async def test_subentry_need_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model needs to be downloaded.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop 1 iteration + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM, new_flow + assert new_flow["step_id"] == "set_options" + + # Configure the new subentry with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", # not cached + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + await hass.async_block_till_done() + + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "New Test Conversation" + assert result["data"] == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50.0, + ollama.CONF_NUM_CTX: 16384.0, + ollama.CONF_THINK: False, + } + + +async def test_subentry_download_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when model download fails.""" + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + await asyncio.sleep(0) # yield + + raise RuntimeError("Download failed") + + with ( + patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, + ), + patch("ollama.AsyncClient.pull", delayed_pull), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model that needs downloading but will fail + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + CONF_NAME: "New Test Conversation", + ollama.CONF_PROMPT: "new test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + # Should show progress flow result for download + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + assert result["progress_action"] == "download" + + # Wait for download task to complete (with error) + await hass.async_block_till_done() + + # Submit the progress flow - should get failure + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], {} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "download_failed" + + @pytest.mark.parametrize( ("side_effect", "error"), [ @@ -262,40 +332,132 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["errors"] == {"base": error} -async def test_download_error(hass: HomeAssistant) -> None: - """Test we handle errors while downloading a model.""" +async def test_form_invalid_url(hass: HomeAssistant) -> None: + """Test we handle invalid URL.""" result = await hass.config_entries.flow.async_init( ollama.DOMAIN, context={"source": config_entries.SOURCE_USER} ) - async def _delayed_runtime_error(*args, **kwargs): - await asyncio.sleep(0) - raise RuntimeError + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {ollama.CONF_URL: "not-a-valid-url"} + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_url"} + + +async def test_subentry_connection_error( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when connection to Ollama server fails.""" + with patch( + "ollama.AsyncClient.list", + side_effect=ConnectError("Connection failed"), + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.ABORT + assert new_flow["reason"] == "cannot_connect" + + +async def test_subentry_model_check_exception( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test subentry creation when checking model availability throws exception.""" + with patch( + "ollama.AsyncClient.list", + side_effect=[ + {"models": [{"model": TEST_MODEL}]}, # First call succeeds + RuntimeError("Failed to check models"), # Second call fails + ], + ): + new_flow = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert new_flow["type"] is FlowResultType.FORM + assert new_flow["step_id"] == "set_options" + + # Configure with a model, should fail when checking availability + result = await hass.config_entries.subentries.async_configure( + new_flow["flow_id"], + { + ollama.CONF_MODEL: "new_model:latest", + CONF_NAME: "Test Conversation", + ollama.CONF_PROMPT: "test prompt", + ollama.CONF_MAX_HISTORY: 50, + ollama.CONF_NUM_CTX: 16384, + ollama.CONF_THINK: False, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_subentry_reconfigure_with_download( + hass: HomeAssistant, + mock_init_component, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfiguring subentry when model needs to be downloaded.""" + subentry = next(iter(mock_config_entry.subentries.values())) + + async def delayed_pull(self, model: str) -> None: + """Simulate a delayed model download.""" + assert model == "llama3.2:latest" + await asyncio.sleep(0) # yield the event loop with ( patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.list", - return_value={}, - ), - patch( - "homeassistant.components.ollama.config_flow.ollama.AsyncClient.pull", - _delayed_runtime_error, + "ollama.AsyncClient.list", + return_value={"models": [{"model": TEST_MODEL}]}, ), + patch("ollama.AsyncClient.pull", delayed_pull), ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {ollama.CONF_URL: "http://localhost:11434"} + reconfigure_flow = await mock_config_entry.start_subentry_reconfigure_flow( + hass, subentry.subentry_id ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.FORM - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {ollama.CONF_MODEL: TEST_MODEL} + assert reconfigure_flow["type"] is FlowResultType.FORM + assert reconfigure_flow["step_id"] == "set_options" + + # Reconfigure with a model that needs downloading + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], + { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75, + ollama.CONF_NUM_CTX: 8192, + ollama.CONF_THINK: True, + }, ) + + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "download" + await hass.async_block_till_done() - assert result3["type"] is FlowResultType.SHOW_PROGRESS - result4 = await hass.config_entries.flow.async_configure(result3["flow_id"]) - await hass.async_block_till_done() + # Finish download + result = await hass.config_entries.subentries.async_configure( + reconfigure_flow["flow_id"], {} + ) - assert result4["type"] is FlowResultType.ABORT - assert result4["reason"] == "download_failed" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert subentry.data == { + ollama.CONF_MODEL: "llama3.2:latest", + ollama.CONF_PROMPT: "updated prompt", + ollama.CONF_MAX_HISTORY: 75.0, + ollama.CONF_NUM_CTX: 8192.0, + ollama.CONF_THINK: True, + } diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index d33fffe7152..f7e50d61e2c 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -15,7 +15,12 @@ from homeassistant.components.conversation import trace from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent, llm +from homeassistant.helpers import ( + device_registry as dr, + entity_registry as er, + intent, + llm, +) from tests.common import MockConfigEntry @@ -68,7 +73,7 @@ async def test_chat( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -128,7 +133,7 @@ async def test_chat_stream( args = mock_chat.call_args.kwargs prompt = args["messages"][0]["content"] - assert args["model"] == "test model" + assert args["model"] == "test_model:latest" assert args["messages"] == [ Message(role="system", content=prompt), Message(role="user", content="test message"), @@ -158,6 +163,7 @@ async def test_template_variables( "The user name is {{ user_name }}. " "The user id is {{ llm_context.context.user_id }}." ), + ollama.CONF_MODEL: "test_model:latest", }, ) with ( @@ -524,7 +530,9 @@ async def test_message_history_unlimited( ): subentry = next(iter(mock_config_entry.subentries.values())) hass.config_entries.async_update_subentry( - mock_config_entry, subentry, data={ollama.CONF_MAX_HISTORY: 0} + mock_config_entry, + subentry, + data={**subentry.data, ollama.CONF_MAX_HISTORY: 0}, ) for i in range(100): result = await conversation.async_converse( @@ -573,6 +581,7 @@ async def test_template_error( mock_config_entry, subentry, data={ + **subentry.data, "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", }, ) @@ -593,6 +602,8 @@ async def test_conversation_agent( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test OllamaConversationEntity.""" agent = conversation.get_agent_manager(hass).async_get_agent( @@ -604,6 +615,24 @@ async def test_conversation_agent( assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + entity_entry = entity_registry.async_get("conversation.ollama_conversation") + 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 + + assert device_entry.identifiers == {(ollama.DOMAIN, subentry.subentry_id)} + assert device_entry.name == subentry.title + assert device_entry.manufacturer == "Ollama" + assert device_entry.entry_type == dr.DeviceEntryType.SERVICE + + model, _, version = subentry.data[ollama.CONF_MODEL].partition(":") + assert device_entry.model == model + assert device_entry.sw_version == version + async def test_conversation_agent_with_assist( hass: HomeAssistant, @@ -679,6 +708,7 @@ async def test_reasoning_filter( mock_config_entry, subentry, data={ + **subentry.data, ollama.CONF_THINK: think, }, ) diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index a6cfe4c2de0..c7cd78fca9a 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -9,13 +9,26 @@ from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er, llm from homeassistant.setup import async_setup_component -from . import TEST_OPTIONS, TEST_USER_DATA +from . import TEST_OPTIONS from tests.common import MockConfigEntry +V1_TEST_USER_DATA = { + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", +} + +V1_TEST_OPTIONS = { + ollama.CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, + ollama.CONF_MAX_HISTORY: 2, +} + +V21_TEST_USER_DATA = V1_TEST_USER_DATA +V21_TEST_OPTIONS = V1_TEST_OPTIONS + @pytest.mark.parametrize( ("side_effect", "error"), @@ -41,17 +54,17 @@ async def test_init_error( assert error in caplog.text -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2.""" + """Test migration from version 1.""" # Create a v1 config entry with conversation options and an entity mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=TEST_USER_DATA, - options=TEST_OPTIONS, + data=V1_TEST_USER_DATA, + options=V1_TEST_OPTIONS, version=1, title="llama-3.2-8b", ) @@ -81,9 +94,10 @@ async def test_migration_from_v1_to_v2( ): await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 - assert mock_config_entry.data == TEST_USER_DATA + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 1 + # After migration, parent entry should only have URL + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} assert len(mock_config_entry.subentries) == 1 @@ -92,7 +106,9 @@ async def test_migration_from_v1_to_v2( assert subentry.unique_id is None assert subentry.title == "llama-3.2-8b" assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should now include the model from the original options + expected_subentry_data = TEST_OPTIONS.copy() + assert subentry.data == expected_subentry_data migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None @@ -117,17 +133,17 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_urls( +async def test_migration_from_v1_with_multiple_urls( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with different URLs.""" + """Test migration from version 1 with different URLs.""" # Create two v1 config entries with different URLs mock_config_entry = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 1", ) @@ -135,7 +151,7 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( mock_config_entry_2 = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11435", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 2", ) @@ -187,13 +203,16 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( assert len(entries) == 2 for idx, entry in enumerate(entries): - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data assert subentry.title == f"Ollama {idx + 1}" dev = device_registry.async_get_device( @@ -204,17 +223,17 @@ async def test_migration_from_v1_to_v2_with_multiple_urls( assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} -async def test_migration_from_v1_to_v2_with_same_urls( +async def test_migration_from_v1_with_same_urls( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with same URLs consolidates entries.""" + """Test migration from version 1 with same URLs consolidates entries.""" # Create two v1 config entries with the same URL mock_config_entry = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama", ) @@ -222,7 +241,7 @@ async def test_migration_from_v1_to_v2_with_same_urls( mock_config_entry_2 = MockConfigEntry( domain=DOMAIN, data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, # Same URL - options=TEST_OPTIONS, + options=V1_TEST_OPTIONS, version=1, title="Ollama 2", ) @@ -275,8 +294,8 @@ async def test_migration_from_v1_to_v2_with_same_urls( assert len(entries) == 1 entry = entries[0] - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -288,7 +307,10 @@ async def test_migration_from_v1_to_v2_with_same_urls( for subentry in subentries: assert subentry.subentry_type == "conversation" - assert subentry.data == TEST_OPTIONS + # Subentry should include the model along with the original options + expected_subentry_data = TEST_OPTIONS.copy() + expected_subentry_data["model"] = "llama3.2:latest" + assert subentry.data == expected_subentry_data # Check devices were migrated correctly dev = device_registry.async_get_device( @@ -301,12 +323,12 @@ async def test_migration_from_v1_to_v2_with_same_urls( } -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core 2025.7.0b0-2025.7.0b1: @@ -315,20 +337,20 @@ async def test_migration_from_v2_1_to_v2_2( # Create a v2.1 config entry with 2 subentries, devices and entities mock_config_entry = MockConfigEntry( domain=DOMAIN, - data=TEST_USER_DATA, + data=V21_TEST_USER_DATA, entry_id="mock_entry_id", version=2, minor_version=1, subentries_data=[ ConfigSubentryData( - data=TEST_OPTIONS, + data=V21_TEST_OPTIONS, subentry_id="mock_id_1", subentry_type="conversation", title="Ollama", unique_id=None, ), ConfigSubentryData( - data=TEST_OPTIONS, + data=V21_TEST_OPTIONS, subentry_id="mock_id_2", subentry_type="conversation", title="Ollama 2", @@ -392,8 +414,8 @@ async def test_migration_from_v2_1_to_v2_2( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] - assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.version == 3 + assert entry.minor_version == 1 assert not entry.options assert entry.title == "Ollama" assert len(entry.subentries) == 2 @@ -405,6 +427,7 @@ async def test_migration_from_v2_1_to_v2_2( assert len(conversation_subentries) == 2 for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" + # Since TEST_USER_DATA no longer has a model, subentry data should be TEST_OPTIONS assert subentry.data == TEST_OPTIONS assert "Ollama" in subentry.title @@ -450,3 +473,45 @@ async def test_migration_from_v2_1_to_v2_2( assert device.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +async def test_migration_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + subentry_data = ConfigSubentryData( + data=V21_TEST_USER_DATA, + subentry_type="conversation", + title="Test Conversation", + unique_id=None, + ) + + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + ollama.CONF_URL: "http://localhost:11434", + ollama.CONF_MODEL: "test_model:latest", # Model still in main data + }, + version=2, + minor_version=2, + subentries_data=[subentry_data], + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + # Check migration to v3.1 + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 1 + + # Check that model was moved from main data to subentry + assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} + assert len(mock_config_entry.subentries) == 1 + + subentry = next(iter(mock_config_entry.subentries.values())) + assert subentry.data == { + **V21_TEST_USER_DATA, + ollama.CONF_MODEL: "test_model:latest", + } From 12e8b81ec7265d92a8bdb659e66ab313ec0fe0a5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 2 Jul 2025 14:26:19 +0200 Subject: [PATCH 1039/1664] Update frontend to 20250702.0 (#147952) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d9b9527c358..bfd868a5334 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250701.0"] + "requirements": ["home-assistant-frontend==20250702.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d81403c2715..f0fa428fbb3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.104.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c3d981a9e51..e815f8898ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e8db3086d2..c6f0ba0cdf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250701.0 +home-assistant-frontend==20250702.0 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 9472ff5d36ed00b814c793dcd1d6a729eb04f284 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 2 Jul 2025 15:27:51 +0300 Subject: [PATCH 1040/1664] Bump aioamazondevices to 3.2.2 (#147953) --- 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 2e74561b755..7c23edd92ce 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": "bronze", - "requirements": ["aioamazondevices==3.2.1"] + "requirements": ["aioamazondevices==3.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index e815f8898ac..04b3f0eaa2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.1 +aioamazondevices==3.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6f0ba0cdf2..ea925412def 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.1 +aioamazondevices==3.2.2 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 4eb688b5609942f7c4dfdce07bc936c0203eb379 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:23:08 +0200 Subject: [PATCH 1041/1664] Z-Wave JS: rename controller to adapter according to term decision (#147955) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/zwave_js/strings.json | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index b7f9b180624..7445182e5f6 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -15,12 +15,12 @@ "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "migration_low_sdk_version": "The SDK version of the old controller is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old controller to another controller.\n\nCheck the documentation on the manufacturer support pages of the old controller, if it's possible to upgrade the firmware of the old controller to a version that is build with SDK version {ok_sdk_version} or higher.", + "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", - "reset_failed": "Failed to reset controller.", + "reset_failed": "Failed to reset adapter.", "usb_ports_failed": "Failed to get USB devices." }, "error": { @@ -114,19 +114,19 @@ }, "reconfigure": { "title": "Migrate or re-configure", - "description": "Are you migrating to a new controller or re-configuring the current controller?", + "description": "Are you migrating to a new adapter or re-configuring the current adapter?", "menu_options": { - "intent_migrate": "Migrate to a new controller", - "intent_reconfigure": "Re-configure the current controller" + "intent_migrate": "Migrate to a new adapter", + "intent_reconfigure": "Re-configure the current adapter" } }, "instruct_unplug": { - "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." + "title": "Unplug your old adapter", + "description": "Backup saved to \"{file_path}\"\n\nYour old adapter has not been reset. You should now unplug it to prevent it from interfering with the new adapter.\n\nPlease make sure your new adapter is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", - "description": "Your Z-Wave network could not be restored to the new controller. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", + "description": "Your Z-Wave network could not be restored to the new adapter. This means that your Z-Wave devices are not connected to Home Assistant.\n\nThe backup is saved to ”{file_path}”\n\n'<'a href=\"{file_url}\" download=\"{file_name}\"'>'Download backup file'<'/a'>'", "submit": "Try again" }, "choose_serial_port": { @@ -289,12 +289,12 @@ "fix_flow": { "step": { "confirm": { - "description": "A Z-Wave controller of model {controller_model} with a different ID ({new_unique_id}) than the previously connected controller ({old_unique_id}) was connected to the {config_entry_title} configuration entry.\n\nReasons for a different controller ID could be:\n\n1. The controller was factory reset, with a 3rd party application.\n2. A controller Non Volatile Memory (NVM) backup was restored to the controller, with a 3rd party application.\n3. A different controller was connected to this configuration entry.\n\nIf a different controller was connected, you should instead set up a new configuration entry for the new controller.\n\nIf you are sure that the current controller is the correct controller you can confirm this by pressing Submit, and the configuration entry will remember the new controller ID.", - "title": "An unknown controller was detected" + "description": "A Z-Wave adapter of model {controller_model} was connected to the {config_entry_title} configuration entry. This adapter has a different ID ({new_unique_id}) than the previously connected adapter ({old_unique_id}).\n\nReasons for a different adapter ID could be:\n\n1. The adapter was factory reset using a 3rd party application.\n2. A backup of the adapter's non-volatile memory was restored to the adapter using a 3rd party application.\n3. A different adapter was connected to this configuration entry.\n\nIf a different adapter was connected, you should instead set up a new configuration entry for the new adapter.\n\nIf you are sure that the current adapter is the correct adapter, confirm by pressing Submit. The configuration entry will remember the new adapter ID.", + "title": "An unknown adapter was detected" } } }, - "title": "An unknown controller was detected" + "title": "An unknown adapter was detected" } }, "services": { From 8fc3fa51a85061047b4164dc044176f962c58bb5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Jul 2025 13:30:51 +0000 Subject: [PATCH 1042/1664] Bump version to 2025.7.0b9 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index db378d77902..9c78cddc505 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b8" +PATCH_VERSION: Final = "0b9" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index dfc393f69bc..242de41247d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b8" +version = "2025.7.0b9" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From adec157d43e6301998b4935e2aeb87c2ea8bc4e7 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 2 Jul 2025 09:35:47 -0400 Subject: [PATCH 1043/1664] Allow trigger based numeric sensors to be set to unknown (#137047) * Allow trigger based numeric sensors to be set to unknown * resolve comments * Do case insensitive check * use _parse_result --------- Co-authored-by: abmantis --- homeassistant/components/template/sensor.py | 1 + tests/components/template/test_sensor.py | 39 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 508c8b2aed4..c25a2a0e3cb 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -339,6 +339,7 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Initialize.""" super().__init__(hass, coordinator, config) + self._parse_result.add(CONF_STATE) if (last_reset_template := config.get(ATTR_LAST_RESET)) is not None: if last_reset_template.is_static: self._static_rendered[ATTR_LAST_RESET] = last_reset_template.template diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 56eaa120b20..eb4f6c3596b 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1527,6 +1527,45 @@ async def test_trigger_entity_available(hass: HomeAssistant) -> None: assert state.state == "unavailable" +@pytest.mark.parametrize(("source_event_value"), [None, "None"]) +async def test_numeric_trigger_entity_set_unknown( + hass: HomeAssistant, source_event_value: str | None +) -> None: + """Test trigger entity state parsing with numeric sensors.""" + assert await async_setup_component( + hass, + "template", + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensor": [ + { + "name": "Source", + "state": "{{ trigger.event.data.value }}", + }, + ], + }, + ], + }, + ) + await hass.async_block_till_done() + + hass.bus.async_fire("test_event", {"value": 1}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.source") + assert state is not None + assert state.state == "1" + + hass.bus.async_fire("test_event", {"value": source_event_value}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.source") + assert state is not None + assert state.state == STATE_UNKNOWN + + async def test_trigger_entity_available_skips_state( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 3778f537d551fd2a9bd2817a9745747b6b701b36 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 2 Jul 2025 16:28:42 +0200 Subject: [PATCH 1044/1664] Remove noisy debug logs in Husgvarna Automower (#147958) --- homeassistant/components/husqvarna_automower/calendar.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index a26b9bf72bd..b4d3d2176af 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -73,7 +73,6 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): schedule = self.mower_attributes.calendar cursor = schedule.timeline.active_after(dt_util.now()) program_event = next(cursor, None) - _LOGGER.debug("program_event %s", program_event) if not program_event: return None work_area_name = None From 80a1e0e4cda4644cb12e30ebdffef8229b7bc03f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 2 Jul 2025 15:02:39 +0000 Subject: [PATCH 1045/1664] Improve huawei_lte config flow class naming (#147910) --- homeassistant/components/huawei_lte/config_flow.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index 88167fab4b9..f574441afed 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -63,8 +63,8 @@ from .utils import get_device_macs, non_verifying_requests_session _LOGGER = logging.getLogger(__name__) -class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): - """Handle Huawei LTE config flow.""" +class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): + """Huawei LTE config flow.""" VERSION = 3 @@ -75,9 +75,9 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlowHandler: + ) -> HuaweiLteOptionsFlow: """Get options flow.""" - return OptionsFlowHandler() + return HuaweiLteOptionsFlow() async def _async_show_user_form( self, @@ -354,7 +354,7 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(entry, data=new_data) -class OptionsFlowHandler(OptionsFlow): +class HuaweiLteOptionsFlow(OptionsFlow): """Huawei LTE options flow.""" async def async_step_init( From 8334a0398c17065594424e66b6e1c4518cfd3ed7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 2 Jul 2025 15:12:16 +0000 Subject: [PATCH 1046/1664] Bump version to 2025.7.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9c78cddc505..f1a9f7f79c2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2025 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b9" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2) diff --git a/pyproject.toml b/pyproject.toml index 242de41247d..c34a268e347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2025.7.0b9" +version = "2025.7.0" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3." From e31470ba5b05c6f10f98f1b371d69b667cb7292c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 2 Jul 2025 19:06:56 +0200 Subject: [PATCH 1047/1664] Change breaking version for battery props in vacuum (#147956) --- homeassistant/components/vacuum/__init__.py | 4 ++-- tests/components/vacuum/test_init.py | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 11d13431f9d..9108fc5d879 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -327,7 +327,7 @@ class StateVacuumEntity( " instead with a correct device class and link it to the same device", core_integration_behavior=ReportBehavior.LOG, custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2026.7", + breaks_in_ha_version="2026.8", integration_domain=self.platform.platform_name if self.platform else None, exclude_integrations={DOMAIN}, ) @@ -346,7 +346,7 @@ class StateVacuumEntity( core_behavior=ReportBehavior.LOG, core_integration_behavior=ReportBehavior.LOG, custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2026.7", + breaks_in_ha_version="2026.8", integration_domain=self.platform.platform_name if self.platform else None, exclude_integrations={DOMAIN}, ) diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 77debf634ad..488852521ed 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -488,14 +488,14 @@ async def test_vacuum_log_deprecated_battery_properties( assert ( "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." " Integration test should implement a sensor instead with a correct device class and link it" - " to the same device. This will stop working in Home Assistant 2026.7," + " to the same device. This will stop working in Home Assistant 2026.8," " please report it to the author of the 'test' custom integration" in caplog.text ) assert ( "Detected that custom integration 'test' is setting the battery_level which has been deprecated." " Integration test should implement a sensor instead with a correct device class and link it" - " to the same device. This will stop working in Home Assistant 2026.7," + " to the same device. This will stop working in Home Assistant 2026.8," " please report it to the author of the 'test' custom integration" in caplog.text ) @@ -543,14 +543,14 @@ async def test_vacuum_log_deprecated_battery_properties_using_attr( assert ( "Detected that custom integration 'test' is setting the battery_level which has been deprecated." " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.7," + " the same device. This will stop working in Home Assistant 2026.8," " please report it to the author of the 'test' custom integration" in caplog.text ) assert ( "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.7," + " the same device. This will stop working in Home Assistant 2026.8," " please report it to the author of the 'test' custom integration" in caplog.text ) @@ -563,14 +563,14 @@ async def test_vacuum_log_deprecated_battery_properties_using_attr( assert ( "Detected that custom integration 'test' is setting the battery_level which has been deprecated." " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.7," + " the same device. This will stop working in Home Assistant 2026.8," " please report it to the author of the 'test' custom integration" not in caplog.text ) assert ( "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.7," + " the same device. This will stop working in Home Assistant 2026.8," " please report it to the author of the 'test' custom integration" not in caplog.text ) @@ -609,7 +609,7 @@ async def test_vacuum_log_deprecated_battery_supported_feature( assert ( "Detected that custom integration 'test' is setting the battery supported feature" " which has been deprecated. Integration test should remove this as part of migrating" - " the battery level and icon to a sensor. This will stop working in Home Assistant 2026.7" + " the battery level and icon to a sensor. This will stop working in Home Assistant 2026.8" ", please report it to the author of the 'test' custom integration" in caplog.text ) From ebe04466f40921c4fc94eee5f2b211d73fac4086 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 2 Jul 2025 15:19:32 -0400 Subject: [PATCH 1048/1664] Bump ZHA to 0.0.62 (#147966) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../zha/snapshots/test_diagnostics.ambr | 51 ------------------- 4 files changed, 3 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4fb5f57320f..2cbc962a305 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "zha", "universal_silabs_flasher" ], - "requirements": ["zha==0.0.61"], + "requirements": ["zha==0.0.62"], "usb": [ { "vid": "10C4", diff --git a/requirements_all.txt b/requirements_all.txt index 4aa32680fad..14888ab9d28 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3190,7 +3190,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.61 +zha==0.0.62 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 135623f78ef..f68e1afd310 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2634,7 +2634,7 @@ zeroconf==0.147.0 zeversolar==0.3.2 # homeassistant.components.zha -zha==0.0.61 +zha==0.0.62 # homeassistant.components.zwave_js zwave-js-server-python==0.65.0 diff --git a/tests/components/zha/snapshots/test_diagnostics.ambr b/tests/components/zha/snapshots/test_diagnostics.ambr index 44fb913489d..35eb320893f 100644 --- a/tests/components/zha/snapshots/test_diagnostics.ambr +++ b/tests/components/zha/snapshots/test_diagnostics.ambr @@ -168,7 +168,6 @@ dict({ 'id': '0x0010', 'name': 'cie_addr', - 'unsupported': False, 'value': list([ 50, 79, @@ -181,68 +180,18 @@ ]), 'zcl_type': 'EUI64', }), - dict({ - 'id': '0x0013', - 'name': 'current_zone_sensitivity_level', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint8', - }), dict({ 'id': '0x0012', 'name': 'num_zone_sensitivity_levels_supported', 'unsupported': True, - 'value': None, 'zcl_type': 'uint8', }), - dict({ - 'id': '0x0011', - 'name': 'zone_id', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint8', - }), - dict({ - 'id': '0x0000', - 'name': 'zone_state', - 'unsupported': False, - 'value': None, - 'zcl_type': 'enum8', - }), - dict({ - 'id': '0x0002', - 'name': 'zone_status', - 'unsupported': False, - 'value': None, - 'zcl_type': 'map16', - }), - dict({ - 'id': '0x0001', - 'name': 'zone_type', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint16', - }), ]), 'cluster_id': '0x0500', 'endpoint_attribute': 'ias_zone', }), dict({ 'attributes': list([ - dict({ - 'id': '0xfffd', - 'name': 'cluster_revision', - 'unsupported': False, - 'value': None, - 'zcl_type': 'uint16', - }), - dict({ - 'id': '0xfffe', - 'name': 'reporting_status', - 'unsupported': False, - 'value': None, - 'zcl_type': 'enum8', - }), ]), 'cluster_id': '0x0501', 'endpoint_attribute': 'ias_ace', From 8968cf704b1ade3d9137421adae704afc4735cbf Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 2 Jul 2025 21:34:30 +0200 Subject: [PATCH 1049/1664] Use `send_json_auto_id` in KNX tests (#147982) --- tests/components/knx/test_websocket.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index 7054d415ee9..ab4ecf876dc 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -22,7 +22,7 @@ async def test_knx_info_command( """Test knx/info command.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/info"}) + await client.send_json_auto_id({"type": "knx/info"}) res = await client.receive_json() assert res["success"], res @@ -41,7 +41,7 @@ async def test_knx_info_command_with_project( """Test knx/info command with loaded project.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/info"}) + await client.send_json_auto_id({"type": "knx/info"}) res = await client.receive_json() assert res["success"], res @@ -69,9 +69,8 @@ async def test_knx_project_file_process( client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "knx/project_file_process", "file_id": _file_id, "password": _password, @@ -104,9 +103,8 @@ async def test_knx_project_file_process_error( client = await hass_ws_client(hass) assert not hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json( + await client.send_json_auto_id( { - "id": 6, "type": "knx/project_file_process", "file_id": "1234", "password": "", @@ -139,7 +137,7 @@ async def test_knx_project_file_remove( client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json({"id": 6, "type": "knx/project_file_remove"}) + await client.send_json_auto_id({"type": "knx/project_file_remove"}) res = await client.receive_json() assert res["success"], res @@ -158,7 +156,7 @@ async def test_knx_get_project( client = await hass_ws_client(hass) assert hass.data[KNX_MODULE_KEY].project.loaded - await client.send_json({"id": 3, "type": "knx/get_knx_project"}) + await client.send_json_auto_id({"type": "knx/get_knx_project"}) res = await client.receive_json() assert res["success"], res assert res["result"]["project_loaded"] is True @@ -172,7 +170,7 @@ async def test_knx_group_monitor_info_command( await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + await client.send_json_auto_id({"type": "knx/group_monitor_info"}) res = await client.receive_json() assert res["success"], res @@ -234,7 +232,7 @@ async def test_knx_subscribe_telegrams_command_recent_telegrams( # connect websocket after telegrams have been sent client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + await client.send_json_auto_id({"type": "knx/group_monitor_info"}) res = await client.receive_json() assert res["success"], res assert res["result"]["project_loaded"] is False @@ -272,7 +270,7 @@ async def test_knx_subscribe_telegrams_command_no_project( } ) client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + await client.send_json_auto_id({"type": "knx/subscribe_telegrams"}) res = await client.receive_json() assert res["success"], res @@ -340,7 +338,7 @@ async def test_knx_subscribe_telegrams_command_project( """Test knx/subscribe_telegrams command with project data.""" await knx.setup_integration() client = await hass_ws_client(hass) - await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + await client.send_json_auto_id({"type": "knx/subscribe_telegrams"}) res = await client.receive_json() assert res["success"], res From 8ca1fe83b74f95a10cf2e771239be05f683b8497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Wed, 2 Jul 2025 21:36:06 +0200 Subject: [PATCH 1050/1664] Bump switchbot-api to v2.7.0 (#147978) --- homeassistant/components/switchbot_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/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 076fa8dd6fb..b07bae88072 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["switchbot_api"], - "requirements": ["switchbot-api==2.5.0"] + "requirements": ["switchbot-api==2.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 14888ab9d28..38e4eaffa22 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2863,7 +2863,7 @@ surepy==0.9.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.5.0 +switchbot-api==2.7.0 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f68e1afd310..324e57b8f45 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2364,7 +2364,7 @@ subarulink==0.7.13 surepy==0.9.0 # homeassistant.components.switchbot_cloud -switchbot-api==2.5.0 +switchbot-api==2.7.0 # homeassistant.components.system_bridge systembridgeconnector==4.1.5 From a748525e03f0fd1b914bc99743d235dd2b59b6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 2 Jul 2025 21:48:15 +0200 Subject: [PATCH 1051/1664] Allow LevelControl Cluster for Matter Pump devices (#145004) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/number.py | 47 +++++++++++++++ homeassistant/components/matter/strings.json | 3 + .../matter/snapshots/test_number.ambr | 58 +++++++++++++++++++ tests/components/matter/test_number.py | 41 +++++++++++++ 4 files changed, 149 insertions(+) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index b811a3c19d3..7d138ba5018 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -8,6 +8,7 @@ from typing import Any, cast from chip.clusters import Objects as clusters from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand +from matter_server.client.models import device_types from matter_server.common import custom_clusters from homeassistant.components.number import ( @@ -18,6 +19,7 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + PERCENTAGE, EntityCategory, Platform, UnitOfLength, @@ -123,6 +125,31 @@ class MatterRangeNumber(MatterEntity, NumberEntity): ) +class MatterLevelControlNumber(MatterEntity, NumberEntity): + """Representation of a Matter Attribute as a Number entity.""" + + entity_description: MatterNumberEntityDescription + + async def async_set_native_value(self, value: float) -> None: + """Set level value.""" + send_value = int(value) + if value_convert := self.entity_description.ha_to_native_value: + send_value = value_convert(value) + await self.send_device_command( + clusters.LevelControl.Commands.MoveToLevel( + level=send_value, + ) + ) + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + value = self.get_matter_attribute_value(self._entity_info.primary_attribute) + if value_convert := self.entity_description.measurement_to_ha: + value = value_convert(value) + self._attr_native_value = value + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -239,6 +266,26 @@ DISCOVERY_SCHEMAS = [ ), vendor_id=(4874,), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="pump_setpoint", + native_unit_of_measurement=PERCENTAGE, + translation_key="pump_setpoint", + native_max_value=100, + native_min_value=0.5, + native_step=0.5, + measurement_to_ha=( + lambda x: None if x is None else x / 2 # Matter range (1-200) + ), + ha_to_native_value=lambda x: round(x * 2), # HA range 0.5–100.0% + mode=NumberMode.SLIDER, + ), + entity_class=MatterLevelControlNumber, + required_attributes=(clusters.LevelControl.Attributes.CurrentLevel,), + device_type=(device_types.Pump,), + allow_multi=True, + ), MatterDiscoverySchema( platform=Platform.NUMBER, entity_description=MatterNumberEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index d1367ba66e2..df1cbc5adb0 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -180,6 +180,9 @@ "altitude": { "name": "Altitude above sea level" }, + "pump_setpoint": { + "name": "Setpoint" + }, "temperature_offset": { "name": "Temperature offset" }, diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index d71980c0613..c1d08dba8a1 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -1961,6 +1961,64 @@ 'state': '0', }) # --- +# name: test_numbers[pump][number.mock_pump_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100, + 'min': 0.5, + 'mode': , + 'step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.mock_pump_setpoint', + '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': 'Setpoint', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_setpoint', + 'unique_id': '00000000000004D2-0000000000000003-MatterNodeDevice-1-pump_setpoint-8-0', + 'unit_of_measurement': '%', + }) +# --- +# name: test_numbers[pump][number.mock_pump_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Pump Setpoint', + 'max': 100, + 'min': 0.5, + 'mode': , + 'step': 0.5, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.mock_pump_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '127.0', + }) +# --- # name: test_numbers[silabs_laundrywasher][number.laundrywasher_temperature_setpoint-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_number.py b/tests/components/matter/test_number.py index d1ccc1a229b..0ba2886b089 100644 --- a/tests/components/matter/test_number.py +++ b/tests/components/matter/test_number.py @@ -160,3 +160,44 @@ async def test_matter_exception_on_write_attribute( }, blocking=True, ) + + +@pytest.mark.parametrize("node_fixture", ["pump"]) +async def test_pump_level( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test level control for pump.""" + # CurrentLevel on LevelControl cluster + state = hass.states.get("number.mock_pump_setpoint") + assert state + assert state.state == "127.0" + + set_node_attribute(matter_node, 1, 8, 0, 100) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("number.mock_pump_setpoint") + assert state + assert state.state == "50.0" + + # test set value + await hass.services.async_call( + "number", + "set_value", + { + "entity_id": "number.mock_pump_setpoint", + "value": 75, + }, + 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.LevelControl.Commands.MoveToLevel( + level=150 + ), # 75 * 2 = 150, as the value is multiplied by 2 in the HA to native value conversion + ) + ) From 78c39f8a063104f911c39e0cca2a49e689135e82 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 2 Jul 2025 21:49:12 +0200 Subject: [PATCH 1052/1664] Remove deprecated battery properties from demo vacuum (#147980) --- homeassistant/components/demo/vacuum.py | 15 +-------------- tests/components/demo/test_vacuum.py | 13 +++---------- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 38019cff3c1..11bf3e3118b 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -19,10 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF SUPPORT_BASIC_SERVICES = ( - VacuumEntityFeature.STATE - | VacuumEntityFeature.START - | VacuumEntityFeature.STOP - | VacuumEntityFeature.BATTERY + VacuumEntityFeature.STATE | VacuumEntityFeature.START | VacuumEntityFeature.STOP ) SUPPORT_MOST_SERVICES = ( @@ -31,7 +28,6 @@ SUPPORT_MOST_SERVICES = ( | VacuumEntityFeature.STOP | VacuumEntityFeature.PAUSE | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED ) @@ -46,7 +42,6 @@ SUPPORT_ALL_SERVICES = ( | VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.LOCATE | VacuumEntityFeature.STATUS - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.LOCATE | VacuumEntityFeature.MAP | VacuumEntityFeature.CLEAN_SPOT @@ -90,12 +85,6 @@ class StateDemoVacuum(StateVacuumEntity): self._attr_activity = VacuumActivity.DOCKED self._fan_speed = FAN_SPEEDS[1] self._cleaned_area: float = 0 - self._battery_level = 100 - - @property - def battery_level(self) -> int: - """Return the current battery level of the vacuum.""" - return max(0, min(100, self._battery_level)) @property def fan_speed(self) -> str: @@ -117,7 +106,6 @@ class StateDemoVacuum(StateVacuumEntity): if self._attr_activity != VacuumActivity.CLEANING: self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 - self._battery_level -= 1 self.schedule_update_ha_state() def pause(self) -> None: @@ -142,7 +130,6 @@ class StateDemoVacuum(StateVacuumEntity): """Perform a spot clean-up.""" self._attr_activity = VacuumActivity.CLEANING self._cleaned_area += 1.32 - self._battery_level -= 1 self.schedule_update_ha_state() def set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index f910e6e53ac..3a627efd3f1 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -14,7 +14,6 @@ from homeassistant.components.demo.vacuum import ( FAN_SPEEDS, ) from homeassistant.components.vacuum import ( - ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, @@ -67,36 +66,31 @@ async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): async def test_supported_features(hass: HomeAssistant) -> None: """Test vacuum supported features.""" state = hass.states.get(ENTITY_VACUUM_COMPLETE) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16380 - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 16316 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MOST) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12412 - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12348 assert state.attributes.get(ATTR_FAN_SPEED) == "medium" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == FAN_SPEEDS assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_BASIC) - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12360 - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 12296 assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_MINIMAL) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 3 - assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.state == VacuumActivity.DOCKED state = hass.states.get(ENTITY_VACUUM_NONE) assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 - assert state.attributes.get(ATTR_BATTERY_LEVEL) is None assert state.attributes.get(ATTR_FAN_SPEED) is None assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None assert state.state == VacuumActivity.DOCKED @@ -116,7 +110,6 @@ async def test_methods(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_VACUUM_COMPLETE) await hass.async_block_till_done() - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 100 assert state.state == VacuumActivity.DOCKED await async_setup_component(hass, "notify", {}) From 53d2f6b0c67e760fe0f3d87704b3f893a97fd775 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 2 Jul 2025 21:49:24 +0200 Subject: [PATCH 1053/1664] KNX: Use a ConfigExtractor helper class for value retrieval (#147983) --- homeassistant/components/knx/binary_sensor.py | 21 +-- homeassistant/components/knx/cover.py | 48 +++---- homeassistant/components/knx/light.py | 133 +++++++++--------- homeassistant/components/knx/storage/util.py | 51 +++++++ homeassistant/components/knx/switch.py | 23 ++- 5 files changed, 150 insertions(+), 126 deletions(-) create mode 100644 homeassistant/components/knx/storage/util.py diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index c11612f79bf..1bad8bafdf0 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -39,7 +39,8 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity -from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA_STATE +from .storage.const import CONF_ENTITY, CONF_GA_SENSOR +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -146,17 +147,17 @@ class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity): unique_id=unique_id, entity_config=config[CONF_ENTITY], ) + knx_conf = ConfigExtractor(config[DOMAIN]) self._device = XknxBinarySensor( xknx=knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address_state=[ - config[DOMAIN][CONF_GA_SENSOR][CONF_GA_STATE], - *config[DOMAIN][CONF_GA_SENSOR][CONF_GA_PASSIVE], - ], - sync_state=config[DOMAIN][CONF_SYNC_STATE], - invert=config[DOMAIN].get(CONF_INVERT, False), - ignore_internal_state=config[DOMAIN].get(CONF_IGNORE_INTERNAL_STATE, False), - context_timeout=config[DOMAIN].get(CONF_CONTEXT_TIMEOUT), - reset_after=config[DOMAIN].get(CONF_RESET_AFTER), + group_address_state=knx_conf.get_state_and_passive(CONF_GA_SENSOR), + sync_state=knx_conf.get(CONF_SYNC_STATE), + invert=knx_conf.get(CONF_INVERT, default=False), + ignore_internal_state=knx_conf.get( + CONF_IGNORE_INTERNAL_STATE, default=False + ), + context_timeout=knx_conf.get(CONF_CONTEXT_TIMEOUT), + reset_after=knx_conf.get(CONF_RESET_AFTER), ) self._attr_force_update = self._device.ignore_internal_state diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index 3068e5d7ef1..f5d482b9d14 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any, Literal +from typing import Any from xknx import XKNX from xknx.devices import Cover as XknxCover @@ -35,15 +35,13 @@ from .schema import CoverSchema from .storage.const import ( CONF_ENTITY, CONF_GA_ANGLE, - CONF_GA_PASSIVE, CONF_GA_POSITION_SET, CONF_GA_POSITION_STATE, - CONF_GA_STATE, CONF_GA_STEP, CONF_GA_STOP, CONF_GA_UP_DOWN, - CONF_GA_WRITE, ) +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -230,38 +228,24 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity): def _create_ui_cover(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxCover: """Return a KNX Light device to be used within XKNX.""" - def get_address( - key: str, address_type: Literal["write", "state"] = CONF_GA_WRITE - ) -> str | None: - """Get a single group address for given key.""" - return knx_config[key][address_type] if key in knx_config else None - - def get_addresses( - key: str, address_type: Literal["write", "state"] = CONF_GA_STATE - ) -> list[Any] | None: - """Get group address including passive addresses as list.""" - return ( - [knx_config[key][address_type], *knx_config[key][CONF_GA_PASSIVE]] - if key in knx_config - else None - ) + conf = ConfigExtractor(knx_config) return XknxCover( xknx=xknx, name=name, - group_address_long=get_addresses(CONF_GA_UP_DOWN, CONF_GA_WRITE), - group_address_short=get_addresses(CONF_GA_STEP, CONF_GA_WRITE), - group_address_stop=get_addresses(CONF_GA_STOP, CONF_GA_WRITE), - group_address_position=get_addresses(CONF_GA_POSITION_SET, CONF_GA_WRITE), - group_address_position_state=get_addresses(CONF_GA_POSITION_STATE), - group_address_angle=get_address(CONF_GA_ANGLE), - group_address_angle_state=get_addresses(CONF_GA_ANGLE), - travel_time_down=knx_config[CoverConf.TRAVELLING_TIME_DOWN], - travel_time_up=knx_config[CoverConf.TRAVELLING_TIME_UP], - invert_updown=knx_config.get(CoverConf.INVERT_UPDOWN, False), - invert_position=knx_config.get(CoverConf.INVERT_POSITION, False), - invert_angle=knx_config.get(CoverConf.INVERT_ANGLE, False), - sync_state=knx_config[CONF_SYNC_STATE], + group_address_long=conf.get_write_and_passive(CONF_GA_UP_DOWN), + group_address_short=conf.get_write_and_passive(CONF_GA_STEP), + group_address_stop=conf.get_write_and_passive(CONF_GA_STOP), + group_address_position=conf.get_write_and_passive(CONF_GA_POSITION_SET), + group_address_position_state=conf.get_state_and_passive(CONF_GA_POSITION_STATE), + group_address_angle=conf.get_write(CONF_GA_ANGLE), + group_address_angle_state=conf.get_state_and_passive(CONF_GA_ANGLE), + travel_time_down=conf.get(CoverConf.TRAVELLING_TIME_DOWN), + travel_time_up=conf.get(CoverConf.TRAVELLING_TIME_UP), + invert_updown=conf.get(CoverConf.INVERT_UPDOWN, default=False), + invert_position=conf.get(CoverConf.INVERT_POSITION, default=False), + invert_angle=conf.get(CoverConf.INVERT_ANGLE, default=False), + sync_state=conf.get(CONF_SYNC_STATE), ) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index 865cfdc6e25..ff0f4538089 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -35,7 +35,6 @@ from .schema import LightSchema from .storage.const import ( CONF_COLOR_TEMP_MAX, CONF_COLOR_TEMP_MIN, - CONF_DPT, CONF_ENTITY, CONF_GA_BLUE_BRIGHTNESS, CONF_GA_BLUE_SWITCH, @@ -45,17 +44,15 @@ from .storage.const import ( CONF_GA_GREEN_BRIGHTNESS, CONF_GA_GREEN_SWITCH, CONF_GA_HUE, - CONF_GA_PASSIVE, CONF_GA_RED_BRIGHTNESS, CONF_GA_RED_SWITCH, CONF_GA_SATURATION, - CONF_GA_STATE, CONF_GA_SWITCH, CONF_GA_WHITE_BRIGHTNESS, CONF_GA_WHITE_SWITCH, - CONF_GA_WRITE, ) from .storage.entity_store_schema import LightColorMode +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -203,94 +200,92 @@ def _create_yaml_light(xknx: XKNX, config: ConfigType) -> XknxLight: def _create_ui_light(xknx: XKNX, knx_config: ConfigType, name: str) -> XknxLight: """Return a KNX Light device to be used within XKNX.""" - def get_write(key: str) -> str | None: - """Get the write group address.""" - return knx_config[key][CONF_GA_WRITE] if key in knx_config else None - - def get_state(key: str) -> list[Any] | None: - """Get the state group address.""" - return ( - [knx_config[key][CONF_GA_STATE], *knx_config[key][CONF_GA_PASSIVE]] - if key in knx_config - else None - ) - - def get_dpt(key: str) -> str | None: - """Get the DPT.""" - return knx_config[key].get(CONF_DPT) if key in knx_config else None + conf = ConfigExtractor(knx_config) group_address_tunable_white = None group_address_tunable_white_state = None group_address_color_temp = None group_address_color_temp_state = None + color_temperature_type = ColorTemperatureType.UINT_2_BYTE - if ga_color_temp := knx_config.get(CONF_GA_COLOR_TEMP): - if ga_color_temp[CONF_DPT] == ColorTempModes.RELATIVE.value: - group_address_tunable_white = ga_color_temp[CONF_GA_WRITE] - group_address_tunable_white_state = [ - ga_color_temp[CONF_GA_STATE], - *ga_color_temp[CONF_GA_PASSIVE], - ] + if _color_temp_dpt := conf.get_dpt(CONF_GA_COLOR_TEMP): + if _color_temp_dpt == ColorTempModes.RELATIVE.value: + group_address_tunable_white = conf.get_write(CONF_GA_COLOR_TEMP) + group_address_tunable_white_state = conf.get_state_and_passive( + CONF_GA_COLOR_TEMP + ) else: # absolute uint or float - group_address_color_temp = ga_color_temp[CONF_GA_WRITE] - group_address_color_temp_state = [ - ga_color_temp[CONF_GA_STATE], - *ga_color_temp[CONF_GA_PASSIVE], - ] - if ga_color_temp[CONF_DPT] == ColorTempModes.ABSOLUTE_FLOAT.value: + group_address_color_temp = conf.get_write(CONF_GA_COLOR_TEMP) + group_address_color_temp_state = conf.get_state_and_passive( + CONF_GA_COLOR_TEMP + ) + if _color_temp_dpt == ColorTempModes.ABSOLUTE_FLOAT.value: color_temperature_type = ColorTemperatureType.FLOAT_2_BYTE - _color_dpt = get_dpt(CONF_GA_COLOR) + color_dpt = conf.get_dpt(CONF_GA_COLOR) + return XknxLight( xknx, name=name, - group_address_switch=get_write(CONF_GA_SWITCH), - group_address_switch_state=get_state(CONF_GA_SWITCH), - group_address_brightness=get_write(CONF_GA_BRIGHTNESS), - group_address_brightness_state=get_state(CONF_GA_BRIGHTNESS), - group_address_color=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGB + group_address_switch=conf.get_write(CONF_GA_SWITCH), + 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=get_state(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGB + group_address_color_state=conf.get_state_and_passive(CONF_GA_COLOR) + if color_dpt == LightColorMode.RGB else None, - group_address_rgbw=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGBW + group_address_rgbw=conf.get_write(CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW else None, - group_address_rgbw_state=get_state(CONF_GA_COLOR) - if _color_dpt == LightColorMode.RGBW + group_address_rgbw_state=conf.get_state_and_passive(CONF_GA_COLOR) + if color_dpt == LightColorMode.RGBW else None, - group_address_hue=get_write(CONF_GA_HUE), - group_address_hue_state=get_state(CONF_GA_HUE), - group_address_saturation=get_write(CONF_GA_SATURATION), - group_address_saturation_state=get_state(CONF_GA_SATURATION), - group_address_xyy_color=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.XYY + 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=get_write(CONF_GA_COLOR) - if _color_dpt == LightColorMode.XYY + group_address_xyy_color_state=conf.get_write(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=get_write(CONF_GA_RED_SWITCH), - group_address_switch_red_state=get_state(CONF_GA_RED_SWITCH), - group_address_brightness_red=get_write(CONF_GA_RED_BRIGHTNESS), - group_address_brightness_red_state=get_state(CONF_GA_RED_BRIGHTNESS), - group_address_switch_green=get_write(CONF_GA_GREEN_SWITCH), - group_address_switch_green_state=get_state(CONF_GA_GREEN_SWITCH), - group_address_brightness_green=get_write(CONF_GA_GREEN_BRIGHTNESS), - group_address_brightness_green_state=get_state(CONF_GA_GREEN_BRIGHTNESS), - group_address_switch_blue=get_write(CONF_GA_BLUE_SWITCH), - group_address_switch_blue_state=get_state(CONF_GA_BLUE_SWITCH), - group_address_brightness_blue=get_write(CONF_GA_BLUE_BRIGHTNESS), - group_address_brightness_blue_state=get_state(CONF_GA_BLUE_BRIGHTNESS), - group_address_switch_white=get_write(CONF_GA_WHITE_SWITCH), - group_address_switch_white_state=get_state(CONF_GA_WHITE_SWITCH), - group_address_brightness_white=get_write(CONF_GA_WHITE_BRIGHTNESS), - group_address_brightness_white_state=get_state(CONF_GA_WHITE_BRIGHTNESS), + 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_green=conf.get_write(CONF_GA_GREEN_SWITCH), + group_address_switch_green_state=conf.get_state_and_passive( + 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 + ), + 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 + ), + group_address_switch_white=conf.get_write(CONF_GA_WHITE_SWITCH), + group_address_switch_white_state=conf.get_state_and_passive( + CONF_GA_WHITE_SWITCH + ), + 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 + ), color_temperature_type=color_temperature_type, min_kelvin=knx_config[CONF_COLOR_TEMP_MIN], max_kelvin=knx_config[CONF_COLOR_TEMP_MAX], diff --git a/homeassistant/components/knx/storage/util.py b/homeassistant/components/knx/storage/util.py new file mode 100644 index 00000000000..a3831070a7e --- /dev/null +++ b/homeassistant/components/knx/storage/util.py @@ -0,0 +1,51 @@ +"""Utility functions for the KNX integration.""" + +from functools import partial +from typing import Any + +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_DPT, CONF_GA_PASSIVE, CONF_GA_STATE, CONF_GA_WRITE + + +def nested_get(dic: ConfigType, *keys: str, default: Any | None = None) -> Any: + """Get the value from a nested dictionary.""" + for key in keys: + if key not in dic: + return default + dic = dic[key] + return dic + + +class ConfigExtractor: + """Helper class for extracting values from a knx config store dictionary.""" + + __slots__ = ("get",) + + def __init__(self, config: ConfigType) -> None: + """Initialize the extractor.""" + self.get = partial(nested_get, config) + + def get_write(self, *path: str) -> str | None: + """Get the write group address.""" + return self.get(*path, CONF_GA_WRITE) # type: ignore[no-any-return] + + def get_state(self, *path: str) -> str | None: + """Get the state group address.""" + return self.get(*path, CONF_GA_STATE) # type: ignore[no-any-return] + + def get_write_and_passive(self, *path: str) -> list[Any | None]: + """Get the group addresses of write and passive.""" + write = self.get(*path, CONF_GA_WRITE) + passive = self.get(*path, CONF_GA_PASSIVE) + return [write, *passive] if passive else [write] + + def get_state_and_passive(self, *path: str) -> list[Any | None]: + """Get the group addresses of state and passive.""" + state = self.get(*path, CONF_GA_STATE) + passive = self.get(*path, CONF_GA_PASSIVE) + return [state, *passive] if passive else [state] + + def get_dpt(self, *path: str) -> str | None: + """Get the data point type of a group address config key.""" + return self.get(*path, CONF_DPT) # type: ignore[no-any-return] diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 730c5b788ff..5a01457d8d3 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -36,13 +36,8 @@ from .const import ( ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity from .schema import SwitchSchema -from .storage.const import ( - CONF_ENTITY, - CONF_GA_PASSIVE, - CONF_GA_STATE, - CONF_GA_SWITCH, - CONF_GA_WRITE, -) +from .storage.const import CONF_ENTITY, CONF_GA_SWITCH +from .storage.util import ConfigExtractor async def async_setup_entry( @@ -142,15 +137,13 @@ class KnxUiSwitch(_KnxSwitch, KnxUiEntity): unique_id=unique_id, entity_config=config[CONF_ENTITY], ) + knx_conf = ConfigExtractor(config[DOMAIN]) self._device = XknxSwitch( knx_module.xknx, name=config[CONF_ENTITY][CONF_NAME], - group_address=config[DOMAIN][CONF_GA_SWITCH][CONF_GA_WRITE], - group_address_state=[ - config[DOMAIN][CONF_GA_SWITCH][CONF_GA_STATE], - *config[DOMAIN][CONF_GA_SWITCH][CONF_GA_PASSIVE], - ], - respond_to_read=config[DOMAIN][CONF_RESPOND_TO_READ], - sync_state=config[DOMAIN][CONF_SYNC_STATE], - invert=config[DOMAIN][CONF_INVERT], + group_address=knx_conf.get_write(CONF_GA_SWITCH), + group_address_state=knx_conf.get_state_and_passive(CONF_GA_SWITCH), + respond_to_read=knx_conf.get(CONF_RESPOND_TO_READ), + sync_state=knx_conf.get(CONF_SYNC_STATE), + invert=knx_conf.get(CONF_INVERT), ) From 681961d3a50165a53424930c7208fe30a83b478e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 2 Jul 2025 22:14:55 +0200 Subject: [PATCH 1054/1664] Use common config_flow strings in `vegehub` (#147984) --- homeassistant/components/vegehub/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json index aa9b3aad227..c35fe0d83c9 100644 --- a/homeassistant/components/vegehub/strings.json +++ b/homeassistant/components/vegehub/strings.json @@ -27,8 +27,8 @@ "cannot_connect": "Failed to connect to the device. Please try again.", "timeout_connect": "Timed out connecting. Ensure VegeHub is awake, and try again.", "already_in_progress": "Device already detected. Check discovered devices.", - "already_configured": "Device is already configured.", - "unknown_error": "An unknown error has occurred." + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "unknown_error": "[%key:common::config_flow::error::unknown%]" } }, "entity": { From f0e0c954e7b33c8d4710ee11d81bf150880f77a6 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 2 Jul 2025 23:10:21 +0200 Subject: [PATCH 1055/1664] Bump aiounifi to v84 (#147987) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index dd255c57c13..d13b180d62d 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["aiounifi"], - "requirements": ["aiounifi==83"], + "requirements": ["aiounifi==84"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 38e4eaffa22..319da532663 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==83 +aiounifi==84 # homeassistant.components.usb aiousbwatcher==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 324e57b8f45..324667af4dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -396,7 +396,7 @@ aiotedee==0.2.25 aiotractive==0.6.0 # homeassistant.components.unifi -aiounifi==83 +aiounifi==84 # homeassistant.components.usb aiousbwatcher==1.1.1 From c137c96cfd29bd130bee8cbb78380ecaed733928 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 3 Jul 2025 08:00:34 +0200 Subject: [PATCH 1056/1664] KNX: use `async_load_json_object_fixture` in tests (#147991) --- tests/components/knx/conftest.py | 20 +++++++++++--------- tests/components/knx/test_websocket.py | 17 +++++++++-------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 4eefe3166b5..26683ced66e 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -40,15 +40,9 @@ from homeassistant.setup import async_setup_component from . import KnxEntityGenerator -from tests.common import ( - MockConfigEntry, - async_load_json_object_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, async_load_json_object_fixture from tests.typing import WebSocketGenerator -FIXTURE_PROJECT_DATA = load_json_object_fixture("project.json", DOMAIN) - class KNXTestKit: """Test helper for the KNX integration.""" @@ -338,11 +332,19 @@ async def knx( @pytest.fixture -def load_knxproj(hass_storage: dict[str, Any]) -> None: +async def project_data(hass: HomeAssistant) -> dict[str, Any]: + """Return the fixture project data.""" + return await async_load_json_object_fixture(hass, "project.json", DOMAIN) + + +@pytest.fixture +async def load_knxproj( + project_data: dict[str, Any], hass_storage: dict[str, Any] +) -> None: """Mock KNX project data.""" hass_storage[KNX_PROJECT_STORAGE_KEY] = { "version": 1, - "data": FIXTURE_PROJECT_DATA, + "data": project_data, } diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py index ab4ecf876dc..5c0f002a541 100644 --- a/tests/components/knx/test_websocket.py +++ b/tests/components/knx/test_websocket.py @@ -11,7 +11,7 @@ from homeassistant.components.knx.schema import SwitchSchema from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from .conftest import FIXTURE_PROJECT_DATA, KNXTestKit +from .conftest import KNXTestKit from tests.typing import WebSocketGenerator @@ -32,11 +32,11 @@ async def test_knx_info_command( assert res["result"]["project"] is None +@pytest.mark.usefixtures("load_knxproj") async def test_knx_info_command_with_project( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, - load_knxproj: None, ) -> None: """Test knx/info command with loaded project.""" await knx.setup_integration() @@ -59,11 +59,11 @@ async def test_knx_project_file_process( knx: KNXTestKit, hass_ws_client: WebSocketGenerator, hass_storage: dict[str, Any], + project_data: dict[str, Any], ) -> None: """Test knx/project_file_process command for storing and loading new data.""" _file_id = "1234" _password = "pw-test" - _parse_result = FIXTURE_PROJECT_DATA await knx.setup_integration() client = await hass_ws_client(hass) @@ -80,7 +80,7 @@ async def test_knx_project_file_process( patch( "homeassistant.components.knx.project.process_uploaded_file", ) as file_upload_mock, - patch("xknxproject.XKNXProj.parse", return_value=_parse_result) as parse_mock, + patch("xknxproject.XKNXProj.parse", return_value=project_data) as parse_mock, ): file_upload_mock.return_value.__enter__.return_value = "" res = await client.receive_json() @@ -90,7 +90,7 @@ async def test_knx_project_file_process( assert res["success"], res assert hass.data[KNX_MODULE_KEY].project.loaded - assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == _parse_result + assert hass_storage[KNX_PROJECT_STORAGE_KEY]["data"] == project_data async def test_knx_project_file_process_error( @@ -124,11 +124,11 @@ async def test_knx_project_file_process_error( assert not hass.data[KNX_MODULE_KEY].project.loaded +@pytest.mark.usefixtures("load_knxproj") async def test_knx_project_file_remove( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, - load_knxproj: None, hass_storage: dict[str, Any], ) -> None: """Test knx/project_file_remove command.""" @@ -145,11 +145,12 @@ async def test_knx_project_file_remove( assert not hass_storage.get(KNX_PROJECT_STORAGE_KEY) +@pytest.mark.usefixtures("load_knxproj") async def test_knx_get_project( hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator, - load_knxproj: None, + project_data: dict[str, Any], ) -> None: """Test retrieval of kxnproject from store.""" await knx.setup_integration() @@ -160,7 +161,7 @@ async def test_knx_get_project( res = await client.receive_json() assert res["success"], res assert res["result"]["project_loaded"] is True - assert res["result"]["knxproject"] == FIXTURE_PROJECT_DATA + assert res["result"]["knxproject"] == project_data async def test_knx_group_monitor_info_command( From 142c10cccc5f9e14899ca1573646d93f6b9e5f72 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Thu, 3 Jul 2025 08:50:41 +0200 Subject: [PATCH 1057/1664] Fix state being incorrectly reported in some situations on Music Assistant players (#147997) --- homeassistant/components/music_assistant/manifest.json | 2 +- homeassistant/components/music_assistant/media_player.py | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json index e29491e2b21..4b28a1029a4 100644 --- a/homeassistant/components/music_assistant/manifest.json +++ b/homeassistant/components/music_assistant/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/music_assistant", "iot_class": "local_push", "loggers": ["music_assistant"], - "requirements": ["music-assistant-client==1.2.3"], + "requirements": ["music-assistant-client==1.2.4"], "zeroconf": ["_mass._tcp.local."] } diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py index b748aad241c..3a210856391 100644 --- a/homeassistant/components/music_assistant/media_player.py +++ b/homeassistant/components/music_assistant/media_player.py @@ -248,8 +248,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity): player = self.player active_queue = self.active_queue # update generic attributes - if player.powered and active_queue is not None: - self._attr_state = MediaPlayerState(active_queue.state.value) if player.powered and player.playback_state is not None: self._attr_state = MediaPlayerState(player.playback_state.value) else: diff --git a/requirements_all.txt b/requirements_all.txt index 319da532663..7e2ca341263 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1467,7 +1467,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.3 +music-assistant-client==1.2.4 # homeassistant.components.tts mutagen==1.47.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 324667af4dd..89ec74a587c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1259,7 +1259,7 @@ mozart-api==4.1.1.116.4 mullvad-api==1.0.0 # homeassistant.components.music_assistant -music-assistant-client==1.2.3 +music-assistant-client==1.2.4 # homeassistant.components.tts mutagen==1.47.0 From a6962e9e1e4bd43063a92cb7a1e1f8c181325484 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:51:38 +0200 Subject: [PATCH 1058/1664] Fix missing port in samsungtv (#147962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../components/samsungtv/config_flow.py | 25 +++++++++++-------- .../components/samsungtv/test_config_flow.py | 17 +++++++++++++ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index dbde1ee1ef3..e2b9f8631d8 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -124,6 +124,7 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): self._model: str | None = None self._connect_result: str | None = None self._method: str | None = None + self._port: int | None = None self._name: str | None = None self._title: str = "" self._id: int | None = None @@ -199,33 +200,37 @@ class SamsungTVConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_create_bridge(self) -> None: """Create the bridge.""" - result, method, _info = await self._async_get_device_info_and_method() + result = await self._async_load_device_info() if result not in SUCCESSFUL_RESULTS: LOGGER.debug("No working config found for %s", self._host) raise AbortFlow(result) - assert method is not None - self._bridge = SamsungTVBridge.get_bridge(self.hass, method, self._host) + assert self._method is not None + self._bridge = SamsungTVBridge.get_bridge( + self.hass, self._method, self._host, self._port + ) - async def _async_get_device_info_and_method( + async def _async_load_device_info( self, - ) -> tuple[str, str | None, dict[str, Any] | None]: + ) -> str: """Get device info and method only once.""" if self._connect_result is None: - result, _, method, info = await async_get_device_info(self.hass, self._host) + result, port, method, info = await async_get_device_info( + self.hass, self._host + ) self._connect_result = result self._method = method + self._port = port self._device_info = info if not method: LOGGER.debug("Host:%s did not return device info", self._host) - return result, None, None - return self._connect_result, self._method, self._device_info + return self._connect_result async def _async_get_and_check_device_info(self) -> bool: """Try to get the device info.""" - result, _method, info = await self._async_get_device_info_and_method() + result = await self._async_load_device_info() if result not in SUCCESSFUL_RESULTS: raise AbortFlow(result) - if not info: + if not (info := self._device_info): return False dev_info = info.get("device", {}) assert dev_info is not None diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index d63e5a7ae2a..dd6b21ab5e5 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -161,6 +161,7 @@ async def test_user_legacy(hass: HomeAssistant) -> None: assert result["data"][CONF_METHOD] == METHOD_LEGACY assert result["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result["data"][CONF_MODEL] is None + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id is None @@ -195,6 +196,7 @@ async def test_user_legacy_does_not_ok_first_time(hass: HomeAssistant) -> None: assert result3["data"][CONF_METHOD] == METHOD_LEGACY assert result3["data"][CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert result3["data"][CONF_MODEL] is None + assert result3["data"][CONF_PORT] == 55000 assert result3["result"].unique_id is None @@ -224,6 +226,7 @@ async def test_user_websocket(hass: HomeAssistant) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -272,6 +275,7 @@ async def test_user_encrypted_websocket( assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung" assert result4["data"][CONF_MODEL] == "UE48JU6400" + assert result4["data"][CONF_PORT] == 8000 assert result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] is None assert result4["data"][CONF_TOKEN] == "037739871315caef138547b03e348b72" assert result4["data"][CONF_SESSION_ID] == "1" @@ -402,6 +406,7 @@ async def test_user_websocket_auth_retry(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.20.43.21" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -464,6 +469,7 @@ async def test_ssdp(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -522,6 +528,7 @@ async def test_ssdp_noprefix(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -557,6 +564,7 @@ async def test_ssdp_legacy_missing_auth(hass: HomeAssistant) -> None: assert result["data"][CONF_HOST] == "10.10.12.34" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "UE55H6400" + assert result["data"][CONF_PORT] == 55000 assert result["result"].unique_id == "068e7781-006e-1000-bbbf-84a4668d8423" @@ -599,6 +607,7 @@ async def test_ssdp_websocket_success_populates_mac_address_and_ssdp_location( assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert ( result["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] == "http://10.10.12.34:7676/smp_15_" @@ -630,6 +639,7 @@ async def test_ssdp_websocket_success_populates_mac_address_and_main_tv_ssdp_loc assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert ( result["data"][CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "http://10.10.12.34:7676/smp_2_" @@ -681,6 +691,7 @@ async def test_ssdp_encrypted_websocket_success_populates_mac_address_and_ssdp_l assert result4["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result4["data"][CONF_MANUFACTURER] == "Samsung Electronics" assert result4["data"][CONF_MODEL] == "UE48JU6400" + assert result4["data"][CONF_PORT] == 8000 assert ( result4["data"][CONF_SSDP_RENDERING_CONTROL_LOCATION] == "http://10.10.12.34:7676/smp_15_" @@ -887,6 +898,7 @@ async def test_dhcp_wireless(hass: HomeAssistant) -> None: assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE48JU6400" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "223da676-497a-4e06-9507-5e27ec4f0fb3" @@ -919,6 +931,7 @@ async def test_dhcp_wired(hass: HomeAssistant, rest_api: Mock) -> None: assert result["data"][CONF_MAC] == "aa:ee:tt:hh:ee:rr" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "UE43LS003" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -1020,6 +1033,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["data"][CONF_MAC] == "aa:bb:aa:aa:aa:aa" assert result["data"][CONF_MANUFACTURER] == "Samsung" assert result["data"][CONF_MODEL] == "82GXARRS" + assert result["data"][CONF_PORT] == 8002 assert result["result"].unique_id == "be9554b9-c9fb-41f4-8920-22da015376a4" @@ -1129,6 +1143,7 @@ async def test_autodetect_websocket(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" + assert result["data"][CONF_PORT] == 8002 remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -1180,6 +1195,7 @@ async def test_websocket_no_mac(hass: HomeAssistant, mac_address: Mock) -> None: assert result["data"][CONF_METHOD] == "websocket" assert result["data"][CONF_TOKEN] == "123456789" assert result["data"][CONF_MAC] == "gg:ee:tt:mm:aa:cc" + assert result["data"][CONF_PORT] == 8002 remote_websocket.assert_called_once_with(**AUTODETECT_WEBSOCKET_SSL) rest_api_class.assert_called_once_with(**DEVICEINFO_WEBSOCKET_SSL) await hass.async_block_till_done() @@ -2091,6 +2107,7 @@ async def test_ssdp_update_mac(hass: HomeAssistant) -> None: assert entry.data[CONF_MANUFACTURER] == DEFAULT_MANUFACTURER assert entry.data[CONF_MODEL] == "fake_model" assert entry.data[CONF_MAC] is None + assert entry.data[CONF_PORT] == 8002 assert entry.unique_id == "123" device_info = deepcopy(MOCK_DEVICE_INFO) From 6f4757ef42b285c0763708a829c1c335acbf3a83 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:52:40 +0200 Subject: [PATCH 1059/1664] Use runtime_data in melnor (#148013) --- homeassistant/components/melnor/__init__.py | 23 +++++-------------- .../components/melnor/coordinator.py | 6 +++-- homeassistant/components/melnor/number.py | 8 +++---- homeassistant/components/melnor/sensor.py | 8 +++---- homeassistant/components/melnor/switch.py | 8 +++---- homeassistant/components/melnor/time.py | 8 +++---- 6 files changed, 22 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 6ab725d747c..2d9faf91bd2 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -6,13 +6,11 @@ from melnor_bluetooth.device import Device from homeassistant.components import bluetooth from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator PLATFORMS: list[Platform] = [ Platform.NUMBER, @@ -22,11 +20,8 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MelnorConfigEntry) -> bool: """Set up melnor from a config entry.""" - - hass.data.setdefault(DOMAIN, {}).setdefault(entry.entry_id, {}) - ble_device = bluetooth.async_ble_device_from_address(hass, entry.data[CONF_ADDRESS]) if not ble_device: @@ -60,20 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = MelnorDataUpdateCoordinator(hass, entry, device) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator 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, entry: MelnorConfigEntry) -> bool: """Unload a config entry.""" + await entry.runtime_data.data.disconnect() - device: Device = hass.data[DOMAIN][entry.entry_id].data - - await device.disconnect() - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/melnor/coordinator.py b/homeassistant/components/melnor/coordinator.py index 52662fd0c4c..a57a1816e37 100644 --- a/homeassistant/components/melnor/coordinator.py +++ b/homeassistant/components/melnor/coordinator.py @@ -11,15 +11,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +type MelnorConfigEntry = ConfigEntry[MelnorDataUpdateCoordinator] + class MelnorDataUpdateCoordinator(DataUpdateCoordinator[Device]): """Melnor data update coordinator.""" - config_entry: ConfigEntry + config_entry: MelnorConfigEntry _device: Device def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, device: Device + self, hass: HomeAssistant, config_entry: MelnorConfigEntry, device: Device ) -> None: """Initialize my coordinator.""" super().__init__( diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index 42c22ae5a43..863faf080bd 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -13,13 +13,11 @@ from homeassistant.components.number import ( NumberEntityDescription, NumberMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorZoneEntity, get_entities_for_valves @@ -67,12 +65,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( get_entities_for_valves( diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 525a29dc6cf..e645019f1e8 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, @@ -26,8 +25,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorBluetoothEntity, MelnorZoneEntity, get_entities_for_valves @@ -104,12 +102,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data # Device-level sensors async_add_entities( diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index cc5abe8f6f3..d0240a471b6 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -13,12 +13,10 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorZoneEntity, get_entities_for_valves @@ -51,12 +49,12 @@ ZONE_ENTITY_DESCRIPTIONS = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the switch platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( get_entities_for_valves( diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py index 277eb6e36eb..978801dd64c 100644 --- a/homeassistant/components/melnor/time.py +++ b/homeassistant/components/melnor/time.py @@ -10,13 +10,11 @@ from typing import Any from melnor_bluetooth.device import Valve from homeassistant.components.time import TimeEntity, TimeEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN -from .coordinator import MelnorDataUpdateCoordinator +from .coordinator import MelnorConfigEntry, MelnorDataUpdateCoordinator from .entity import MelnorZoneEntity, get_entities_for_valves @@ -41,12 +39,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: MelnorConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the number platform.""" - coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data async_add_entities( get_entities_for_valves( From b97391603280b27f720fbc3f307ea19bfa88f2b0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:53:22 +0200 Subject: [PATCH 1060/1664] Move met_eireann coordinator to separate module (#148014) --- .../components/met_eireann/__init__.py | 69 +---------------- .../components/met_eireann/coordinator.py | 76 +++++++++++++++++++ .../components/met_eireann/weather.py | 5 +- tests/components/met_eireann/__init__.py | 2 +- tests/components/met_eireann/test_weather.py | 2 +- 5 files changed, 83 insertions(+), 71 deletions(-) create mode 100644 homeassistant/components/met_eireann/coordinator.py diff --git a/homeassistant/components/met_eireann/__init__.py b/homeassistant/components/met_eireann/__init__.py index 62d7d21134c..05be5134283 100644 --- a/homeassistant/components/met_eireann/__init__.py +++ b/homeassistant/components/met_eireann/__init__.py @@ -1,59 +1,21 @@ """The met_eireann component.""" -from collections.abc import Mapping -from datetime import timedelta -import logging -from typing import Any, Self - -import meteireann - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt as dt_util from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -UPDATE_INTERVAL = timedelta(minutes=60) +from .coordinator import MetEireannUpdateCoordinator PLATFORMS = [Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Met Éireann as config entry.""" - hass.data.setdefault(DOMAIN, {}) - - raw_weather_data = meteireann.WeatherData( - async_get_clientsession(hass), - latitude=config_entry.data[CONF_LATITUDE], - longitude=config_entry.data[CONF_LONGITUDE], - altitude=config_entry.data[CONF_ELEVATION], - ) - - weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data) - - async def _async_update_data() -> MetEireannWeatherData: - """Fetch data from Met Éireann.""" - try: - return await weather_data.fetch_data() - except Exception as err: - raise UpdateFailed(f"Update failed: {err}") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=config_entry, - name=DOMAIN, - update_method=_async_update_data, - update_interval=UPDATE_INTERVAL, - ) + coordinator = MetEireannUpdateCoordinator(hass, config_entry=config_entry) await coordinator.async_refresh() - hass.data[DOMAIN][config_entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -68,26 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok - - -class MetEireannWeatherData: - """Keep data for Met Éireann weather entities.""" - - def __init__( - self, config: Mapping[str, Any], weather_data: meteireann.WeatherData - ) -> None: - """Initialise the weather entity data.""" - self._config = config - self._weather_data = weather_data - self.current_weather_data: dict[str, Any] = {} - self.daily_forecast: list[dict[str, Any]] = [] - self.hourly_forecast: list[dict[str, Any]] = [] - - async def fetch_data(self) -> Self: - """Fetch data from API - (current weather and forecast).""" - await self._weather_data.fetching_data() - self.current_weather_data = self._weather_data.get_current_weather() - time_zone = dt_util.get_default_time_zone() - self.daily_forecast = self._weather_data.get_forecast(time_zone, False) - self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) - return self diff --git a/homeassistant/components/met_eireann/coordinator.py b/homeassistant/components/met_eireann/coordinator.py new file mode 100644 index 00000000000..fb8c85f6b8d --- /dev/null +++ b/homeassistant/components/met_eireann/coordinator.py @@ -0,0 +1,76 @@ +"""The met_eireann component.""" + +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any, Self + +import meteireann + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(minutes=60) + + +class MetEireannWeatherData: + """Keep data for Met Éireann weather entities.""" + + def __init__( + self, config: Mapping[str, Any], weather_data: meteireann.WeatherData + ) -> None: + """Initialise the weather entity data.""" + self._config = config + self._weather_data = weather_data + self.current_weather_data: dict[str, Any] = {} + self.daily_forecast: list[dict[str, Any]] = [] + self.hourly_forecast: list[dict[str, Any]] = [] + + async def fetch_data(self) -> Self: + """Fetch data from API - (current weather and forecast).""" + await self._weather_data.fetching_data() + self.current_weather_data = self._weather_data.get_current_weather() + time_zone = dt_util.get_default_time_zone() + self.daily_forecast = self._weather_data.get_forecast(time_zone, False) + self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) + return self + + +class MetEireannUpdateCoordinator(DataUpdateCoordinator[MetEireannWeatherData]): + """Coordinator for Met Éireann weather data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + ) + raw_weather_data = meteireann.WeatherData( + async_get_clientsession(hass), + latitude=config_entry.data[CONF_LATITUDE], + longitude=config_entry.data[CONF_LONGITUDE], + altitude=config_entry.data[CONF_ELEVATION], + ) + self._weather_data = MetEireannWeatherData(config_entry.data, raw_weather_data) + + async def _async_update_data(self) -> MetEireannWeatherData: + """Fetch data from Met Éireann.""" + try: + return await self._weather_data.fetch_data() + except Exception as err: + raise UpdateFailed(f"Update failed: {err}") from err diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 97bbd952740..68f46f0a656 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -1,7 +1,6 @@ """Support for Met Éireann weather service.""" from collections.abc import Mapping -import logging from typing import Any, cast from homeassistant.components.weather import ( @@ -29,10 +28,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from . import MetEireannWeatherData from .const import CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP - -_LOGGER = logging.getLogger(__name__) +from .coordinator import MetEireannWeatherData def format_condition(condition: str | None) -> str | None: diff --git a/tests/components/met_eireann/__init__.py b/tests/components/met_eireann/__init__.py index c38f197691a..a65ba64accd 100644 --- a/tests/components/met_eireann/__init__.py +++ b/tests/components/met_eireann/__init__.py @@ -19,7 +19,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry: } entry = MockConfigEntry(domain=DOMAIN, data=entry_data) with patch( - "homeassistant.components.met_eireann.meteireann.WeatherData.fetching_data", + "homeassistant.components.met_eireann.coordinator.meteireann.WeatherData.fetching_data", return_value=True, ): entry.add_to_hass(hass) diff --git a/tests/components/met_eireann/test_weather.py b/tests/components/met_eireann/test_weather.py index 1e385c9a600..54931dd4c12 100644 --- a/tests/components/met_eireann/test_weather.py +++ b/tests/components/met_eireann/test_weather.py @@ -6,8 +6,8 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.met_eireann import UPDATE_INTERVAL from homeassistant.components.met_eireann.const import DOMAIN +from homeassistant.components.met_eireann.coordinator import UPDATE_INTERVAL from homeassistant.components.weather import ( DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECASTS, From 04e69479f4832d8255a312d266a08f537934319f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:54:20 +0200 Subject: [PATCH 1061/1664] Fix hass.data reference in lookin (#148008) --- homeassistant/components/lookin/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 7eff68703a5..1814f95d5a1 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -200,7 +200,7 @@ async def async_remove_config_entry_device( hass: HomeAssistant, entry: LookinConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove lookin config entry from a device.""" - data: LookinData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data all_identifiers: set[tuple[str, str]] = { (DOMAIN, data.lookin_device.id), *((DOMAIN, remote["UUID"]) for remote in data.devices), From e42235285de88e692099d94618149c272dc08844 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 08:57:22 +0200 Subject: [PATCH 1062/1664] Use runtime_data in melcloud (#148012) Co-authored-by: Franck Nijhof Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/melcloud/__init__.py | 16 ++++++---------- homeassistant/components/melcloud/climate.py | 8 +++----- homeassistant/components/melcloud/diagnostics.py | 5 +++-- homeassistant/components/melcloud/sensor.py | 8 +++----- .../components/melcloud/water_heater.py | 7 +++---- 5 files changed, 18 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index 30645661ff1..d78807106c1 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -27,9 +27,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] +type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Establish connection with MELClooud.""" + +async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool: + """Establish connection with MELCloud.""" conf = entry.data try: mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) @@ -40,20 +42,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except (TimeoutError, ClientConnectionError) as ex: raise ConfigEntryNotReady from ex - hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: mel_devices}) + entry.runtime_data = mel_devices await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - hass.data[DOMAIN].pop(config_entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) class MelCloudDevice: diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 19c333e5825..b5fd57c716d 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -24,13 +24,12 @@ from homeassistant.components.climate import ( HVACAction, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudDevice +from . import MelCloudConfigEntry, MelCloudDevice from .const import ( ATTR_STATUS, ATTR_VANE_HORIZONTAL, @@ -38,7 +37,6 @@ from .const import ( ATTR_VANE_VERTICAL, ATTR_VANE_VERTICAL_POSITIONS, CONF_POSITION, - DOMAIN, SERVICE_SET_VANE_HORIZONTAL, SERVICE_SET_VANE_VERTICAL, ) @@ -77,11 +75,11 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" - mel_devices = hass.data[DOMAIN][entry.entry_id] + mel_devices = entry.runtime_data entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [ AtaDeviceClimate(mel_device, mel_device.device) for mel_device in mel_devices[DEVICE_TYPE_ATA] diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py index 31e52bf2bde..4606b7c25e5 100644 --- a/homeassistant/components/melcloud/diagnostics.py +++ b/homeassistant/components/melcloud/diagnostics.py @@ -5,11 +5,12 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import MelCloudConfigEntry + TO_REDACT = { CONF_USERNAME, CONF_TOKEN, @@ -17,7 +18,7 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: MelCloudConfigEntry ) -> dict[str, Any]: """Return diagnostics for the config entry.""" ent_reg = er.async_get(hass) diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 51a026e717a..36800b2645d 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -15,13 +15,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfEnergy, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudDevice -from .const import DOMAIN +from . import MelCloudConfigEntry, MelCloudDevice @dataclasses.dataclass(frozen=True, kw_only=True) @@ -105,11 +103,11 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud device sensors based on config_entry.""" - mel_devices = hass.data[DOMAIN].get(entry.entry_id) + mel_devices = entry.runtime_data entities: list[MelDeviceSensor] = [ MelDeviceSensor(mel_device, description) diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index 76fbad41575..f006df2478e 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -17,22 +17,21 @@ from homeassistant.components.water_heater import ( WaterHeaterEntity, WaterHeaterEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import DOMAIN, MelCloudDevice +from . import MelCloudConfigEntry, MelCloudDevice from .const import ATTR_STATUS async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" - mel_devices = hass.data[DOMAIN][entry.entry_id] + mel_devices = entry.runtime_data async_add_entities( [ AtwWaterHeater(mel_device, mel_device.device) From 500815168857321ff83c8a2c6643b1f5c903202b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:20:50 +0200 Subject: [PATCH 1063/1664] Use entry.async_on_unload in monoprice (#148016) --- homeassistant/components/monoprice/__init__.py | 13 ++----------- homeassistant/components/monoprice/const.py | 1 - 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index c7683ebedd6..6e5c4c6181f 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -10,13 +10,7 @@ from homeassistant.const import CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import ( - CONF_NOT_FIRST_RUN, - DOMAIN, - FIRST_RUN, - MONOPRICE_OBJECT, - UNDO_UPDATE_LISTENER, -) +from .const import CONF_NOT_FIRST_RUN, DOMAIN, FIRST_RUN, MONOPRICE_OBJECT PLATFORMS = [Platform.MEDIA_PLAYER] @@ -41,11 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, CONF_NOT_FIRST_RUN: True} ) - undo_listener = entry.add_update_listener(_update_listener) + entry.async_on_unload(entry.add_update_listener(_update_listener)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { MONOPRICE_OBJECT: monoprice, - UNDO_UPDATE_LISTENER: undo_listener, FIRST_RUN: first_run, } @@ -60,8 +53,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not unload_ok: return False - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - def _cleanup(monoprice) -> None: """Destroy the Monoprice object. diff --git a/homeassistant/components/monoprice/const.py b/homeassistant/components/monoprice/const.py index 576e4aa0e69..9dc9cad3831 100644 --- a/homeassistant/components/monoprice/const.py +++ b/homeassistant/components/monoprice/const.py @@ -18,4 +18,3 @@ SERVICE_RESTORE = "restore" FIRST_RUN = "first_run" MONOPRICE_OBJECT = "monoprice_object" -UNDO_UPDATE_LISTENER = "update_update_listener" From bfc814c83995e4bfb42477c86f5b9c36ff9c750a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:22:27 +0200 Subject: [PATCH 1064/1664] Use entry.async_on_unload in meteo_france (#148015) --- homeassistant/components/meteo_france/__init__.py | 5 +---- homeassistant/components/meteo_france/const.py | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index 5f1d5269538..20e6c02f5d4 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -23,7 +23,6 @@ from .const import ( COORDINATOR_RAIN, DOMAIN, PLATFORMS, - UNDO_UPDATE_LISTENER, ) _LOGGER = logging.getLogger(__name__) @@ -130,10 +129,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.title, ) - undo_listener = entry.add_update_listener(_async_update_listener) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) hass.data[DOMAIN][entry.entry_id] = { - UNDO_UPDATE_LISTENER: undo_listener, COORDINATOR_FORECAST: coordinator_forecast, } if coordinator_rain and coordinator_rain.last_update_success: @@ -163,7 +161,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() hass.data[DOMAIN].pop(entry.entry_id) if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 382a56d50d7..cde2812b059 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -26,7 +26,6 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] COORDINATOR_FORECAST = "coordinator_forecast" COORDINATOR_RAIN = "coordinator_rain" COORDINATOR_ALERT = "coordinator_alert" -UNDO_UPDATE_LISTENER = "undo_update_listener" ATTRIBUTION = "Data provided by Météo-France" MODEL = "Météo-France mobile API" MANUFACTURER = "Météo-France" From b1e3561ead7a120960e4b5a4e99bd9d1685a08f7 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 3 Jul 2025 09:23:45 +0200 Subject: [PATCH 1065/1664] Clarify description of autorelock setting in `zwave_js` (#148019) --- homeassistant/components/zwave_js/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 7445182e5f6..5029e8c6108 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -548,8 +548,8 @@ "description": "Sets the configuration for a lock.", "fields": { "auto_relock_time": { - "description": "Duration in seconds until lock returns to secure state. Only enforced when operation type is `constant`.", - "name": "Auto relock time" + "description": "Duration in seconds until lock returns to locked state. Only enforced when operation type is `constant`.", + "name": "Autorelock time" }, "block_to_block": { "description": "Whether the lock should run the motor until it hits resistance.", From 7d36a2e3a7f318461266cf9f2b1886c4371d4677 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:26:24 +0200 Subject: [PATCH 1066/1664] Move meteoclimatic coordinator to separate module (#148018) --- .../components/meteoclimatic/__init__.py | 34 ++------------- .../components/meteoclimatic/coordinator.py | 43 +++++++++++++++++++ .../components/meteoclimatic/sensor.py | 16 ++++--- .../components/meteoclimatic/weather.py | 14 +++--- tests/components/meteoclimatic/conftest.py | 4 +- 5 files changed, 65 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/meteoclimatic/coordinator.py diff --git a/homeassistant/components/meteoclimatic/__init__.py b/homeassistant/components/meteoclimatic/__init__.py index 8c2fb41c634..99f72fe726b 100644 --- a/homeassistant/components/meteoclimatic/__init__.py +++ b/homeassistant/components/meteoclimatic/__init__.py @@ -1,43 +1,15 @@ """Support for Meteoclimatic weather data.""" -import logging - -from meteoclimatic import MeteoclimaticClient -from meteoclimatic.exceptions import MeteoclimaticError - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_STATION_CODE, DOMAIN, PLATFORMS, SCAN_INTERVAL - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, PLATFORMS +from .coordinator import MeteoclimaticUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Meteoclimatic entry.""" - station_code = entry.data[CONF_STATION_CODE] - meteoclimatic_client = MeteoclimaticClient() - - async def async_update_data(): - """Obtain the latest data from Meteoclimatic.""" - try: - data = await hass.async_add_executor_job( - meteoclimatic_client.weather_at_station, station_code - ) - except MeteoclimaticError as err: - raise UpdateFailed(f"Error while retrieving data: {err}") from err - return data.__dict__ - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=f"Meteoclimatic weather for {entry.title} ({station_code})", - update_method=async_update_data, - update_interval=SCAN_INTERVAL, - ) - + coordinator = MeteoclimaticUpdateCoordinator(hass, entry) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/meteoclimatic/coordinator.py b/homeassistant/components/meteoclimatic/coordinator.py new file mode 100644 index 00000000000..2e9264dd3ef --- /dev/null +++ b/homeassistant/components/meteoclimatic/coordinator.py @@ -0,0 +1,43 @@ +"""Support for Meteoclimatic weather data.""" + +import logging +from typing import Any + +from meteoclimatic import MeteoclimaticClient +from meteoclimatic.exceptions import MeteoclimaticError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_CODE, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class MeteoclimaticUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Coordinator for Meteoclimatic weather data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + self._station_code = entry.data[CONF_STATION_CODE] + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"Meteoclimatic weather for {entry.title} ({self._station_code})", + update_interval=SCAN_INTERVAL, + ) + self._meteoclimatic_client = MeteoclimaticClient() + + async def _async_update_data(self) -> dict[str, Any]: + """Obtain the latest data from Meteoclimatic.""" + try: + data = await self.hass.async_add_executor_job( + self._meteoclimatic_client.weather_at_station, self._station_code + ) + except MeteoclimaticError as err: + raise UpdateFailed(f"Error while retrieving data: {err}") from err + return data.__dict__ diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index 6e508bd63d8..2d80ccda30c 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -18,12 +18,10 @@ from homeassistant.const import ( 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL +from .coordinator import MeteoclimaticUpdateCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -119,7 +117,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( [MeteoclimaticSensor(coordinator, description) for description in SENSOR_TYPES], @@ -127,13 +125,17 @@ async def async_setup_entry( ) -class MeteoclimaticSensor(CoordinatorEntity, SensorEntity): +class MeteoclimaticSensor( + CoordinatorEntity[MeteoclimaticUpdateCoordinator], SensorEntity +): """Representation of a Meteoclimatic sensor.""" _attr_attribution = ATTRIBUTION def __init__( - self, coordinator: DataUpdateCoordinator, description: SensorEntityDescription + self, + coordinator: MeteoclimaticUpdateCoordinator, + description: SensorEntityDescription, ) -> None: """Initialize the Meteoclimatic sensor.""" super().__init__(coordinator) diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index fa3b3c92288..ba74cfeca5e 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -8,12 +8,10 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature 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, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION, CONDITION_MAP, DOMAIN, MANUFACTURER, MODEL +from .coordinator import MeteoclimaticUpdateCoordinator def format_condition(condition): @@ -31,12 +29,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Meteoclimatic weather platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: MeteoclimaticUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities([MeteoclimaticWeather(coordinator)], False) -class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): +class MeteoclimaticWeather( + CoordinatorEntity[MeteoclimaticUpdateCoordinator], WeatherEntity +): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION @@ -44,7 +44,7 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR - def __init__(self, coordinator: DataUpdateCoordinator) -> None: + def __init__(self, coordinator: MeteoclimaticUpdateCoordinator) -> None: """Initialise the weather platform.""" super().__init__(coordinator) self._unique_id = self.coordinator.data["station"].code diff --git a/tests/components/meteoclimatic/conftest.py b/tests/components/meteoclimatic/conftest.py index a481b811a77..8bd600a4f6f 100644 --- a/tests/components/meteoclimatic/conftest.py +++ b/tests/components/meteoclimatic/conftest.py @@ -8,7 +8,9 @@ import pytest @pytest.fixture(autouse=True) def patch_requests(): """Stub out services that makes requests.""" - patch_client = patch("homeassistant.components.meteoclimatic.MeteoclimaticClient") + patch_client = patch( + "homeassistant.components.meteoclimatic.coordinator.MeteoclimaticClient" + ) with patch_client: yield From 3bc00824e2bc1143a8d917190f120f2caa984493 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:27:38 +0200 Subject: [PATCH 1067/1664] Use runtime_data in mystrom (#148020) --- homeassistant/components/mystrom/__init__.py | 17 ++++++----------- homeassistant/components/mystrom/light.py | 8 ++++---- homeassistant/components/mystrom/models.py | 4 ++++ homeassistant/components/mystrom/sensor.py | 6 +++--- homeassistant/components/mystrom/switch.py | 6 +++--- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 09cd7b42da0..9094fc11e1c 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -9,13 +9,11 @@ from pymystrom.bulb import MyStromBulb from pymystrom.exceptions import MyStromConnectionError from pymystrom.switch import MyStromSwitch -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import DOMAIN -from .models import MyStromData +from .models import MyStromConfigEntry, MyStromData PLATFORMS_PLUGS = [Platform.SENSOR, Platform.SWITCH] PLATFORMS_BULB = [Platform.LIGHT] @@ -41,7 +39,7 @@ def _get_mystrom_switch(host: str) -> MyStromSwitch: return MyStromSwitch(host) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> bool: """Set up myStrom from a config entry.""" host = entry.data[CONF_HOST] try: @@ -73,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.error("Unsupported myStrom device type: %s", device_type) return False - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = MyStromData( + entry.runtime_data = MyStromData( device=device, info=info, ) @@ -82,15 +80,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: MyStromConfigEntry) -> bool: """Unload a config entry.""" - device_type = hass.data[DOMAIN][entry.entry_id].info["type"] + device_type = entry.runtime_data.info["type"] platforms = [] if device_type in [101, 106, 107, 120]: platforms.extend(PLATFORMS_PLUGS) elif device_type in [102, 105]: platforms.extend(PLATFORMS_BULB) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, platforms) diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 3942f601a20..67964d7d5b4 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -15,12 +15,12 @@ from homeassistant.components.light import ( LightEntity, LightEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .models import MyStromConfigEntry _LOGGER = logging.getLogger(__name__) @@ -32,12 +32,12 @@ EFFECT_SUNRISE = "sunrise" async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MyStromConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" - info = hass.data[DOMAIN][entry.entry_id].info - device = hass.data[DOMAIN][entry.entry_id].device + info = entry.runtime_data.info + device = entry.runtime_data.device async_add_entities([MyStromLight(device, entry.title, info["mac"])]) diff --git a/homeassistant/components/mystrom/models.py b/homeassistant/components/mystrom/models.py index 694a2f43df6..a96837070fd 100644 --- a/homeassistant/components/mystrom/models.py +++ b/homeassistant/components/mystrom/models.py @@ -6,6 +6,10 @@ from typing import Any from pymystrom.bulb import MyStromBulb from pymystrom.switch import MyStromSwitch +from homeassistant.config_entries import ConfigEntry + +type MyStromConfigEntry = ConfigEntry[MyStromData] + @dataclass class MyStromData: diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index bd5c9b923a2..251765d1658 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -13,13 +13,13 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .models import MyStromConfigEntry @dataclass(frozen=True) @@ -56,11 +56,11 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MyStromConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" - device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device + device: MyStromSwitch = entry.runtime_data.device async_add_entities( MyStromSwitchSensor(device, entry.title, description) diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index f626656a4e3..860d2dff727 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -8,12 +8,12 @@ from typing import Any from pymystrom.exceptions import MyStromConnectionError from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo, format_mac from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN, MANUFACTURER +from .models import MyStromConfigEntry DEFAULT_NAME = "myStrom Switch" @@ -22,11 +22,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: MyStromConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the myStrom entities.""" - device = hass.data[DOMAIN][entry.entry_id].device + device = entry.runtime_data.device async_add_entities([MyStromSwitch(device, entry.title)]) From 691681a78ada4f69348f8ad600f27b389fb82ae8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:32:57 +0200 Subject: [PATCH 1068/1664] Move medcom_ble coordinator to separate module (#148009) --- .../components/medcom_ble/__init__.py | 36 ++----------- .../components/medcom_ble/coordinator.py | 50 +++++++++++++++++++ homeassistant/components/medcom_ble/sensor.py | 18 ++----- 3 files changed, 58 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/medcom_ble/coordinator.py diff --git a/homeassistant/components/medcom_ble/__init__.py b/homeassistant/components/medcom_ble/__init__.py index 8603e1b9ce5..5c508688b54 100644 --- a/homeassistant/components/medcom_ble/__init__.py +++ b/homeassistant/components/medcom_ble/__init__.py @@ -2,34 +2,23 @@ from __future__ import annotations -from datetime import timedelta -import logging - -from bleak import BleakError -from medcom_ble import MedcomBleDeviceData - from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.unit_system import METRIC_SYSTEM -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN +from .coordinator import MedcomBleUpdateCoordinator # Supported platforms PLATFORMS: list[Platform] = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Medcom BLE radiation monitor from a config entry.""" address = entry.unique_id - elevation = hass.config.elevation - is_metric = hass.config.units is METRIC_SYSTEM assert address is not None ble_device = bluetooth.async_ble_device_from_address(hass, address) @@ -38,26 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not find Medcom BLE device with address {address}" ) - async def _async_update_method(): - """Get data from Medcom BLE radiation monitor.""" - ble_device = bluetooth.async_ble_device_from_address(hass, address) - inspector = MedcomBleDeviceData(_LOGGER, elevation, is_metric) - - try: - data = await inspector.update_device(ble_device) - except BleakError as err: - raise UpdateFailed(f"Unable to fetch data: {err}") from err - - return data - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - config_entry=entry, - name=DOMAIN, - update_method=_async_update_method, - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) + coordinator = MedcomBleUpdateCoordinator(hass, entry, address) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/medcom_ble/coordinator.py b/homeassistant/components/medcom_ble/coordinator.py new file mode 100644 index 00000000000..2b326c4196d --- /dev/null +++ b/homeassistant/components/medcom_ble/coordinator.py @@ -0,0 +1,50 @@ +"""The Medcom BLE integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from bleak import BleakError +from medcom_ble import MedcomBleDevice, MedcomBleDeviceData + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.unit_system import METRIC_SYSTEM + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MedcomBleUpdateCoordinator(DataUpdateCoordinator[MedcomBleDevice]): + """Coordinator for Medcom BLE radiation monitor data.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry, address: str) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=DOMAIN, + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._address = address + self._elevation = hass.config.elevation + self._is_metric = hass.config.units is METRIC_SYSTEM + + async def _async_update_data(self) -> MedcomBleDevice: + """Get data from Medcom BLE radiation monitor.""" + ble_device = bluetooth.async_ble_device_from_address(self.hass, self._address) + inspector = MedcomBleDeviceData(_LOGGER, self._elevation, self._is_metric) + + try: + data = await inspector.update_device(ble_device) + except BleakError as err: + raise UpdateFailed(f"Unable to fetch data: {err}") from err + + return data diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py index f837620c829..cf78b5dc41a 100644 --- a/homeassistant/components/medcom_ble/sensor.py +++ b/homeassistant/components/medcom_ble/sensor.py @@ -4,8 +4,6 @@ from __future__ import annotations import logging -from medcom_ble import MedcomBleDevice - from homeassistant import config_entries from homeassistant.components.sensor import ( SensorEntity, @@ -15,12 +13,10 @@ from homeassistant.components.sensor import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, UNIT_CPM +from .coordinator import MedcomBleUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -41,9 +37,7 @@ async def async_setup_entry( ) -> None: """Set up Medcom BLE radiation monitor sensors.""" - coordinator: DataUpdateCoordinator[MedcomBleDevice] = hass.data[DOMAIN][ - entry.entry_id - ] + coordinator: MedcomBleUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] _LOGGER.debug("got sensors: %s", coordinator.data.sensors) @@ -62,16 +56,14 @@ async def async_setup_entry( async_add_entities(entities) -class MedcomSensor( - CoordinatorEntity[DataUpdateCoordinator[MedcomBleDevice]], SensorEntity -): +class MedcomSensor(CoordinatorEntity[MedcomBleUpdateCoordinator], SensorEntity): """Medcom BLE radiation monitor sensors for the device.""" _attr_has_entity_name = True def __init__( self, - coordinator: DataUpdateCoordinator[MedcomBleDevice], + coordinator: MedcomBleUpdateCoordinator, entity_description: SensorEntityDescription, ) -> None: """Populate the medcom entity with relevant data.""" From a656b6e26afe434d01d76663665ad64fb34e6f1a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 09:56:46 +0200 Subject: [PATCH 1069/1664] Use HassKey in media_source (#148011) --- homeassistant/components/media_source/__init__.py | 9 +++++---- homeassistant/components/media_source/const.py | 8 ++++++++ homeassistant/components/media_source/local_source.py | 8 ++++---- homeassistant/components/media_source/models.py | 10 ++++++---- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index e1e9a4feb4b..efd7c6670d2 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -30,6 +30,7 @@ from .const import ( DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, + MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX, ) @@ -78,7 +79,7 @@ def generate_media_source_id(domain: str, identifier: str) -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the media_source component.""" - hass.data[DOMAIN] = {} + hass.data[MEDIA_SOURCE_DATA] = {} websocket_api.async_register_command(hass, websocket_browse_media) websocket_api.async_register_command(hass, websocket_resolve_media) frontend.async_register_built_in_panel( @@ -97,7 +98,7 @@ async def _process_media_source_platform( platform: MediaSourceProtocol, ) -> None: """Process a media source platform.""" - hass.data[DOMAIN][domain] = await platform.async_get_media_source(hass) + hass.data[MEDIA_SOURCE_DATA][domain] = await platform.async_get_media_source(hass) @callback @@ -109,10 +110,10 @@ def _get_media_item( item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) else: # We default to our own domain if its only one registered - domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN + domain = None if len(hass.data[MEDIA_SOURCE_DATA]) > 1 else DOMAIN return MediaSourceItem(hass, domain, "", target_media_player) - if item.domain is not None and item.domain not in hass.data[DOMAIN]: + if item.domain is not None and item.domain not in hass.data[MEDIA_SOURCE_DATA]: raise UnknownMediaSource( translation_domain=DOMAIN, translation_key="unknown_media_source", diff --git a/homeassistant/components/media_source/const.py b/homeassistant/components/media_source/const.py index 809e0d8a1fd..38c75f19b22 100644 --- a/homeassistant/components/media_source/const.py +++ b/homeassistant/components/media_source/const.py @@ -1,10 +1,18 @@ """Constants for the media_source integration.""" +from __future__ import annotations + import re +from typing import TYPE_CHECKING from homeassistant.components.media_player import MediaClass +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from .models import MediaSource DOMAIN = "media_source" +MEDIA_SOURCE_DATA: HassKey[dict[str, MediaSource]] = HassKey(DOMAIN) MEDIA_MIME_TYPES = ("audio", "video", "image") MEDIA_CLASS_MAP = { "audio": MediaClass.MUSIC, diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 4e3d6ff59db..c9b81e6534e 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -6,7 +6,7 @@ import logging import mimetypes from pathlib import Path import shutil -from typing import Any +from typing import Any, cast from aiohttp import web from aiohttp.web_request import FileField @@ -18,7 +18,7 @@ from homeassistant.components.media_player import BrowseError, MediaClass from homeassistant.core import HomeAssistant, callback from homeassistant.util import raise_if_invalid_filename, raise_if_invalid_path -from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES +from .const import DOMAIN, MEDIA_CLASS_MAP, MEDIA_MIME_TYPES, MEDIA_SOURCE_DATA from .error import Unresolvable from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia @@ -30,7 +30,7 @@ LOGGER = logging.getLogger(__name__) def async_setup(hass: HomeAssistant) -> None: """Set up local media source.""" source = LocalSource(hass) - hass.data[DOMAIN][DOMAIN] = source + hass.data[MEDIA_SOURCE_DATA][DOMAIN] = source hass.http.register_view(LocalMediaView(hass, source)) hass.http.register_view(UploadMediaView(hass, source)) websocket_api.async_register_command(hass, websocket_remove_media) @@ -352,7 +352,7 @@ async def websocket_remove_media( connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return - source: LocalSource = hass.data[DOMAIN][DOMAIN] + source = cast(LocalSource, hass.data[MEDIA_SOURCE_DATA][DOMAIN]) try: source_dir_id, location = source.async_parse_identifier(item) diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 53bd8213262..5e64dc867f2 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -3,12 +3,12 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, cast +from typing import TYPE_CHECKING, Any from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType from homeassistant.core import HomeAssistant, callback -from .const import DOMAIN, URI_SCHEME, URI_SCHEME_REGEX +from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX @dataclass(slots=True) @@ -70,7 +70,7 @@ class MediaSourceItem: can_play=False, can_expand=True, ) - for source in self.hass.data[DOMAIN].values() + for source in self.hass.data[MEDIA_SOURCE_DATA].values() ), key=lambda item: item.title, ) @@ -85,7 +85,9 @@ class MediaSourceItem: @callback def async_media_source(self) -> MediaSource: """Return media source that owns this item.""" - return cast(MediaSource, self.hass.data[DOMAIN][self.domain]) + if TYPE_CHECKING: + assert self.domain is not None + return self.hass.data[MEDIA_SOURCE_DATA][self.domain] @classmethod def from_uri( From 244e0f5ea864e46918e6d8273399efc887b7f029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 3 Jul 2025 13:24:51 +0100 Subject: [PATCH 1070/1664] Bump hass-nabucasa from 0.104.0 to 0.105.0 (#148040) --- 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 70cf6a2c072..0d44d57ac5e 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.104.0"], + "requirements": ["hass-nabucasa==0.105.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 769e8d9162e..2b891e1678d 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==3.49.0 -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.0 diff --git a/pyproject.toml b/pyproject.toml index eb6bdbcef2a..399d35ffb41 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.104.0", + "hass-nabucasa==0.105.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 ce583741763..d6912b8898b 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.104.0 +hass-nabucasa==0.105.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 7e2ca341263..588508c3a36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89ec74a587c..a82b835cfde 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.104.0 +hass-nabucasa==0.105.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 3c4ecffa1bcb72dd737742051bcce814248dbfb6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 3 Jul 2025 10:33:44 -0500 Subject: [PATCH 1071/1664] Bump aioesphomeapi to 34.1.0 (#148048) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/snapshots/test_diagnostics.ambr | 1 + tests/components/esphome/test_diagnostics.py | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 89ffde03a7f..01e04df6db8 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==33.1.1", + "aioesphomeapi==34.1.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 588508c3a36..09142fb10a8 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==33.1.1 +aioesphomeapi==34.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a82b835cfde..7cc3f43014a 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==33.1.1 +aioesphomeapi==34.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/snapshots/test_diagnostics.ambr b/tests/components/esphome/snapshots/test_diagnostics.ambr index dac224c802f..6b7a1c64c9f 100644 --- a/tests/components/esphome/snapshots/test_diagnostics.ambr +++ b/tests/components/esphome/snapshots/test_diagnostics.ambr @@ -82,6 +82,7 @@ 'minor': 99, }), 'device_info': dict({ + 'api_encryption_supported': False, 'area': dict({ 'area_id': 0, 'name': '', diff --git a/tests/components/esphome/test_diagnostics.py b/tests/components/esphome/test_diagnostics.py index 2653df57adb..ebfe15d562f 100644 --- a/tests/components/esphome/test_diagnostics.py +++ b/tests/components/esphome/test_diagnostics.py @@ -124,6 +124,7 @@ async def test_diagnostics_with_bluetooth( "storage_data": { "api_version": {"major": 99, "minor": 99}, "device_info": { + "api_encryption_supported": False, "area": {"area_id": 0, "name": ""}, "areas": [], "bluetooth_mac_address": "**REDACTED**", From 6a88ee7a8f1559fd47842dd73f806d713d392a3e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 3 Jul 2025 18:27:51 +0200 Subject: [PATCH 1072/1664] Add Task issue form (#148038) --- .github/ISSUE_TEMPLATE/task.yml | 51 ++++++++++++ .github/workflows/restrict-task-creation.yml | 84 ++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/task.yml create mode 100644 .github/workflows/restrict-task-creation.yml diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml new file mode 100644 index 00000000000..b5d2b1deb06 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -0,0 +1,51 @@ +name: Task +description: For staff only - Create a task +type: Task +body: + - type: markdown + attributes: + value: | + ## ⚠️ RESTRICTED ACCESS + + **This form is restricted to Open Home Foundation staff, authorized contributors, and integration code owners only.** + + If you are a community member wanting to contribute, please: + - For bug reports: Use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml) + - For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions) + + --- + + ### For authorized contributors + + Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked. + - type: textarea + id: description + attributes: + label: Task description + description: | + Provide a clear and detailed description of the task that needs to be accomplished. + + Be specific about what needs to be done, why it's important, and any constraints or requirements. + placeholder: | + Describe the task, including: + - What needs to be done + - Why this task is needed + - Expected outcome + - Any constraints or requirements + validations: + required: true + - type: textarea + id: additional_context + attributes: + label: Additional context + description: | + Any additional information, links, research, or context that would be helpful. + + Include links to related issues, research, prototypes, roadmap opportunities etc. + placeholder: | + - Roadmap opportunity: [links] + - Feature request: [link] + - Technical design documents: [link] + - Prototype/mockup: [link] + validations: + required: false diff --git a/.github/workflows/restrict-task-creation.yml b/.github/workflows/restrict-task-creation.yml new file mode 100644 index 00000000000..0a6be15180b --- /dev/null +++ b/.github/workflows/restrict-task-creation.yml @@ -0,0 +1,84 @@ +name: Restrict task creation + +# yamllint disable-line rule:truthy +on: + issues: + types: [opened] + +jobs: + check-authorization: + runs-on: ubuntu-latest + # Only run if this is a Task issue type (from the issue form) + if: github.event.issue.issue_type == 'Task' + steps: + - name: Check if user is authorized + uses: actions/github-script@v7 + with: + script: | + const issueAuthor = context.payload.issue.user.login; + + // First check if user is an organization member + try { + await github.rest.orgs.checkMembershipForUser({ + org: 'home-assistant', + username: issueAuthor + }); + console.log(`✅ ${issueAuthor} is an organization member`); + return; // Authorized, no need to check further + } catch (error) { + console.log(`ℹ️ ${issueAuthor} is not an organization member, checking codeowners...`); + } + + // If not an org member, check if they're a codeowner + try { + // Fetch CODEOWNERS file from the repository + const { data: codeownersFile } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: 'CODEOWNERS', + ref: 'dev' + }); + + // Decode the content (it's base64 encoded) + const codeownersContent = Buffer.from(codeownersFile.content, 'base64').toString('utf-8'); + + // Check if the issue author is mentioned in CODEOWNERS + // GitHub usernames in CODEOWNERS are prefixed with @ + if (codeownersContent.includes(`@${issueAuthor}`)) { + console.log(`✅ ${issueAuthor} is a integration code owner`); + return; // Authorized + } + } catch (error) { + console.error('Error checking CODEOWNERS:', error); + } + + // If we reach here, user is not authorized + console.log(`❌ ${issueAuthor} is not authorized to create Task issues`); + + // Close the issue with a comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` + + `Task issues are restricted to Open Home Foundation staff, authorized contributors, and integration code owners.\n\n` + + `If you would like to:\n` + + `- Report a bug: Please use the [bug report form](https://github.com/home-assistant/core/issues/new?template=bug_report.yml)\n` + + `- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` + + `If you believe you should have access to create Task issues, please contact the maintainers.` + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + state: 'closed' + }); + + // Add a label to indicate this was auto-closed + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['auto-closed'] + }); From 4e71745c62d8c99abba98b81841916d059a23b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 3 Jul 2025 17:41:08 +0100 Subject: [PATCH 1073/1664] Set assist_satellite preannounce default to True (#148060) --- .../components/assist_satellite/__init__.py | 8 ++++---- .../components/assist_satellite/test_entity.py | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 26ce9e75428..62dcb8c1d80 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -72,7 +72,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("message"): str, vol.Optional("media_id"): _media_id_validator, - vol.Optional("preannounce"): bool, + vol.Optional("preannounce", default=True): bool, vol.Optional("preannounce_media_id"): _media_id_validator, } ), @@ -89,7 +89,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: { vol.Optional("start_message"): str, vol.Optional("start_media_id"): _media_id_validator, - vol.Optional("preannounce"): bool, + vol.Optional("preannounce", default=True): bool, vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("extra_system_prompt"): str, } @@ -114,7 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ask_question_args = { "question": call.data.get("question"), "question_media_id": call.data.get("question_media_id"), - "preannounce": call.data.get("preannounce", False), + "preannounce": call.data.get("preannounce", True), "answers": call.data.get("answers"), } @@ -137,7 +137,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, vol.Optional("question_media_id"): _media_id_validator, - vol.Optional("preannounce"): bool, + vol.Optional("preannounce", default=True): bool, vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("answers"): [ { diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 9f14be6c50f..4b7a11edfee 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -793,12 +793,19 @@ async def test_start_conversation_default_preannounce( @pytest.mark.parametrize( - ("service_data", "response_text", "expected_answer"), + ("service_data", "response_text", "expected_answer", "should_preannounce"), [ + ( + {}, + "jazz", + AssistSatelliteAnswer(id=None, sentence="jazz"), + True, + ), ( {"preannounce": False}, "jazz", AssistSatelliteAnswer(id=None, sentence="jazz"), + False, ), ( { @@ -810,6 +817,7 @@ async def test_start_conversation_default_preannounce( }, "Some Rock, please.", AssistSatelliteAnswer(id="rock", sentence="Some Rock, please."), + False, ), ( { @@ -827,7 +835,7 @@ async def test_start_conversation_default_preannounce( "sentences": ["artist {artist} [please]"], }, ], - "preannounce": False, + "preannounce": True, }, "artist Pink Floyd", AssistSatelliteAnswer( @@ -835,6 +843,7 @@ async def test_start_conversation_default_preannounce( sentence="artist Pink Floyd", slots={"artist": "Pink Floyd"}, ), + True, ), ], ) @@ -845,6 +854,7 @@ async def test_ask_question( service_data: dict, response_text: str, expected_answer: AssistSatelliteAnswer, + should_preannounce: bool, ) -> None: """Test asking a question on a device and matching an answer.""" entity_id = "assist_satellite.test_entity" @@ -868,6 +878,9 @@ async def test_ask_question( async def async_start_conversation(start_announcement): # Verify state change assert entity.state == AssistSatelliteState.RESPONDING + assert ( + start_announcement.preannounce_media_id is not None + ) is should_preannounce await original_start_conversation(start_announcement) audio_stream = object() From 01b4a5ceed0eae64a9972586446c0c322f63c3ca Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Thu, 3 Jul 2025 12:04:18 -0500 Subject: [PATCH 1074/1664] Bump aiorussound to 4.7.0 (#148057) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index a74a1887836..955ab451d3d 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.6.1"], + "requirements": ["aiorussound==4.7.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 09142fb10a8..7e103ea3bbb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.6.1 +aiorussound==4.7.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cc3f43014a..f700259233e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -354,7 +354,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.6.1 +aiorussound==4.7.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 4a937d2452dcf26ea4833308c447622998060dd4 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:08:58 +0200 Subject: [PATCH 1075/1664] Set timeout for remote calendar (#147024) --- .../components/remote_calendar/client.py | 12 ++++++++++++ .../components/remote_calendar/config_flow.py | 12 +++++++++--- .../components/remote_calendar/coordinator.py | 15 +++++++++++---- .../components/remote_calendar/strings.json | 6 +++++- .../remote_calendar/test_config_flow.py | 12 +++++++----- tests/components/remote_calendar/test_init.py | 7 ++++--- 6 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 homeassistant/components/remote_calendar/client.py diff --git a/homeassistant/components/remote_calendar/client.py b/homeassistant/components/remote_calendar/client.py new file mode 100644 index 00000000000..f0f243ca386 --- /dev/null +++ b/homeassistant/components/remote_calendar/client.py @@ -0,0 +1,12 @@ +"""Specifies the parameter for the httpx download.""" + +from httpx import AsyncClient, Response, Timeout + + +async def get_calendar(client: AsyncClient, url: str) -> Response: + """Make an HTTP GET request using Home Assistant's async HTTPX client with timeout.""" + return await client.get( + url, + follow_redirects=True, + timeout=Timeout(5, read=30, write=5, pool=5), + ) diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py index 558a3d668ae..3f835b5d82b 100644 --- a/homeassistant/components/remote_calendar/config_flow.py +++ b/homeassistant/components/remote_calendar/config_flow.py @@ -4,13 +4,14 @@ from http import HTTPStatus import logging from typing import Any -from httpx import HTTPError, InvalidURL +from httpx import HTTPError, InvalidURL, TimeoutException import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.helpers.httpx_client import get_async_client +from .client import get_calendar from .const import CONF_CALENDAR_NAME, DOMAIN from .ics import InvalidIcsException, parse_calendar @@ -49,7 +50,7 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]}) client = get_async_client(self.hass) try: - res = await client.get(user_input[CONF_URL], follow_redirects=True) + res = await get_calendar(client, user_input[CONF_URL]) if res.status_code == HTTPStatus.FORBIDDEN: errors["base"] = "forbidden" return self.async_show_form( @@ -58,9 +59,14 @@ class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) res.raise_for_status() + except TimeoutException as err: + errors["base"] = "timeout_connect" + _LOGGER.debug( + "A timeout error occurred: %s", str(err) or type(err).__name__ + ) except (HTTPError, InvalidURL) as err: errors["base"] = "cannot_connect" - _LOGGER.debug("An error occurred: %s", err) + _LOGGER.debug("An error occurred: %s", str(err) or type(err).__name__) else: try: await parse_calendar(self.hass, res.text) diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py index 1eead7682d3..26876b53224 100644 --- a/homeassistant/components/remote_calendar/coordinator.py +++ b/homeassistant/components/remote_calendar/coordinator.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging -from httpx import HTTPError, InvalidURL +from httpx import HTTPError, InvalidURL, TimeoutException from ical.calendar import Calendar from homeassistant.config_entries import ConfigEntry @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .client import get_calendar from .const import DOMAIN from .ics import InvalidIcsException, parse_calendar @@ -36,7 +37,7 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): super().__init__( hass, _LOGGER, - name=DOMAIN, + name=f"{DOMAIN}_{config_entry.title}", update_interval=SCAN_INTERVAL, always_update=True, ) @@ -46,13 +47,19 @@ class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]): async def _async_update_data(self) -> Calendar: """Update data from the url.""" try: - res = await self._client.get(self._url, follow_redirects=True) + res = await get_calendar(self._client, self._url) res.raise_for_status() + except TimeoutException as err: + _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="timeout", + ) from err except (HTTPError, InvalidURL) as err: + _LOGGER.debug("%s: %s", self._url, str(err) or type(err).__name__) raise UpdateFailed( translation_domain=DOMAIN, translation_key="unable_to_fetch", - translation_placeholders={"err": str(err)}, ) from err try: self.ics = res.text diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json index ef7f20d4699..48ef6080bdb 100644 --- a/homeassistant/components/remote_calendar/strings.json +++ b/homeassistant/components/remote_calendar/strings.json @@ -18,14 +18,18 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "forbidden": "The server understood the request but refuses to authorize it.", "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details." } }, "exceptions": { + "timeout": { + "message": "The connection timed out. See the debug log for additional details." + }, "unable_to_fetch": { - "message": "Unable to fetch calendar data: {err}" + "message": "Unable to fetch calendar data. See the debug log for additional details." }, "unable_to_parse": { "message": "Unable to parse calendar data: {err}" diff --git a/tests/components/remote_calendar/test_config_flow.py b/tests/components/remote_calendar/test_config_flow.py index 9aff1594db3..9bea46ab27e 100644 --- a/tests/components/remote_calendar/test_config_flow.py +++ b/tests/components/remote_calendar/test_config_flow.py @@ -1,6 +1,6 @@ """Test the Remote Calendar config flow.""" -from httpx import ConnectError, Response, UnsupportedProtocol +from httpx import HTTPError, InvalidURL, Response, TimeoutException import pytest import respx @@ -75,10 +75,11 @@ async def test_form_import_webcal(hass: HomeAssistant, ics_content: str) -> None @pytest.mark.parametrize( - ("side_effect"), + ("side_effect", "base_error"), [ - ConnectError("Connection failed"), - UnsupportedProtocol("Unsupported protocol"), + (TimeoutException("Connection timed out"), "timeout_connect"), + (HTTPError("Connection failed"), "cannot_connect"), + (InvalidURL("Unsupported protocol"), "cannot_connect"), ], ) @respx.mock @@ -86,6 +87,7 @@ async def test_form_inavild_url( hass: HomeAssistant, side_effect: Exception, ics_content: str, + base_error: str, ) -> None: """Test we get the import form.""" result = await hass.config_entries.flow.async_init( @@ -102,7 +104,7 @@ async def test_form_inavild_url( }, ) assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["errors"] == {"base": base_error} respx.get(CALENDER_URL).mock( return_value=Response( status_code=200, diff --git a/tests/components/remote_calendar/test_init.py b/tests/components/remote_calendar/test_init.py index f4ca500b2e1..d3e6b439805 100644 --- a/tests/components/remote_calendar/test_init.py +++ b/tests/components/remote_calendar/test_init.py @@ -1,6 +1,6 @@ """Tests for init platform of Remote Calendar.""" -from httpx import ConnectError, Response, UnsupportedProtocol +from httpx import HTTPError, InvalidURL, Response, TimeoutException import pytest import respx @@ -56,8 +56,9 @@ async def test_raise_for_status( @pytest.mark.parametrize( "side_effect", [ - ConnectError("Connection failed"), - UnsupportedProtocol("Unsupported protocol"), + TimeoutException("Connection timed out"), + HTTPError("Connection failed"), + InvalidURL("Unsupported protocol"), ValueError("Invalid response"), ], ) From 419e4f3b1d7157fb304e08ca53d775221c4560f7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 3 Jul 2025 19:14:27 +0200 Subject: [PATCH 1076/1664] Remove unused module in tuya tests (#148058) --- tests/components/tuya/common.py | 75 --------------------------------- 1 file changed, 75 deletions(-) delete mode 100644 tests/components/tuya/common.py diff --git a/tests/components/tuya/common.py b/tests/components/tuya/common.py deleted file mode 100644 index 8dcef136b7f..00000000000 --- a/tests/components/tuya/common.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Test code shared between test files.""" - -from tuyaha.devices import climate, light, switch - -CLIMATE_ID = "1" -CLIMATE_DATA = { - "data": {"state": "true", "temp_unit": climate.UNIT_CELSIUS}, - "id": CLIMATE_ID, - "ha_type": "climate", - "name": "TestClimate", - "dev_type": "climate", -} - -LIGHT_ID = "2" -LIGHT_DATA = { - "data": {"state": "true"}, - "id": LIGHT_ID, - "ha_type": "light", - "name": "TestLight", - "dev_type": "light", -} - -SWITCH_ID = "3" -SWITCH_DATA = { - "data": {"state": True}, - "id": SWITCH_ID, - "ha_type": "switch", - "name": "TestSwitch", - "dev_type": "switch", -} - -LIGHT_ID_FAKE1 = "9998" -LIGHT_DATA_FAKE1 = { - "data": {"state": "true"}, - "id": LIGHT_ID_FAKE1, - "ha_type": "light", - "name": "TestLightFake1", - "dev_type": "light", -} - -LIGHT_ID_FAKE2 = "9999" -LIGHT_DATA_FAKE2 = { - "data": {"state": "true"}, - "id": LIGHT_ID_FAKE2, - "ha_type": "light", - "name": "TestLightFake2", - "dev_type": "light", -} - -TUYA_DEVICES = [ - climate.TuyaClimate(CLIMATE_DATA, None), - light.TuyaLight(LIGHT_DATA, None), - switch.TuyaSwitch(SWITCH_DATA, None), - light.TuyaLight(LIGHT_DATA_FAKE1, None), - light.TuyaLight(LIGHT_DATA_FAKE2, None), -] - - -class MockTuya: - """Mock for Tuya devices.""" - - def get_all_devices(self): - """Return all configured devices.""" - return TUYA_DEVICES - - def get_device_by_id(self, dev_id): - """Return configured device with dev id.""" - if dev_id == LIGHT_ID_FAKE1: - return None - if dev_id == LIGHT_ID_FAKE2: - return switch.TuyaSwitch(SWITCH_DATA, None) - for device in TUYA_DEVICES: - if device.object_id() == dev_id: - return device - return None From d2825e1c807f48a195b1a749e43be92990df047a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Jul 2025 19:33:28 +0200 Subject: [PATCH 1077/1664] Don't gather TRIGGER_PLATFORM_SUBSCRIPTIONS (#147954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/helpers/trigger.py | 14 +++++++---- tests/helpers/test_trigger.py | 42 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 66d1560ac70..57ee6b99029 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -147,11 +147,15 @@ async def _register_trigger_platform( ) return - tasks: list[asyncio.Task[None]] = [ - create_eager_task(listener(new_triggers)) - for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS] - ] - await asyncio.gather(*tasks) + # We don't use gather here because gather adds additional overhead + # when wrapping each coroutine in a task, and we expect our listeners + # to call trigger.async_get_all_descriptions which will only yield + # the first time it's called, after that it returns cached data. + for listener in hass.data[TRIGGER_PLATFORM_SUBSCRIPTIONS]: + try: + await listener(new_triggers) + except Exception: + _LOGGER.exception("Error while notifying trigger platform listener") class Trigger(abc.ABC): diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py index 27cde92d14f..ba9db9cb053 100644 --- a/tests/helpers/test_trigger.py +++ b/tests/helpers/test_trigger.py @@ -738,3 +738,45 @@ async def test_invalid_trigger_platform( await async_setup_component(hass, "test", {}) assert "Integration test does not provide trigger support, skipping" in caplog.text + + +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_triggers", return_value=True) +async def test_subscribe_triggers( + mock_has_triggers: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test trigger.async_subscribe_platform_events.""" + sun_trigger_descriptions = """ + sun: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/triggers.yaml"): + trigger_descriptions = sun_trigger_descriptions + else: + raise FileNotFoundError + with io.StringIO(trigger_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + async def broken_subscriber(_): + """Simulate a broken subscriber.""" + raise Exception("Boom!") # noqa: TRY002 + + trigger_events = [] + + async def good_subscriber(new_triggers: set[str]): + """Simulate a working subscriber.""" + trigger_events.append(new_triggers) + + trigger.async_subscribe_platform_events(hass, broken_subscriber) + trigger.async_subscribe_platform_events(hass, good_subscriber) + + assert await async_setup_component(hass, "sun", {}) + + assert trigger_events == [{"sun"}] + assert "Error while notifying trigger platform listener" in caplog.text From b999c5906efc7cddea63a86e2a9491918b4b48e8 Mon Sep 17 00:00:00 2001 From: Jeef Date: Thu, 3 Jul 2025 12:11:33 -0600 Subject: [PATCH 1078/1664] Bump weatherflow4py to 1.4.1 (#148054) --- homeassistant/components/weatherflow_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/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json index 9ffa457a355..d39e373312d 100644 --- a/homeassistant/components/weatherflow_cloud/manifest.json +++ b/homeassistant/components/weatherflow_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud", "iot_class": "cloud_polling", "loggers": ["weatherflow4py"], - "requirements": ["weatherflow4py==1.3.1"] + "requirements": ["weatherflow4py==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7e103ea3bbb..e0fbd3ccdc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3084,7 +3084,7 @@ waterfurnace==1.1.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.3.1 +weatherflow4py==1.4.1 # homeassistant.components.cisco_webex_teams webexpythonsdk==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f700259233e..7d16f98d736 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2543,7 +2543,7 @@ watchdog==6.0.0 watergate-local-api==2024.4.1 # homeassistant.components.weatherflow_cloud -weatherflow4py==1.3.1 +weatherflow4py==1.4.1 # homeassistant.components.nasweb webio-api==0.1.11 From bc4a322e81279b517372a598749ed4dc7736162d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 3 Jul 2025 20:12:52 +0200 Subject: [PATCH 1079/1664] Improve `helpers.frame.report_usage` when called from outside the event loop (#148021) --- homeassistant/helpers/frame.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index ca7b097d90d..d7a647e02eb 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -185,6 +185,16 @@ def report_usage( """ if (hass := _hass.hass) is None: raise RuntimeError("Frame helper not set up") + integration_frame: IntegrationFrame | None = None + integration_frame_err: MissingIntegrationFrame | None = None + if not integration_domain: + try: + integration_frame = get_integration_frame( + exclude_integrations=exclude_integrations + ) + except MissingIntegrationFrame as err: + if core_behavior is ReportBehavior.ERROR: + integration_frame_err = err _report_usage_partial = functools.partial( _report_usage, hass, @@ -193,8 +203,9 @@ def report_usage( core_behavior=core_behavior, core_integration_behavior=core_integration_behavior, custom_integration_behavior=custom_integration_behavior, - exclude_integrations=exclude_integrations, integration_domain=integration_domain, + integration_frame=integration_frame, + integration_frame_err=integration_frame_err, level=level, ) if hass.loop_thread_id != threading.get_ident(): @@ -212,8 +223,9 @@ def _report_usage( core_behavior: ReportBehavior, core_integration_behavior: ReportBehavior, custom_integration_behavior: ReportBehavior, - exclude_integrations: set[str] | None, integration_domain: str | None, + integration_frame: IntegrationFrame | None, + integration_frame_err: MissingIntegrationFrame | None, level: int, ) -> None: """Report incorrect code usage. @@ -235,12 +247,10 @@ def _report_usage( _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None) return - try: - integration_frame = get_integration_frame( - exclude_integrations=exclude_integrations + if not integration_frame: + _report_usage_no_integration( + what, core_behavior, breaks_in_ha_version, integration_frame_err ) - except MissingIntegrationFrame as err: - _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err) return integration_behavior = core_integration_behavior From 5f9cc0a5f649fe44268e8937f803bce1d5bc3319 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 3 Jul 2025 11:13:44 -0700 Subject: [PATCH 1080/1664] Add data_description to forms in Android TV Remote (#148045) Co-authored-by: Franck Nijhof Co-authored-by: Artem Draft --- .../components/androidtv_remote/strings.json | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index c82b815e27a..7130c5b2b3b 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -6,6 +6,9 @@ "description": "Enter the IP address of the Android TV you want to add to Home Assistant. It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen.", "data": { "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Android TV device." } }, "zeroconf_confirm": { @@ -16,6 +19,9 @@ "description": "Enter the pairing code displayed on the Android TV ({name}).", "data": { "pin": "[%key:common::config_flow::data::pin%]" + }, + "data_description": { + "pin": "Pairing code displayed on the Android TV device." } }, "reauth_confirm": { @@ -40,7 +46,11 @@ "init": { "data": { "apps": "Configure applications list", - "enable_ime": "Enable IME. Needed for getting the current app. Disable for devices that show 'Use keyboard on mobile device screen' instead of the on screen keyboard." + "enable_ime": "Enable IME" + }, + "data_description": { + "apps": "Here you can define the list of applications, specify names and icons that will be displayed in the UI.", + "enable_ime": "Enable this option to be able to get the current app name and send text as keyboard input. Disable it for devices that show 'Use keyboard on mobile device screen' instead of the on-screen keyboard." } }, "apps": { @@ -53,8 +63,10 @@ "app_delete": "Check to delete this application" }, "data_description": { + "app_name": "Name of the application as you would like it to be displayed in Home Assistant.", "app_id": "E.g. com.plexapp.android for https://play.google.com/store/apps/details?id=com.plexapp.android", - "app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename" + "app_icon": "Image URL. From the Play Store app page, right click on the icon and select 'Copy image address' and then paste it here. Alternatively, download the image, upload it under /config/www/ and use the URL /local/filename", + "app_delete": "Check this box to delete the application from the list." } } } From 9c558fabcdcb7aca0add994409f31a33a701f7b8 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 3 Jul 2025 11:15:36 -0700 Subject: [PATCH 1081/1664] Use AndroidTVRemoteConfigEntry (#148046) --- .../components/androidtv_remote/__init__.py | 20 ++++++++----------- .../androidtv_remote/config_flow.py | 7 +++---- .../androidtv_remote/diagnostics.py | 2 +- .../components/androidtv_remote/entity.py | 6 ++++-- .../components/androidtv_remote/helpers.py | 4 +++- .../androidtv_remote/media_player.py | 2 +- .../components/androidtv_remote/remote.py | 2 +- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index 28a372da4ea..c8556b6da90 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -5,26 +5,18 @@ from __future__ import annotations from asyncio import timeout import logging -from androidtvremote2 import ( - AndroidTVRemote, - CannotConnect, - ConnectionClosed, - InvalidAuth, -) +from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .helpers import create_api, get_enable_ime +from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] -AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote] - async def async_setup_entry( hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry @@ -82,13 +74,17 @@ async def async_setup_entry( return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry +) -> bool: """Unload a config entry.""" _LOGGER.debug("async_unload_entry: %s", entry.data) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_update_options( + hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry +) -> None: """Handle options update.""" _LOGGER.debug( "async_update_options: data: %s options: %s", entry.data, entry.options diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 78f24fc498c..25a26fc92df 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -16,7 +16,6 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -33,7 +32,7 @@ from homeassistant.helpers.selector import ( from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_APP_ICON, CONF_APP_NAME, CONF_APPS, CONF_ENABLE_IME, DOMAIN -from .helpers import create_api, get_enable_ime +from .helpers import AndroidTVRemoteConfigEntry, create_api, get_enable_ime _LOGGER = logging.getLogger(__name__) @@ -220,7 +219,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: AndroidTVRemoteConfigEntry, ) -> AndroidTVRemoteOptionsFlowHandler: """Create the options flow.""" return AndroidTVRemoteOptionsFlowHandler(config_entry) @@ -229,7 +228,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): """Android TV Remote options flow.""" - def __init__(self, config_entry: ConfigEntry) -> None: + def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None: """Initialize options flow.""" self._apps: dict[str, Any] = dict(config_entry.options.get(CONF_APPS, {})) self._conf_app_id: str | None = None diff --git a/homeassistant/components/androidtv_remote/diagnostics.py b/homeassistant/components/androidtv_remote/diagnostics.py index 41595451be8..add28b807e9 100644 --- a/homeassistant/components/androidtv_remote/diagnostics.py +++ b/homeassistant/components/androidtv_remote/diagnostics.py @@ -8,7 +8,7 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import HomeAssistant -from . import AndroidTVRemoteConfigEntry +from .helpers import AndroidTVRemoteConfigEntry TO_REDACT = {CONF_HOST, CONF_MAC} diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py index bf146a11e13..7a1e2d6bf06 100644 --- a/homeassistant/components/androidtv_remote/entity.py +++ b/homeassistant/components/androidtv_remote/entity.py @@ -6,7 +6,6 @@ from typing import Any from androidtvremote2 import AndroidTVRemote, ConnectionClosed -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -14,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, Device from homeassistant.helpers.entity import Entity from .const import CONF_APPS, DOMAIN +from .helpers import AndroidTVRemoteConfigEntry class AndroidTVRemoteBaseEntity(Entity): @@ -23,7 +23,9 @@ class AndroidTVRemoteBaseEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + def __init__( + self, api: AndroidTVRemote, config_entry: AndroidTVRemoteConfigEntry + ) -> None: """Initialize the entity.""" self._api = api self._host = config_entry.data[CONF_HOST] diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index cdd67b029fc..a67d5839ee6 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -10,6 +10,8 @@ from homeassistant.helpers.storage import STORAGE_DIR from .const import CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE +AndroidTVRemoteConfigEntry = ConfigEntry[AndroidTVRemote] + def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRemote: """Create an AndroidTVRemote instance.""" @@ -23,6 +25,6 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem ) -def get_enable_ime(entry: ConfigEntry) -> bool: +def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool: """Get value of enable_ime option or its default value.""" return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index 5bc205b32df..ac1c62e0826 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -20,9 +20,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import AndroidTVRemoteConfigEntry from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity +from .helpers import AndroidTVRemoteConfigEntry PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 212b0491d2d..40220834e53 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -20,9 +20,9 @@ from homeassistant.components.remote import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import AndroidTVRemoteConfigEntry from .const import CONF_APP_NAME from .entity import AndroidTVRemoteBaseEntity +from .helpers import AndroidTVRemoteConfigEntry PARALLEL_UPDATES = 0 From 4b162f09bd0177a5c40deafa5e2546759dcab21a Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 3 Jul 2025 11:15:47 -0700 Subject: [PATCH 1082/1664] Bump androidtvremote2 to 0.2.3 (#148042) --- .../components/androidtv_remote/manifest.json | 2 +- .../androidtv_remote/media_player.py | 18 +++++++++--------- .../components/androidtv_remote/remote.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 14 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 7896f7eefc8..9f41d8230c6 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], - "requirements": ["androidtvremote2==0.2.2"], + "requirements": ["androidtvremote2==0.2.3"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py index ac1c62e0826..e4f653cbcf1 100644 --- a/homeassistant/components/androidtv_remote/media_player.py +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from typing import Any -from androidtvremote2 import AndroidTVRemote, ConnectionClosed +from androidtvremote2 import AndroidTVRemote, ConnectionClosed, VolumeInfo from homeassistant.components.media_player import ( BrowseMedia, @@ -75,13 +75,11 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt else current_app ) - def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None: + def _update_volume_info(self, volume_info: VolumeInfo) -> None: """Update volume info.""" if volume_info.get("max"): - self._attr_volume_level = int(volume_info["level"]) / int( - volume_info["max"] - ) - self._attr_is_volume_muted = bool(volume_info["muted"]) + self._attr_volume_level = volume_info["level"] / volume_info["max"] + self._attr_is_volume_muted = volume_info["muted"] else: self._attr_volume_level = None self._attr_is_volume_muted = None @@ -93,7 +91,7 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt self.async_write_ha_state() @callback - def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: + def _volume_info_updated(self, volume_info: VolumeInfo) -> None: """Update the state when the volume info changes.""" self._update_volume_info(volume_info) self.async_write_ha_state() @@ -102,8 +100,10 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt """Register callbacks.""" await super().async_added_to_hass() - self._update_current_app(self._api.current_app) - self._update_volume_info(self._api.volume_info) + if self._api.current_app is not None: + self._update_current_app(self._api.current_app) + if self._api.volume_info is not None: + self._update_volume_info(self._api.volume_info) self._api.add_current_app_updated_callback(self._current_app_updated) self._api.add_volume_info_updated_callback(self._volume_info_updated) diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 40220834e53..612d27de189 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -63,7 +63,8 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): self._attr_activity_list = [ app.get(CONF_APP_NAME, "") for app in self._apps.values() ] - self._update_current_app(self._api.current_app) + if self._api.current_app is not None: + self._update_current_app(self._api.current_app) self._api.add_current_app_updated_callback(self._current_app_updated) async def async_will_remove_from_hass(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index e0fbd3ccdc6..f80bb901946 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -477,7 +477,7 @@ amcrest==1.9.8 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.2 +androidtvremote2==0.2.3 # homeassistant.components.anel_pwrctrl anel-pwrctrl-homeassistant==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d16f98d736..0211c9803c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -453,7 +453,7 @@ amberelectric==2.0.12 androidtv[async]==0.0.75 # homeassistant.components.androidtv_remote -androidtvremote2==0.2.2 +androidtvremote2==0.2.3 # homeassistant.components.anova anova-wifi==0.17.0 From 8330ae2d3a9682bc5c01d460ceffeb3e3f78fe0b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 3 Jul 2025 20:22:10 +0200 Subject: [PATCH 1083/1664] Update license-expression to 30.4.3 (#147941) --- requirements_test.txt | 2 +- script/licenses.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4b2b7ec4909..386e380911a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.3.10 coverage==7.9.1 freezegun==1.5.2 go2rtc-client==0.2.1 -license-expression==30.4.1 +license-expression==30.4.3 mock-open==1.4.0 mypy-dev==1.17.0a4 pre-commit==4.2.0 diff --git a/script/licenses.py b/script/licenses.py index 3330d99b4a5..6d5f7e58f2f 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -205,11 +205,17 @@ EXCEPTIONS = { "tapsaff", # https://github.com/bazwilliams/python-taps-aff/pull/5 } +# fmt: off TODO = { + "TravisPy": AwesomeVersion("0.3.5"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] "aiocache": AwesomeVersion( "0.12.3" ), # https://github.com/aio-libs/aiocache/blob/master/LICENSE all rights reserved? + "caldav": AwesomeVersion("1.6.0"), # None -- GPL -- ['GNU General Public License (GPL)', 'Apache Software License'] # https://github.com/python-caldav/caldav + "pyiskra": AwesomeVersion("0.1.21"), # None -- GPL -- ['GNU General Public License v3 (GPLv3)'] + "xbox-webapi": AwesomeVersion("2.1.0"), # None -- GPL -- ['MIT License'] } +# fmt: on EXCEPTIONS_AND_TODOS = EXCEPTIONS.union(TODO) From e5f7421703b88e74a68c1589093d59cf68181a4b Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:04:13 +0200 Subject: [PATCH 1084/1664] Bump pyenphase to 2.2.0 (#148070) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 5f74da954a0..8387ecc9c9f 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.1.0"], + "requirements": ["pyenphase==2.2.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index f80bb901946..4b622fe9c35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.2.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0211c9803c9..4fbebe1bf9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.2.0 # homeassistant.components.everlights pyeverlights==0.1.0 From b410b414ec146f9b9e0e535781f481fc0e1f5e23 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 3 Jul 2025 13:00:07 -0700 Subject: [PATCH 1085/1664] Add reconfigure flow in Android TV Remote (#148044) --- .../androidtv_remote/config_flow.py | 36 +++++-- .../components/androidtv_remote/strings.json | 13 ++- .../androidtv_remote/test_config_flow.py | 97 +++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 25a26fc92df..351cae61b1d 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -40,12 +41,6 @@ APPS_NEW_ID = "NewApp" CONF_APP_DELETE = "app_delete" CONF_APP_ID = "app_id" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required("host"): str, - } -) - STEP_PAIR_DATA_SCHEMA = vol.Schema( { vol.Required("pin"): str, @@ -66,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial and reconfigure step.""" errors: dict[str, str] = {} if user_input is not None: self.host = user_input[CONF_HOST] @@ -75,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): await api.async_generate_cert_if_missing() self.name, self.mac = await api.async_get_name_and_mac() await self.async_set_unique_id(format_mac(self.mac)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) return await self._async_start_pair() except (CannotConnect, ConnectionClosed): # Likely invalid IP address or device is network unreachable. Stay # in the user step allowing the user to enter a different host. errors["base"] = "cannot_connect" + else: + user_input = {} + default_host = user_input.get(CONF_HOST, vol.UNDEFINED) + if self.source == SOURCE_RECONFIGURE: + default_host = self._get_reconfigure_entry().data[CONF_HOST] return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user", + data_schema=vol.Schema( + {vol.Required(CONF_HOST, default=default_host): str} + ), errors=errors, ) @@ -216,6 +228,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user(user_input) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 7130c5b2b3b..d0eb1d0dca4 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -11,6 +11,15 @@ "host": "The hostname or IP address of the Android TV device." } }, + "reconfigure": { + "description": "Update the IP address of this previously configured Android TV device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Android TV device." + } + }, "zeroconf_confirm": { "title": "Discovered Android TV", "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." @@ -38,7 +47,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." } }, "options": { diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 0968ea5acff..9652ac0c3a9 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1069,3 +1069,100 @@ async def test_options_flow( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_config_entry.options == {CONF_ENABLE_IME: True} + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full reconfigure flow from start to finish without any exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + assert "host" in result["data_schema"].schema + # Form should have as default value the existing host + host_key = next(k for k in result["data_schema"].schema if k.schema == "host") + assert host_key.default() == mock_config_entry.data["host"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock( + return_value=(mock_config_entry.data["name"], mock_config_entry.data["mac"]) + ) + + # Simulate user input with a new host + new_host = "4.3.2.1" + assert new_host != mock_config_entry.data["host"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data["host"] == new_host + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with CannotConnect exception.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect()) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with a different device (unique_id mismatch).""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + # The new host corresponds to a device with a different MAC/unique_id + new_mac = "FF:EE:DD:CC:BB:AA" + assert new_mac != mock_config_entry.data["mac"] + mock_api.async_get_name_and_mac = AsyncMock(return_value=("name", new_mac)) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 From 8ef6b62d9a4a19f10c9240279f1b9c3d7ce43cff Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Jul 2025 22:06:38 +0200 Subject: [PATCH 1086/1664] Cancel enphase mac verification on unload. (#148072) --- homeassistant/components/enphase_envoy/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index eee6cb85e6d..f43d89aa098 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -63,6 +63,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() coordinator.async_cancel_firmware_refresh() + coordinator.async_cancel_mac_verification() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From 11c75d7ef2e7985e222502a6532a6be85d854958 Mon Sep 17 00:00:00 2001 From: HeroOfCanton16 <49348182+HeroOfCanton16@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:10:26 -0400 Subject: [PATCH 1087/1664] Add sensor attributes restore to modem_callerid integration (#147753) --- .../components/modem_callerid/sensor.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index de8e4b2f73c..db901511d5f 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from phone_modem import PhoneModem -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import RestoreSensor from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback @@ -40,7 +40,7 @@ async def async_setup_entry( ) -class ModemCalleridSensor(SensorEntity): +class ModemCalleridSensor(RestoreSensor): """Implementation of USB modem caller ID sensor.""" _attr_should_poll = False @@ -62,9 +62,21 @@ class ModemCalleridSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Call when the modem sensor is added to Home Assistant.""" - self.api.registercallback(self._async_incoming_call) await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_extra_state_attributes[CID.CID_NAME] = last_state.attributes.get( + CID.CID_NAME, "" + ) + self._attr_extra_state_attributes[CID.CID_NUMBER] = ( + last_state.attributes.get(CID.CID_NUMBER, "") + ) + self._attr_extra_state_attributes[CID.CID_TIME] = last_state.attributes.get( + CID.CID_TIME, 0 + ) + + self.api.registercallback(self._async_incoming_call) + @callback def _async_incoming_call(self, new_state: str) -> None: """Handle new states.""" From 49d1d781b8991e82cd8c531981129629c9999594 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 3 Jul 2025 23:11:54 +0200 Subject: [PATCH 1088/1664] Fix ezviz test timeout (#148066) --- tests/components/ezviz/test_config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 20d70902e83..ff34134b3fb 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -129,6 +129,7 @@ async def test_async_step_reauth( CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -639,6 +640,7 @@ async def test_reauth_errors( CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" From a3b03caead353924fb46e4f4e3524e7682709c71 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 07:55:20 +0200 Subject: [PATCH 1089/1664] Deduce integration from module in `loader.async_get_issue_tracker` (#148017) --- homeassistant/loader.py | 7 +++++++ tests/test_loader.py | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ae3709e383b..a66a09d7407 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1789,6 +1789,13 @@ def async_get_issue_tracker( # If we know nothing about the integration, suggest opening an issue on HA core return issue_tracker + if module and not integration_domain: + # If we only have a module, we can try to get the integration domain from it + if module.startswith("custom_components."): + integration_domain = module.split(".")[1] + elif module.startswith("homeassistant.components."): + integration_domain = module.split(".")[2] + if not integration: integration = async_get_issue_integration(hass, integration_domain) diff --git a/tests/test_loader.py b/tests/test_loader.py index 2d5ad76aa8a..c67b520c7dc 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1143,10 +1143,10 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE), ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), - # Integration domain is not currently deduced from module - (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), # Loaded custom integration with known issue tracker + (None, "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER), # Loaded custom integration without known issue tracker @@ -1155,6 +1155,7 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" ("bla_custom_no_tracker", None, None), ("hue", "custom_components.bla.sensor", None), # Unloaded custom integration with known issue tracker + (None, "custom_components.bla_custom_not_loaded.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER), # Unloaded custom integration without known issue tracker ("bla_custom_not_loaded_no_tracker", None, None), @@ -1218,8 +1219,7 @@ async def test_async_get_issue_tracker( ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE), ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), - # Integration domain is not currently deduced from module - (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), # Custom integration with known issue tracker - can't find it without hass ("bla_custom", "custom_components.bla_custom.sensor", None), From 04cc451c765703d8714993b296a06fb182a78dd8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 4 Jul 2025 08:36:34 +0200 Subject: [PATCH 1090/1664] Add AI Task platform to Google Gen AI (#146766) --- .../__init__.py | 25 +++- .../ai_task.py | 57 ++++++++ .../config_flow.py | 26 +++- .../const.py | 6 + .../strings.json | 28 ++++ .../conftest.py | 9 ++ .../snapshots/test_diagnostics.ambr | 8 + .../snapshots/test_init.ambr | 31 ++++ .../test_ai_task.py | 62 ++++++++ .../test_config_flow.py | 58 +++++++- .../test_init.py | 138 ++++++++++++++++-- 11 files changed, 423 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/ai_task.py create mode 100644 tests/components/google_generative_ai_conversation/test_ai_task.py diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 346d5322b02..99e475a376b 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import partial import mimetypes from pathlib import Path from types import MappingProxyType @@ -37,11 +38,13 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_AI_TASK_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, @@ -53,6 +56,7 @@ CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( + Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS, ) @@ -187,11 +191,9 @@ async def async_setup_entry( """Set up Google Generative AI Conversation from a config entry.""" try: - - def _init_client() -> Client: - return Client(api_key=entry.data[CONF_API_KEY]) - - client = await hass.async_add_executor_job(_init_client) + client = await hass.async_add_executor_job( + partial(Client, api_key=entry.data[CONF_API_KEY]) + ) await client.aio.models.get( model=RECOMMENDED_CHAT_MODEL, config={"http_options": {"timeout": TIMEOUT_MILLIS}}, @@ -350,6 +352,19 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Add AI Task subentry with default options + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py new file mode 100644 index 00000000000..ab34af71ebe --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -0,0 +1,57 @@ +"""AI Task integration for Google Generative AI Conversation.""" + +from __future__ import annotations + +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import LOGGER +from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [GoogleGenerativeAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAITaskEntity( + ai_task.AITaskEntity, + GoogleGenerativeAILLMBaseEntity, +): + """Google Generative AI AI Task entity.""" + + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + chat_log.content[-1], + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=chat_log.content[-1].content or "", + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ade326cf71b..a68ca09e76d 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from functools import partial import logging from typing import Any, cast @@ -46,10 +47,12 @@ from .const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -72,12 +75,14 @@ STEP_API_DATA_SCHEMA = vol.Schema( ) -async def validate_input(data: dict[str, Any]) -> None: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = genai.Client(api_key=data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(genai.Client, api_key=data[CONF_API_KEY]) + ) await client.aio.models.list( config={ "http_options": { @@ -92,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_api( self, user_input: dict[str, Any] | None = None @@ -102,7 +107,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match(user_input) try: - await validate_input(user_input) + await validate_input(self.hass, user_input) except (APIError, Timeout) as err: if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err): errors["base"] = "invalid_auth" @@ -133,6 +138,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): "title": DEFAULT_TTS_NAME, "unique_id": None, }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -181,6 +192,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): return { "conversation": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler, + "ai_task_data": LLMSubentryFlowHandler, } @@ -214,6 +226,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): options: dict[str, Any] if self._subentry_type == "tts": options = RECOMMENDED_TTS_OPTIONS.copy() + elif self._subentry_type == "ai_task_data": + options = RECOMMENDED_AI_TASK_OPTIONS.copy() else: options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: @@ -288,6 +302,8 @@ async def google_generative_ai_config_option_schema( default_name = options[CONF_NAME] elif subentry_type == "tts": default_name = DEFAULT_TTS_NAME + elif subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME else: default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { @@ -315,6 +331,7 @@ async def google_generative_ai_config_option_schema( ), } ) + schema.update( { vol.Required( @@ -443,4 +460,5 @@ async def google_generative_ai_config_option_schema( ): bool, } ) + return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 72665cd3437..e7c5ba6bd22 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -12,6 +12,7 @@ CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" DEFAULT_TTS_NAME = "Google AI TTS" +DEFAULT_AI_TASK_NAME = "Google AI Task" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" @@ -35,6 +36,7 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 + RECOMMENDED_CONVERSATION_OPTIONS = { CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], @@ -44,3 +46,7 @@ RECOMMENDED_CONVERSATION_OPTIONS = { RECOMMENDED_TTS_OPTIONS = { CONF_RECOMMENDED: True, } + +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index eef595ad05d..774f41f0279 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -88,6 +88,34 @@ "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add Generate data with AI service", + "reconfigure": "Reconfigure Generate data with AI service" + }, + "entry_type": "Generate data with AI service", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } } }, "services": { diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 331afc723ae..244ac518fbd 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_TTS_NAME, ) @@ -29,6 +30,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "api_key": "bla", }, version=2, + minor_version=3, subentries_data=[ { "data": {}, @@ -44,6 +46,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_id": "ulid-tts", "unique_id": None, }, + { + "data": {}, + "subentry_type": "ai_task_data", + "title": DEFAULT_AI_TASK_NAME, + "subentry_id": "ulid-ai-task", + "unique_id": None, + }, ], ) entry.runtime_data = Mock() diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 48091d83a00..bf44b1cbc04 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -7,6 +7,14 @@ 'options': dict({ }), 'subentries': dict({ + 'ulid-ai-task': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-ai-task', + 'subentry_type': 'ai_task_data', + 'title': 'Google AI Task', + 'unique_id': None, + }), 'ulid-conversation': dict({ 'data': dict({ 'chat_model': 'models/gemini-2.5-flash', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 5722713bc56..a2603328959 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -32,6 +32,37 @@ 'sw_version': None, 'via_device_id': None, }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-ai-task', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Task', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py new file mode 100644 index 00000000000..72b62b64615 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -0,0 +1,62 @@ +"""Test AI Task platform of Google Generative AI Conversation integration.""" + +from unittest.mock import AsyncMock + +from google.genai.types import GenerateContentResponse +import pytest + +from homeassistant.components import ai_task +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.conversation import ( + MockChatLog, + mock_chat_log, # noqa: F401 +) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test empty response.""" + entity_id = "ai_task.google_ai_task" + + # Ensure it's linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + ) + assert result.data == "Hi there!" diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index a3fa487e1d3..bf3e2aedb45 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -19,9 +19,11 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -121,6 +123,12 @@ async def test_form(hass: HomeAssistant) -> None: "title": DEFAULT_TTS_NAME, "unique_id": None, }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -222,7 +230,7 @@ async def test_creating_tts_subentry( assert result2["title"] == "Mock TTS" assert result2["data"] == RECOMMENDED_TTS_OPTIONS - assert len(mock_config_entry.subentries) == 3 + assert len(mock_config_entry.subentries) == 4 new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] new_subentry = mock_config_entry.subentries[new_subentry_id] @@ -232,13 +240,59 @@ async def test_creating_tts_subentry( assert new_subentry.title == "Mock TTS" +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "set_options" + assert not result["errors"] + + old_subentries = set(mock_config_entry.subentries) + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock AI Task", **RECOMMENDED_AI_TASK_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock AI Task" + assert result2["data"] == RECOMMENDED_AI_TASK_OPTIONS + + assert len(mock_config_entry.subentries) == 4 + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + assert new_subentry.title == "Mock AI Task" + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, ) -> None: - """Test creating a conversation subentry.""" + """Test that subentry fails to init if entry not loaded.""" await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 9702aae4c9e..c0a610f6a0a 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -8,9 +8,13 @@ from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData @@ -397,7 +401,7 @@ async def test_load_entry_with_unloaded_entries( assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -473,10 +477,10 @@ async def test_migration_from_v1_to_v2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -495,6 +499,14 @@ async def test_migration_from_v1_to_v2( assert len(tts_subentries) == 1 assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME subentry = conversation_subentries[0] @@ -542,7 +554,7 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_keys( +async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -619,10 +631,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 2 + assert len(entry.subentries) == 3 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -631,6 +643,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( assert subentry.subentry_type == "tts" assert subentry.data == RECOMMENDED_TTS_OPTIONS assert subentry.title == DEFAULT_TTS_NAME + subentry = list(entry.subentries.values())[2] + assert subentry.subentry_type == "ai_task_data" + assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS + assert subentry.title == DEFAULT_AI_TASK_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -642,7 +658,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( } -async def test_migration_from_v1_to_v2_with_same_keys( +async def test_migration_from_v1_with_same_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -718,10 +734,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -740,6 +756,14 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(tts_subentries) == 1 assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME subentry = conversation_subentries[0] @@ -829,7 +853,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( ), ], ) -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -837,12 +861,13 @@ async def test_migration_from_v2_1_to_v2_2( extra_subentries: list[ConfigSubentryData], expected_device_subentries: dict[str, set[str | None]], ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core - 2025.7.0b0-2025.7.0b1: + 2025.7.0b0-2025.7.0b1 and add AI Task subentry: - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) + - Add AI Task subentry (Added in version 2.3) """ # Create a v2.1 config entry with 2 subentries, devices and entities options = { @@ -930,10 +955,10 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -952,6 +977,14 @@ async def test_migration_from_v2_1_to_v2_2( assert len(tts_subentries) == 1 assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME subentry = conversation_subentries[0] @@ -1011,3 +1044,80 @@ async def test_devices( device_registry, mock_config_entry.entry_id ) assert devices == snapshot + + +async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with conversation and TTS subentries + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + version=2, + minor_version=2, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "data": RECOMMENDED_TTS_OPTIONS, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + + # Run setup to trigger migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is True + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == 3 + + # Check we now have conversation, tts and ai_task_data subentries + assert len(entry.subentries) == 3 + + subentries = { + subentry.subentry_type: subentry for subentry in entry.subentries.values() + } + assert "conversation" in subentries + assert "tts" in subentries + assert "ai_task_data" in subentries + + # Find and verify the ai_task_data subentry + ai_task_subentry = subentries["ai_task_data"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + + # Verify conversation subentry is still there and unchanged + conversation_subentry = subentries["conversation"] + assert conversation_subentry is not None + assert conversation_subentry.title == DEFAULT_CONVERSATION_NAME + assert conversation_subentry.data == RECOMMENDED_CONVERSATION_OPTIONS + + # Verify TTS subentry is still there and unchanged + tts_subentry = subentries["tts"] + assert tts_subentry is not None + assert tts_subentry.title == DEFAULT_TTS_NAME + assert tts_subentry.data == RECOMMENDED_TTS_OPTIONS From 8641a2141c0e0343e6aa580a6294f659d738f311 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 4 Jul 2025 01:10:21 -0700 Subject: [PATCH 1091/1664] Fix has-entity-name and entity-translations in Opower (#148098) --- homeassistant/components/opower/sensor.py | 43 ++++++++-------- homeassistant/components/opower/strings.json | 52 ++++++++++++++++++++ tests/components/opower/test_sensor.py | 28 ++++++++--- 3 files changed, 94 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 46aa9e9b318..9fc4d7e536a 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OpowerConfigEntry, OpowerCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class OpowerEntityDescription(SensorEntityDescription): @@ -38,7 +40,7 @@ class OpowerEntityDescription(SensorEntityDescription): ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( OpowerEntityDescription( key="elec_usage_to_date", - name="Current bill electric usage to date", + translation_key="elec_usage_to_date", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, # Not TOTAL_INCREASING because it can decrease for accounts with solar @@ -48,7 +50,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_forecasted_usage", - name="Current bill electric forecasted usage", + translation_key="elec_forecasted_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -57,7 +59,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_typical_usage", - name="Typical monthly electric usage", + translation_key="elec_typical_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -66,7 +68,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_cost_to_date", - name="Current bill electric cost to date", + translation_key="elec_cost_to_date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -75,7 +77,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_forecasted_cost", - name="Current bill electric forecasted cost", + translation_key="elec_forecasted_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -84,7 +86,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_typical_cost", - name="Typical monthly electric cost", + translation_key="elec_typical_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -93,7 +95,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_start_date", - name="Current bill electric start date", + translation_key="elec_start_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -101,7 +103,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_end_date", - name="Current bill electric end date", + translation_key="elec_end_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -111,7 +113,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( OpowerEntityDescription( key="gas_usage_to_date", - name="Current bill gas usage to date", + translation_key="gas_usage_to_date", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -120,7 +122,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_forecasted_usage", - name="Current bill gas forecasted usage", + translation_key="gas_forecasted_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -129,7 +131,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_typical_usage", - name="Typical monthly gas usage", + translation_key="gas_typical_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -138,7 +140,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_cost_to_date", - name="Current bill gas cost to date", + translation_key="gas_cost_to_date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -147,7 +149,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_forecasted_cost", - name="Current bill gas forecasted cost", + translation_key="gas_forecasted_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -156,7 +158,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_typical_cost", - name="Typical monthly gas cost", + translation_key="gas_typical_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -165,7 +167,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_start_date", - name="Current bill gas start date", + translation_key="gas_start_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -173,7 +175,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_end_date", - name="Current bill gas end date", + translation_key="gas_end_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -229,6 +231,7 @@ async def async_setup_entry( class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): """Representation of an Opower sensor.""" + _attr_has_entity_name = True entity_description: OpowerEntityDescription def __init__( @@ -249,8 +252,6 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): @property def native_value(self) -> StateType | date: """Return the state.""" - if self.coordinator.data is not None: - return self.entity_description.value_fn( - self.coordinator.data[self.utility_account_id] - ) - return None + return self.entity_description.value_fn( + self.coordinator.data[self.utility_account_id] + ) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 3af968cf789..cd22bd8d7a1 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -37,5 +37,57 @@ "title": "Return to grid statistics for account: {utility_account_id}", "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue." } + }, + "entity": { + "sensor": { + "elec_usage_to_date": { + "name": "Current bill electric usage to date" + }, + "elec_forecasted_usage": { + "name": "Current bill electric forecasted usage" + }, + "elec_typical_usage": { + "name": "Typical monthly electric usage" + }, + "elec_cost_to_date": { + "name": "Current bill electric cost to date" + }, + "elec_forecasted_cost": { + "name": "Current bill electric forecasted cost" + }, + "elec_typical_cost": { + "name": "Typical monthly electric cost" + }, + "elec_start_date": { + "name": "Current bill electric start date" + }, + "elec_end_date": { + "name": "Current bill electric end date" + }, + "gas_usage_to_date": { + "name": "Current bill gas usage to date" + }, + "gas_forecasted_usage": { + "name": "Current bill gas forecasted usage" + }, + "gas_typical_usage": { + "name": "Typical monthly gas usage" + }, + "gas_cost_to_date": { + "name": "Current bill gas cost to date" + }, + "gas_forecasted_cost": { + "name": "Current bill gas forecasted cost" + }, + "gas_typical_cost": { + "name": "Typical monthly gas cost" + }, + "gas_start_date": { + "name": "Current bill gas start date" + }, + "gas_end_date": { + "name": "Current bill gas end date" + } + } } } diff --git a/tests/components/opower/test_sensor.py b/tests/components/opower/test_sensor.py index 91ffb271b2b..883bf86f883 100644 --- a/tests/components/opower/test_sensor.py +++ b/tests/components/opower/test_sensor.py @@ -25,36 +25,48 @@ async def test_sensors( entity_registry = er.async_get(hass) # Check electric sensors - entry = entity_registry.async_get("sensor.current_bill_electric_usage_to_date") + entry = entity_registry.async_get( + "sensor.elec_account_111111_current_bill_electric_usage_to_date" + ) assert entry assert entry.unique_id == "pge_111111_elec_usage_to_date" - state = hass.states.get("sensor.current_bill_electric_usage_to_date") + state = hass.states.get( + "sensor.elec_account_111111_current_bill_electric_usage_to_date" + ) assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.state == "100" - entry = entity_registry.async_get("sensor.current_bill_electric_cost_to_date") + entry = entity_registry.async_get( + "sensor.elec_account_111111_current_bill_electric_cost_to_date" + ) assert entry assert entry.unique_id == "pge_111111_elec_cost_to_date" - state = hass.states.get("sensor.current_bill_electric_cost_to_date") + state = hass.states.get( + "sensor.elec_account_111111_current_bill_electric_cost_to_date" + ) assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.state == "20.0" # Check gas sensors - entry = entity_registry.async_get("sensor.current_bill_gas_usage_to_date") + entry = entity_registry.async_get( + "sensor.gas_account_222222_current_bill_gas_usage_to_date" + ) assert entry assert entry.unique_id == "pge_222222_gas_usage_to_date" - state = hass.states.get("sensor.current_bill_gas_usage_to_date") + state = hass.states.get("sensor.gas_account_222222_current_bill_gas_usage_to_date") assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS # Convert 50 CCF to m³ assert float(state.state) == pytest.approx(50 * 2.83168, abs=1e-3) - entry = entity_registry.async_get("sensor.current_bill_gas_cost_to_date") + entry = entity_registry.async_get( + "sensor.gas_account_222222_current_bill_gas_cost_to_date" + ) assert entry assert entry.unique_id == "pge_222222_gas_cost_to_date" - state = hass.states.get("sensor.current_bill_gas_cost_to_date") + state = hass.states.get("sensor.gas_account_222222_current_bill_gas_cost_to_date") assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.state == "15.0" From 1fc624c7a7cef1e6745bb98eeb3355be6dae17ad Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 4 Jul 2025 04:05:16 -0700 Subject: [PATCH 1092/1664] Update LLM selector serializer to support ObjectSelector fields and arrays (#148094) --- homeassistant/helpers/llm.py | 18 +++++++++++- tests/helpers/test_llm.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5d9e4c3bdef..bf89e693870 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -777,7 +777,23 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return result if isinstance(schema, selector.ObjectSelector): - return {"type": "object", "additionalProperties": True} + result = {"type": "object"} + if fields := schema.config.get("fields"): + result["properties"] = { + field: convert( + selector.selector(field_schema["selector"]), + custom_serializer=_selector_serializer, + ) + for field, field_schema in fields.items() + } + else: + result["additionalProperties"] = True + if schema.config.get("multiple"): + result = { + "type": "array", + "items": result, + } + return result if isinstance(schema, selector.SelectSelector): options = [ diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index b6894505534..b978559130c 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1139,6 +1139,59 @@ async def test_selector_serializer( "type": "object", "additionalProperties": True, } + assert selector_serializer( + selector.ObjectSelector( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {"min": 30, "max": 100}}, + }, + }, + "multiple": False, + "label_field": "name", + }, + ) + ) == { + "type": "object", + "properties": { + "name": {"type": "string"}, + "percentage": {"type": "number", "minimum": 30, "maximum": 100}, + }, + } + assert selector_serializer( + selector.ObjectSelector( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {"min": 30, "max": 100}}, + }, + }, + "multiple": True, + "label_field": "name", + }, + ) + ) == { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "percentage": { + "type": "number", + "minimum": 30, + "maximum": 100, + }, + }, + }, + } assert selector_serializer( selector.SelectSelector( { From 4be2e84ce65de68883f16770818cf2e354cd6cc7 Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Fri, 4 Jul 2025 14:36:25 +0200 Subject: [PATCH 1093/1664] Add backward compatibility with older versions of Traccar server (#146639) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Joakim Sørensen --- .../components/traccar_server/coordinator.py | 6 +++--- .../components/traccar_server/helpers.py | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 2c878856cc2..3a0bfe47e5f 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -31,7 +31,7 @@ from .const import ( EVENTS, LOGGER, ) -from .helpers import get_device, get_first_geofence +from .helpers import get_device, get_first_geofence, get_geofence_ids class TraccarServerCoordinatorDataDevice(TypedDict): @@ -131,7 +131,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat "device": device, "geofence": get_first_geofence( geofences, - position["geofenceIds"] or [], + get_geofence_ids(device, position), ), "position": position, "attributes": attr, @@ -187,7 +187,7 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat self.data[device_id]["attributes"] = attr self.data[device_id]["geofence"] = get_first_geofence( self._geofences, - position["geofenceIds"] or [], + get_geofence_ids(self.data[device_id]["device"], position), ) update_devices.add(device_id) diff --git a/homeassistant/components/traccar_server/helpers.py b/homeassistant/components/traccar_server/helpers.py index 971f51376b8..9a22f2784bc 100644 --- a/homeassistant/components/traccar_server/helpers.py +++ b/homeassistant/components/traccar_server/helpers.py @@ -2,7 +2,7 @@ from __future__ import annotations -from pytraccar import DeviceModel, GeofenceModel +from pytraccar import DeviceModel, GeofenceModel, PositionModel def get_device(device_id: int, devices: list[DeviceModel]) -> DeviceModel | None: @@ -22,3 +22,17 @@ def get_first_geofence( (geofence for geofence in geofences if geofence["id"] in target), None, ) + + +def get_geofence_ids( + device: DeviceModel, + position: PositionModel, +) -> list[int]: + """Compatibility helper to return a list of geofence IDs.""" + # For Traccar >=5.8 https://github.com/traccar/traccar/commit/30bafaed42e74863c5ca68a33c87f39d1e2de93d + if "geofenceIds" in position: + return position["geofenceIds"] or [] + # For Traccar <5.8 + if "geofenceIds" in device: + return device["geofenceIds"] or [] + return [] From 99d63c49bbe23e2acb2fc80b28aa8f1f8499608c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 14:47:01 +0200 Subject: [PATCH 1094/1664] Add comment about error assigning in frame.report_usage (#148105) --- homeassistant/helpers/frame.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index d7a647e02eb..8f0741b5166 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -193,6 +193,11 @@ def report_usage( exclude_integrations=exclude_integrations ) except MissingIntegrationFrame as err: + # We need to be careful with assigning the error here as it affects the + # cleanup of objects referenced from the stack trace as seen in + # https://github.com/home-assistant/core/pull/148021#discussion_r2182379834 + # When core_behavior is ReportBehavior.ERROR, we will re-raise the error, + # so we can safely assign it to integration_frame_err. if core_behavior is ReportBehavior.ERROR: integration_frame_err = err _report_usage_partial = functools.partial( From b3d9908cd978df3943bfce4563f9660f78187f5a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 4 Jul 2025 06:03:34 -0700 Subject: [PATCH 1095/1664] Add AI task structured output (#148083) Co-authored-by: Paulus Schoutsen Co-authored-by: Claude Co-authored-by: Paulus Schoutsen --- homeassistant/components/ai_task/__init__.py | 32 +++- homeassistant/components/ai_task/const.py | 2 + .../components/ai_task/services.yaml | 6 + homeassistant/components/ai_task/strings.json | 4 + homeassistant/components/ai_task/task.py | 7 + homeassistant/helpers/service.py | 2 + tests/components/ai_task/conftest.py | 12 +- tests/components/ai_task/test_entity.py | 39 +++++ tests/components/ai_task/test_init.py | 163 +++++++++++++++++- 9 files changed, 262 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 692e5d410ae..95c080cc472 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -1,11 +1,12 @@ """Integration to offer AI tasks to Home Assistant.""" import logging +from typing import Any import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR from homeassistant.core import ( HassJobType, HomeAssistant, @@ -14,12 +15,14 @@ from homeassistant.core import ( SupportsResponse, callback, ) -from homeassistant.helpers import config_validation as cv, storage +from homeassistant.helpers import config_validation as cv, selector, storage from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( ATTR_INSTRUCTIONS, + ATTR_REQUIRED, + ATTR_STRUCTURE, ATTR_TASK_NAME, DATA_COMPONENT, DATA_PREFERENCES, @@ -47,6 +50,27 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +STRUCTURE_FIELD_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DESCRIPTION): str, + vol.Optional(ATTR_REQUIRED): bool, + vol.Required(CONF_SELECTOR): selector.validate_selector, + } +) + + +def _validate_structure_fields(value: dict[str, Any]) -> vol.Schema: + """Validate the structure fields as a voluptuous Schema.""" + if not isinstance(value, dict): + raise vol.Invalid("Structure must be a dictionary") + fields = {} + for k, v in value.items(): + field_class = vol.Required if v.get(ATTR_REQUIRED, False) else vol.Optional + fields[field_class(k, description=v.get(CONF_DESCRIPTION))] = selector.selector( + v[CONF_SELECTOR] + ) + return vol.Schema(fields, extra=vol.PREVENT_EXTRA) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the process service.""" @@ -64,6 +88,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Required(ATTR_TASK_NAME): cv.string, vol.Optional(ATTR_ENTITY_ID): cv.entity_id, vol.Required(ATTR_INSTRUCTIONS): cv.string, + vol.Optional(ATTR_STRUCTURE): vol.All( + vol.Schema({str: STRUCTURE_FIELD_SCHEMA}), + _validate_structure_fields, + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index 8b612e90560..fa8702ed69e 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -21,6 +21,8 @@ SERVICE_GENERATE_DATA = "generate_data" ATTR_INSTRUCTIONS: Final = "instructions" ATTR_TASK_NAME: Final = "task_name" +ATTR_STRUCTURE: Final = "structure" +ATTR_REQUIRED: Final = "required" DEFAULT_SYSTEM_PROMPT = ( "You are a Home Assistant expert and help users with their tasks." diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index a531ca599b1..d55b0e60fac 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -17,3 +17,9 @@ generate_data: domain: ai_task supported_features: - ai_task.AITaskEntityFeature.GENERATE_DATA + structure: + advanced: true + required: false + example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }' + selector: + object: diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index 877174de681..92106c3baca 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -15,6 +15,10 @@ "entity_id": { "name": "Entity ID", "description": "Entity ID to run the task on. If not provided, the preferred entity will be used." + }, + "structure": { + "name": "Structured output", + "description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field." } } } diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 2e546897602..b6defbfad31 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -5,6 +5,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +import voluptuous as vol + from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -17,6 +19,7 @@ async def async_generate_data( task_name: str, entity_id: str | None = None, instructions: str, + structure: vol.Schema | None = None, ) -> GenDataTaskResult: """Run a task in the AI Task integration.""" if entity_id is None: @@ -38,6 +41,7 @@ async def async_generate_data( GenDataTask( name=task_name, instructions=instructions, + structure=structure, ) ) @@ -52,6 +56,9 @@ class GenDataTask: instructions: str """Instructions on what needs to be done.""" + structure: vol.Schema | None = None + """Optional structure for the data to be generated.""" + def __str__(self) -> str: """Return task as a string.""" return f"" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 51d9c97ceeb..c7d4a26c86e 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -86,6 +86,7 @@ ALL_SERVICE_DESCRIPTIONS_CACHE: HassKey[ def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" from homeassistant.components import ( # noqa: PLC0415 + ai_task, alarm_control_panel, assist_satellite, calendar, @@ -107,6 +108,7 @@ def _base_components() -> dict[str, ModuleType]: ) return { + "ai_task": ai_task, "alarm_control_panel": alarm_control_panel, "assist_satellite": assist_satellite, "calendar": calendar, diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index 7efbd1ffcdb..e80e70ddaed 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -1,5 +1,7 @@ """Test helpers for AI Task integration.""" +import json + import pytest from homeassistant.components.ai_task import ( @@ -45,12 +47,18 @@ class MockAITaskEntity(AITaskEntity): ) -> GenDataTaskResult: """Mock handling of generate data task.""" self.mock_generate_data_tasks.append(task) + if task.structure is not None: + data = {"name": "Tracy Chen", "age": 30} + data_chat_log = json.dumps(data) + else: + data = "Mock result" + data_chat_log = data chat_log.async_add_assistant_content_without_tools( - AssistantContent(self.entity_id, "Mock result") + AssistantContent(self.entity_id, data_chat_log) ) return GenDataTaskResult( conversation_id=chat_log.conversation_id, - data="Mock result", + data=data, ) diff --git a/tests/components/ai_task/test_entity.py b/tests/components/ai_task/test_entity.py index 3ed1c393588..08f1bb42836 100644 --- a/tests/components/ai_task/test_entity.py +++ b/tests/components/ai_task/test_entity.py @@ -1,10 +1,12 @@ """Tests for the AI Task entity model.""" from freezegun import freeze_time +import voluptuous as vol from homeassistant.components.ai_task import async_generate_data from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import selector from .conftest import TEST_ENTITY_ID, MockAITaskEntity @@ -37,3 +39,40 @@ async def test_state_generate_data( assert mock_ai_task_entity.mock_generate_data_tasks task = mock_ai_task_entity.mock_generate_data_tasks[0] assert task.instructions == "Test prompt" + + +async def test_generate_structured_data( + hass: HomeAssistant, + init_components: None, + mock_config_entry: MockConfigEntry, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the entity can generate structured data.""" + result = await async_generate_data( + hass, + task_name="Test task", + entity_id=TEST_ENTITY_ID, + instructions="Please generate a profile for a new user", + structure=vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + vol.Optional("age"): selector.NumberSelector( + config=selector.NumberSelectorConfig( + min=0, + max=120, + ) + ), + } + ), + ) + # Arbitrary data returned by the mock entity (not determined by above schema in test) + assert result.data == { + "name": "Tracy Chen", + "age": 30, + } + + assert mock_ai_task_entity.mock_generate_data_tasks + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.instructions == "Please generate a profile for a new user" + assert task.structure + assert isinstance(task.structure, vol.Schema) diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index fdfaaccd0a4..d32b09adec5 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -1,13 +1,17 @@ """Test initialization of the AI Task component.""" +from typing import Any + from freezegun.api import FrozenDateTimeFactory import pytest +import voluptuous as vol from homeassistant.components.ai_task import AITaskPreferences from homeassistant.components.ai_task.const import DATA_PREFERENCES from homeassistant.core import HomeAssistant +from homeassistant.helpers import selector -from .conftest import TEST_ENTITY_ID +from .conftest import TEST_ENTITY_ID, MockAITaskEntity from tests.common import flush_store @@ -82,3 +86,160 @@ async def test_generate_data_service( ) assert result["data"] == "Mock result" + + +async def test_generate_data_service_structure_fields( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test the entity can generate structured data with a top level object schema.""" + result = await hass.services.async_call( + "ai_task", + "generate_data", + { + "task_name": "Profile Generation", + "instructions": "Please generate a profile for a new user", + "entity_id": TEST_ENTITY_ID, + "structure": { + "name": { + "description": "First and last name of the user such as Alice Smith", + "required": True, + "selector": {"text": {}}, + }, + "age": { + "description": "Age of the user", + "selector": { + "number": { + "min": 0, + "max": 120, + } + }, + }, + }, + }, + blocking=True, + return_response=True, + ) + # Arbitrary data returned by the mock entity (not determined by above schema in test) + assert result["data"] == { + "name": "Tracy Chen", + "age": 30, + } + + assert mock_ai_task_entity.mock_generate_data_tasks + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.instructions == "Please generate a profile for a new user" + assert task.structure + assert isinstance(task.structure, vol.Schema) + schema = list(task.structure.schema.items()) + assert len(schema) == 2 + + name_key, name_value = schema[0] + assert name_key == "name" + assert isinstance(name_key, vol.Required) + assert name_key.description == "First and last name of the user such as Alice Smith" + assert isinstance(name_value, selector.TextSelector) + + age_key, age_value = schema[1] + assert age_key == "age" + assert isinstance(age_key, vol.Optional) + assert age_key.description == "Age of the user" + assert isinstance(age_value, selector.NumberSelector) + assert age_value.config["min"] == 0 + assert age_value.config["max"] == 120 + + +@pytest.mark.parametrize( + ("structure", "expected_exception", "expected_error"), + [ + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": {"invalid-selector": {}}, + }, + }, + vol.Invalid, + r"Unknown selector type invalid-selector.*", + ), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": { + "text": { + "extra-config": False, + } + }, + }, + }, + vol.Invalid, + r"extra keys not allowed.*", + ), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + }, + }, + vol.Invalid, + r"required key not provided.*selector.*", + ), + (12345, vol.Invalid, r"xpected a dictionary.*"), + ("name", vol.Invalid, r"xpected a dictionary.*"), + (["name"], vol.Invalid, r"xpected a dictionary.*"), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": {"text": {}}, + "extra-fields": "Some extra fields", + }, + }, + vol.Invalid, + r"extra keys not allowed .*", + ), + ( + { + "name": { + "description": "First and last name of the user such as Alice Smith", + "selector": "invalid-schema", + }, + }, + vol.Invalid, + r"xpected a dictionary for dictionary.", + ), + ], + ids=( + "invalid-selector", + "invalid-selector-config", + "missing-selector", + "structure-is-int-not-object", + "structure-is-str-not-object", + "structure-is-list-not-object", + "extra-fields", + "invalid-selector-schema", + ), +) +async def test_generate_data_service_invalid_structure( + hass: HomeAssistant, + init_components: None, + structure: Any, + expected_exception: Exception, + expected_error: str, +) -> None: + """Test the entity can generate structured data.""" + with pytest.raises(expected_exception, match=expected_error): + await hass.services.async_call( + "ai_task", + "generate_data", + { + "task_name": "Profile Generation", + "instructions": "Please generate a profile for a new user", + "entity_id": TEST_ENTITY_ID, + "structure": structure, + }, + blocking=True, + return_response=True, + ) From e47bdc06a0dc84e95e9bbc4af1928bd1a29036ef Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:00:37 +0200 Subject: [PATCH 1096/1664] Set docstyle convention to google in ruff (#148142) --- homeassistant/components/habitica/util.py | 19 ++--- homeassistant/helpers/event.py | 97 +++++++++++------------ pyproject.toml | 1 + tests/conftest.py | 2 +- 4 files changed, 54 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 9ef0b8cbadd..35e1577ae21 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -95,21 +95,16 @@ def get_recurrence_rule(recurrence: rrule) -> str: 'DTSTART:YYYYMMDDTHHMMSS\nRRULE:FREQ=YEARLY;INTERVAL=2' - Parameters - ---------- - recurrence : rrule - An RRULE object. + Args: + recurrence: An RRULE object. - Returns - ------- - str + Returns: The recurrence rule portion of the RRULE string, starting with 'FREQ='. - Example - ------- - >>> rule = get_recurrence_rule(task) - >>> print(rule) - 'FREQ=YEARLY;INTERVAL=2' + Example: + >>> rule = get_recurrence_rule(task) + >>> print(rule) + 'FREQ=YEARLY;INTERVAL=2' """ return str(recurrence).split("RRULE:")[1] diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index baf1f144a3f..3b959337b6d 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -866,19 +866,17 @@ def async_track_state_change_filtered( ) -> _TrackStateChangeFiltered: """Track state changes with a TrackStates filter that can be updated. - Parameters - ---------- - hass - Home assistant object. - track_states - A TrackStates data class. - action - Callable to call with results. + Args: + hass: + Home assistant object. + track_states: + A TrackStates data class. + action: + Callable to call with results. - Returns - ------- - Object used to update the listeners (async_update_listeners) with a new - TrackStates or cancel the tracking (async_remove). + Returns: + Object used to update the listeners (async_update_listeners) with a new + TrackStates or cancel the tracking (async_remove). """ tracker = _TrackStateChangeFiltered(hass, track_states, action) @@ -907,29 +905,26 @@ def async_track_template( exception, the listener will still be registered but will only fire if the template result becomes true without an exception. - Action arguments - ---------------- - entity_id - ID of the entity that triggered the state change. - old_state - The old state of the entity that changed. - new_state - New state of the entity that changed. + Action args: + entity_id: + ID of the entity that triggered the state change. + old_state: + The old state of the entity that changed. + new_state: + New state of the entity that changed. - Parameters - ---------- - hass - Home assistant object. - template - The template to calculate. - action - Callable to call with results. See above for arguments. - variables - Variables to pass to the template. + Args: + hass: + Home assistant object. + template: + The template to calculate. + action: + Callable to call with results. See above for arguments. + variables: + Variables to pass to the template. - Returns - ------- - Callable to unregister the listener. + Returns: + Callable to unregister the listener. """ job = HassJob(action, f"track template {template}") @@ -1361,26 +1356,24 @@ def async_track_template_result( Once the template returns to a non-error condition the result is sent to the action as usual. - Parameters - ---------- - hass - Home assistant object. - track_templates - An iterable of TrackTemplate. - action - Callable to call with results. - strict - When set to True, raise on undefined variables. - log_fn - If not None, template error messages will logging by calling log_fn - instead of the normal logging facility. - has_super_template - When set to True, the first template will block rendering of other - templates if it doesn't render as True. + Args: + hass: + Home assistant object. + track_templates: + An iterable of TrackTemplate. + action: + Callable to call with results. + strict: + When set to True, raise on undefined variables. + log_fn: + If not None, template error messages will logging by calling log_fn + instead of the normal logging facility. + has_super_template: + When set to True, the first template will block rendering of other + templates if it doesn't render as True. - Returns - ------- - Info object used to unregister the listener, and refresh the template. + Returns: + Info object used to unregister the listener, and refresh the template. """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) diff --git a/pyproject.toml b/pyproject.toml index 399d35ffb41..25f4d6d4a1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -913,4 +913,5 @@ split-on-trailing-comma = false max-complexity = 25 [tool.ruff.lint.pydocstyle] +convention = "google" property-decorators = ["propcache.api.cached_property"] diff --git a/tests/conftest.py b/tests/conftest.py index ef31eee4004..9fdf010eb64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1724,7 +1724,7 @@ async def async_test_recorder( wait_recorder: bool = True, wait_recorder_setup: bool = True, ) -> AsyncGenerator[recorder.Recorder]: - """Setup and return recorder instance.""" # noqa: D401 + """Setup and return recorder instance.""" await _async_init_recorder_component( hass, config, From 510fd09163a82c3bad97d8da559d24257c767eef Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:03:42 +0200 Subject: [PATCH 1097/1664] Allow core integrations to describe their conditions (#147529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/bootstrap.py | 2 + .../components/websocket_api/commands.py | 53 ++++ homeassistant/helpers/condition.py | 222 +++++++++++++- homeassistant/loader.py | 6 + script/hassfest/__main__.py | 2 + script/hassfest/conditions.py | 225 ++++++++++++++ script/hassfest/icons.py | 11 + script/hassfest/translations.py | 16 + tests/common.py | 2 + .../components/websocket_api/test_commands.py | 86 ++++++ tests/helpers/test_condition.py | 288 +++++++++++++++++- 11 files changed, 907 insertions(+), 6 deletions(-) create mode 100644 script/hassfest/conditions.py diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 0b86bdb7087..397f765174d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -76,6 +76,7 @@ from .exceptions import HomeAssistantError from .helpers import ( area_registry, category_registry, + condition, config_validation as cv, device_registry, entity, @@ -452,6 +453,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None: create_eager_task(restore_state.async_load(hass)), create_eager_task(hass.config_entries.async_initialize()), create_eager_task(async_get_system_info(hass)), + create_eager_task(condition.async_setup(hass)), create_eager_task(trigger.async_setup(hass)), ) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 701a9a659b1..b63e5e14820 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -35,6 +35,10 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, entity, template +from homeassistant.helpers.condition import ( + async_get_all_descriptions as async_get_all_condition_descriptions, + async_subscribe_platform_events as async_subscribe_condition_platform_events, +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entityfilter import ( INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA, @@ -76,6 +80,7 @@ from . import const, decorators, messages from .connection import ActiveConnection from .messages import construct_event_message, construct_result_message +ALL_CONDITION_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_condition_descriptions_json" ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_trigger_descriptions_json" @@ -101,6 +106,7 @@ def async_register_commands( async_reg(hass, handle_ping) async_reg(hass, handle_render_template) async_reg(hass, handle_subscribe_bootstrap_integrations) + async_reg(hass, handle_subscribe_condition_platforms) async_reg(hass, handle_subscribe_events) async_reg(hass, handle_subscribe_trigger) async_reg(hass, handle_subscribe_trigger_platforms) @@ -501,6 +507,53 @@ def _send_handle_entities_init_response( ) +async def _async_get_all_condition_descriptions_json(hass: HomeAssistant) -> bytes: + """Return JSON of descriptions (i.e. user documentation) for all condition.""" + descriptions = await async_get_all_condition_descriptions(hass) + if ALL_CONDITION_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_CONDITION_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(bytes, cached_json_payload) + json_payload = json_bytes( + { + condition: description + for condition, description in descriptions.items() + if description is not None + } + ) + hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload + + +@decorators.websocket_command({vol.Required("type"): "condition_platforms/subscribe"}) +@decorators.async_response +async def handle_subscribe_condition_platforms( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle subscribe conditions command.""" + + async def on_new_conditions(new_conditions: set[str]) -> None: + """Forward new conditions to websocket.""" + descriptions = await async_get_all_condition_descriptions(hass) + new_condition_descriptions = {} + for condition in new_conditions: + if (description := descriptions[condition]) is not None: + new_condition_descriptions[condition] = description + if not new_condition_descriptions: + return + connection.send_event(msg["id"], new_condition_descriptions) + + connection.subscriptions[msg["id"]] = async_subscribe_condition_platform_events( + hass, on_new_conditions + ) + connection.send_result(msg["id"]) + conditions_json = await _async_get_all_condition_descriptions_json(hass) + connection.send_message(construct_event_message(msg["id"], conditions_json)) + + async def _async_get_all_service_descriptions_json(hass: HomeAssistant) -> bytes: """Return JSON of descriptions (i.e. user documentation) for all service calls.""" descriptions = await async_get_all_service_descriptions(hass) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 86b8a1002f1..5a9ffb6d91b 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -5,19 +5,17 @@ from __future__ import annotations import abc import asyncio from collections import deque -from collections.abc import Callable, Container, Generator +from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft import logging import re import sys -from typing import Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Protocol, cast import voluptuous as vol -from homeassistant.components import zone as zone_cmp -from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_GPS_ACCURACY, @@ -54,11 +52,20 @@ from homeassistant.exceptions import ( HomeAssistantError, TemplateError, ) -from homeassistant.loader import IntegrationNotFound, async_get_integration +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_integration, + async_get_integrations, +) from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.hass_dict import HassKey +from homeassistant.util.yaml import load_yaml_dict +from homeassistant.util.yaml.loader import JSON_TYPE from . import config_validation as cv, entity_registry as er +from .integration_platform import async_process_integration_platforms from .template import Template, render_complex from .trace import ( TraceElement, @@ -76,6 +83,8 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config" FROM_CONFIG_FORMAT = "{}_from_config" VALIDATE_CONFIG_FORMAT = "{}_validate_config" +_LOGGER = logging.getLogger(__name__) + _PLATFORM_ALIASES: dict[str | None, str | None] = { "and": None, "device": "device_automation", @@ -94,6 +103,99 @@ INPUT_ENTITY_ID = re.compile( ) +CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey( + "condition_description_cache" +) +CONDITION_PLATFORM_SUBSCRIPTIONS: HassKey[ + list[Callable[[set[str]], Coroutine[Any, Any, None]]] +] = HassKey("condition_platform_subscriptions") +CONDITIONS: HassKey[dict[str, str]] = HassKey("conditions") + + +# Basic schemas to sanity check the condition descriptions, +# full validation is done by hassfest.conditions +_FIELD_SCHEMA = vol.Schema( + {}, + extra=vol.ALLOW_EXTRA, +) + +_CONDITION_SCHEMA = vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), + }, + extra=vol.ALLOW_EXTRA, +) + + +def starts_with_dot(key: str) -> str: + """Check if key starts with dot.""" + if not key.startswith("."): + raise vol.Invalid("Key does not start with .") + return key + + +_CONDITIONS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, starts_with_dot)): object, + cv.slug: vol.Any(None, _CONDITION_SCHEMA), + } +) + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the condition helper.""" + hass.data[CONDITION_DESCRIPTION_CACHE] = {} + hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] = [] + hass.data[CONDITIONS] = {} + await async_process_integration_platforms( + hass, "condition", _register_condition_platform, wait_for_platforms=True + ) + + +@callback +def async_subscribe_platform_events( + hass: HomeAssistant, + on_event: Callable[[set[str]], Coroutine[Any, Any, None]], +) -> Callable[[], None]: + """Subscribe to condition platform events.""" + condition_platform_event_subscriptions = hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS] + + def remove_subscription() -> None: + condition_platform_event_subscriptions.remove(on_event) + + condition_platform_event_subscriptions.append(on_event) + return remove_subscription + + +async def _register_condition_platform( + hass: HomeAssistant, integration_domain: str, platform: ConditionProtocol +) -> None: + """Register a condition platform.""" + + new_conditions: set[str] = set() + + if hasattr(platform, "async_get_conditions"): + for condition_key in await platform.async_get_conditions(hass): + hass.data[CONDITIONS][condition_key] = integration_domain + new_conditions.add(condition_key) + else: + _LOGGER.debug( + "Integration %s does not provide condition support, skipping", + integration_domain, + ) + return + + # We don't use gather here because gather adds additional overhead + # when wrapping each coroutine in a task, and we expect our listeners + # to call condition.async_get_all_descriptions which will only yield + # the first time it's called, after that it returns cached data. + for listener in hass.data[CONDITION_PLATFORM_SUBSCRIPTIONS]: + try: + await listener(new_conditions) + except Exception: + _LOGGER.exception("Error while notifying condition platform listener") + + class Condition(abc.ABC): """Condition class.""" @@ -717,6 +819,8 @@ def time( for the opposite. "(23:59 <= now < 00:01)" would be the same as "not (00:01 <= now < 23:59)". """ + from homeassistant.components.sensor import SensorDeviceClass # noqa: PLC0415 + now = dt_util.now() now_time = now.time() @@ -824,6 +928,8 @@ def zone( Async friendly. """ + from homeassistant.components import zone as zone_cmp # noqa: PLC0415 + if zone_ent is None: raise ConditionErrorMessage("zone", "no zone specified") @@ -1080,3 +1186,109 @@ def async_extract_devices(config: ConfigType | Template) -> set[str]: referenced.add(device_id) return referenced + + +def _load_conditions_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE: + """Load conditions file for an integration.""" + try: + return cast( + JSON_TYPE, + _CONDITIONS_SCHEMA( + load_yaml_dict(str(integration.file_path / "conditions.yaml")) + ), + ) + except FileNotFoundError: + _LOGGER.warning( + "Unable to find conditions.yaml for the %s integration", integration.domain + ) + return {} + except (HomeAssistantError, vol.Invalid) as ex: + _LOGGER.warning( + "Unable to parse conditions.yaml for the %s integration: %s", + integration.domain, + ex, + ) + return {} + + +def _load_conditions_files( + hass: HomeAssistant, integrations: Iterable[Integration] +) -> dict[str, JSON_TYPE]: + """Load condition files for multiple integrations.""" + return { + integration.domain: _load_conditions_file(hass, integration) + for integration in integrations + } + + +async def async_get_all_descriptions( + hass: HomeAssistant, +) -> dict[str, dict[str, Any] | None]: + """Return descriptions (i.e. user documentation) for all conditions.""" + descriptions_cache = hass.data[CONDITION_DESCRIPTION_CACHE] + + conditions = hass.data[CONDITIONS] + # See if there are new conditions not seen before. + # Any condition that we saw before already has an entry in description_cache. + all_conditions = set(conditions) + previous_all_conditions = set(descriptions_cache) + # If the conditions are the same, we can return the cache + if previous_all_conditions == all_conditions: + return descriptions_cache + + # Files we loaded for missing descriptions + new_conditions_descriptions: dict[str, JSON_TYPE] = {} + # We try to avoid making a copy in the event the cache is good, + # but now we must make a copy in case new conditions get added + # while we are loading the missing ones so we do not + # add the new ones to the cache without their descriptions + conditions = conditions.copy() + + if missing_conditions := all_conditions.difference(descriptions_cache): + domains_with_missing_conditions = { + conditions[missing_condition] for missing_condition in missing_conditions + } + ints_or_excs = await async_get_integrations( + hass, domains_with_missing_conditions + ) + integrations: list[Integration] = [] + for domain, int_or_exc in ints_or_excs.items(): + if type(int_or_exc) is Integration and int_or_exc.has_conditions: + integrations.append(int_or_exc) + continue + if TYPE_CHECKING: + assert isinstance(int_or_exc, Exception) + _LOGGER.debug( + "Failed to load conditions.yaml for integration: %s", + domain, + exc_info=int_or_exc, + ) + + if integrations: + new_conditions_descriptions = await hass.async_add_executor_job( + _load_conditions_files, hass, integrations + ) + + # Make a copy of the old cache and add missing descriptions to it + new_descriptions_cache = descriptions_cache.copy() + for missing_condition in missing_conditions: + domain = conditions[missing_condition] + + if ( + yaml_description := new_conditions_descriptions.get(domain, {}).get( # type: ignore[union-attr] + missing_condition + ) + ) is None: + _LOGGER.debug( + "No condition descriptions found for condition %s, skipping", + missing_condition, + ) + new_descriptions_cache[missing_condition] = None + continue + + description = {"fields": yaml_description.get("fields", {})} + + new_descriptions_cache[missing_condition] = description + + hass.data[CONDITION_DESCRIPTION_CACHE] = new_descriptions_cache + return new_descriptions_cache diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a66a09d7407..1e338be0a0f 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -67,6 +67,7 @@ _LOGGER = logging.getLogger(__name__) # BASE_PRELOAD_PLATFORMS = [ "backup", + "condition", "config", "config_flow", "diagnostics", @@ -857,6 +858,11 @@ class Integration: # True. return self.manifest.get("import_executor", True) + @cached_property + def has_conditions(self) -> bool: + """Return if the integration has conditions.""" + return "conditions.yaml" in self._top_level_files + @cached_property def has_services(self) -> bool: """Return if the integration has services.""" diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 05c0d455af6..dfa99c6bc75 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -12,6 +12,7 @@ from . import ( application_credentials, bluetooth, codeowners, + conditions, config_flow, config_schema, dependencies, @@ -38,6 +39,7 @@ INTEGRATION_PLUGINS = [ application_credentials, bluetooth, codeowners, + conditions, config_schema, dependencies, dhcp, diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py new file mode 100644 index 00000000000..7eb9a2c3fc0 --- /dev/null +++ b/script/hassfest/conditions.py @@ -0,0 +1,225 @@ +"""Validate conditions.""" + +from __future__ import annotations + +import contextlib +import json +import pathlib +import re +from typing import Any + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.const import CONF_SELECTOR +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import condition, config_validation as cv, selector +from homeassistant.util.yaml import load_yaml_dict + +from .model import Config, Integration + + +def exists(value: Any) -> Any: + """Check if value exists.""" + if value is None: + raise vol.Invalid("Value cannot be None") + return value + + +FIELD_SCHEMA = vol.Schema( + { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, + } +) + +CONDITION_SCHEMA = vol.Any( + vol.Schema( + { + vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), + } + ), + None, +) + +CONDITIONS_SCHEMA = vol.Schema( + { + vol.Remove(vol.All(str, condition.starts_with_dot)): object, + cv.slug: CONDITION_SCHEMA, + } +) + +NON_MIGRATED_INTEGRATIONS = { + "device_automation", + "sun", +} + + +def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) -> bool: + """Recursively go through a dir and it's children and find the regex.""" + pattern = re.compile(search_pattern) + + for fil in path.glob(glob_pattern): + if not fil.is_file(): + continue + + if pattern.search(fil.read_text()): + return True + + return False + + +def validate_conditions(config: Config, integration: Integration) -> None: # noqa: C901 + """Validate conditions.""" + try: + data = load_yaml_dict(str(integration.path / "conditions.yaml")) + except FileNotFoundError: + # Find if integration uses conditions + has_conditions = grep_dir( + integration.path, + "**/condition.py", + r"async_get_conditions", + ) + + if has_conditions and integration.domain not in NON_MIGRATED_INTEGRATIONS: + integration.add_error( + "conditions", "Registers conditions but has no conditions.yaml" + ) + return + except HomeAssistantError: + integration.add_error("conditions", "Invalid conditions.yaml") + return + + try: + conditions = CONDITIONS_SCHEMA(data) + except vol.Invalid as err: + integration.add_error( + "conditions", f"Invalid conditions.yaml: {humanize_error(data, err)}" + ) + return + + icons_file = integration.path / "icons.json" + icons = {} + if icons_file.is_file(): + with contextlib.suppress(ValueError): + icons = json.loads(icons_file.read_text()) + condition_icons = icons.get("conditions", {}) + + # Try loading translation strings + if integration.core: + strings_file = integration.path / "strings.json" + else: + # For custom integrations, use the en.json file + strings_file = integration.path / "translations/en.json" + + strings = {} + if strings_file.is_file(): + with contextlib.suppress(ValueError): + strings = json.loads(strings_file.read_text()) + + error_msg_suffix = "in the translations file" + if not integration.core: + error_msg_suffix = f"and is not {error_msg_suffix}" + + # For each condition in the integration: + # 1. Check if the condition description is set, if not, + # check if it's in the strings file else add an error. + # 2. Check if the condition has an icon set in icons.json. + # raise an error if not., + for condition_name, condition_schema in conditions.items(): + if integration.core and condition_name not in condition_icons: + # This is enforced for Core integrations only + integration.add_error( + "conditions", + f"Condition {condition_name} has no icon in icons.json.", + ) + if condition_schema is None: + continue + if "name" not in condition_schema and integration.core: + try: + strings["conditions"][condition_name]["name"] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has no name {error_msg_suffix}", + ) + + if "description" not in condition_schema and integration.core: + try: + strings["conditions"][condition_name]["description"] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has no description {error_msg_suffix}", + ) + + # The same check is done for the description in each of the fields of the + # condition schema. + for field_name, field_schema in condition_schema.get("fields", {}).items(): + if "fields" in field_schema: + # This is a section + continue + if "name" not in field_schema and integration.core: + try: + strings["conditions"][condition_name]["fields"][field_name]["name"] + except KeyError: + integration.add_error( + "conditions", + ( + f"Condition {condition_name} has a field {field_name} with no " + f"name {error_msg_suffix}" + ), + ) + + if "description" not in field_schema and integration.core: + try: + strings["conditions"][condition_name]["fields"][field_name][ + "description" + ] + except KeyError: + integration.add_error( + "conditions", + ( + f"Condition {condition_name} has a field {field_name} with no " + f"description {error_msg_suffix}" + ), + ) + + if "selector" in field_schema: + with contextlib.suppress(KeyError): + translation_key = field_schema["selector"]["select"][ + "translation_key" + ] + try: + strings["selector"][translation_key] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has a field {field_name} with a selector with a translation key {translation_key} that is not in the translations file", + ) + + # The same check is done for the description in each of the sections of the + # condition schema. + for section_name, section_schema in condition_schema.get("fields", {}).items(): + if "fields" not in section_schema: + # This is not a section + continue + if "name" not in section_schema and integration.core: + try: + strings["conditions"][condition_name]["sections"][section_name][ + "name" + ] + except KeyError: + integration.add_error( + "conditions", + f"Condition {condition_name} has a section {section_name} with no name {error_msg_suffix}", + ) + + +def validate(integrations: dict[str, Integration], config: Config) -> None: + """Handle dependencies for integrations.""" + # check conditions.yaml is valid + for integration in integrations.values(): + validate_conditions(config, integration) diff --git a/script/hassfest/icons.py b/script/hassfest/icons.py index 6abe338e45b..79ad7eec5ff 100644 --- a/script/hassfest/icons.py +++ b/script/hassfest/icons.py @@ -120,6 +120,16 @@ CUSTOM_INTEGRATION_SERVICE_ICONS_SCHEMA = cv.schema_with_slug_keys( ) +CONDITION_ICONS_SCHEMA = cv.schema_with_slug_keys( + vol.Schema( + { + vol.Optional("condition"): icon_value_validator, + } + ), + slug_validator=translation_key_validator, +) + + TRIGGER_ICONS_SCHEMA = cv.schema_with_slug_keys( vol.Schema( { @@ -166,6 +176,7 @@ def icon_schema( schema = vol.Schema( { + vol.Optional("conditions"): CONDITION_ICONS_SCHEMA, vol.Optional("config"): DATA_ENTRY_ICONS_SCHEMA, vol.Optional("issues"): vol.Schema( {str: {"fix_flow": DATA_ENTRY_ICONS_SCHEMA}} diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 93fd212b981..4e0cf349aec 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -416,6 +416,22 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: }, slug_validator=translation_key_validator, ), + vol.Optional("conditions"): cv.schema_with_slug_keys( + { + vol.Required("name"): translation_value_validator, + vol.Required("description"): translation_value_validator, + vol.Required("description_configured"): translation_value_validator, + vol.Optional("fields"): cv.schema_with_slug_keys( + { + vol.Required("name"): str, + vol.Required("description"): translation_value_validator, + vol.Optional("example"): translation_value_validator, + }, + slug_validator=translation_key_validator, + ), + }, + slug_validator=translation_key_validator, + ), vol.Optional("triggers"): cv.schema_with_slug_keys( { vol.Required("name"): translation_value_validator, diff --git a/tests/common.py b/tests/common.py index ff64dcb33a7..7652a020117 100644 --- a/tests/common.py +++ b/tests/common.py @@ -75,6 +75,7 @@ from homeassistant.core import ( from homeassistant.helpers import ( area_registry as ar, category_registry as cr, + condition, device_registry as dr, entity, entity_platform, @@ -296,6 +297,7 @@ async def async_test_home_assistant( # Load the registries entity.async_setup(hass) loader.async_setup(hass) + await condition.async_setup(hass) await trigger.async_setup(hass) # setup translation cache instead of calling translation.async_setup(hass) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index bfb8c917f71..b513a04a40b 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -19,6 +19,7 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH_REQUIRED, ) from homeassistant.components.websocket_api.commands import ( + ALL_CONDITION_DESCRIPTIONS_JSON_CACHE, ALL_SERVICE_DESCRIPTIONS_JSON_CACHE, ALL_TRIGGER_DESCRIPTIONS_JSON_CACHE, ) @@ -710,6 +711,91 @@ async def test_get_services( assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_conditions", return_value=True) +async def test_subscribe_conditions( + mock_has_conditions: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, +) -> None: + """Test condition_platforms/subscribe command.""" + sun_condition_descriptions = """ + sun: {} + """ + device_automation_condition_descriptions = """ + device: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("device_automation/conditions.yaml"): + condition_descriptions = device_automation_condition_descriptions + elif fname.endswith("sun/conditions.yaml"): + condition_descriptions = sun_condition_descriptions + else: + raise FileNotFoundError + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + assert await async_setup_component(hass, "sun", {}) + assert await async_setup_component(hass, "system_health", {}) + await hass.async_block_till_done() + + assert ALL_CONDITION_DESCRIPTIONS_JSON_CACHE not in hass.data + + await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"}) + + # Test start subscription with initial event + msg = await websocket_client.receive_json() + assert msg == {"id": 1, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == {"event": {"sun": {"fields": {}}}, "id": 1, "type": "event"} + + old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] + + # Test we receive an event when a new platform is loaded, if it has descriptions + assert await async_setup_component(hass, "calendar", {}) + assert await async_setup_component(hass, "device_automation", {}) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg == { + "event": {"device": {"fields": {}}}, + "id": 1, + "type": "event", + } + + # Initiate a second subscription to check the cache is updated because of the new + # condition + await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 2, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"device": {"fields": {}}, "sun": {"fields": {}}}, + "id": 2, + "type": "event", + } + + assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is not old_cache + + # Initiate a third subscription to check the cache is not updated because no new + # condition was added + old_cache = hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] + await websocket_client.send_json_auto_id({"type": "condition_platforms/subscribe"}) + msg = await websocket_client.receive_json() + assert msg == {"id": 3, "result": None, "success": True, "type": "result"} + msg = await websocket_client.receive_json() + assert msg == { + "event": {"device": {"fields": {}}, "sun": {"fields": {}}}, + "id": 3, + "type": "event", + } + + assert hass.data[ALL_CONDITION_DESCRIPTIONS_JSON_CACHE] is old_cache + + @patch("annotatedyaml.loader.load_yaml") @patch.object(Integration, "has_triggers", return_value=True) async def test_subscribe_triggers( diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 246afcb3022..1c10048fee9 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1,14 +1,21 @@ """Test the condition helper.""" from datetime import timedelta +import io from typing import Any from unittest.mock import AsyncMock, Mock, patch from freezegun import freeze_time import pytest +from pytest_unordered import unordered import voluptuous as vol +from homeassistant.components.device_automation import ( + DOMAIN as DOMAIN_DEVICE_AUTOMATION, +) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sun import DOMAIN as DOMAIN_SUN +from homeassistant.components.system_health import DOMAIN as DOMAIN_SYSTEM_HEALTH from homeassistant.const import ( ATTR_DEVICE_CLASS, CONF_CONDITION, @@ -27,10 +34,12 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import Integration, async_get_integration from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.yaml.loader import parse_yaml -from tests.common import MockModule, mock_integration, mock_platform +from tests.common import MockModule, MockPlatform, mock_integration, mock_platform def assert_element(trace_element, expected_element, path): @@ -2517,3 +2526,280 @@ async def test_or_condition_with_disabled_condition(hass: HomeAssistant) -> None "conditions/1/entity_id/0": [{"result": {"result": True, "state": 100.0}}], } ) + + +@pytest.mark.parametrize( + "sun_condition_descriptions", + [ + """ + sun: + fields: + after: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + after_offset: + selector: + time: null + before: + example: sunrise + selector: + select: + options: + - sunrise + - sunset + before_offset: + selector: + time: null + """, + """ + .sunrise_sunset_selector: &sunrise_sunset_selector + example: sunrise + selector: + select: + options: + - sunrise + - sunset + .offset_selector: &offset_selector + selector: + time: null + sun: + fields: + after: *sunrise_sunset_selector + after_offset: *offset_selector + before: *sunrise_sunset_selector + before_offset: *offset_selector + """, + ], +) +async def test_async_get_all_descriptions( + hass: HomeAssistant, sun_condition_descriptions: str +) -> None: + """Test async_get_all_descriptions.""" + device_automation_condition_descriptions = """ + device: {} + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + assert await async_setup_component(hass, DOMAIN_SYSTEM_HEALTH, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + if fname.endswith("device_automation/conditions.yaml"): + condition_descriptions = device_automation_condition_descriptions + elif fname.endswith("sun/conditions.yaml"): + condition_descriptions = sun_condition_descriptions + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "homeassistant.helpers.condition._load_conditions_files", + side_effect=condition._load_conditions_files, + ) as proxy_load_conditions_files, + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + descriptions = await condition.async_get_all_descriptions(hass) + + # Test we only load conditions.yaml for integrations with conditions, + # system_health has no conditions + assert proxy_load_conditions_files.mock_calls[0][1][1] == unordered( + [ + await async_get_integration(hass, DOMAIN_SUN), + ] + ) + + # system_health does not have conditions and should not be in descriptions + assert descriptions == { + DOMAIN_SUN: { + "fields": { + "after": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "after_offset": {"selector": {"time": None}}, + "before": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "before_offset": {"selector": {"time": None}}, + } + } + } + + # Verify the cache returns the same object + assert await condition.async_get_all_descriptions(hass) is descriptions + + # Load the device_automation integration and check a new cache object is created + assert await async_setup_component(hass, DOMAIN_DEVICE_AUTOMATION, {}) + await hass.async_block_till_done() + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + new_descriptions = await condition.async_get_all_descriptions(hass) + assert new_descriptions is not descriptions + assert new_descriptions == { + "device": { + "fields": {}, + }, + DOMAIN_SUN: { + "fields": { + "after": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "after_offset": {"selector": {"time": None}}, + "before": { + "example": "sunrise", + "selector": {"select": {"options": ["sunrise", "sunset"]}}, + }, + "before_offset": {"selector": {"time": None}}, + } + }, + } + + # Verify the cache returns the same object + assert await condition.async_get_all_descriptions(hass) is new_descriptions + + +@pytest.mark.parametrize( + ("yaml_error", "expected_message"), + [ + ( + FileNotFoundError("Blah"), + "Unable to find conditions.yaml for the sun integration", + ), + ( + HomeAssistantError("Test error"), + "Unable to parse conditions.yaml for the sun integration: Test error", + ), + ], +) +async def test_async_get_all_descriptions_with_yaml_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + yaml_error: Exception, + expected_message: str, +) -> None: + """Test async_get_all_descriptions.""" + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml_dict(fname, secrets=None): + raise yaml_error + + with ( + patch( + "homeassistant.helpers.condition.load_yaml_dict", + side_effect=_load_yaml_dict, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + descriptions = await condition.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert expected_message in caplog.text + + +async def test_async_get_all_descriptions_with_bad_description( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test async_get_all_descriptions.""" + sun_service_descriptions = """ + sun: + fields: not_a_dict + """ + + assert await async_setup_component(hass, DOMAIN_SUN, {}) + await hass.async_block_till_done() + + def _load_yaml(fname, secrets=None): + with io.StringIO(sun_service_descriptions) as file: + return parse_yaml(file) + + with ( + patch( + "annotatedyaml.loader.load_yaml", + side_effect=_load_yaml, + ), + patch.object(Integration, "has_conditions", return_value=True), + ): + descriptions = await condition.async_get_all_descriptions(hass) + + assert descriptions == {DOMAIN_SUN: None} + + assert ( + "Unable to parse conditions.yaml for the sun integration: " + "expected a dictionary for dictionary value @ data['sun']['fields']" + ) in caplog.text + + +async def test_invalid_condition_platform( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test invalid condition platform.""" + mock_integration(hass, MockModule("test", async_setup=AsyncMock(return_value=True))) + mock_platform(hass, "test.condition", MockPlatform()) + + await async_setup_component(hass, "test", {}) + + assert ( + "Integration test does not provide condition support, skipping" in caplog.text + ) + + +@patch("annotatedyaml.loader.load_yaml") +@patch.object(Integration, "has_conditions", return_value=True) +async def test_subscribe_conditions( + mock_has_conditions: Mock, + mock_load_yaml: Mock, + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test condition.async_subscribe_platform_events.""" + sun_condition_descriptions = """ + sun: {} + """ + + def _load_yaml(fname, secrets=None): + if fname.endswith("sun/conditions.yaml"): + condition_descriptions = sun_condition_descriptions + else: + raise FileNotFoundError + with io.StringIO(condition_descriptions) as file: + return parse_yaml(file) + + mock_load_yaml.side_effect = _load_yaml + + async def broken_subscriber(_): + """Simulate a broken subscriber.""" + raise Exception("Boom!") # noqa: TRY002 + + condition_events = [] + + async def good_subscriber(new_conditions: set[str]): + """Simulate a working subscriber.""" + condition_events.append(new_conditions) + + condition.async_subscribe_platform_events(hass, broken_subscriber) + condition.async_subscribe_platform_events(hass, good_subscriber) + + assert await async_setup_component(hass, "sun", {}) + + assert condition_events == [{"sun"}] + assert "Error while notifying condition platform listener" in caplog.text From 40fcc3b75bc74875d5e4d303ead44800ccee3f62 Mon Sep 17 00:00:00 2001 From: Harry Heymann Date: Fri, 4 Jul 2025 10:13:40 -0400 Subject: [PATCH 1098/1664] Rename Matter device conversion methods (#148090) --- .../components/matter/binary_sensor.py | 40 ++++++++-------- homeassistant/components/matter/entity.py | 4 +- homeassistant/components/matter/number.py | 42 ++++++++--------- homeassistant/components/matter/select.py | 34 +++++++------- homeassistant/components/matter/sensor.py | 46 +++++++++---------- homeassistant/components/matter/switch.py | 12 ++--- 6 files changed, 89 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 95efe46309c..09321bd33b2 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -54,7 +54,7 @@ class MatterBinarySensor(MatterEntity, BinarySensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self.entity_description.measurement_to_ha: + elif value_convert := self.entity_description.device_to_ha: value = value_convert(value) if TYPE_CHECKING: value = cast(bool | None, value) @@ -70,7 +70,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="HueMotionSensor", device_class=BinarySensorDeviceClass.MOTION, - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, + device_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), @@ -83,7 +83,7 @@ DISCOVERY_SCHEMAS = [ key="OccupancySensor", device_class=BinarySensorDeviceClass.OCCUPANCY, # The first bit = if occupied - measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None, + device_to_ha=lambda x: (x & 1 == 1) if x is not None else None, ), entity_class=MatterBinarySensor, required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), @@ -94,7 +94,7 @@ DISCOVERY_SCHEMAS = [ key="BatteryChargeLevel", device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: x + device_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevelEnum.kOk, ), entity_class=MatterBinarySensor, @@ -109,7 +109,7 @@ DISCOVERY_SCHEMAS = [ key="ContactSensor", device_class=BinarySensorDeviceClass.DOOR, # value is inverted on matter to what we expect - measurement_to_ha=lambda x: not x, + device_to_ha=lambda x: not x, ), entity_class=MatterBinarySensor, required_attributes=(clusters.BooleanState.Attributes.StateValue,), @@ -153,7 +153,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="LockDoorStateSensor", device_class=BinarySensorDeviceClass.DOOR, - measurement_to_ha={ + device_to_ha={ clusters.DoorLock.Enums.DoorStateEnum.kDoorOpen: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorJammed: True, clusters.DoorLock.Enums.DoorStateEnum.kDoorForcedOpen: True, @@ -168,7 +168,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmDeviceMutedSensor", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.SmokeCoAlarm.Enums.MuteStateEnum.kMuted ), translation_key="muted", @@ -181,7 +181,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmEndfOfServiceSensor", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.SmokeCoAlarm.Enums.EndOfServiceEnum.kExpired ), translation_key="end_of_service", @@ -195,7 +195,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmBatteryAlertSensor", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), translation_key="battery_alert", @@ -232,7 +232,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmSmokeStateSensor", device_class=BinarySensorDeviceClass.SMOKE, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), ), @@ -244,7 +244,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmInterconnectSmokeAlarmSensor", device_class=BinarySensorDeviceClass.SMOKE, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), translation_key="interconnected_smoke_alarm", @@ -257,7 +257,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="SmokeCoAlarmInterconnectCOAlarmSensor", device_class=BinarySensorDeviceClass.CO, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x != clusters.SmokeCoAlarm.Enums.AlarmStateEnum.kNormal ), translation_key="interconnected_co_alarm", @@ -271,7 +271,7 @@ DISCOVERY_SCHEMAS = [ key="EnergyEvseChargingStatusSensor", translation_key="evse_charging_status", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - measurement_to_ha={ + device_to_ha={ clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False, clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False, @@ -291,7 +291,7 @@ DISCOVERY_SCHEMAS = [ key="EnergyEvsePlugStateSensor", translation_key="evse_plug_state", device_class=BinarySensorDeviceClass.PLUG, - measurement_to_ha={ + device_to_ha={ clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False, clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True, clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True, @@ -311,7 +311,7 @@ DISCOVERY_SCHEMAS = [ key="EnergyEvseSupplyStateSensor", translation_key="evse_supply_charging_state", device_class=BinarySensorDeviceClass.RUNNING, - measurement_to_ha={ + device_to_ha={ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True, clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False, @@ -327,7 +327,7 @@ DISCOVERY_SCHEMAS = [ entity_description=MatterBinarySensorEntityDescription( key="WaterHeaterManagementBoostStateSensor", translation_key="boost_state", - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.WaterHeaterManagement.Enums.BoostStateEnum.kActive ), ), @@ -342,7 +342,7 @@ DISCOVERY_SCHEMAS = [ device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, # DeviceFault or SupplyFault bit enabled - measurement_to_ha={ + device_to_ha={ clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True, clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True, clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False, @@ -366,7 +366,7 @@ DISCOVERY_SCHEMAS = [ key="PumpStatusRunning", translation_key="pump_running", device_class=BinarySensorDeviceClass.RUNNING, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning ), @@ -384,7 +384,7 @@ DISCOVERY_SCHEMAS = [ translation_key="dishwasher_alarm_inflow", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError ), ), @@ -399,7 +399,7 @@ DISCOVERY_SCHEMAS = [ translation_key="dishwasher_alarm_door", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - measurement_to_ha=lambda x: ( + device_to_ha=lambda x: ( x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError ), ), diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index fded57d34f5..028feab9c88 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -59,8 +59,8 @@ class MatterEntityDescription(EntityDescription): """Describe the Matter entity.""" # convert the value from the primary attribute to the value used by HA - measurement_to_ha: Callable[[Any], Any] | None = None - ha_to_native_value: Callable[[Any], Any] | None = None + device_to_ha: Callable[[Any], Any] | None = None + ha_to_device: Callable[[Any], Any] | None = None command_timeout: int | None = None diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index 7d138ba5018..c948f39834a 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -55,7 +55,7 @@ class MatterRangeNumberEntityDescription( ): """Describe Matter Number Input entities with min and max values.""" - ha_to_native_value: Callable[[Any], Any] + ha_to_device: Callable[[Any], Any] # attribute descriptors to get the min and max value min_attribute: type[ClusterAttributeDescriptor] @@ -74,7 +74,7 @@ class MatterNumber(MatterEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" sendvalue = int(value) - if value_convert := self.entity_description.ha_to_native_value: + if value_convert := self.entity_description.ha_to_device: sendvalue = value_convert(value) await self.write_attribute( value=sendvalue, @@ -84,7 +84,7 @@ class MatterNumber(MatterEntity, NumberEntity): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value @@ -96,7 +96,7 @@ class MatterRangeNumber(MatterEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - send_value = self.entity_description.ha_to_native_value(value) + send_value = self.entity_description.ha_to_device(value) # custom command defined to set the new value await self.send_device_command( self.entity_description.command(send_value), @@ -106,7 +106,7 @@ class MatterRangeNumber(MatterEntity, NumberEntity): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value self._attr_native_min_value = ( @@ -133,7 +133,7 @@ class MatterLevelControlNumber(MatterEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set level value.""" send_value = int(value) - if value_convert := self.entity_description.ha_to_native_value: + if value_convert := self.entity_description.ha_to_device: send_value = value_convert(value) await self.send_device_command( clusters.LevelControl.Commands.MoveToLevel( @@ -145,7 +145,7 @@ class MatterLevelControlNumber(MatterEntity, NumberEntity): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value @@ -162,8 +162,8 @@ DISCOVERY_SCHEMAS = [ native_min_value=0, mode=NumberMode.BOX, # use 255 to indicate that the value should revert to the default - measurement_to_ha=lambda x: 255 if x is None else x, - ha_to_native_value=lambda x: None if x == 255 else int(x), + device_to_ha=lambda x: 255 if x is None else x, + ha_to_device=lambda x: None if x == 255 else int(x), native_step=1, native_unit_of_measurement=None, ), @@ -180,8 +180,8 @@ DISCOVERY_SCHEMAS = [ translation_key="on_transition_time", native_max_value=65534, native_min_value=0, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), native_step=0.1, native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -199,8 +199,8 @@ DISCOVERY_SCHEMAS = [ translation_key="off_transition_time", native_max_value=65534, native_min_value=0, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), native_step=0.1, native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -218,8 +218,8 @@ DISCOVERY_SCHEMAS = [ translation_key="on_off_transition_time", native_max_value=65534, native_min_value=0, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), native_step=0.1, native_unit_of_measurement=UnitOfTime.SECONDS, mode=NumberMode.BOX, @@ -256,8 +256,8 @@ DISCOVERY_SCHEMAS = [ native_min_value=-50, native_step=0.5, native_unit_of_measurement=UnitOfTemperature.CELSIUS, - measurement_to_ha=lambda x: None if x is None else x / 10, - ha_to_native_value=lambda x: round(x * 10), + device_to_ha=lambda x: None if x is None else x / 10, + ha_to_device=lambda x: round(x * 10), mode=NumberMode.BOX, ), entity_class=MatterNumber, @@ -275,10 +275,10 @@ DISCOVERY_SCHEMAS = [ native_max_value=100, native_min_value=0.5, native_step=0.5, - measurement_to_ha=( + device_to_ha=( lambda x: None if x is None else x / 2 # Matter range (1-200) ), - ha_to_native_value=lambda x: round(x * 2), # HA range 0.5–100.0% + ha_to_device=lambda x: round(x * 2), # HA range 0.5–100.0% mode=NumberMode.SLIDER, ), entity_class=MatterLevelControlNumber, @@ -326,8 +326,8 @@ DISCOVERY_SCHEMAS = [ targetTemperature=value ), native_unit_of_measurement=UnitOfTemperature.CELSIUS, - measurement_to_ha=lambda x: None if x is None else x / 100, - ha_to_native_value=lambda x: round(x * 100), + device_to_ha=lambda x: None if x is None else x / 100, + ha_to_device=lambda x: round(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 ac1bc2d1f8f..d700b39258c 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -71,8 +71,8 @@ class MatterSelectEntityDescription(SelectEntityDescription, MatterEntityDescrip class MatterMapSelectEntityDescription(MatterSelectEntityDescription): """Describe Matter select entities for MatterMapSelectEntityDescription.""" - measurement_to_ha: Callable[[int], str | None] - ha_to_native_value: Callable[[str], int | None] + device_to_ha: Callable[[int], str | None] + ha_to_device: Callable[[str], int | None] # list attribute: the attribute descriptor to get the list of values (= list of integers) list_attribute: type[ClusterAttributeDescriptor] @@ -97,7 +97,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected mode.""" - value_convert = self.entity_description.ha_to_native_value + value_convert = self.entity_description.ha_to_device if TYPE_CHECKING: assert value_convert is not None await self.write_attribute( @@ -109,7 +109,7 @@ class MatterAttributeSelectEntity(MatterEntity, SelectEntity): """Update from device.""" value: Nullable | int | None value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - value_convert = self.entity_description.measurement_to_ha + value_convert = self.entity_description.device_to_ha if TYPE_CHECKING: assert value_convert is not None self._attr_current_option = value_convert(value) @@ -132,7 +132,7 @@ class MatterMapSelectEntity(MatterAttributeSelectEntity): self._attr_options = [ mapped_value for value in available_values - if (mapped_value := self.entity_description.measurement_to_ha(value)) + if (mapped_value := self.entity_description.device_to_ha(value)) ] # use base implementation from MatterAttributeSelectEntity to set the current option super()._update_from_device() @@ -333,13 +333,13 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="startup_on_off", options=["on", "off", "toggle", "previous"], - measurement_to_ha={ + device_to_ha={ 0: "off", 1: "on", 2: "toggle", None: "previous", }.get, - ha_to_native_value={ + ha_to_device={ "off": 0, "on": 1, "toggle": 2, @@ -358,12 +358,12 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="sensitivity_level", options=["high", "standard", "low"], - measurement_to_ha={ + device_to_ha={ 0: "high", 1: "standard", 2: "low", }.get, - ha_to_native_value={ + ha_to_device={ "high": 0, "standard": 1, "low": 2, @@ -379,11 +379,11 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="temperature_display_mode", options=["Celsius", "Fahrenheit"], - measurement_to_ha={ + device_to_ha={ 0: "Celsius", 1: "Fahrenheit", }.get, - ha_to_native_value={ + ha_to_device={ "Celsius": 0, "Fahrenheit": 1, }.get, @@ -432,8 +432,8 @@ DISCOVERY_SCHEMAS = [ key="MatterLaundryWasherNumberOfRinses", translation_key="laundry_washer_number_of_rinses", list_attribute=clusters.LaundryWasherControls.Attributes.SupportedRinses, - measurement_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, - ha_to_native_value=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, + device_to_ha=NUMBER_OF_RINSES_STATE_MAP.get, + ha_to_device=NUMBER_OF_RINSES_STATE_MAP_REVERSE.get, ), entity_class=MatterMapSelectEntity, required_attributes=( @@ -450,13 +450,13 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.CONFIG, translation_key="door_lock_sound_volume", options=["silent", "low", "medium", "high"], - measurement_to_ha={ + device_to_ha={ 0: "silent", 1: "low", 3: "medium", 2: "high", }.get, - ha_to_native_value={ + ha_to_device={ "silent": 0, "low": 1, "medium": 3, @@ -472,8 +472,8 @@ DISCOVERY_SCHEMAS = [ key="PumpConfigurationAndControlOperationMode", translation_key="pump_operation_mode", options=list(PUMP_OPERATION_MODE_MAP.values()), - measurement_to_ha=PUMP_OPERATION_MODE_MAP.get, - ha_to_native_value=PUMP_OPERATION_MODE_MAP_REVERSE.get, + device_to_ha=PUMP_OPERATION_MODE_MAP.get, + ha_to_device=PUMP_OPERATION_MODE_MAP_REVERSE.get, ), entity_class=MatterAttributeSelectEntity, required_attributes=( diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index f744ec8885a..62c70f777e7 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -194,7 +194,7 @@ class MatterSensor(MatterEntity, SensorEntity): value = self.get_matter_attribute_value(self._entity_info.primary_attribute) if value in (None, NullValue): value = None - elif value_convert := self.entity_description.measurement_to_ha: + elif value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_native_value = value @@ -296,7 +296,7 @@ DISCOVERY_SCHEMAS = [ key="TemperatureSensor", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - measurement_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / 100, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -308,7 +308,7 @@ DISCOVERY_SCHEMAS = [ key="PressureSensor", native_unit_of_measurement=UnitOfPressure.KPA, device_class=SensorDeviceClass.PRESSURE, - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -320,7 +320,7 @@ DISCOVERY_SCHEMAS = [ key="FlowSensor", native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, translation_key="flow", - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -332,7 +332,7 @@ DISCOVERY_SCHEMAS = [ key="HumiditySensor", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, - measurement_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / 100, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -346,7 +346,7 @@ DISCOVERY_SCHEMAS = [ key="LightSensor", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, - measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), + device_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -360,7 +360,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, # value has double precision - measurement_to_ha=lambda x: int(x / 2), + device_to_ha=lambda x: int(x / 2), state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -402,7 +402,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=[state for state in CHARGE_STATE_MAP.values() if state is not None], - measurement_to_ha=CHARGE_STATE_MAP.get, + device_to_ha=CHARGE_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.PowerSource.Attributes.BatChargeState,), @@ -589,7 +589,7 @@ DISCOVERY_SCHEMAS = [ state_class=None, # convert to set first to remove the duplicate unknown value options=[x for x in AIR_QUALITY_MAP.values() if x is not None], - measurement_to_ha=lambda x: AIR_QUALITY_MAP[x], + device_to_ha=lambda x: AIR_QUALITY_MAP[x], ), entity_class=MatterSensor, required_attributes=(clusters.AirQuality.Attributes.AirQuality,), @@ -668,7 +668,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 1000, + device_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=( @@ -685,7 +685,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, - measurement_to_ha=lambda x: x / 1000, + device_to_ha=lambda x: x / 1000, ), entity_class=MatterSensor, required_attributes=( @@ -702,7 +702,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfPower.WATT, suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Watt,), @@ -731,7 +731,7 @@ DISCOVERY_SCHEMAS = [ native_unit_of_measurement=UnitOfElectricPotential.VOLT, suggested_display_precision=0, state_class=SensorStateClass.MEASUREMENT, - measurement_to_ha=lambda x: x / 10, + device_to_ha=lambda x: x / 10, ), entity_class=MatterSensor, required_attributes=(NeoCluster.Attributes.Voltage,), @@ -823,7 +823,7 @@ DISCOVERY_SCHEMAS = [ suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) - measurement_to_ha=lambda x: x.energy, + device_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, required_attributes=( @@ -842,7 +842,7 @@ DISCOVERY_SCHEMAS = [ suggested_display_precision=3, state_class=SensorStateClass.TOTAL_INCREASING, # id 0 of the EnergyMeasurementStruct is the cumulative energy (in mWh) - measurement_to_ha=lambda x: x.energy, + device_to_ha=lambda x: x.energy, ), entity_class=MatterSensor, required_attributes=( @@ -910,7 +910,7 @@ DISCOVERY_SCHEMAS = [ translation_key="contamination_state", device_class=SensorDeviceClass.ENUM, options=list(CONTAMINATION_STATE_MAP.values()), - measurement_to_ha=CONTAMINATION_STATE_MAP.get, + device_to_ha=CONTAMINATION_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.ContaminationState,), @@ -922,7 +922,7 @@ DISCOVERY_SCHEMAS = [ translation_key="expiry_date", device_class=SensorDeviceClass.TIMESTAMP, # raw value is epoch seconds - measurement_to_ha=datetime.fromtimestamp, + device_to_ha=datetime.fromtimestamp, ), entity_class=MatterSensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.ExpiryDate,), @@ -993,7 +993,7 @@ DISCOVERY_SCHEMAS = [ key="ThermostatLocalTemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, - measurement_to_ha=lambda x: x / 100, + device_to_ha=lambda x: x / 100, state_class=SensorStateClass.MEASUREMENT, ), entity_class=MatterSensor, @@ -1044,7 +1044,7 @@ DISCOVERY_SCHEMAS = [ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, translation_key="window_covering_target_position", - measurement_to_ha=lambda x: round((10000 - x) / 100), + device_to_ha=lambda x: round((10000 - x) / 100), native_unit_of_measurement=PERCENTAGE, ), entity_class=MatterSensor, @@ -1060,7 +1060,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=list(EVSE_FAULT_STATE_MAP.values()), - measurement_to_ha=EVSE_FAULT_STATE_MAP.get, + device_to_ha=EVSE_FAULT_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.EnergyEvse.Attributes.FaultState,), @@ -1173,7 +1173,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=list(ESA_STATE_MAP.values()), - measurement_to_ha=ESA_STATE_MAP.get, + device_to_ha=ESA_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.DeviceEnergyManagement.Attributes.ESAState,), @@ -1186,7 +1186,7 @@ DISCOVERY_SCHEMAS = [ device_class=SensorDeviceClass.ENUM, entity_category=EntityCategory.DIAGNOSTIC, options=list(DEM_OPT_OUT_STATE_MAP.values()), - measurement_to_ha=DEM_OPT_OUT_STATE_MAP.get, + device_to_ha=DEM_OPT_OUT_STATE_MAP.get, ), entity_class=MatterSensor, required_attributes=(clusters.DeviceEnergyManagement.Attributes.OptOutState,), @@ -1200,7 +1200,7 @@ DISCOVERY_SCHEMAS = [ options=[ mode for mode in PUMP_CONTROL_MODE_MAP.values() if mode is not None ], - measurement_to_ha=PUMP_CONTROL_MODE_MAP.get, + device_to_ha=PUMP_CONTROL_MODE_MAP.get, ), entity_class=MatterSensor, required_attributes=( diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 870a9098492..df8581c5c4f 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -95,7 +95,7 @@ class MatterGenericCommandSwitch(MatterSwitch): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_is_on = value @@ -141,7 +141,7 @@ class MatterNumericSwitch(MatterSwitch): async def _async_set_native_value(self, value: bool) -> None: """Update the current value.""" - if value_convert := self.entity_description.ha_to_native_value: + if value_convert := self.entity_description.ha_to_device: send_value = value_convert(value) await self.write_attribute( value=send_value, @@ -159,7 +159,7 @@ class MatterNumericSwitch(MatterSwitch): def _update_from_device(self) -> None: """Update from device.""" value = self.get_matter_attribute_value(self._entity_info.primary_attribute) - if value_convert := self.entity_description.measurement_to_ha: + if value_convert := self.entity_description.device_to_ha: value = value_convert(value) self._attr_is_on = value @@ -248,11 +248,11 @@ DISCOVERY_SCHEMAS = [ key="EveTrvChildLock", entity_category=EntityCategory.CONFIG, translation_key="child_lock", - measurement_to_ha={ + device_to_ha={ 0: False, 1: True, }.get, - ha_to_native_value={ + ha_to_device={ False: 0, True: 1, }.get, @@ -275,7 +275,7 @@ DISCOVERY_SCHEMAS = [ ), off_command=clusters.EnergyEvse.Commands.Disable, command_timeout=3000, - measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get, + device_to_ha=EVSE_SUPPLY_STATE_MAP.get, ), entity_class=MatterGenericCommandSwitch, required_attributes=( From 40ec51c0a3fb108d6c5b5e258be2ff71a04a0cd4 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 4 Jul 2025 07:17:10 -0700 Subject: [PATCH 1099/1664] Add redirect URL in Google Assistant SDK setup (#148076) --- .../application_credentials.py | 18 ++++++---- .../google_assistant_sdk/strings.json | 2 +- .../test_application_credentials.py | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) create mode 100644 tests/components/google_assistant_sdk/test_application_credentials.py diff --git a/homeassistant/components/google_assistant_sdk/application_credentials.py b/homeassistant/components/google_assistant_sdk/application_credentials.py index 8fa99157479..8f5b00edc7c 100644 --- a/homeassistant/components/google_assistant_sdk/application_credentials.py +++ b/homeassistant/components/google_assistant_sdk/application_credentials.py @@ -2,6 +2,10 @@ from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + AUTH_CALLBACK_PATH, + MY_AUTH_CALLBACK_PATH, +) async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: @@ -14,12 +18,14 @@ async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationSe async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: """Return description placeholders for the credentials dialog.""" + if "my" in hass.config.components: + redirect_url = MY_AUTH_CALLBACK_PATH + else: + ha_host = hass.config.external_url or "https://YOUR_DOMAIN:PORT" + redirect_url = f"{ha_host}{AUTH_CALLBACK_PATH}" return { - "oauth_consent_url": ( - "https://console.cloud.google.com/apis/credentials/consent" - ), - "more_info_url": ( - "https://www.home-assistant.io/integrations/google_assistant_sdk/" - ), + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": redirect_url, } diff --git a/homeassistant/components/google_assistant_sdk/strings.json b/homeassistant/components/google_assistant_sdk/strings.json index 2622333e15f..2ebd04db4b6 100644 --- a/homeassistant/components/google_assistant_sdk/strings.json +++ b/homeassistant/components/google_assistant_sdk/strings.json @@ -46,7 +46,7 @@ } }, "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type." + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Assistant SDK. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." }, "services": { "send_text_command": { diff --git a/tests/components/google_assistant_sdk/test_application_credentials.py b/tests/components/google_assistant_sdk/test_application_credentials.py new file mode 100644 index 00000000000..e7811677c53 --- /dev/null +++ b/tests/components/google_assistant_sdk/test_application_credentials.py @@ -0,0 +1,36 @@ +"""Test the Google Assistant SDK application_credentials.""" + +import pytest + +from homeassistant import setup +from homeassistant.components.google_assistant_sdk.application_credentials import ( + async_get_description_placeholders, +) +from homeassistant.core import HomeAssistant + + +@pytest.mark.parametrize( + ("additional_components", "external_url", "expected_redirect_uri"), + [ + ([], "https://example.com", "https://example.com/auth/external/callback"), + ([], None, "https://YOUR_DOMAIN:PORT/auth/external/callback"), + (["my"], "https://example.com", "https://my.home-assistant.io/redirect/oauth"), + ], +) +async def test_description_placeholders( + hass: HomeAssistant, + additional_components: list[str], + external_url: str | None, + expected_redirect_uri: str, +) -> None: + """Test description placeholders.""" + for component in additional_components: + assert await setup.async_setup_component(hass, component, {}) + hass.config.external_url = external_url + placeholders = await async_get_description_placeholders(hass) + assert placeholders == { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_assistant_sdk/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": expected_redirect_uri, + } From e98fe7dc9c409229b9071930ce6952c27a8fa3f3 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 4 Jul 2025 07:17:41 -0700 Subject: [PATCH 1100/1664] Add data_description to Opower forms (#148099) --- homeassistant/components/opower/strings.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index cd22bd8d7a1..8d8cecff905 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -6,12 +6,24 @@ "utility": "Utility name", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "utility": "The name of your utility provider", + "username": "The username for your utility account", + "password": "The password for your utility account" } }, "mfa": { "description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", "totp_secret": "TOTP secret" + }, + "data_description": { + "username": "[%key:component::opower::config::step::user::data_description::username%]", + "password": "[%key:component::opower::config::step::user::data_description::password%]", + "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." } }, "reauth_confirm": { @@ -20,6 +32,11 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" + }, + "data_description": { + "username": "[%key:component::opower::config::step::user::data_description::username%]", + "password": "[%key:component::opower::config::step::user::data_description::password%]", + "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." } } }, From 1cb9767bb8bde5ced38bfec4875af252f40f397b Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 4 Jul 2025 07:19:04 -0700 Subject: [PATCH 1101/1664] Enable strict typing for Opower (#148096) --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index a76ba3885bc..77e853262a1 100644 --- a/.strict-typing +++ b/.strict-typing @@ -381,6 +381,7 @@ homeassistant.components.openai_conversation.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* homeassistant.components.openuv.* +homeassistant.components.opower.* homeassistant.components.oralb.* homeassistant.components.otbr.* homeassistant.components.overkiz.* diff --git a/mypy.ini b/mypy.ini index a6b673be03b..48432118fa8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3566,6 +3566,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.opower.*] +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.oralb.*] check_untyped_defs = true disallow_incomplete_defs = true From 83ae5f52da9f5e6be33000072e259d76fba655a7 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Fri, 4 Jul 2025 10:20:24 -0400 Subject: [PATCH 1102/1664] Bump pydrawise to 2025.7.0 (#148088) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 03b9dc68a79..a599ffa888e 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2025.6.0"] + "requirements": ["pydrawise==2025.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4b622fe9c35..491ded4102e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1929,7 +1929,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2025.6.0 +pydrawise==2025.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4fbebe1bf9e..19f2df682a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1610,7 +1610,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.2 # homeassistant.components.hydrawise -pydrawise==2025.6.0 +pydrawise==2025.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 From cde17fc0ca3d95a81f7c9675d98eda7aba565e66 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 4 Jul 2025 16:21:11 +0200 Subject: [PATCH 1103/1664] add extra tests for media source URI parsing (#148114) --- .../components/media_source/models.py | 10 +++ tests/components/media_source/test_const.py | 80 +++++++++++++++++++ tests/components/media_source/test_models.py | 16 ++++ 3 files changed, 106 insertions(+) create mode 100644 tests/components/media_source/test_const.py diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 5e64dc867f2..8588c5bcacc 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -45,6 +45,16 @@ class MediaSourceItem: identifier: str target_media_player: str | None + @property + def media_source_id(self) -> str: + """Return the media source ID.""" + uri = URI_SCHEME + if self.domain: + uri += self.domain + if self.identifier: + uri += f"/{self.identifier}" + return uri + async def async_browse(self) -> BrowseMediaSource: """Browse this item.""" if self.domain is None: diff --git a/tests/components/media_source/test_const.py b/tests/components/media_source/test_const.py new file mode 100644 index 00000000000..115c98a2c09 --- /dev/null +++ b/tests/components/media_source/test_const.py @@ -0,0 +1,80 @@ +"""Test constants for the media source component.""" + +import pytest + +from homeassistant.components.media_source.const import URI_SCHEME_REGEX + + +@pytest.mark.parametrize( + ("uri", "expected_domain", "expected_identifier"), + [ + ("media-source://", None, None), + ("media-source://local_media", "local_media", None), + ( + "media-source://local_media/some/path/file.mp3", + "local_media", + "some/path/file.mp3", + ), + ("media-source://a/b", "a", "b"), + ( + "media-source://domain/file with spaces.mp4", + "domain", + "file with spaces.mp4", + ), + ( + "media-source://domain/file-with-dashes.mp3", + "domain", + "file-with-dashes.mp3", + ), + ("media-source://domain/file.with.dots.mp3", "domain", "file.with.dots.mp3"), + ( + "media-source://domain/special!@#$%^&*()chars", + "domain", + "special!@#$%^&*()chars", + ), + ], +) +def test_valid_uri_patterns( + uri: str, expected_domain: str | None, expected_identifier: str | None +) -> None: + """Test various valid URI patterns.""" + match = URI_SCHEME_REGEX.match(uri) + assert match is not None + assert match.group("domain") == expected_domain + assert match.group("identifier") == expected_identifier + + +@pytest.mark.parametrize( + "uri", + [ + "media-source:", # missing // + "media-source:/", # missing second / + "media-source:///", # extra / + "media-source://domain/", # trailing slash after domain + "invalid-scheme://domain", # wrong scheme + "media-source//domain", # missing : + "MEDIA-SOURCE://domain", # uppercase scheme + "media_source://domain", # underscore in scheme + "", # empty string + "media-source", # scheme only + "media-source://domain extra", # extra content + "prefix media-source://domain", # prefix content + "media-source://domain suffix", # suffix content + # Invalid domain names + "media-source://_test", # starts with underscore + "media-source://test_", # ends with underscore + "media-source://_test_", # starts and ends with underscore + "media-source://_", # single underscore + "media-source://test-123", # contains hyphen + "media-source://test.123", # contains dot + "media-source://test 123", # contains space + "media-source://TEST", # uppercase letters + "media-source://Test", # mixed case + # Identifier cannot start with slash + "media-source://domain//invalid", # identifier starts with slash + ], +) +def test_invalid_uris(uri: str) -> None: + """Test invalid URI formats.""" + match = URI_SCHEME_REGEX.match(uri) + assert match is None, f"URI '{uri}' should be invalid" diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py index 12685e28d69..1ed03a83961 100644 --- a/tests/components/media_source/test_models.py +++ b/tests/components/media_source/test_models.py @@ -2,6 +2,7 @@ from homeassistant.components.media_player import MediaClass, MediaType from homeassistant.components.media_source import const, models +from homeassistant.core import HomeAssistant async def test_browse_media_as_dict() -> None: @@ -68,3 +69,18 @@ async def test_media_source_default_name() -> None: """Test MediaSource uses domain as default name.""" source = models.MediaSource(const.DOMAIN) assert source.name == const.DOMAIN + + +async def test_media_source_item_media_source_id(hass: HomeAssistant) -> None: + """Test MediaSourceItem media_source_id property.""" + # Test with domain and identifier + item = models.MediaSourceItem(hass, "test_domain", "test/identifier", None) + assert item.media_source_id == "media-source://test_domain/test/identifier" + + # Test with domain only + item = models.MediaSourceItem(hass, "test_domain", "", None) + assert item.media_source_id == "media-source://test_domain" + + # Test with no domain (root) + item = models.MediaSourceItem(hass, None, "", None) + assert item.media_source_id == "media-source://" From 8ce30d9559ee3042acac2d23739e5d34dc74ce84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:21:48 +0200 Subject: [PATCH 1104/1664] Add tests of legacy entity without platform writing state (#148109) --- tests/helpers/test_entity.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 24205870779..30b25e9725d 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -2713,6 +2713,41 @@ async def test_platform_state( assert hass.states.get("test.test") is None +async def test_platform_state_no_platform(hass: HomeAssistant) -> None: + """Test platform state for entities which are not added by an entity platform.""" + + class MockEntity(entity.Entity): + entity_id = "test.test" + + def async_set_state(self, state: str) -> None: + self._attr_state = state + self.async_write_ha_state() + + ent = MockEntity() + ent.hass = hass + assert hass.states.get("test.test") is None + + # The attempt to write when in state NOT_ADDED should be allowed + assert ent._platform_state == entity.EntityPlatformState.NOT_ADDED + ent.async_set_state("not_added") + assert hass.states.get("test.test").state == "not_added" + + # The attempt to write when in state ADDING should be allowed + ent._platform_state = entity.EntityPlatformState.ADDING + ent.async_set_state("adding") + assert hass.states.get("test.test").state == "adding" + + # The attempt to write when in state ADDED should be allowed + ent._platform_state = entity.EntityPlatformState.ADDED + ent.async_set_state("added") + assert hass.states.get("test.test").state == "added" + + # The attempt to write when in state REMOVED should be ignored + ent._platform_state = entity.EntityPlatformState.REMOVED + ent.async_set_state("removed") + assert hass.states.get("test.test").state == "added" + + async def test_platform_state_fail_to_add( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: From 783102f2f6bd1c54c17bab28beda8650a8ceaa1a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:22:38 +0200 Subject: [PATCH 1105/1664] [ci] Fix typing issue with aiohttp and aiosignal (#148141) --- .github/workflows/ci.yaml | 2 +- homeassistant/components/http/ban.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f727d258d1e..ce7cf1ac124 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ on: type: boolean env: - CACHE_VERSION: 3 + CACHE_VERSION: 4 UV_CACHE_VERSION: 1 MYPY_CACHE_VERSION: 1 HA_SHORT_VERSION: "2025.8" diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 71f3d54bef6..7e55191639b 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N """Initialize bans when app starts up.""" await app[KEY_BAN_MANAGER].async_load() - app.on_startup.append(ban_startup) + app.on_startup.append(ban_startup) # type: ignore[arg-type] @middleware From 3f752e13ff8aad45b0af0845a5080fb5f95ccf07 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:23:18 +0200 Subject: [PATCH 1106/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.OFF in roku (#148137) --- homeassistant/components/roku/media_player.py | 8 ++++---- tests/components/roku/test_media_player.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index d0e1e3a53c0..7f815c4e458 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -142,7 +142,7 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): def state(self) -> MediaPlayerState | None: """Return the state of the device.""" if self.coordinator.data.state.standby: - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF if self.coordinator.data.app is None: return None @@ -308,21 +308,21 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): @roku_exception_handler() async def async_media_pause(self) -> None: """Send pause command.""" - if self.state not in {MediaPlayerState.STANDBY, MediaPlayerState.PAUSED}: + if self.state not in {MediaPlayerState.OFF, MediaPlayerState.PAUSED}: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() @roku_exception_handler() async def async_media_play(self) -> None: """Send play command.""" - if self.state not in {MediaPlayerState.STANDBY, MediaPlayerState.PLAYING}: + if self.state not in {MediaPlayerState.OFF, MediaPlayerState.PLAYING}: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() @roku_exception_handler() async def async_media_play_pause(self) -> None: """Send play/pause command.""" - if self.state != MediaPlayerState.STANDBY: + if self.state != MediaPlayerState.OFF: await self.coordinator.roku.remote("play") await self.coordinator.async_request_refresh() diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py index 5f8a41d16ac..7586e85b715 100644 --- a/tests/components/roku/test_media_player.py +++ b/tests/components/roku/test_media_player.py @@ -52,10 +52,10 @@ from homeassistant.const import ( SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, STATE_IDLE, + STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, - STATE_STANDBY, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -112,7 +112,7 @@ async def test_idle_setup( """Test setup with idle device.""" state = hass.states.get(MAIN_ENTITY_ID) assert state - assert state.state == STATE_STANDBY + assert state.state == STATE_OFF @pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True) From b7f830523e6b64cbefe51129a69ba18e42251d0c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 4 Jul 2025 16:25:28 +0200 Subject: [PATCH 1107/1664] Update frontend to 20250702.1 (#148131) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index bfd868a5334..748d8f0c6f0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250702.0"] + "requirements": ["home-assistant-frontend==20250702.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2b891e1678d..9d985fae6c5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.105.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.0 +home-assistant-frontend==20250702.1 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 491ded4102e..205d5f36ace 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.0 +home-assistant-frontend==20250702.1 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19f2df682a6..bc1bc2ee792 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.8.0 holidays==0.75 # homeassistant.components.frontend -home-assistant-frontend==20250702.0 +home-assistant-frontend==20250702.1 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From fd86a43b287fb252ac269e4a0618490a9e8a4989 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:25:59 +0200 Subject: [PATCH 1108/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.OFF in ps4 (#148136) --- homeassistant/components/ps4/media_player.py | 4 ++-- tests/components/ps4/test_media_player.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index aaec7cdf105..ea866aa3942 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -191,7 +191,7 @@ class PS4Device(MediaPlayerEntity): ) elif self.state != MediaPlayerState.IDLE: self.idle() - elif self.state != MediaPlayerState.STANDBY: + elif self.state != MediaPlayerState.OFF: self.state_standby() elif self._retry > DEFAULT_RETRIES: @@ -223,7 +223,7 @@ class PS4Device(MediaPlayerEntity): def state_standby(self) -> None: """Set states for state standby.""" self.reset_title() - self._attr_state = MediaPlayerState.STANDBY + self._attr_state = MediaPlayerState.OFF def state_unknown(self) -> None: """Set states for state unknown.""" diff --git a/tests/components/ps4/test_media_player.py b/tests/components/ps4/test_media_player.py index 737cc3c9f1b..af1f09d7d73 100644 --- a/tests/components/ps4/test_media_player.py +++ b/tests/components/ps4/test_media_player.py @@ -33,8 +33,8 @@ from homeassistant.const import ( CONF_REGION, CONF_TOKEN, STATE_IDLE, + STATE_OFF, STATE_PLAYING, - STATE_STANDBY, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -188,7 +188,7 @@ async def test_state_standby_is_set(hass: HomeAssistant) -> None: await mock_ddp_response(hass, MOCK_STATUS_STANDBY) - assert hass.states.get(mock_entity_id).state == STATE_STANDBY + assert hass.states.get(mock_entity_id).state == STATE_OFF async def test_state_playing_is_set(hass: HomeAssistant) -> None: @@ -308,7 +308,7 @@ async def test_device_info_is_set_from_status_correctly( mock_d_entries = device_registry.devices mock_entry = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_HOST_ID)}) - assert mock_state == STATE_STANDBY + assert mock_state == STATE_OFF assert len(mock_d_entries) == 1 assert mock_entry.name == MOCK_HOST_NAME From 811f085556376be42cc571dea5278278a506014b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:27:01 +0200 Subject: [PATCH 1109/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.IDLE in androidtv (#148130) --- homeassistant/components/androidtv/media_player.py | 2 +- tests/components/androidtv/test_media_player.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index c9e62908cac..6a60d84e39e 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -56,7 +56,7 @@ SERVICE_UPLOAD = "upload" ANDROIDTV_STATES = { "off": MediaPlayerState.OFF, "idle": MediaPlayerState.IDLE, - "standby": MediaPlayerState.STANDBY, + "standby": MediaPlayerState.IDLE, "playing": MediaPlayerState.PLAYING, "paused": MediaPlayerState.PAUSED, } diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py index 5a8d88dd9f6..efc05772a9a 100644 --- a/tests/components/androidtv/test_media_player.py +++ b/tests/components/androidtv/test_media_player.py @@ -54,9 +54,9 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_IDLE, STATE_OFF, STATE_PLAYING, - STATE_STANDBY, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant @@ -163,7 +163,7 @@ async def test_reconnect( state = hass.states.get(entity_id) assert state is not None - assert state.state == STATE_STANDBY + assert state.state == STATE_IDLE assert MSG_RECONNECT[patch_key] in caplog.record_tuples[2] @@ -672,7 +672,7 @@ async def test_update_lock_not_acquired(hass: HomeAssistant) -> None: await async_update_entity(hass, entity_id) state = hass.states.get(entity_id) assert state is not None - assert state.state == STATE_STANDBY + assert state.state == STATE_IDLE async def test_download(hass: HomeAssistant) -> None: From dc203755060ab7709ad4a78cd8c859fd419e2ea5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:27:33 +0200 Subject: [PATCH 1110/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.OFF in snapcast (#148138) --- homeassistant/components/snapcast/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 5f011ca41ee..7d9cf74b2cc 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -343,7 +343,7 @@ class SnapcastClientDevice(SnapcastBaseDevice): if self.is_volume_muted or self._current_group.muted: return MediaPlayerState.IDLE return STREAM_STATUS.get(self._current_group.stream_status) - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF @property def extra_state_attributes(self) -> Mapping[str, Any]: From 631523dfafba1d07b3e7870cdd246f3a401c2e83 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:27:54 +0200 Subject: [PATCH 1111/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.OFF in lookin (#148134) --- homeassistant/components/lookin/media_player.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py index f395c2b3885..16b69971370 100644 --- a/homeassistant/components/lookin/media_player.py +++ b/homeassistant/components/lookin/media_player.py @@ -136,7 +136,7 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity): async def async_turn_off(self) -> None: """Turn the media player off.""" await self._async_send_command(self._power_off_command) - self._attr_state = MediaPlayerState.STANDBY + self._attr_state = MediaPlayerState.OFF self.async_write_ha_state() async def async_turn_on(self) -> None: @@ -159,7 +159,5 @@ class LookinMedia(LookinPowerPushRemoteEntity, MediaPlayerEntity): state = status[0] mute = status[2] - self._attr_state = ( - MediaPlayerState.ON if state == "1" else MediaPlayerState.STANDBY - ) + self._attr_state = MediaPlayerState.ON if state == "1" else MediaPlayerState.OFF self._attr_is_volume_muted = mute == "0" From a046530eaf06480b4cd5b5400ef49f3aa80bacba Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:30:03 +0200 Subject: [PATCH 1112/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.IDLE in mediaroom (#148135) --- homeassistant/components/mediaroom/media_player.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index bccbe9f66ac..c7f7ee12ae8 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -134,7 +134,7 @@ class MediaroomDevice(MediaPlayerEntity): state_map = { State.OFF: MediaPlayerState.OFF, - State.STANDBY: MediaPlayerState.STANDBY, + State.STANDBY: MediaPlayerState.IDLE, State.PLAYING_LIVE_TV: MediaPlayerState.PLAYING, State.PLAYING_RECORDED_TV: MediaPlayerState.PLAYING, State.PLAYING_TIMESHIFT_TV: MediaPlayerState.PLAYING, @@ -155,7 +155,7 @@ class MediaroomDevice(MediaPlayerEntity): self._channel = None self._optimistic = optimistic self._attr_state = ( - MediaPlayerState.PLAYING if optimistic else MediaPlayerState.STANDBY + MediaPlayerState.PLAYING if optimistic else MediaPlayerState.IDLE ) self._name = f"Mediaroom {device_id if device_id else host}" self._available = True @@ -254,7 +254,7 @@ class MediaroomDevice(MediaPlayerEntity): try: self.set_state(await self.stb.turn_off()) if self._optimistic: - self._attr_state = MediaPlayerState.STANDBY + self._attr_state = MediaPlayerState.IDLE self._available = True except PyMediaroomError: self._available = False From 04bd1967a7166c9bafb623262ebc8b1a43544610 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 16:31:44 +0200 Subject: [PATCH 1113/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.OFF in apple_tv (#148132) --- homeassistant/components/apple_tv/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index b6d451a9ea0..12a27fb195f 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -191,7 +191,7 @@ class AppleTvMediaPlayer( self._is_feature_available(FeatureName.PowerState) and self.atv.power.power_state == PowerState.Off ): - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF if self._playing: state = self._playing.device_state if state in (DeviceState.Idle, DeviceState.Loading): @@ -200,7 +200,7 @@ class AppleTvMediaPlayer( return MediaPlayerState.PLAYING if state in (DeviceState.Paused, DeviceState.Seeking, DeviceState.Stopped): return MediaPlayerState.PAUSED - return MediaPlayerState.STANDBY # Bad or unknown state? + return MediaPlayerState.IDLE # Bad or unknown state? return None @callback From cc2aca2c2c093a135f28537dcfd853949f5ce96c Mon Sep 17 00:00:00 2001 From: hanwg Date: Fri, 4 Jul 2025 22:32:46 +0800 Subject: [PATCH 1114/1664] Fix Telegram bots using plain text parser failing to load on restart (#148050) --- homeassistant/components/telegram_bot/bot.py | 6 +++--- homeassistant/components/telegram_bot/config_flow.py | 2 -- homeassistant/components/telegram_bot/services.yaml | 5 +++++ tests/components/telegram_bot/test_config_flow.py | 2 +- tests/components/telegram_bot/test_telegram_bot.py | 5 ++++- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/bot.py b/homeassistant/components/telegram_bot/bot.py index a3feb120460..c57648c9551 100644 --- a/homeassistant/components/telegram_bot/bot.py +++ b/homeassistant/components/telegram_bot/bot.py @@ -374,9 +374,7 @@ class TelegramNotificationService: } if data is not None: if ATTR_PARSER in data: - params[ATTR_PARSER] = self._parsers.get( - data[ATTR_PARSER], self.parse_mode - ) + params[ATTR_PARSER] = data[ATTR_PARSER] if ATTR_TIMEOUT in data: params[ATTR_TIMEOUT] = data[ATTR_TIMEOUT] if ATTR_DISABLE_NOTIF in data: @@ -408,6 +406,8 @@ class TelegramNotificationService: params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup( [_make_row_inline_keyboard(row) for row in keys] ) + if params[ATTR_PARSER] == PARSER_PLAIN_TEXT: + params[ATTR_PARSER] = None return params async def _send_msg( diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 41f26ccd48d..8d3d9b0cd7b 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -159,8 +159,6 @@ class OptionsFlowHandler(OptionsFlow): """Manage the options.""" if user_input is not None: - if user_input[ATTR_PARSER] == PARSER_PLAIN_TEXT: - user_input[ATTR_PARSER] = None return self.async_create_entry(data=user_input) return self.async_show_form( diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index d5fc0e134d5..b1d94d381ac 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -109,6 +109,7 @@ send_photo: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -261,6 +262,7 @@ send_animation: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -341,6 +343,7 @@ send_video: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -493,6 +496,7 @@ send_document: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_notification: selector: boolean: @@ -670,6 +674,7 @@ edit_message: - "markdown" - "markdownv2" - "plain_text" + translation_key: "parse_mode" disable_web_page_preview: selector: boolean: diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 2586761b584..9a076016a32 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -63,7 +63,7 @@ async def test_options_flow( await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][ATTR_PARSER] is None + assert result["data"][ATTR_PARSER] == PARSER_PLAIN_TEXT async def test_reconfigure_flow_broadcast( diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index 6590bbed1cf..73dd9e27763 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -50,6 +50,7 @@ from homeassistant.components.telegram_bot.const import ( ATTR_VERIFY_SSL, CONF_CONFIG_ENTRY_ID, DOMAIN, + PARSER_PLAIN_TEXT, PLATFORM_BROADCAST, SECTION_ADVANCED_SETTINGS, SERVICE_ANSWER_CALLBACK_QUERY, @@ -183,6 +184,7 @@ async def test_send_message( ( { ATTR_MESSAGE: "test_message", + ATTR_PARSER: PARSER_PLAIN_TEXT, ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link", }, InlineKeyboardMarkup( @@ -199,6 +201,7 @@ async def test_send_message( ( { ATTR_MESSAGE: "test_message", + ATTR_PARSER: PARSER_PLAIN_TEXT, ATTR_KEYBOARD_INLINE: [ [["command1", "/cmd1"]], [["mock_link", "https://mock_link"]], @@ -250,7 +253,7 @@ async def test_send_message_with_inline_keyboard( mock_send_message.assert_called_once_with( 12345678, "test_message", - parse_mode=ParseMode.MARKDOWN, + parse_mode=None, disable_web_page_preview=None, disable_notification=False, reply_to_message_id=None, From 5d258c2f8216db0788d59ccaf74835446def1644 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 4 Jul 2025 17:33:16 +0300 Subject: [PATCH 1115/1664] Bump aioamazondevices to 3.2.3 (#148082) --- 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 7c23edd92ce..70281390436 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": "bronze", - "requirements": ["aioamazondevices==3.2.2"] + "requirements": ["aioamazondevices==3.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 205d5f36ace..63332a285a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.2 +aioamazondevices==3.2.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc1bc2ee792..30a05f2cd53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.2 +aioamazondevices==3.2.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 6235adc69a0bb9832b41a4bbf2a98c70f6071229 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Jul 2025 16:42:24 +0200 Subject: [PATCH 1116/1664] Fix flaky emulated_roku/test_binding.py::test_events_fired_properly test (#148069) --- tests/components/emulated_roku/test_binding.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py index ec3f064dfe0..a05660519c9 100644 --- a/tests/components/emulated_roku/test_binding.py +++ b/tests/components/emulated_roku/test_binding.py @@ -15,7 +15,7 @@ from homeassistant.components.emulated_roku.binding import ( ROKU_COMMAND_LAUNCH, EmulatedRoku, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback async def test_events_fired_properly(hass: HomeAssistant) -> None: @@ -43,6 +43,7 @@ async def test_events_fired_properly(hass: HomeAssistant) -> None: return Mock(start=AsyncMock(), close=AsyncMock()) + @callback def listener(event: Event) -> None: if event.data[ATTR_SOURCE_NAME] == random_name: events.append(event) From 3250a2fb46096049c7c3e62c944baa6946a07796 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 4 Jul 2025 16:43:36 +0200 Subject: [PATCH 1117/1664] Bump aioautomower to 1.2.0 (#148078) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 34ec6693865..046c20c1ddd 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==1.0.1"] + "requirements": ["aioautomower==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63332a285a1..391829a25e0 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==1.0.1 +aioautomower==1.2.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30a05f2cd53..b8da24b1752 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==1.0.1 +aioautomower==1.2.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index 2c3352ecf8e..d1e1f08f867 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -63,6 +63,7 @@ 'stay_out_zones': True, 'work_areas': True, }), + 'messages': None, 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-06-05T00:00:00+00:00', @@ -80,7 +81,7 @@ 'work_area_name': 'Front lawn', }), 'planner': dict({ - 'external_reason': 'ifttt_wildlife', + 'external_reason': 'ifttt', 'next_start_datetime': '2023-06-05T19:00:00+02:00', 'override': dict({ 'action': 'not_active', From cf931a75a756c063095d0bd471fb3974f4c3c36f Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Fri, 4 Jul 2025 16:04:16 +0100 Subject: [PATCH 1118/1664] Remove incorrect use of via_device in roon component (#146572) --- homeassistant/components/roon/event.py | 7 ++++--- homeassistant/components/roon/media_player.py | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index 2f2967c5789..b2a491c8d28 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -31,7 +31,7 @@ async def async_setup_entry( if dev_id in event_entities: return # new player! - event_entity = RoonEventEntity(roon_server, player_data) + event_entity = RoonEventEntity(roon_server, player_data, config_entry.entry_id) event_entities.add(dev_id) async_add_entities([event_entity]) @@ -50,13 +50,14 @@ class RoonEventEntity(EventEntity): _attr_event_types = ["volume_up", "volume_down", "mute_toggle"] _attr_translation_key = "volume" - def __init__(self, server, player_data): + def __init__(self, server, player_data, entry_id): """Initialize the entity.""" self._server = server self._player_data = player_data player_name = player_data["display_name"] self._attr_name = f"{player_name} roon volume" self._attr_unique_id = self._player_data["dev_id"] + self._entry_id = entry_id if self._player_data.get("source_controls"): dev_model = self._player_data["source_controls"][0].get("display_name") @@ -69,7 +70,7 @@ class RoonEventEntity(EventEntity): name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, - via_device=(DOMAIN, self._server.roon_id), + via_device=(DOMAIN, self._entry_id), ) def _roonapi_volume_callback( diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index 4a87601a24f..0c4f8394989 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -72,7 +72,7 @@ async def async_setup_entry( dev_id = player_data["dev_id"] if dev_id not in media_players: # new player! - media_player = RoonDevice(roon_server, player_data) + media_player = RoonDevice(roon_server, player_data, config_entry.entry_id) media_players.add(dev_id) async_add_entities([media_player]) else: @@ -106,7 +106,7 @@ class RoonDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY_MEDIA ) - def __init__(self, server, player_data): + def __init__(self, server, player_data, entry_id): """Initialize Roon device object.""" self._remove_signal_status = None self._server = server @@ -125,6 +125,7 @@ class RoonDevice(MediaPlayerEntity): self._attr_volume_level = 0 self._volume_fixed = True self._volume_incremental = False + self._entry_id = entry_id self.update_data(player_data) async def async_added_to_hass(self) -> None: @@ -166,7 +167,7 @@ class RoonDevice(MediaPlayerEntity): name=cast(str | None, self.name), manufacturer="RoonLabs", model=dev_model, - via_device=(DOMAIN, self._server.roon_id), + via_device=(DOMAIN, self._entry_id), ) def update_data(self, player_data=None): From 8e6b9c04f6fb2aadd720af4be1a3b51d2bd6c40e Mon Sep 17 00:00:00 2001 From: Michael Freeman Date: Fri, 4 Jul 2025 11:46:59 -0400 Subject: [PATCH 1119/1664] Bump venstarcolortouch to 0.21 (#148152) --- homeassistant/components/venstar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index f3045fe49e8..5991dc8fe51 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/venstar", "iot_class": "local_polling", "loggers": ["venstarcolortouch"], - "requirements": ["venstarcolortouch==0.19"] + "requirements": ["venstarcolortouch==0.21"] } diff --git a/requirements_all.txt b/requirements_all.txt index 391829a25e0..862c95d2a47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3041,7 +3041,7 @@ vehicle==2.2.2 velbus-aio==2025.5.0 # homeassistant.components.venstar -venstarcolortouch==0.19 +venstarcolortouch==0.21 # homeassistant.components.vilfo vilfo-api-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b8da24b1752..2515980ccfd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2509,7 +2509,7 @@ vehicle==2.2.2 velbus-aio==2025.5.0 # homeassistant.components.venstar -venstarcolortouch==0.19 +venstarcolortouch==0.21 # homeassistant.components.vilfo vilfo-api-client==0.5.0 From bb1e26314995e12ff861eaf08dcbea931441def0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 18:34:55 +0200 Subject: [PATCH 1120/1664] Remove cv.SUN_CONDITION_SCHEMA (#148158) --- homeassistant/helpers/config_validation.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 5445cb51ac9..ab347e803d6 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1537,22 +1537,6 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict[str, Any]: return key_dependency("for", "state")(validated) -SUN_CONDITION_SCHEMA = vol.All( - vol.Schema( - { - **CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "sun", - vol.Optional("before"): sun_event, - vol.Optional("before_offset"): time_period, - vol.Optional("after"): vol.All( - vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE) - ), - vol.Optional("after_offset"): time_period, - } - ), - has_at_least_one_key("before", "after"), -) - TEMPLATE_CONDITION_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, From 0b2db2510f1fc0707b752764eec386dd90a1549b Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 4 Jul 2025 11:06:33 -0700 Subject: [PATCH 1121/1664] Support translating number selector UoM (#148162) --- homeassistant/components/derivative/config_flow.py | 1 + homeassistant/components/derivative/strings.json | 5 +++++ homeassistant/helpers/selector.py | 2 ++ script/hassfest/translations.py | 4 ++++ tests/helpers/test_selector.py | 8 +++++++- 5 files changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 37d54e04f7f..dc12e1bbfe2 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -94,6 +94,7 @@ async def _get_options_dict(handler: SchemaCommonFlowHandler | None) -> dict: max=6, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="decimals", + translation_key="round", ), ), vol.Required(CONF_TIME_WINDOW): selector.DurationSelector(), diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index 5081e7f3b35..551d0912a94 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -52,6 +52,11 @@ "h": "Hours", "d": "Days" } + }, + "round": { + "unit_of_measurement": { + "decimals": "decimals" + } } } } diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index e4277aac98e..4fa31ee78a2 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1066,6 +1066,7 @@ class NumberSelectorConfig(BaseSelectorConfig, total=False): step: float | Literal["any"] unit_of_measurement: str mode: NumberSelectorMode + translation_key: str class NumberSelectorMode(StrEnum): @@ -1106,6 +1107,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All( vol.Coerce(NumberSelectorMode), lambda val: val.value ), + vol.Optional("translation_key"): str, } ), validate_slider, diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 4e0cf349aec..974c932ae5c 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -310,6 +310,10 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema: translation_value_validator, slug_validator=translation_key_validator, ), + vol.Optional("unit_of_measurement"): cv.schema_with_slug_keys( + translation_value_validator, + slug_validator=translation_key_validator, + ), vol.Optional("fields"): cv.schema_with_slug_keys(str), }, slug_validator=vol.Any("_", cv.slug), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 8947ea8099c..dd8cd1c1b64 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -396,7 +396,13 @@ def test_assist_pipeline_selector_schema( ({"min": -100, "max": 100, "step": 5}, (), ()), ({"min": -20, "max": -10, "mode": "box"}, (), ()), ( - {"min": 0, "max": 100, "unit_of_measurement": "seconds", "mode": "slider"}, + { + "min": 0, + "max": 100, + "unit_of_measurement": "seconds", + "mode": "slider", + "translation_key": "foo", + }, (), (), ), From c61cd422d191535d3852865c1f486230f0651e6c Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 4 Jul 2025 20:47:32 +0200 Subject: [PATCH 1122/1664] Delete stale icon translation in Husqvarna Automower (#148168) --- homeassistant/components/husqvarna_automower/icons.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index 14ac5ce4068..e9bc5901b97 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -3,9 +3,6 @@ "binary_sensor": { "leaving_dock": { "default": "mdi:debug-step-out" - }, - "returning_to_dock": { - "default": "mdi:debug-step-into" } }, "button": { From 70624f72b65f75a7210c4f044c79e2fe760d9c5c Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 4 Jul 2025 21:51:47 +0200 Subject: [PATCH 1123/1664] Additional icon translation for Husqvarna Automower (#148167) --- .../components/husqvarna_automower/icons.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e9bc5901b97..e1b355959d9 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -45,6 +45,26 @@ "work_area_progress": { "default": "mdi:collage" } + }, + "switch": { + "my_lawn_work_area": { + "default": "mdi:square-outline", + "state": { + "on": "mdi:square" + } + }, + "work_area_work_area": { + "default": "mdi:square-outline", + "state": { + "on": "mdi:square" + } + }, + "stay_out_zones": { + "default": "mdi:rhombus-outline", + "state": { + "on": "mdi:rhombus" + } + } } }, "services": { From b6b6de24aca0ed099db481f564fc777feb8d975a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 21:54:11 +0200 Subject: [PATCH 1124/1664] Replace MediaPlayerState.STANDBY with MediaPlayerState.OFF in cambridge_audio (#148133) --- homeassistant/components/cambridge_audio/media_player.py | 2 +- tests/components/cambridge_audio/test_media_player.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py index e8f92c0b25c..75e537e457c 100644 --- a/homeassistant/components/cambridge_audio/media_player.py +++ b/homeassistant/components/cambridge_audio/media_player.py @@ -107,7 +107,7 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity): """Return the state of the device.""" media_state = self.client.play_state.state if media_state == "NETWORK": - return MediaPlayerState.STANDBY + return MediaPlayerState.OFF if self.client.state.power: if media_state == "play": return MediaPlayerState.PLAYING diff --git a/tests/components/cambridge_audio/test_media_player.py b/tests/components/cambridge_audio/test_media_player.py index 10e9311c4b0..7bdc2dddc8d 100644 --- a/tests/components/cambridge_audio/test_media_player.py +++ b/tests/components/cambridge_audio/test_media_player.py @@ -45,7 +45,6 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, - STATE_STANDBY, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -156,8 +155,8 @@ async def test_entity_supported_features_with_control_bus( @pytest.mark.parametrize( ("power_state", "play_state", "media_player_state"), [ - (True, "NETWORK", STATE_STANDBY), - (False, "NETWORK", STATE_STANDBY), + (True, "NETWORK", STATE_OFF), + (False, "NETWORK", STATE_OFF), (False, "play", STATE_OFF), (True, "play", STATE_PLAYING), (True, "pause", STATE_PAUSED), From bfccee17efffc29163f4e0b200915fd99728aa1f Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 4 Jul 2025 21:56:44 +0200 Subject: [PATCH 1125/1664] Wallbox, Improve test setup (#148036) --- tests/components/wallbox/__init__.py | 386 ----------------- tests/components/wallbox/conftest.py | 254 ++++++++++- tests/components/wallbox/test_config_flow.py | 57 ++- tests/components/wallbox/test_init.py | 115 ++--- tests/components/wallbox/test_lock.py | 162 ++----- tests/components/wallbox/test_number.py | 424 ++++++------------- tests/components/wallbox/test_select.py | 126 +++--- tests/components/wallbox/test_sensor.py | 4 +- tests/components/wallbox/test_switch.py | 93 +--- 9 files changed, 567 insertions(+), 1054 deletions(-) diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 37e7d5059f0..35bf3cee242 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -1,387 +1 @@ """Tests for the Wallbox integration.""" - -from http import HTTPStatus - -import requests -import requests_mock - -from homeassistant.components.wallbox.const import ( - CHARGER_ADDED_ENERGY_KEY, - CHARGER_ADDED_RANGE_KEY, - CHARGER_CHARGING_POWER_KEY, - CHARGER_CHARGING_SPEED_KEY, - CHARGER_CURRENCY_KEY, - CHARGER_CURRENT_VERSION_KEY, - CHARGER_DATA_KEY, - CHARGER_ECO_SMART_KEY, - CHARGER_ECO_SMART_MODE_KEY, - CHARGER_ECO_SMART_STATUS_KEY, - CHARGER_ENERGY_PRICE_KEY, - CHARGER_FEATURES_KEY, - CHARGER_LOCKED_UNLOCKED_KEY, - CHARGER_MAX_AVAILABLE_POWER_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_MAX_ICP_CURRENT_KEY, - CHARGER_NAME_KEY, - CHARGER_PART_NUMBER_KEY, - CHARGER_PLAN_KEY, - CHARGER_POWER_BOOST_KEY, - CHARGER_SERIAL_NUMBER_KEY, - CHARGER_SOFTWARE_KEY, - CHARGER_STATUS_ID_KEY, -) -from homeassistant.core import HomeAssistant - -from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID - -from tests.common import MockConfigEntry - -test_response = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: False, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - -test_response_bidir = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: False, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - -test_response_eco_mode = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: True, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - - -test_response_full_solar = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: True, - CHARGER_ECO_SMART_MODE_KEY: 1, - }, - }, -} - -test_response_no_power_boost = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, - }, -} - - -http_404_error = requests.exceptions.HTTPError() -http_404_error.response = requests.Response() -http_404_error.response.status_code = HTTPStatus.NOT_FOUND -http_429_error = requests.exceptions.HTTPError() -http_429_error.response = requests.Response() -http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS - -authorisation_response = { - "data": { - "attributes": { - "token": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - REFRESH_TOKEN_TTL: 145756758, - ERROR: "false", - STATUS: 200, - } - } -} - - -authorisation_response_unauthorised = { - "data": { - "attributes": { - "token": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - REFRESH_TOKEN_TTL: 145756758, - ERROR: "false", - STATUS: 404, - } - } -} - -invalid_reauth_response = { - "jwt": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - "user_id": 12345, - "ttl": 145656758, - "refresh_token_ttl": 145756758, - "error": False, - "status": 200, -} - -http_403_error = requests.exceptions.HTTPError() -http_403_error.response = requests.Response() -http_403_error.response.status_code = HTTPStatus.FORBIDDEN - -http_404_error = requests.exceptions.HTTPError() -http_404_error.response = requests.Response() -http_404_error.response.status_code = HTTPStatus.NOT_FOUND - - -async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_no_eco_mode( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response_no_power_boost, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_select( - hass: HomeAssistant, entry: MockConfigEntry, response -) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None: - """Test wallbox sensor class setup.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response_bidir, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.OK, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_connection_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup with a connection error.""" - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}, - status_code=HTTPStatus.FORBIDDEN, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_read_only( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup for read only.""" - - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json=test_response, - status_code=HTTPStatus.FORBIDDEN, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - -async def setup_integration_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class setup for read only.""" - - with requests_mock.Mocker() as mock_request: - mock_request.get( - "https://user-api.wall-box.com/users/signin", - json=authorisation_response, - status_code=HTTPStatus.OK, - ) - mock_request.get( - "https://api.wall-box.com/chargers/status/12345", - json=test_response, - status_code=HTTPStatus.OK, - ) - mock_request.put( - "https://api.wall-box.com/v2/charger/12345", - json=test_response, - status_code=HTTPStatus.NOT_FOUND, - ) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() diff --git a/tests/components/wallbox/conftest.py b/tests/components/wallbox/conftest.py index 72d493ceb69..ab1032b3816 100644 --- a/tests/components/wallbox/conftest.py +++ b/tests/components/wallbox/conftest.py @@ -1,13 +1,220 @@ """Test fixtures for the Wallbox integration.""" -import pytest +from http import HTTPStatus +from unittest.mock import MagicMock, Mock, patch -from homeassistant.components.wallbox.const import CONF_STATION, DOMAIN +import pytest +import requests + +from homeassistant.components.wallbox.const import ( + CHARGER_ADDED_ENERGY_KEY, + CHARGER_ADDED_RANGE_KEY, + CHARGER_CHARGING_POWER_KEY, + CHARGER_CHARGING_SPEED_KEY, + CHARGER_CURRENCY_KEY, + CHARGER_CURRENT_VERSION_KEY, + CHARGER_DATA_KEY, + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, + CHARGER_ENERGY_PRICE_KEY, + CHARGER_FEATURES_KEY, + CHARGER_LOCKED_UNLOCKED_KEY, + CHARGER_MAX_AVAILABLE_POWER_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, + CHARGER_NAME_KEY, + CHARGER_PART_NUMBER_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, + CHARGER_SERIAL_NUMBER_KEY, + CHARGER_SOFTWARE_KEY, + CHARGER_STATUS_ID_KEY, + CONF_STATION, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID + from tests.common import MockConfigEntry +test_response = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + +test_response_bidir = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + +test_response_eco_mode = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + + +test_response_full_solar = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 1, + }, + }, +} + +test_response_no_power_boost = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, + }, +} + + +http_403_error = requests.exceptions.HTTPError() +http_403_error.response = requests.Response() +http_403_error.response.status_code = HTTPStatus.FORBIDDEN +http_404_error = requests.exceptions.HTTPError() +http_404_error.response = requests.Response() +http_404_error.response.status_code = HTTPStatus.NOT_FOUND +http_429_error = requests.exceptions.HTTPError() +http_429_error.response = requests.Response() +http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS + +authorisation_response = { + "data": { + "attributes": { + "token": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + REFRESH_TOKEN_TTL: 145756758, + ERROR: "false", + STATUS: 200, + } + } +} + + +authorisation_response_unauthorised = { + "data": { + "attributes": { + "token": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + REFRESH_TOKEN_TTL: 145756758, + ERROR: "false", + STATUS: 404, + } + } +} + +invalid_reauth_response = { + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, +} + @pytest.fixture def entry(hass: HomeAssistant) -> MockConfigEntry: @@ -23,3 +230,46 @@ def entry(hass: HomeAssistant) -> MockConfigEntry: ) entry.add_to_hass(hass) return entry + + +@pytest.fixture +def mock_wallbox(): + """Patch Wallbox class for tests.""" + with patch("homeassistant.components.wallbox.Wallbox") as mock: + wallbox = MagicMock() + wallbox.authenticate = Mock(return_value=authorisation_response) + wallbox.lockCharger = Mock( + return_value={ + CHARGER_DATA_POST_L1_KEY: { + CHARGER_DATA_POST_L2_KEY: {CHARGER_LOCKED_UNLOCKED_KEY: True} + } + } + ) + wallbox.unlockCharger = Mock( + return_value={ + CHARGER_DATA_POST_L1_KEY: { + CHARGER_DATA_POST_L2_KEY: {CHARGER_LOCKED_UNLOCKED_KEY: True} + } + } + ) + wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 0.25}) + wallbox.setMaxChargingCurrent = Mock( + return_value={ + CHARGER_DATA_POST_L1_KEY: { + CHARGER_DATA_POST_L2_KEY: { + CHARGER_MAX_CHARGING_CURRENT_POST_KEY: True + } + } + } + ) + wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25}) + wallbox.getChargerStatus = Mock(return_value=test_response) + mock.return_value = wallbox + yield wallbox + + +async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test wallbox sensor class setup.""" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index bdfb4cad18d..d0c34ae0bce 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from . import ( +from .conftest import ( authorisation_response, authorisation_response_unauthorised, http_403_error, @@ -38,7 +38,7 @@ test_response = { } -async def test_show_set_form(hass: HomeAssistant) -> None: +async def test_show_set_form(hass: HomeAssistant, mock_wallbox) -> None: """Test that the setup form is served.""" flow = config_flow.WallboxConfigFlow() flow.hass = hass @@ -53,7 +53,6 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", @@ -73,8 +72,8 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} async def test_form_cannot_connect(hass: HomeAssistant) -> None: @@ -82,7 +81,6 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", @@ -102,8 +100,8 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_validate_input(hass: HomeAssistant) -> None: @@ -111,15 +109,14 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), + return_value=authorisation_response, ), patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), + "homeassistant.components.wallbox.Wallbox.pauseChargingSession", + return_value=test_response, ), ): result2 = await hass.config_entries.flow.async_configure( @@ -135,20 +132,20 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: assert result2["data"]["station"] == "12345" -async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_form_reauth( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test we handle reauth flow.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response_unauthorised), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), + patch.object( + mock_wallbox, + "authenticate", + return_value=authorisation_response_unauthorised, ), + patch.object(mock_wallbox, "getChargerStatus", return_value=test_response), ): result = await entry.start_reauth_flow(hass) @@ -161,27 +158,27 @@ async def test_form_reauth(hass: HomeAssistant, entry: MockConfigEntry) -> None: }, ) - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" await hass.async_block_till_done() await hass.config_entries.async_unload(entry.entry_id) -async def test_form_reauth_invalid(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_form_reauth_invalid( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test we handle reauth invalid flow.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response_unauthorised), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), + patch.object( + mock_wallbox, + "authenticate", + return_value=authorisation_response_unauthorised, ), + patch.object(mock_wallbox, "getChargerStatus", return_value=test_response), ): result = await entry.start_reauth_flow(hass) diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 5048385aaf6..ef73decea8f 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,27 +1,23 @@ """Test Wallbox Init Component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch from homeassistant.components.wallbox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from . import ( - authorisation_response, +from .conftest import ( http_403_error, http_429_error, setup_integration, - setup_integration_connection_error, - setup_integration_no_eco_mode, - setup_integration_read_only, - test_response, + test_response_no_power_boost, ) from tests.common import MockConfigEntry async def test_wallbox_setup_unload_entry( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox Unload.""" @@ -33,37 +29,27 @@ async def test_wallbox_setup_unload_entry( async def test_wallbox_unload_entry_connection_error( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox Unload Connection Error.""" + with patch.object(mock_wallbox, "authenticate", side_effect=http_403_error): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_ERROR - await setup_integration_connection_error(hass, entry) - assert entry.state is ConfigEntryState.SETUP_ERROR - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED async def test_wallbox_refresh_failed_connection_error_auth( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with connection error.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(return_value=test_response), - ), - ): + with patch.object(mock_wallbox, "authenticate", side_effect=http_429_error): wallbox = hass.data[DOMAIN][entry.entry_id] - await wallbox.async_refresh() assert await hass.config_entries.async_unload(entry.entry_id) @@ -71,7 +57,7 @@ async def test_wallbox_refresh_failed_connection_error_auth( async def test_wallbox_refresh_failed_invalid_auth( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with authentication error.""" @@ -79,14 +65,8 @@ async def test_wallbox_refresh_failed_invalid_auth( assert entry.state is ConfigEntryState.LOADED with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(side_effect=http_403_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(side_effect=http_403_error), - ), + patch.object(mock_wallbox, "authenticate", side_effect=http_403_error), + patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error), ): wallbox = hass.data[DOMAIN][entry.entry_id] @@ -97,23 +77,14 @@ async def test_wallbox_refresh_failed_invalid_auth( async def test_wallbox_refresh_failed_http_error( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with authentication error.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(side_effect=http_403_error), - ), - ): + with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_403_error): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -123,23 +94,14 @@ async def test_wallbox_refresh_failed_http_error( async def test_wallbox_refresh_failed_too_many_requests( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with authentication error.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(side_effect=http_429_error), - ), - ): + with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -149,23 +111,14 @@ async def test_wallbox_refresh_failed_too_many_requests( async def test_wallbox_refresh_failed_connection_error( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with connection error.""" await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(side_effect=http_403_error), - ), - ): + with patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error): wallbox = hass.data[DOMAIN][entry.entry_id] await wallbox.async_refresh() @@ -174,25 +127,15 @@ async def test_wallbox_refresh_failed_connection_error( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_read_only( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test Wallbox setup for read-only user.""" - - await setup_integration_read_only(hass, entry) - assert entry.state is ConfigEntryState.LOADED - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED - - async def test_wallbox_setup_load_entry_no_eco_mode( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox Unload.""" + with patch.object( + mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost + ): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.LOADED - await setup_integration_no_eco_mode(hass, entry) - assert entry.state is ConfigEntryState.LOADED - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is ConfigEntryState.NOT_LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 7d95aed7a5d..e3c6048e928 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -1,28 +1,24 @@ """Test Wallbox Lock component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import ( - authorisation_response, - http_403_error, - http_404_error, - http_429_error, - setup_integration, -) +from .conftest import http_403_error, http_404_error, http_429_error, setup_integration from .const import MOCK_LOCK_ENTITY_ID from tests.common import MockConfigEntry -async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) -> None: +async def test_wallbox_lock_class( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: """Test wallbox lock class.""" await setup_integration(hass, entry) @@ -31,60 +27,35 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - assert state assert state.state == "unlocked" - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock( - return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 1}}} - ), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock( - return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 0}}} - ), - ), - ): - await hass.services.async_call( - "lock", - SERVICE_LOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) - await hass.services.async_call( - "lock", - SERVICE_UNLOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) -async def test_wallbox_lock_class_connection_error( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_lock_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox lock class connection error.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=ConnectionError), - ), - pytest.raises(ConnectionError), + patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", @@ -96,42 +67,8 @@ async def test_wallbox_lock_class_connection_error( ) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=ConnectionError), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(side_effect=ConnectionError), - ), - pytest.raises(ConnectionError), - ): - await hass.services.async_call( - "lock", - SERVICE_UNLOCK, - { - ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, - }, - blocking=True, - ) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -143,18 +80,8 @@ async def test_wallbox_lock_class_connection_error( blocking=True, ) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=http_403_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(side_effect=http_403_error), - ), + patch.object(mock_wallbox, "lockCharger", side_effect=http_404_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -165,19 +92,24 @@ async def test_wallbox_lock_class_connection_error( }, blocking=True, ) + with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(side_effect=http_404_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "lockCharger", side_effect=http_403_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_403_error), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "lockCharger", side_effect=http_429_error), + patch.object(mock_wallbox, "unlockCharger", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 8067917977d..3aba0792baa 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -1,28 +1,22 @@ """Test Wallbox Switch component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox.const import ( - CHARGER_ENERGY_PRICE_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_MAX_ICP_CURRENT_KEY, -) from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import ( - authorisation_response, +from .conftest import ( http_403_error, http_404_error, http_429_error, setup_integration, - setup_integration_bidir, + test_response_bidir, ) from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, @@ -32,105 +26,87 @@ from .const import ( from tests.common import MockConfigEntry -mock_wallbox = Mock() -mock_wallbox.authenticate = Mock(return_value=authorisation_response) -mock_wallbox.setEnergyCost = Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}) -mock_wallbox.setMaxChargingCurrent = Mock( - return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20} -) -mock_wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}) - -async def test_wallbox_number_class( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_number_power_class( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock( - return_value={"data": {"chargerData": {"maxChargingCurrent": 20}}} - ), - ), - ): - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - assert state.attributes["min"] == 6 - assert state.attributes["max"] == 25 + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == 6 + assert state.attributes["max"] == 25 - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, - ATTR_VALUE: 20, - }, - blocking=True, - ) + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, + ATTR_VALUE: 20, + }, + blocking=True, + ) -async def test_wallbox_number_class_bidir( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_number_power_class_bidir( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" + with patch.object( + mock_wallbox, "getChargerStatus", return_value=test_response_bidir + ): + await setup_integration(hass, entry) - await setup_integration_bidir(hass, entry) - - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) - assert state.attributes["min"] == -25 - assert state.attributes["max"] == 25 + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == -25 + assert state.attributes["max"] == 25 async def test_wallbox_number_energy_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + +async def test_wallbox_number_icp_power_class( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + +async def test_wallbox_number_power_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(return_value={CHARGER_ENERGY_PRICE_KEY: 1.1}), - ), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_connection_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -143,23 +119,8 @@ async def test_wallbox_number_class_connection_error( blocking=True, ) - -async def test_wallbox_number_class_too_many_requests( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -172,167 +133,8 @@ async def test_wallbox_number_class_too_many_requests( blocking=True, ) - -async def test_wallbox_number_class_energy_price_update_failed( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(side_effect=http_429_error), - ), - pytest.raises(HomeAssistantError), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_energy_price_update_connection_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(side_effect=http_404_error), - ), - pytest.raises(HomeAssistantError), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_energy_price_auth_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setEnergyCost", - new=Mock(side_effect=http_429_error), - ), - pytest.raises(HomeAssistantError), - ): - await hass.services.async_call( - "number", - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, - ATTR_VALUE: 1.1, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_icp_energy( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(return_value={"icp_max_current": 20}), - ), - ): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_icp_energy_auth_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(side_effect=http_403_error), - ), - pytest.raises(InvalidAuth), - ): - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, - ATTR_VALUE: 10, - }, - blocking=True, - ) - - -async def test_wallbox_number_class_energy_auth_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(side_effect=http_403_error), - ), + patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_403_error), pytest.raises(InvalidAuth), ): await hass.services.async_call( @@ -346,22 +148,79 @@ async def test_wallbox_number_class_energy_auth_error( ) -async def test_wallbox_number_class_icp_energy_connection_error( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_number_energy_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "setEnergyCost", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "setEnergyCost", side_effect=http_404_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "setEnergyCost", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + + +async def test_wallbox_number_icp_power_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with ( + patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_403_error), + pytest.raises(InvalidAuth), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) + + with ( + patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -374,23 +233,8 @@ async def test_wallbox_number_class_icp_energy_connection_error( blocking=True, ) - -async def test_wallbox_number_class_icp_energy_too_many_request( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox sensor class.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index e46347bfa5a..f194566dbae 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -1,6 +1,6 @@ """Test Wallbox Select component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest @@ -9,15 +9,14 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY, EcoSmartMode +from homeassistant.components.wallbox.const import EcoSmartMode from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, HomeAssistantError -from . import ( - authorisation_response, +from .conftest import ( http_404_error, http_429_error, - setup_integration_select, + setup_integration, test_response, test_response_eco_mode, test_response_full_solar, @@ -34,44 +33,13 @@ TEST_OPTIONS = [ ] -@pytest.fixture -def mock_authenticate(): - """Fixture to patch Wallbox methods.""" - with patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ): - yield - - @pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) async def test_wallbox_select_solar_charging_class( - hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_authenticate + hass: HomeAssistant, entry: MockConfigEntry, mode, response, mock_wallbox ) -> None: """Test wallbox select class.""" - - if mode == EcoSmartMode.OFF: - response = test_response - elif mode == EcoSmartMode.ECO_MODE: - response = test_response_eco_mode - elif mode == EcoSmartMode.FULL_SOLAR: - response = test_response_full_solar - - with ( - patch( - "homeassistant.components.wallbox.Wallbox.enableEcoSmart", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.disableEcoSmart", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=response), - ), - ): - await setup_integration_select(hass, entry, response) + with patch.object(mock_wallbox, "getChargerStatus", return_value=response): + await setup_integration(hass, entry) await hass.services.async_call( SELECT_DOMAIN, @@ -88,43 +56,35 @@ async def test_wallbox_select_solar_charging_class( async def test_wallbox_select_no_power_boost_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox select class.""" - await setup_integration_select(hass, entry, test_response_no_power_boost) + with patch.object( + mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost + ): + await setup_integration(hass, entry) - state = hass.states.get(MOCK_SELECT_ENTITY_ID) - assert state is None + state = hass.states.get(MOCK_SELECT_ENTITY_ID) + assert state is None @pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) -@pytest.mark.parametrize("error", [http_404_error, ConnectionError]) async def test_wallbox_select_class_error( hass: HomeAssistant, entry: MockConfigEntry, mode, response, - error, - mock_authenticate, + mock_wallbox, ) -> None: """Test wallbox select class connection error.""" - await setup_integration_select(hass, entry, response) + await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.disableEcoSmart", - new=Mock(side_effect=error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.enableEcoSmart", - new=Mock(side_effect=error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response_eco_mode), - ), + patch.object(mock_wallbox, "getChargerStatus", return_value=response), + patch.object(mock_wallbox, "disableEcoSmart", side_effect=http_404_error), + patch.object(mock_wallbox, "enableEcoSmart", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -144,25 +104,45 @@ async def test_wallbox_select_too_many_requests_error( entry: MockConfigEntry, mode, response, - mock_authenticate, + mock_wallbox, ) -> None: """Test wallbox select class connection error.""" - await setup_integration_select(hass, entry, response) + await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.disableEcoSmart", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.enableEcoSmart", - new=Mock(side_effect=http_429_error), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response_eco_mode), - ), + patch.object(mock_wallbox, "getChargerStatus", return_value=response), + patch.object(mock_wallbox, "disableEcoSmart", side_effect=http_429_error), + patch.object(mock_wallbox, "enableEcoSmart", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: MOCK_SELECT_ENTITY_ID, + ATTR_OPTION: mode, + }, + blocking=True, + ) + + +@pytest.mark.parametrize(("mode", "response"), TEST_OPTIONS) +async def test_wallbox_select_connection_error( + hass: HomeAssistant, + entry: MockConfigEntry, + mode, + response, + mock_wallbox, +) -> None: + """Test wallbox select class connection error.""" + + await setup_integration(hass, entry) + + with ( + patch.object(mock_wallbox, "getChargerStatus", return_value=response), + patch.object(mock_wallbox, "disableEcoSmart", side_effect=ConnectionError), + patch.object(mock_wallbox, "enableEcoSmart", side_effect=ConnectionError), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_sensor.py b/tests/components/wallbox/test_sensor.py index 69d0cc57340..7373b5e70bb 100644 --- a/tests/components/wallbox/test_sensor.py +++ b/tests/components/wallbox/test_sensor.py @@ -3,7 +3,7 @@ from homeassistant.const import CONF_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant -from . import setup_integration +from .conftest import setup_integration from .const import ( MOCK_SENSOR_CHARGING_POWER_ID, MOCK_SENSOR_CHARGING_SPEED_ID, @@ -14,7 +14,7 @@ from tests.common import MockConfigEntry async def test_wallbox_sensor_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox sensor class.""" diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 98b87828f74..189ce59f55c 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -1,29 +1,22 @@ """Test Wallbox Lock component.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import ( - authorisation_response, - http_404_error, - http_429_error, - setup_integration, - test_response, -) +from .conftest import http_404_error, http_429_error, setup_integration from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry async def test_wallbox_switch_class( - hass: HomeAssistant, entry: MockConfigEntry + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox switch class.""" @@ -33,59 +26,34 @@ async def test_wallbox_switch_class( assert state assert state.state == "on" - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.pauseChargingSession", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.resumeChargingSession", - new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), - ), - patch( - "homeassistant.components.wallbox.Wallbox.getChargerStatus", - new=Mock(return_value=test_response), - ), - ): - await hass.services.async_call( - "switch", - SERVICE_TURN_ON, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, + ) - await hass.services.async_call( - "switch", - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, - }, - blocking=True, - ) + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: MOCK_SWITCH_ENTITY_ID, + }, + blocking=True, + ) -async def test_wallbox_switch_class_connection_error( - hass: HomeAssistant, entry: MockConfigEntry +async def test_wallbox_switch_class_error_handling( + hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test wallbox switch class connection error.""" await setup_integration(hass, entry) with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.resumeChargingSession", - new=Mock(side_effect=http_404_error), - ), + patch.object(mock_wallbox, "resumeChargingSession", side_effect=http_404_error), pytest.raises(HomeAssistantError), ): # Test behavior when a connection error occurs @@ -98,23 +66,8 @@ async def test_wallbox_switch_class_connection_error( blocking=True, ) - -async def test_wallbox_switch_class_too_many_requests( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox switch class connection error.""" - - await setup_integration(hass, entry) - with ( - patch( - "homeassistant.components.wallbox.Wallbox.authenticate", - new=Mock(return_value=authorisation_response), - ), - patch( - "homeassistant.components.wallbox.Wallbox.resumeChargingSession", - new=Mock(side_effect=http_429_error), - ), + patch.object(mock_wallbox, "resumeChargingSession", side_effect=http_429_error), pytest.raises(HomeAssistantError), ): # Test behavior when a connection error occurs From f5b51c6cf0732ff5288f9a4c9049a8bbd000e846 Mon Sep 17 00:00:00 2001 From: Wesley Vos <17592840+Wesley-Vos@users.noreply.github.com> Date: Fri, 4 Jul 2025 22:04:48 +0200 Subject: [PATCH 1126/1664] Add serial_numbers to device_info of inverters, encharge and enpower (#147964) --- homeassistant/components/enphase_envoy/binary_sensor.py | 2 ++ homeassistant/components/enphase_envoy/number.py | 1 + homeassistant/components/enphase_envoy/select.py | 1 + homeassistant/components/enphase_envoy/sensor.py | 3 +++ homeassistant/components/enphase_envoy/switch.py | 2 ++ .../enphase_envoy/snapshots/test_diagnostics.ambr | 8 ++++---- 6 files changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py index dcffef8271b..2628406f56f 100644 --- a/homeassistant/components/enphase_envoy/binary_sensor.py +++ b/homeassistant/components/enphase_envoy/binary_sensor.py @@ -126,6 +126,7 @@ class EnvoyEnchargeBinarySensorEntity(EnvoyBaseBinarySensorEntity): name=f"Encharge {serial_number}", sw_version=str(encharge_inventory[self._serial_number].firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=serial_number, ) @property @@ -158,6 +159,7 @@ class EnvoyEnpowerBinarySensorEntity(EnvoyBaseBinarySensorEntity): name=f"Enpower {enpower.serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=enpower.serial_number, ) @property diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 91e93d9c59b..6e8e48d684b 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -165,6 +165,7 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) else: # If no enpower device assign numbers to Envoy itself diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 42b47e5d793..358275942ca 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -223,6 +223,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) else: # If no enpower device assign selects to Envoy itself diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index c1088252618..63a2a09a6f5 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -1313,6 +1313,7 @@ class EnvoyInverterEntity(EnvoySensorBaseEntity): manufacturer="Enphase", model="Inverter", via_device=(DOMAIN, self.envoy_serial_num), + serial_number=serial_number, ) @property @@ -1356,6 +1357,7 @@ class EnvoyEnchargeEntity(EnvoySensorBaseEntity): name=f"Encharge {serial_number}", sw_version=str(encharge_inventory[self._serial_number].firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=serial_number, ) @@ -1420,6 +1422,7 @@ class EnvoyEnpowerEntity(EnvoySensorBaseEntity): name=f"Enpower {enpower_data.serial_number}", sw_version=str(enpower_data.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=enpower_data.serial_number, ) @property diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index bb4ed874b1d..02736979e66 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -138,6 +138,7 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) @property @@ -235,6 +236,7 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): name=f"Enpower {self._serial_number}", sw_version=str(enpower.firmware_version), via_device=(DOMAIN, self.envoy_serial_num), + serial_number=self._serial_number, ) else: # If no enpower device assign switches to Envoy itself diff --git a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr index 7ad45ff51f3..3a7f4e4fb9f 100644 --- a/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr +++ b/tests/components/enphase_envoy/snapshots/test_diagnostics.ambr @@ -307,7 +307,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, + 'serial_number': '1', 'suggested_area': None, 'sw_version': None, }), @@ -1186,7 +1186,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, + 'serial_number': '1', 'suggested_area': None, 'sw_version': None, }), @@ -2109,7 +2109,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, + 'serial_number': '1', 'suggested_area': None, 'sw_version': None, }), @@ -2805,7 +2805,7 @@ 'name': 'Inverter 1', 'name_by_user': None, 'primary_config_entry': '45a36e55aaddb2007c5f6602e0c38e72', - 'serial_number': None, + 'serial_number': '1', 'suggested_area': None, 'sw_version': None, }), From 6e607ffa01bd57c1590a662d6bcca6eec97e7561 Mon Sep 17 00:00:00 2001 From: Sid <27780930+autinerd@users.noreply.github.com> Date: Fri, 4 Jul 2025 22:18:13 +0200 Subject: [PATCH 1127/1664] Add reconfigure flow to eheimdigital (#147930) --- .../components/eheimdigital/config_flow.py | 56 ++++++++++++- .../eheimdigital/quality_scale.yaml | 2 +- .../components/eheimdigital/strings.json | 12 ++- .../eheimdigital/test_config_flow.py | 81 ++++++++++++++++++- 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py index b0432267c8e..09fbaa601b3 100644 --- a/homeassistant/components/eheimdigital/config_flow.py +++ b/homeassistant/components/eheimdigital/config_flow.py @@ -10,7 +10,12 @@ from eheimdigital.device import EheimDigitalDevice from eheimdigital.hub import EheimDigitalHub import voluptuous as vol -from homeassistant.config_entries import SOURCE_USER, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + ConfigFlow, + ConfigFlowResult, +) from homeassistant.const import CONF_HOST from homeassistant.helpers import selector from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -126,3 +131,52 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN): data_schema=CONFIG_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the config entry.""" + if user_input is None: + return self.async_show_form( + step_id=SOURCE_RECONFIGURE, data_schema=CONFIG_SCHEMA + ) + + self._async_abort_entries_match(user_input) + errors: dict[str, str] = {} + hub = EheimDigitalHub( + host=user_input[CONF_HOST], + session=async_get_clientsession(self.hass), + loop=self.hass.loop, + main_device_added_event=self.main_device_added_event, + ) + + try: + await hub.connect() + + async with asyncio.timeout(2): + # This event gets triggered when the first message is received from + # the device, it contains the data necessary to create the main device. + # This removes the race condition where the main device is accessed + # before the response from the device is parsed. + await self.main_device_added_event.wait() + if TYPE_CHECKING: + # At this point the main device is always set + assert isinstance(hub.main, EheimDigitalDevice) + await self.async_set_unique_id(hub.main.mac_address) + await hub.close() + except (ClientError, TimeoutError): + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + errors["base"] = "unknown" + LOGGER.exception("Unknown exception occurred") + else: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=user_input, + ) + return self.async_show_form( + step_id=SOURCE_RECONFIGURE, + data_schema=CONFIG_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/eheimdigital/quality_scale.yaml b/homeassistant/components/eheimdigital/quality_scale.yaml index c1490b352c2..801e0748310 100644 --- a/homeassistant/components/eheimdigital/quality_scale.yaml +++ b/homeassistant/components/eheimdigital/quality_scale.yaml @@ -60,7 +60,7 @@ rules: entity-translations: done exception-translations: done icon-translations: todo - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: done diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json index 77cffb4a709..c629ff622cb 100644 --- a/homeassistant/components/eheimdigital/strings.json +++ b/homeassistant/components/eheimdigital/strings.json @@ -4,6 +4,14 @@ "discovery_confirm": { "description": "[%key:common::config_flow::description::confirm_setup%]" }, + "reconfigure": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "[%key:component::eheimdigital::config::step::user::data_description::host%]" + } + }, "user": { "data": { "host": "[%key:common::config_flow::data::host%]" @@ -15,7 +23,9 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "The identifier does not match the previous identifier" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", diff --git a/tests/components/eheimdigital/test_config_flow.py b/tests/components/eheimdigital/test_config_flow.py index 4bfd45e9259..53c036c802d 100644 --- a/tests/components/eheimdigital/test_config_flow.py +++ b/tests/components/eheimdigital/test_config_flow.py @@ -7,12 +7,20 @@ from aiohttp import ClientConnectionError import pytest from homeassistant.components.eheimdigital.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + SOURCE_USER, + SOURCE_ZEROCONF, +) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +from .conftest import init_integration + +from tests.common import MockConfigEntry + ZEROCONF_DISCOVERY = ZeroconfServiceInfo( ip_address=ip_address("192.0.2.1"), ip_addresses=[ip_address("192.0.2.1")], @@ -210,3 +218,74 @@ async def test_abort(hass: HomeAssistant, eheimdigital_hub_mock: AsyncMock) -> N assert result2["type"] is FlowResultType.ABORT assert result2["reason"] == "already_configured" + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +@pytest.mark.parametrize( + ("side_effect", "error_value"), + [(ClientConnectionError(), "cannot_connect"), (Exception(), "unknown")], +) +async def test_reconfigure( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + mock_config_entry: MockConfigEntry, + side_effect: Exception, + error_value: str, +) -> None: + """Test reconfigure flow.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + eheimdigital_hub_mock.return_value.connect.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_value} + + eheimdigital_hub_mock.return_value.connect.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert ( + mock_config_entry.unique_id + == eheimdigital_hub_mock.return_value.main.mac_address + ) + + +@patch("homeassistant.components.eheimdigital.config_flow.asyncio.Event", new=AsyncMock) +async def test_reconfigure_different_device( + hass: HomeAssistant, + eheimdigital_hub_mock: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + + await init_integration(hass, mock_config_entry) + + # Simulate a different device + eheimdigital_hub_mock.return_value.main.mac_address = "00:00:00:00:00:02" + + result = await mock_config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == SOURCE_RECONFIGURE + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" From 470baa782e2122d14069b9aaf0e4cb6ba08fb970 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 4 Jul 2025 22:24:40 +0200 Subject: [PATCH 1128/1664] Add zeroconf discovery to philips_js (#147913) Co-authored-by: Joost Lekkerkerker --- .../components/philips_js/config_flow.py | 118 ++++++++++++------ .../components/philips_js/manifest.json | 3 +- .../components/philips_js/strings.json | 5 + homeassistant/generated/zeroconf.py | 10 ++ tests/components/philips_js/__init__.py | 1 + tests/components/philips_js/conftest.py | 1 + .../components/philips_js/test_config_flow.py | 92 +++++++++++--- 7 files changed, 178 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 66b4439acd8..a568d51e5ea 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -6,7 +6,13 @@ from collections.abc import Mapping import platform from typing import Any -from haphilipsjs import ConnectionFailure, PairingFailure, PhilipsTV +from haphilipsjs import ( + DEFAULT_API_VERSION, + ConnectionFailure, + GeneralFailure, + PairingFailure, + PhilipsTV, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -18,16 +24,18 @@ from homeassistant.config_entries import ( from homeassistant.const import ( CONF_API_VERSION, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PIN, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN @@ -54,21 +62,6 @@ OPTIONS_FLOW = { } -async def _validate_input( - hass: HomeAssistant, host: str, api_version: int -) -> PhilipsTV: - """Validate the user input allows us to connect.""" - hub = PhilipsTV(host, api_version) - - await hub.getSystem() - await hub.setTransport(hub.secured_transport) - - if not hub.system: - raise ConnectionFailure("System data is empty") - - return hub - - class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Philips TV.""" @@ -81,6 +74,38 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): self._hub: PhilipsTV | None = None self._pair_state: Any = None + async def _async_attempt_prepare( + self, host: str, api_version: int, secured_transport: bool + ) -> None: + hub = PhilipsTV( + host, api_version=api_version, secured_transport=secured_transport + ) + + await hub.getSystem() + await hub.setTransport(hub.secured_transport) + + if not hub.system or not hub.name: + raise ConnectionFailure("System data or name is empty") + + self._hub = hub + self._current[CONF_HOST] = host + self._current[CONF_SYSTEM] = hub.system + self._current[CONF_API_VERSION] = hub.api_version + self.context.update({"title_placeholders": {CONF_NAME: hub.name}}) + + if serialnumber := hub.system.get("serialnumber"): + await self.async_set_unique_id(serialnumber) + if self.source != SOURCE_REAUTH: + self._abort_if_unique_id_configured( + updates=self._current, reload_on_update=True + ) + + async def _async_attempt_add(self) -> ConfigFlowResult: + assert self._hub + if self._hub.pairing_type == "digest_auth_pairing": + return await self.async_step_pair() + return await self._async_create_current() + async def _async_create_current(self) -> ConfigFlowResult: system = self._current[CONF_SYSTEM] if self.source == SOURCE_REAUTH: @@ -154,6 +179,43 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): self._current[CONF_API_VERSION] = entry_data[CONF_API_VERSION] return await self.async_step_user() + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + LOGGER.debug( + "Checking discovered device: {discovery_info.name} on {discovery_info.host}" + ) + + secured_transport = discovery_info.type == "_philipstv_s_rpc._tcp.local." + api_version = 6 if secured_transport else DEFAULT_API_VERSION + + try: + await self._async_attempt_prepare( + discovery_info.host, api_version, secured_transport + ) + except GeneralFailure: + LOGGER.debug("Failed to get system info from discovery", exc_info=True) + return self.async_abort(reason="discovery_failure") + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by zeroconf.""" + if user_input is not None: + return await self._async_attempt_add() + + name = self.context.get("title_placeholders", {CONF_NAME: "Philips TV"})[ + CONF_NAME + ] + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={CONF_NAME: name}, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -162,28 +224,14 @@ class PhilipsJSConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: self._current = user_input try: - hub = await _validate_input( - self.hass, user_input[CONF_HOST], user_input[CONF_API_VERSION] + await self._async_attempt_prepare( + user_input[CONF_HOST], user_input[CONF_API_VERSION], False ) - except ConnectionFailure as exc: + except GeneralFailure as exc: LOGGER.error(exc) errors["base"] = "cannot_connect" - except Exception: # noqa: BLE001 - LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" else: - if serialnumber := hub.system.get("serialnumber"): - await self.async_set_unique_id(serialnumber) - if self.source != SOURCE_REAUTH: - self._abort_if_unique_id_configured() - - self._current[CONF_SYSTEM] = hub.system - self._current[CONF_API_VERSION] = hub.api_version - self._hub = hub - - if hub.pairing_type == "digest_auth_pairing": - return await self.async_step_pair() - return await self._async_create_current() + return await self._async_attempt_add() schema = self.add_suggested_values_to_schema(USER_SCHEMA, self._current) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json index bba9a1a8762..0e88d6d44a9 100644 --- a/homeassistant/components/philips_js/manifest.json +++ b/homeassistant/components/philips_js/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/philips_js", "iot_class": "local_polling", "loggers": ["haphilipsjs"], - "requirements": ["ha-philipsjs==3.2.2"] + "requirements": ["ha-philipsjs==3.2.2"], + "zeroconf": ["_philipstv_s_rpc._tcp.local.", "_philipstv_rpc._tcp.local."] } diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 1f187d89dda..6c5a1fcce0a 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "user": { "data": { @@ -7,6 +8,10 @@ "api_version": "API Version" } }, + "zeroconf_confirm": { + "title": "Discovered Philips TV", + "description": "Do you want to add the TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." + }, "pair": { "title": "Pair", "description": "Enter the PIN displayed on your TV", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3af4b8caa8d..274fafa51cf 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -771,6 +771,16 @@ ZEROCONF = { "domain": "onewire", }, ], + "_philipstv_rpc._tcp.local.": [ + { + "domain": "philips_js", + }, + ], + "_philipstv_s_rpc._tcp.local.": [ + { + "domain": "philips_js", + }, + ], "_plexmediasvr._tcp.local.": [ { "domain": "plex", diff --git a/tests/components/philips_js/__init__.py b/tests/components/philips_js/__init__.py index 60e8b238917..4703f3cb430 100644 --- a/tests/components/philips_js/__init__.py +++ b/tests/components/philips_js/__init__.py @@ -5,6 +5,7 @@ MOCK_NAME = "Philips TV" MOCK_USERNAME = "mock_user" MOCK_PASSWORD = "mock_password" +MOCK_HOSTNAME = "mock_hostname" MOCK_SYSTEM = { "menulanguage": "English", diff --git a/tests/components/philips_js/conftest.py b/tests/components/philips_js/conftest.py index 4a79fce85a2..911753a8852 100644 --- a/tests/components/philips_js/conftest.py +++ b/tests/components/philips_js/conftest.py @@ -38,6 +38,7 @@ def mock_tv(): tv.application = None tv.applications = {} tv.system = MOCK_SYSTEM + tv.name = MOCK_NAME tv.api_version = 1 tv.api_version_detected = None tv.on = True diff --git a/tests/components/philips_js/test_config_flow.py b/tests/components/philips_js/test_config_flow.py index 4b8048a8ebe..c4dcc44e619 100644 --- a/tests/components/philips_js/test_config_flow.py +++ b/tests/components/philips_js/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Philips TV config flow.""" +from ipaddress import ip_address from unittest.mock import ANY from haphilipsjs import PairingFailure @@ -9,10 +10,13 @@ from homeassistant import config_entries from homeassistant.components.philips_js.const import CONF_ALLOW_NOTIFY, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import ( MOCK_CONFIG, MOCK_CONFIG_PAIRED, + MOCK_HOSTNAME, + MOCK_NAME, MOCK_PASSWORD, MOCK_SYSTEM, MOCK_SYSTEM_UNPAIRED, @@ -33,6 +37,7 @@ async def mock_tv_pairable(mock_tv): mock_tv.api_version = 6 mock_tv.api_version_detected = 6 mock_tv.secured_transport = True + mock_tv.name = MOCK_NAME mock_tv.pairRequest.return_value = {} mock_tv.pairGrant.return_value = MOCK_USERNAME, MOCK_PASSWORD @@ -102,21 +107,6 @@ async def test_form_cannot_connect(hass: HomeAssistant, mock_tv) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_error(hass: HomeAssistant, mock_tv) -> None: - """Test we handle unexpected exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - mock_tv.getSystem.side_effect = Exception("Unexpected exception") - result = await hass.config_entries.flow.async_configure( - result["flow_id"], MOCK_USERINPUT - ) - - assert result["type"] is FlowResultType.FORM - assert result["errors"] == {"base": "unknown"} - - async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) -> None: """Test we get the form.""" mock_tv = mock_tv_pairable @@ -143,7 +133,13 @@ async def test_pairing(hass: HomeAssistant, mock_tv_pairable, mock_setup_entry) ) assert result == { - "context": {"source": "user", "unique_id": "ABCDEFGHIJKLF"}, + "context": { + "source": "user", + "unique_id": "ABCDEFGHIJKLF", + "title_placeholders": { + "name": "Philips TV", + }, + }, "flow_id": ANY, "type": "create_entry", "description": None, @@ -258,3 +254,67 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert config_entry.options == {CONF_ALLOW_NOTIFY: True} + + +@pytest.mark.parametrize( + ("secured_transport", "discovery_type"), + [(True, "_philipstv_s_rpc._tcp.local."), (False, "_philipstv_rpc._tcp.local.")], +) +async def test_zeroconf_discovery( + hass: HomeAssistant, mock_tv_pairable, secured_transport, discovery_type +) -> None: + """Test we can setup from zeroconf discovery.""" + + mock_tv_pairable.secured_transport = secured_transport + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname=MOCK_HOSTNAME, + name=MOCK_NAME, + port=None, + properties={}, + type=discovery_type, + ), + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_tv_pairable.setTransport.assert_called_with(secured_transport) + mock_tv_pairable.pairRequest.assert_called() + + +async def test_zeroconf_probe_failed( + hass: HomeAssistant, + mock_tv_pairable, +) -> None: + """Test we can setup from zeroconf discovery.""" + + mock_tv_pairable.system = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname=MOCK_HOSTNAME, + name=MOCK_NAME, + port=None, + properties={}, + type="_philipstv_s_rpc._tcp.local.", + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failure" From 6a7f4953cd9b0be3ee10369154a1f30d5c92be7f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 4 Jul 2025 22:30:35 +0200 Subject: [PATCH 1129/1664] Fix media selector validation (#147855) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/helpers/selector.py | 13 +++++++------ tests/helpers/test_selector.py | 24 ++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 4fa31ee78a2..bc24113251c 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1045,16 +1045,17 @@ class MediaSelector(Selector[MediaSelectorConfig]): def __call__(self, data: Any) -> dict[str, str]: """Validate the passed selection.""" - schema = self.DATA_SCHEMA.schema.copy() + schema = { + key: value + for key, value in self.DATA_SCHEMA.schema.items() + if key != "entity_id" + } - if "accept" in self.config: - # If accept is set, the entity_id field will not be present - schema.pop("entity_id", None) - else: + if "accept" not in self.config: # If accept is not set, the entity_id field is required schema[vol.Required("entity_id")] = cv.entity_id_or_uuid - media: dict[str, str] = self.DATA_SCHEMA(data) + media: dict[str, str] = vol.Schema(schema)(data) return media diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index dd8cd1c1b64..0e68992d0e4 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -842,7 +842,16 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> "metadata": {}, }, ), - (None, "abc", {}), + ( + None, + "abc", + {}, + # We require entity_id when accept is not set + { + "media_content_id": "abc", + "media_content_type": "def", + }, + ), ), ( { @@ -859,7 +868,18 @@ def test_theme_selector_schema(schema, valid_selections, invalid_selections) -> "metadata": {}, }, ), - (None, "abc", {}), + ( + None, + "abc", + {}, + { + # We do not allow entity_id when accept is set + "entity_id": "sensor.abc", + "media_content_id": "abc", + "media_content_type": "def", + "metadata": {}, + }, + ), ), ], ) From c0368f24483c7f99e63b9dc150e3a3d509253d63 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 4 Jul 2025 22:31:11 +0200 Subject: [PATCH 1130/1664] Add weekdays to time trigger (#147505) Co-authored-by: Claude --- .../components/homeassistant/triggers/time.py | 21 +- .../homeassistant/triggers/test_time.py | 199 +++++++++++++++++- 2 files changed, 218 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant/triggers/time.py b/homeassistant/components/homeassistant/triggers/time.py index e07d806d3dc..27c63742f7b 100644 --- a/homeassistant/components/homeassistant/triggers/time.py +++ b/homeassistant/components/homeassistant/triggers/time.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_PLATFORM, STATE_UNAVAILABLE, STATE_UNKNOWN, + WEEKDAYS, ) from homeassistant.core import ( CALLBACK_TYPE, @@ -37,6 +38,8 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util +CONF_WEEKDAY = "weekday" + _TIME_TRIGGER_ENTITY = vol.All(str, cv.entity_domain(["input_datetime", "sensor"])) _TIME_AT_SCHEMA = vol.Any(cv.time, _TIME_TRIGGER_ENTITY) @@ -74,6 +77,10 @@ TRIGGER_SCHEMA = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "time", vol.Required(CONF_AT): vol.All(cv.ensure_list, [_TIME_TRIGGER_SCHEMA]), + vol.Optional(CONF_WEEKDAY): vol.Any( + vol.In(WEEKDAYS), + vol.All(cv.ensure_list, [vol.In(WEEKDAYS)]), + ), } ) @@ -85,7 +92,7 @@ class TrackEntity(NamedTuple): callback: Callable -async def async_attach_trigger( +async def async_attach_trigger( # noqa: C901 hass: HomeAssistant, config: ConfigType, action: TriggerActionType, @@ -103,6 +110,18 @@ async def async_attach_trigger( description: str, now: datetime, *, entity_id: str | None = None ) -> None: """Listen for time changes and calls action.""" + # Check weekday filter if configured + if CONF_WEEKDAY in config: + weekday_config = config[CONF_WEEKDAY] + current_weekday = WEEKDAYS[now.weekday()] + + # Check if current weekday matches the configuration + if isinstance(weekday_config, str): + if current_weekday != weekday_config: + return + elif current_weekday not in weekday_config: + return + hass.async_run_hass_job( job, { diff --git a/tests/components/homeassistant/triggers/test_time.py b/tests/components/homeassistant/triggers/test_time.py index 9a4f41d08e1..dc9fb1d34c2 100644 --- a/tests/components/homeassistant/triggers/test_time.py +++ b/tests/components/homeassistant/triggers/test_time.py @@ -1,6 +1,6 @@ """The tests for the time automation.""" -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory @@ -877,3 +877,200 @@ async def test_if_at_template_limited_template( await hass.async_block_till_done() assert "is not supported in limited templates" in caplog.text + + +async def test_if_fires_using_weekday_single( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing on a specific weekday.""" + # Freeze time to Monday, January 2, 2023 at 5:00:00 + monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0)) + + freezer.move_to(monday_trigger) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": {"platform": "time", "at": "5:00:00", "weekday": "mon"}, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + # Fire the trigger on Monday + async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(service_calls) == 1 + assert service_calls[0].data["some"] == "time - Monday" + + # Fire on Tuesday at the same time - should not trigger + tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0)) + async_fire_time_changed(hass, tuesday_trigger) + await hass.async_block_till_done() + + # Should still be only 1 call + assert len(service_calls) == 1 + + +async def test_if_fires_using_weekday_multiple( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing on multiple weekdays.""" + # Freeze time to Monday, January 2, 2023 at 5:00:00 + monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0)) + + freezer.move_to(monday_trigger) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": "5:00:00", + "weekday": ["mon", "wed", "fri"], + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + # Fire on Monday - should trigger + async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1)) + await hass.async_block_till_done() + assert len(service_calls) == 1 + assert "Monday" in service_calls[0].data["some"] + + # Fire on Tuesday - should not trigger + tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0)) + async_fire_time_changed(hass, tuesday_trigger) + await hass.async_block_till_done() + assert len(service_calls) == 1 + + # Fire on Wednesday - should trigger + wednesday_trigger = dt_util.as_utc(datetime(2023, 1, 4, 5, 0, 0, 0)) + async_fire_time_changed(hass, wednesday_trigger) + await hass.async_block_till_done() + assert len(service_calls) == 2 + assert "Wednesday" in service_calls[1].data["some"] + + # Fire on Friday - should trigger + friday_trigger = dt_util.as_utc(datetime(2023, 1, 6, 5, 0, 0, 0)) + async_fire_time_changed(hass, friday_trigger) + await hass.async_block_till_done() + assert len(service_calls) == 3 + assert "Friday" in service_calls[2].data["some"] + + +async def test_if_fires_using_weekday_with_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + service_calls: list[ServiceCall], +) -> None: + """Test for firing on weekday with input_datetime entity.""" + await async_setup_component( + hass, + "input_datetime", + {"input_datetime": {"trigger": {"has_date": False, "has_time": True}}}, + ) + + # Freeze time to Monday, January 2, 2023 at 5:00:00 + monday_trigger = dt_util.as_utc(datetime(2023, 1, 2, 5, 0, 0, 0)) + + await hass.services.async_call( + "input_datetime", + "set_datetime", + { + ATTR_ENTITY_ID: "input_datetime.trigger", + "time": "05:00:00", + }, + blocking=True, + ) + + freezer.move_to(monday_trigger) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "time", + "at": "input_datetime.trigger", + "weekday": "mon", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "{{ trigger.platform }} - {{ trigger.now.strftime('%A') }}", + "entity": "{{ trigger.entity_id }}", + }, + }, + } + }, + ) + await hass.async_block_till_done() + + # Fire on Monday - should trigger + async_fire_time_changed(hass, monday_trigger + timedelta(seconds=1)) + await hass.async_block_till_done() + automation_calls = [call for call in service_calls if call.domain == "test"] + assert len(automation_calls) == 1 + assert "Monday" in automation_calls[0].data["some"] + assert automation_calls[0].data["entity"] == "input_datetime.trigger" + + # Fire on Tuesday - should not trigger + tuesday_trigger = dt_util.as_utc(datetime(2023, 1, 3, 5, 0, 0, 0)) + async_fire_time_changed(hass, tuesday_trigger) + await hass.async_block_till_done() + automation_calls = [call for call in service_calls if call.domain == "test"] + assert len(automation_calls) == 1 + + +def test_weekday_validation() -> None: + """Test weekday validation in trigger schema.""" + # Valid single weekday + valid_config = {"platform": "time", "at": "5:00:00", "weekday": "mon"} + time.TRIGGER_SCHEMA(valid_config) + + # Valid multiple weekdays + valid_config = { + "platform": "time", + "at": "5:00:00", + "weekday": ["mon", "wed", "fri"], + } + time.TRIGGER_SCHEMA(valid_config) + + # Invalid weekday + invalid_config = {"platform": "time", "at": "5:00:00", "weekday": "invalid"} + with pytest.raises(vol.Invalid): + time.TRIGGER_SCHEMA(invalid_config) + + # Invalid weekday in list + invalid_config = { + "platform": "time", + "at": "5:00:00", + "weekday": ["mon", "invalid"], + } + with pytest.raises(vol.Invalid): + time.TRIGGER_SCHEMA(invalid_config) From 57c04f3a5635c829718810f65c4f7df10423c3a0 Mon Sep 17 00:00:00 2001 From: TimL Date: Sat, 5 Jul 2025 06:35:44 +1000 Subject: [PATCH 1131/1664] Bump pysmlight to v0.2.7 (#148101) Co-authored-by: Franck Nijhof --- homeassistant/components/smlight/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json index 9a37cc554c7..9340573f6ce 100644 --- a/homeassistant/components/smlight/manifest.json +++ b/homeassistant/components/smlight/manifest.json @@ -12,7 +12,7 @@ "integration_type": "device", "iot_class": "local_push", "quality_scale": "silver", - "requirements": ["pysmlight==0.2.6"], + "requirements": ["pysmlight==0.2.7"], "zeroconf": [ { "type": "_slzb-06._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 862c95d2a47..f596a32ed14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2360,7 +2360,7 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.6 +pysmlight==0.2.7 # homeassistant.components.snmp pysnmp==6.2.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2515980ccfd..987d16b6a66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1963,7 +1963,7 @@ pysmhi==1.0.2 pysml==0.1.5 # homeassistant.components.smlight -pysmlight==0.2.6 +pysmlight==0.2.7 # homeassistant.components.snmp pysnmp==6.2.6 From 22e46d9977503dd97ff8a38478b261a8eb4955fe Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 4 Jul 2025 13:48:48 -0700 Subject: [PATCH 1132/1664] Make derivative sensor unavailable when source sensor is unavailable (#147468) --- homeassistant/components/derivative/sensor.py | 75 ++++++-- tests/components/derivative/test_init.py | 3 + tests/components/derivative/test_sensor.py | 168 +++++++++++++++++- 3 files changed, 220 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 0639826b1ee..ab09c17673c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -198,6 +198,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._attr_native_value = round(Decimal(0), round_digits) # List of tuples with (timestamp_start, timestamp_end, derivative) self._state_list: list[tuple[datetime, datetime, Decimal]] = [] + self._last_valid_state_time: tuple[str, datetime] | None = None self._attr_name = name if name is not None else f"{source_entity} derivative" self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} @@ -242,6 +243,25 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if (current_time - time_end).total_seconds() < self._time_window ] + def _handle_invalid_source_state(self, state: State | None) -> bool: + # Check the source state for unknown/unavailable condition. If unusable, write unknown/unavailable state and return false. + if not state or state.state == STATE_UNAVAILABLE: + self._attr_available = False + self.async_write_ha_state() + return False + if not _is_decimal_state(state.state): + self._attr_available = True + self._write_native_value(None) + return False + self._attr_available = True + return True + + def _write_native_value(self, derivative: Decimal | None) -> None: + self._attr_native_value = ( + None if derivative is None else round(derivative, self._round_digits) + ) + self.async_write_ha_state() + async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() @@ -255,8 +275,8 @@ class DerivativeSensor(RestoreSensor, SensorEntity): Decimal(restored_data.native_value), # type: ignore[arg-type] self._round_digits, ) - except SyntaxError as err: - _LOGGER.warning("Could not restore last state: %s", err) + except (InvalidOperation, TypeError): + self._attr_native_value = None def schedule_max_sub_interval_exceeded(source_state: State | None) -> None: """Schedule calculation using the source state and max_sub_interval. @@ -280,9 +300,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._prune_state_list(now) derivative = self._calc_derivative_from_state_list(now) - self._attr_native_value = round(derivative, self._round_digits) - - self.async_write_ha_state() + self._write_native_value(derivative) # If derivative is now zero, don't schedule another timeout callback, as it will have no effect if derivative != 0: @@ -299,36 +317,46 @@ class DerivativeSensor(RestoreSensor, SensorEntity): """Handle constant sensor state.""" self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] + if not self._handle_invalid_source_state(new_state): + return + + assert new_state if self._attr_native_value == Decimal(0): # If the derivative is zero, and the source sensor hasn't # changed state, then we know it will still be zero. return schedule_max_sub_interval_exceeded(new_state) - new_state = event.data["new_state"] - if new_state is not None: - calc_derivative( - new_state, new_state.state, event.data["old_last_reported"] - ) + calc_derivative(new_state, new_state.state, event.data["old_last_reported"]) @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: """Handle changed sensor state.""" self._cancel_max_sub_interval_exceeded_callback() new_state = event.data["new_state"] + if not self._handle_invalid_source_state(new_state): + return + + assert new_state schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] - if new_state is not None and old_state is not None: + if old_state is not None: calc_derivative(new_state, old_state.state, old_state.last_reported) + else: + # On first state change from none, update availability + self.async_write_ha_state() def calc_derivative( new_state: State, old_value: str, old_last_reported: datetime ) -> None: """Handle the sensor state changes.""" - if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - return + if not _is_decimal_state(old_value): + if self._last_valid_state_time: + old_value = self._last_valid_state_time[0] + old_last_reported = self._last_valid_state_time[1] + else: + # Sensor becomes valid for the first time, just keep the restored value + self.async_write_ha_state() + return if self.native_unit_of_measurement is None: unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -373,6 +401,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): self._state_list.append( (old_last_reported, new_state.last_reported, new_derivative) ) + self._last_valid_state_time = ( + new_state.state, + new_state.last_reported, + ) # If outside of time window just report derivative (is the same as modeling it in the window), # otherwise take the weighted average with the previous derivatives @@ -382,11 +414,16 @@ class DerivativeSensor(RestoreSensor, SensorEntity): derivative = self._calc_derivative_from_state_list( new_state.last_reported ) - self._attr_native_value = round(derivative, self._round_digits) - self.async_write_ha_state() + self._write_native_value(derivative) + + source_state = self.hass.states.get(self._sensor_source_id) + if source_state is None or source_state.state in [ + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ]: + self._attr_available = False if self._max_sub_interval is not None: - source_state = self.hass.states.get(self._sensor_source_id) schedule_max_sub_interval_exceeded(source_state) @callback diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index d237703eb2e..533f91c8a33 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -99,6 +99,9 @@ async def test_setup_and_remove_config_entry( input_sensor_entity_id = "sensor.input" derivative_entity_id = "sensor.my_derivative" + hass.states.async_set(input_sensor_entity_id, "10.0", {}) + await hass.async_block_till_done() + # Setup the config entry config_entry = MockConfigEntry( data={}, diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index e4e7097341c..10092e30ca0 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -6,16 +6,26 @@ import random from typing import Any from freezegun import freeze_time +import pytest from homeassistant.components.derivative.const import DOMAIN from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass -from homeassistant.const import STATE_UNAVAILABLE, UnitOfPower, UnitOfTime +from homeassistant.const import ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + UnitOfPower, + UnitOfTime, +) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + mock_restore_cache_with_extra_data, +) async def test_state(hass: HomeAssistant) -> None: @@ -106,6 +116,7 @@ async def _setup_sensor( config = {"sensor": dict(default_config, **config)} assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) @@ -440,16 +451,14 @@ async def test_sub_intervals_instantaneous(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - assert derivative == -0.29 + assert state.state == STATE_UNAVAILABLE now += timedelta(seconds=60) async_fire_time_changed(hass, now) await hass.async_block_till_done() state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - assert derivative == -0.29 + assert state.state == STATE_UNAVAILABLE now += timedelta(seconds=10) freezer.move_to(now) @@ -458,7 +467,7 @@ async def test_sub_intervals_instantaneous(hass: HomeAssistant) -> None: state = hass.states.get("sensor.power") derivative = round(float(state.state), config["sensor"]["round"]) - assert derivative == -0.29 + assert derivative == 0 now += timedelta(seconds=max_sub_interval + 1) async_fire_time_changed(hass, now) @@ -693,3 +702,148 @@ async def test_device_id( derivative_entity = entity_registry.async_get("sensor.derivative") assert derivative_entity is not None assert derivative_entity.device_id == source_entity.device_id + + +@pytest.mark.parametrize("bad_state", [STATE_UNAVAILABLE, STATE_UNKNOWN, "foo"]) +async def test_unavailable( + bad_state: str, + hass: HomeAssistant, +) -> None: + """Test derivative sensor state when unavailable.""" + config, entity_id = await _setup_sensor(hass, {"unit_time": "s"}) + + times = [0, 1, 2, 3] + values = [0, 1, bad_state, 2] + expected_state = [ + 0, + 1, + STATE_UNAVAILABLE if bad_state == STATE_UNAVAILABLE else STATE_UNKNOWN, + 0.5, + ] + + # Testing a energy sensor with non-monotonic intervals and values + base = dt_util.utcnow() + with freeze_time(base) as freezer: + for time, value, expect in zip(times, values, expected_state, strict=False): + freezer.move_to(base + timedelta(seconds=time)) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + rounded_state = ( + state.state + if expect in [STATE_UNKNOWN, STATE_UNAVAILABLE] + else round(float(state.state), config["sensor"]["round"]) + ) + assert rounded_state == expect + + +@pytest.mark.parametrize("bad_state", [STATE_UNAVAILABLE, STATE_UNKNOWN, "foo"]) +async def test_unavailable_2( + bad_state: str, + hass: HomeAssistant, +) -> None: + """Test derivative sensor state when unavailable with a time window.""" + config, entity_id = await _setup_sensor( + hass, {"unit_time": "s", "time_window": {"seconds": 10}} + ) + + # Monotonically increasing by 1, with some unavailable holes + times = list(range(21)) + values = list(range(21)) + values[3] = bad_state + values[6] = bad_state + values[7] = bad_state + values[8] = bad_state + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + for time, value in zip(times, values, strict=False): + freezer.move_to(base + timedelta(seconds=time)) + hass.states.async_set(entity_id, value, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + + if value == bad_state: + assert ( + state.state == STATE_UNAVAILABLE + if bad_state is STATE_UNAVAILABLE + else STATE_UNKNOWN + ) + else: + expect = (time / 10) if time < 10 else 1 + assert round(float(state.state), config["sensor"]["round"]) == round( + expect, config["sensor"]["round"] + ) + + +@pytest.mark.parametrize("restore_state", ["3.00", STATE_UNKNOWN]) +async def test_unavailable_boot( + restore_state, + hass: HomeAssistant, +) -> None: + """Test that the booting sequence does not leave derivative in a bad state.""" + + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.power", + restore_state, + { + "unit_of_measurement": "W", + }, + ), + { + "native_value": restore_state, + "native_unit_of_measurement": "W", + }, + ), + ], + ) + + config = { + "platform": "derivative", + "name": "power", + "source": "sensor.energy", + "round": 2, + "unit_time": "s", + } + + config = {"sensor": config} + entity_id = config["sensor"]["source"] + hass.states.async_set(entity_id, STATE_UNAVAILABLE, {}) + await hass.async_block_till_done() + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + # Sensor is unavailable as source is unavailable + assert state.state == STATE_UNAVAILABLE + + base = dt_util.utcnow() + with freeze_time(base) as freezer: + freezer.move_to(base + timedelta(seconds=1)) + hass.states.async_set(entity_id, 10, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + # The source sensor has moved to a valid value, but we need 2 points to derive, + # so just hold until the next tick + assert state.state == restore_state + + freezer.move_to(base + timedelta(seconds=2)) + hass.states.async_set(entity_id, 15, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.power") + assert state is not None + # Now that the source sensor has two valid datapoints, we can calculate derivative + assert state.state == "5.00" From 520d92b90265b04a5cfd36ddba6ac1b2af5e8396 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 4 Jul 2025 22:53:11 +0200 Subject: [PATCH 1133/1664] Use brightness stored in hardware device when switching LCN lights (#147375) --- homeassistant/components/lcn/light.py | 50 +++++++++++---------- tests/components/lcn/test_light.py | 63 ++++++++------------------- 2 files changed, 47 insertions(+), 66 deletions(-) diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index cd6b5c7057e..b9dad0aeb19 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -18,6 +18,7 @@ from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType +from homeassistant.util.color import brightness_to_value, value_to_brightness from .const import ( CONF_DIMMABLE, @@ -29,6 +30,8 @@ from .const import ( from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry +BRIGHTNESS_SCALE = (1, 100) + PARALLEL_UPDATES = 0 @@ -91,8 +94,6 @@ class LcnOutputLight(LcnEntity, LightEntity): ) self.dimmable = config[CONF_DOMAIN_DATA][CONF_DIMMABLE] - self._is_dimming_to_zero = False - if self.dimmable: self._attr_color_mode = ColorMode.BRIGHTNESS else: @@ -113,10 +114,6 @@ class LcnOutputLight(LcnEntity, LightEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if ATTR_BRIGHTNESS in kwargs: - percent = int(kwargs[ATTR_BRIGHTNESS] / 255.0 * 100) - else: - percent = 100 if ATTR_TRANSITION in kwargs: transition = pypck.lcn_defs.time_to_ramp_value( kwargs[ATTR_TRANSITION] * 1000 @@ -124,12 +121,23 @@ class LcnOutputLight(LcnEntity, LightEntity): else: transition = self._transition - if not await self.device_connection.dim_output( - self.output.value, percent, transition - ): + if ATTR_BRIGHTNESS in kwargs: + percent = int( + brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS]) + ) + if not await self.device_connection.dim_output( + self.output.value, percent, transition + ): + return + elif not self.is_on: + if not await self.device_connection.toggle_output( + self.output.value, transition, to_memory=True + ): + return + else: return + self._attr_is_on = True - self._is_dimming_to_zero = False self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -141,13 +149,13 @@ class LcnOutputLight(LcnEntity, LightEntity): else: transition = self._transition - if not await self.device_connection.dim_output( - self.output.value, 0, transition - ): - return - self._is_dimming_to_zero = bool(transition) - self._attr_is_on = False - self.async_write_ha_state() + if self.is_on: + if not await self.device_connection.toggle_output( + self.output.value, transition, to_memory=True + ): + return + self._attr_is_on = False + self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: """Set light state when LCN input object (command) is received.""" @@ -157,11 +165,9 @@ class LcnOutputLight(LcnEntity, LightEntity): ): return - self._attr_brightness = int(input_obj.get_percent() / 100.0 * 255) - if self._attr_brightness == 0: - self._is_dimming_to_zero = False - if not self._is_dimming_to_zero and self._attr_brightness is not None: - self._attr_is_on = self._attr_brightness > 0 + percent = input_obj.get_percent() + self._attr_brightness = value_to_brightness(BRIGHTNESS_SCALE, percent) + self._attr_is_on = bool(percent) self.async_write_ha_state() diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index 00c2341631e..b13e18bbbd1 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -51,9 +51,9 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No """Test the output light turns on.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: + with patch.object(MockModuleConnection, "toggle_output") as toggle_output: # command failed - dim_output.return_value = False + toggle_output.return_value = False await hass.services.async_call( DOMAIN_LIGHT, @@ -62,15 +62,15 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No blocking=True, ) - dim_output.assert_awaited_with(0, 100, 9) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_ON # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True + toggle_output.reset_mock(return_value=True) + toggle_output.return_value = True await hass.services.async_call( DOMAIN_LIGHT, @@ -79,7 +79,7 @@ async def test_output_turn_on(hass: HomeAssistant, entry: MockConfigEntry) -> No blocking=True, ) - dim_output.assert_awaited_with(0, 100, 9) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None @@ -117,12 +117,16 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N """Test the output light turns off.""" await init_integration(hass, entry) - with patch.object(MockModuleConnection, "dim_output") as dim_output: - state = hass.states.get(LIGHT_OUTPUT1) - state.state = STATE_ON + with patch.object(MockModuleConnection, "toggle_output") as toggle_output: + await hass.services.async_call( + DOMAIN_LIGHT, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, + blocking=True, + ) # command failed - dim_output.return_value = False + toggle_output.return_value = False await hass.services.async_call( DOMAIN_LIGHT, @@ -131,15 +135,15 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N blocking=True, ) - dim_output.assert_awaited_with(0, 0, 9) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_OFF # command success - dim_output.reset_mock(return_value=True) - dim_output.return_value = True + toggle_output.reset_mock(return_value=True) + toggle_output.return_value = True await hass.services.async_call( DOMAIN_LIGHT, @@ -148,36 +152,7 @@ async def test_output_turn_off(hass: HomeAssistant, entry: MockConfigEntry) -> N blocking=True, ) - dim_output.assert_awaited_with(0, 0, 9) - - state = hass.states.get(LIGHT_OUTPUT1) - assert state is not None - assert state.state == STATE_OFF - - -async def test_output_turn_off_with_attributes( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test the output light turns off.""" - await init_integration(hass, entry) - - with patch.object(MockModuleConnection, "dim_output") as dim_output: - dim_output.return_value = True - - state = hass.states.get(LIGHT_OUTPUT1) - state.state = STATE_ON - - await hass.services.async_call( - DOMAIN_LIGHT, - SERVICE_TURN_OFF, - { - ATTR_ENTITY_ID: LIGHT_OUTPUT1, - ATTR_TRANSITION: 2, - }, - blocking=True, - ) - - dim_output.assert_awaited_with(0, 0, 6) + toggle_output.assert_awaited_with(0, 9, to_memory=True) state = hass.states.get(LIGHT_OUTPUT1) assert state is not None @@ -288,7 +263,7 @@ async def test_pushed_output_status_change( state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON - assert state.attributes[ATTR_BRIGHTNESS] == 127 + assert state.attributes[ATTR_BRIGHTNESS] == 128 # push status "off" inp = ModStatusOutput(address, 0, 0) From 8f24ebe96733367cb813ede00d6f0fc93234c12e Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Fri, 4 Jul 2025 22:55:20 +0200 Subject: [PATCH 1134/1664] Remove deprecated support for lock sensors and corresponding actions in lcn (#147143) --- homeassistant/components/lcn/binary_sensor.py | 136 +----------------- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../lcn/fixtures/config_entry_pchk.json | 16 --- .../lcn/snapshots/test_binary_sensor.ambr | 96 ------------- tests/components/lcn/test_binary_sensor.py | 129 +---------------- 7 files changed, 10 insertions(+), 373 deletions(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index b124b3f6188..a9f194fe1b8 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -5,23 +5,16 @@ from functools import partial import pypck -from homeassistant.components.automation import automations_with_entity from homeassistant.components.binary_sensor import ( DOMAIN as DOMAIN_BINARY_SENSOR, BinarySensorEntity, ) -from homeassistant.components.script import scripts_with_entity from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import ConfigType -from .const import BINSENSOR_PORTS, CONF_DOMAIN_DATA, DOMAIN, SETPOINTS +from .const import CONF_DOMAIN_DATA from .entity import LcnEntity from .helpers import InputType, LcnConfigEntry @@ -34,15 +27,9 @@ def add_lcn_entities( entity_configs: Iterable[ConfigType], ) -> None: """Add entities for this domain.""" - entities: list[LcnRegulatorLockSensor | LcnBinarySensor | LcnLockKeysSensor] = [] - for entity_config in entity_configs: - if entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in SETPOINTS: - entities.append(LcnRegulatorLockSensor(entity_config, config_entry)) - elif entity_config[CONF_DOMAIN_DATA][CONF_SOURCE] in BINSENSOR_PORTS: - entities.append(LcnBinarySensor(entity_config, config_entry)) - else: # in KEY - entities.append(LcnLockKeysSensor(entity_config, config_entry)) - + entities = [ + LcnBinarySensor(entity_config, config_entry) for entity_config in entity_configs + ] async_add_entities(entities) @@ -71,65 +58,6 @@ async def async_setup_entry( ) -class LcnRegulatorLockSensor(LcnEntity, BinarySensorEntity): - """Representation of a LCN binary sensor for regulator locks.""" - - def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: - """Initialize the LCN binary sensor.""" - super().__init__(config, config_entry) - - self.setpoint_variable = pypck.lcn_defs.Var[ - config[CONF_DOMAIN_DATA][CONF_SOURCE] - ] - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler( - self.setpoint_variable - ) - - entity_automations = automations_with_entity(self.hass, self.entity_id) - entity_scripts = scripts_with_entity(self.hass, self.entity_id) - if entity_automations + entity_scripts: - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_regulatorlock_sensor", - translation_placeholders={ - "entity": f"{DOMAIN_BINARY_SENSOR}.{self.name.lower().replace(' ', '_')}", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler( - self.setpoint_variable - ) - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" - ) - - def input_received(self, input_obj: InputType) -> None: - """Set sensor value when LCN input object (command) is received.""" - if ( - not isinstance(input_obj, pypck.inputs.ModStatusVar) - or input_obj.get_var() != self.setpoint_variable - ): - return - - self._attr_is_on = input_obj.get_value().is_locked_regulator() - self.async_write_ha_state() - - class LcnBinarySensor(LcnEntity, BinarySensorEntity): """Representation of a LCN binary sensor for binary sensor ports.""" @@ -164,59 +92,3 @@ class LcnBinarySensor(LcnEntity, BinarySensorEntity): self._attr_is_on = input_obj.get_state(self.bin_sensor_port.value) self.async_write_ha_state() - - -class LcnLockKeysSensor(LcnEntity, BinarySensorEntity): - """Representation of a LCN sensor for key locks.""" - - def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None: - """Initialize the LCN sensor.""" - super().__init__(config, config_entry) - - self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - - if not self.device_connection.is_group: - await self.device_connection.activate_status_request_handler(self.source) - - entity_automations = automations_with_entity(self.hass, self.entity_id) - entity_scripts = scripts_with_entity(self.hass, self.entity_id) - if entity_automations + entity_scripts: - async_create_issue( - self.hass, - DOMAIN, - f"deprecated_binary_sensor_{self.entity_id}", - breaks_in_ha_version="2025.5.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_keylock_sensor", - translation_placeholders={ - "entity": f"{DOMAIN_BINARY_SENSOR}.{self.name.lower().replace(' ', '_')}", - }, - ) - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - await super().async_will_remove_from_hass() - if not self.device_connection.is_group: - await self.device_connection.cancel_status_request_handler(self.source) - async_delete_issue( - self.hass, DOMAIN, f"deprecated_binary_sensor_{self.entity_id}" - ) - - def input_received(self, input_obj: InputType) -> None: - """Set sensor value when LCN input object (command) is received.""" - if ( - not isinstance(input_obj, pypck.inputs.ModStatusKeyLocks) - or self.source not in pypck.lcn_defs.Key - ): - return - - table_id = ord(self.source.name[0]) - 65 - key_id = int(self.source.name[1]) - 1 - - self._attr_is_on = input_obj.get_state(table_id, key_id) - self.async_write_ha_state() diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 8a47f1c1359..234178d3e3b 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["pypck"], "quality_scale": "bronze", - "requirements": ["pypck==0.8.10", "lcn-frontend==0.2.5"] + "requirements": ["pypck==0.8.10", "lcn-frontend==0.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index f596a32ed14..d1a2c2a21ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1319,7 +1319,7 @@ lakeside==0.13 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.5 +lcn-frontend==0.2.6 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 987d16b6a66..b4bc9a82158 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1138,7 +1138,7 @@ lacrosse-view==1.1.1 laundrify-aio==1.2.2 # homeassistant.components.lcn -lcn-frontend==0.2.5 +lcn-frontend==0.2.6 # homeassistant.components.ld2410_ble ld2410-ble==0.1.1 diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 5ded11d619a..c0a52821d5a 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -187,14 +187,6 @@ "transition": 10.0 } }, - { - "address": [0, 7, false], - "name": "Sensor_LockRegulator1", - "domain": "binary_sensor", - "domain_data": { - "source": "R1VARSETPOINT" - } - }, { "address": [0, 7, false], "name": "Binary_Sensor1", @@ -203,14 +195,6 @@ "source": "BINSENSOR1" } }, - { - "address": [0, 7, false], - "name": "Sensor_KeyLock", - "domain": "binary_sensor", - "domain_data": { - "source": "A5" - } - }, { "address": [0, 7, false], "name": "Sensor_Var1", diff --git a/tests/components/lcn/snapshots/test_binary_sensor.ambr b/tests/components/lcn/snapshots/test_binary_sensor.ambr index d1a76b98bf1..1317150b19e 100644 --- a/tests/components/lcn/snapshots/test_binary_sensor.ambr +++ b/tests/components/lcn/snapshots/test_binary_sensor.ambr @@ -47,99 +47,3 @@ 'state': 'unknown', }) # --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-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.testmodule_sensor_keylock', - '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': 'Sensor_KeyLock', - 'platform': 'lcn', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk_json-m000007-a5', - 'unit_of_measurement': None, - }) -# --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_keylock-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'TestModule Sensor_KeyLock', - }), - 'context': , - 'entity_id': 'binary_sensor.testmodule_sensor_keylock', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-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.testmodule_sensor_lockregulator1', - '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': 'Sensor_LockRegulator1', - 'platform': 'lcn', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'lcn/config_entry_pchk_json-m000007-r1varsetpoint', - 'unit_of_measurement': None, - }) -# --- -# name: test_setup_lcn_binary_sensor[binary_sensor.testmodule_sensor_lockregulator1-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'TestModule Sensor_LockRegulator1', - }), - 'context': , - 'entity_id': 'binary_sensor.testmodule_sensor_lockregulator1', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py index b9362dcd242..a4712459e78 100644 --- a/tests/components/lcn/test_binary_sensor.py +++ b/tests/components/lcn/test_binary_sensor.py @@ -2,29 +2,20 @@ from unittest.mock import patch -from pypck.inputs import ModStatusBinSensors, ModStatusKeyLocks, ModStatusVar +from pypck.inputs import ModStatusBinSensors from pypck.lcn_addr import LcnAddr -from pypck.lcn_defs import Var, VarValue -import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import automation, script -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.lcn import DOMAIN from homeassistant.components.lcn.helpers import get_device_connection -from homeassistant.components.script import scripts_with_entity from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_registry as er, issue_registry as ir -from homeassistant.setup import async_setup_component +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import MockConfigEntry, init_integration from tests.common import snapshot_platform -BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.testmodule_sensor_lockregulator1" BINARY_SENSOR_SENSOR1 = "binary_sensor.testmodule_binary_sensor1" -BINARY_SENSOR_KEYLOCK = "binary_sensor.testmodule_sensor_keylock" async def test_setup_lcn_binary_sensor( @@ -40,35 +31,6 @@ async def test_setup_lcn_binary_sensor( await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) -async def test_pushed_lock_setpoint_status_change( - hass: HomeAssistant, - entry: MockConfigEntry, -) -> None: - """Test the lock setpoint sensor changes its state on status received.""" - await init_integration(hass, entry) - - device_connection = get_device_connection(hass, (0, 7, False), entry) - address = LcnAddr(0, 7, False) - - # push status lock setpoint - inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x8000)) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) - assert state is not None - assert state.state == STATE_ON - - # push status unlock setpoint - inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x7FFF)) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) - assert state is not None - assert state.state == STATE_OFF - - async def test_pushed_binsensor_status_change( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -99,94 +61,9 @@ async def test_pushed_binsensor_status_change( assert state.state == STATE_ON -async def test_pushed_keylock_status_change( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test the keylock sensor changes its state on status received.""" - await init_integration(hass, entry) - - device_connection = get_device_connection(hass, (0, 7, False), entry) - address = LcnAddr(0, 7, False) - states = [[False] * 8 for i in range(4)] - - # push status keylock "off" - inp = ModStatusKeyLocks(address, states) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_KEYLOCK) - assert state is not None - assert state.state == STATE_OFF - - # push status keylock "on" - states[0][4] = True - inp = ModStatusKeyLocks(address, states) - await device_connection.async_process_input(inp) - await hass.async_block_till_done() - - state = hass.states.get(BINARY_SENSOR_KEYLOCK) - assert state is not None - assert state.state == STATE_ON - - async def test_unload_config_entry(hass: HomeAssistant, entry: MockConfigEntry) -> None: """Test the binary sensor is removed when the config entry is unloaded.""" await init_integration(hass, entry) await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get(BINARY_SENSOR_LOCKREGULATOR1).state == STATE_UNAVAILABLE assert hass.states.get(BINARY_SENSOR_SENSOR1).state == STATE_UNAVAILABLE - assert hass.states.get(BINARY_SENSOR_KEYLOCK).state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize( - "entity_id", - [ - "binary_sensor.testmodule_sensor_lockregulator1", - "binary_sensor.testmodule_sensor_keylock", - ], -) -async def test_create_issue( - hass: HomeAssistant, - service_calls: list[ServiceCall], - issue_registry: ir.IssueRegistry, - entry: MockConfigEntry, - entity_id, -) -> None: - """Test we create an issue when an automation or script is using a deprecated entity.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "test", - "trigger": {"platform": "state", "entity_id": entity_id}, - "action": {"action": "test.automation"}, - } - }, - ) - - assert await async_setup_component( - hass, - script.DOMAIN, - { - script.DOMAIN: { - "test": { - "sequence": { - "condition": "state", - "entity_id": entity_id, - "state": STATE_ON, - } - } - } - }, - ) - - await init_integration(hass, entry) - - assert automations_with_entity(hass, entity_id)[0] == "automation.test" - assert scripts_with_entity(hass, entity_id)[0] == "script.test" - - assert issue_registry.async_get_issue( - DOMAIN, f"deprecated_binary_sensor_{entity_id}" - ) From 79683c8267bf5321c0f40468df6d83d2a5835567 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 4 Jul 2025 22:59:38 +0200 Subject: [PATCH 1135/1664] Log availability of devices in devolo Home Control (#147091) --- .../components/devolo_home_control/entity.py | 17 ++++++++++++++++- .../devolo_home_control/test_switch.py | 14 +++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index 9edc7d54145..dade8d6a2f9 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -87,7 +87,22 @@ class DevoloDeviceEntity(Entity): self._value = message[1] elif len(message) == 3 and message[2] == "status": # Maybe the API wants to tell us, that the device went on- or offline. - self._attr_available = self._device_instance.is_online() + state = self._device_instance.is_online() + if state != self.available and not state: + _LOGGER.info( + "Device %s is unavailable", + self._device_instance.settings_property[ + "general_device_settings" + ].name, + ) + if state != self.available and state: + _LOGGER.info( + "Device %s is back online", + self._device_instance.settings_property[ + "general_device_settings" + ].name, + ) + self._attr_available = state elif message[1] == "del" and self.platform.config_entry: device_registry = dr.async_get(self.hass) device = device_registry.async_get_device( diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py index 46adaf8c8b0..0a66760bc81 100644 --- a/tests/components/devolo_home_control/test_switch.py +++ b/tests/components/devolo_home_control/test_switch.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -20,7 +21,10 @@ from .mocks import HomeControlMock, HomeControlMockSwitch async def test_switch( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + caplog: pytest.LogCaptureFixture, ) -> None: """Test setup and state change of a switch device.""" entry = configure_integration(hass) @@ -69,6 +73,14 @@ async def test_switch( test_gateway.publisher.dispatch("Test", ("Status", False, "status")) await hass.async_block_till_done() assert hass.states.get(f"{SWITCH_DOMAIN}.test").state == STATE_UNAVAILABLE + assert "Device Test is unavailable" in caplog.text + + # Emulate websocket message: device went back online + test_gateway.devices["Test"].status = 0 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{SWITCH_DOMAIN}.test").state == STATE_ON + assert "Device Test is back online" in caplog.text async def test_remove_from_hass(hass: HomeAssistant) -> None: From be7735964b92b170f1bb68cdfc4813dfd799becf Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Fri, 4 Jul 2025 17:02:38 -0400 Subject: [PATCH 1136/1664] Sonos remove unneeded mocking from test (#147064) --- tests/components/sonos/test_init.py | 34 ++++++++++++----------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 1bc8baff752..c1b98b2ec60 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,7 +1,6 @@ """Tests for the Sonos config flow.""" import asyncio -from datetime import timedelta import logging from unittest.mock import Mock, PropertyMock, patch @@ -330,29 +329,24 @@ async def test_async_poll_manual_hosts_5( soco_2.renderingControl = Mock() soco_2.renderingControl.GetVolume = Mock() speaker_2_activity = SpeakerActivity(hass, soco_2) - with patch( - "homeassistant.components.sonos.DISCOVERY_INTERVAL" - ) as mock_discovery_interval: - # Speed up manual discovery interval so second iteration runs sooner - mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) - with caplog.at_level(logging.DEBUG): - caplog.clear() + with caplog.at_level(logging.DEBUG): + caplog.clear() - await _setup_hass(hass) + await _setup_hass(hass) - assert "media_player.bedroom" in entity_registry.entities - assert "media_player.living_room" in entity_registry.entities + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=0.5)) - await hass.async_block_till_done() - await asyncio.gather( - *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] - ) - assert speaker_1_activity.call_count == 1 - assert speaker_2_activity.call_count == 1 - assert "Activity on Living Room" in caplog.text - assert "Activity on Bedroom" in caplog.text + async_fire_time_changed(hass, dt_util.utcnow() + DISCOVERY_INTERVAL) + await hass.async_block_till_done() + await asyncio.gather( + *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] + ) + assert speaker_1_activity.call_count == 1 + assert speaker_2_activity.call_count == 1 + assert "Activity on Living Room" in caplog.text + assert "Activity on Bedroom" in caplog.text await hass.async_block_till_done(wait_background_tasks=True) From 9a5cbe483bf409f8f2f20e32791517875a3f995d Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 4 Jul 2025 23:06:47 +0200 Subject: [PATCH 1137/1664] Remove obsolete string unit_system in here_travel_time (#146656) --- homeassistant/components/here_travel_time/strings.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/here_travel_time/strings.json b/homeassistant/components/here_travel_time/strings.json index c0534fa7154..89350261299 100644 --- a/homeassistant/components/here_travel_time/strings.json +++ b/homeassistant/components/here_travel_time/strings.json @@ -61,8 +61,7 @@ "init": { "data": { "traffic_mode": "Traffic mode", - "route_mode": "Route mode", - "unit_system": "Unit system" + "route_mode": "Route mode" } }, "time_menu": { From ca85ffc06885d81a5052af8d218dd7020ec06e1b Mon Sep 17 00:00:00 2001 From: Michael Podhorodecki Date: Sat, 5 Jul 2025 07:07:13 +1000 Subject: [PATCH 1138/1664] Add Deadlock (SecureMode) support to the Yale Access Bluetooth integration (#144107) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yale/manifest.json | 2 +- .../components/yalexs_ble/__init__.py | 6 +++- .../components/yalexs_ble/icons.json | 11 ++++++ homeassistant/components/yalexs_ble/lock.py | 36 ++++++++++++++++--- .../components/yalexs_ble/manifest.json | 2 +- .../components/yalexs_ble/strings.json | 5 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 58 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/yalexs_ble/icons.json diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a6b2961c2a0..9dc66084a45 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==2.6.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.0.0"] } diff --git a/homeassistant/components/yale/manifest.json b/homeassistant/components/yale/manifest.json index 4d9ea9ec2c9..fee5b0b8310 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==2.6.0"] + "requirements": ["yalexs==8.10.0", "yalexs-ble==3.0.0"] } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index c5183623660..68d64494e41 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -32,7 +32,11 @@ from .util import async_find_existing_service_info, bluetooth_callback_matcher type YALEXSBLEConfigEntry = ConfigEntry[YaleXSBLEData] -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, +] async def async_setup_entry(hass: HomeAssistant, entry: YALEXSBLEConfigEntry) -> bool: diff --git a/homeassistant/components/yalexs_ble/icons.json b/homeassistant/components/yalexs_ble/icons.json new file mode 100644 index 00000000000..0b4929cd778 --- /dev/null +++ b/homeassistant/components/yalexs_ble/icons.json @@ -0,0 +1,11 @@ +{ + "entity": { + "lock": { + "secure_mode": { + "state": { + "locked": "mdi:shield-lock" + } + } + } + } +} diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py index 78b92ab9eb1..3d822714fb5 100644 --- a/homeassistant/components/yalexs_ble/lock.py +++ b/homeassistant/components/yalexs_ble/lock.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import YALEXSBLEConfigEntry from .entity import YALEXSBLEEntity +from .models import YaleXSBLEData async def async_setup_entry( @@ -20,13 +21,15 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up locks.""" - async_add_entities([YaleXSBLELock(entry.runtime_data)]) + async_add_entities( + [YaleXSBLELock(entry.runtime_data), YaleXSBLESecureModeLock(entry.runtime_data)] + ) -class YaleXSBLELock(YALEXSBLEEntity, LockEntity): +class YaleXSBLEBaseLock(YALEXSBLEEntity, LockEntity): """A yale xs ble lock.""" - _attr_name = None + _secure_mode: bool = False @callback def _async_update_state( @@ -39,11 +42,13 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): self._attr_is_jammed = False lock_state = new_state.lock if lock_state is LockStatus.LOCKED: - self._attr_is_locked = True + self._attr_is_locked = not self._secure_mode elif lock_state is LockStatus.LOCKING: self._attr_is_locking = True elif lock_state is LockStatus.UNLOCKING: self._attr_is_unlocking = True + elif lock_state is LockStatus.SECUREMODE: + self._attr_is_locked = True elif lock_state in ( LockStatus.UNKNOWN_01, LockStatus.UNKNOWN_06, @@ -57,6 +62,29 @@ class YaleXSBLELock(YALEXSBLEEntity, LockEntity): """Unlock the lock.""" await self._device.unlock() + +class YaleXSBLELock(YaleXSBLEBaseLock, LockEntity): + """A yale xs ble lock not in secure mode.""" + + _attr_name = None + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" await self._device.lock() + + +class YaleXSBLESecureModeLock(YaleXSBLEBaseLock): + """A yale xs ble lock in secure mode.""" + + _attr_entity_registry_enabled_default = False + _attr_translation_key = "secure_mode" + _secure_mode = True + + def __init__(self, data: YaleXSBLEData) -> None: + """Initialize the entity.""" + super().__init__(data) + self._attr_unique_id = f"{self._device.address}_secure_mode" + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the lock.""" + await self._device.securemode() diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 2387f5dc15f..b3021bd908e 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.6.0"] + "requirements": ["yalexs-ble==3.0.0"] } diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index c79830be3a9..92d807d01f6 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -51,6 +51,11 @@ "battery_voltage": { "name": "Battery voltage" } + }, + "lock": { + "secure_mode": { + "name": "Secure mode" + } } } } diff --git a/requirements_all.txt b/requirements_all.txt index d1a2c2a21ef..8749d653a2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3150,7 +3150,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.6.0 +yalexs-ble==3.0.0 # homeassistant.components.august # homeassistant.components.yale diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4bc9a82158..9e98ca26b18 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2600,7 +2600,7 @@ yalesmartalarmclient==0.4.3 # homeassistant.components.august # homeassistant.components.yale # homeassistant.components.yalexs_ble -yalexs-ble==2.6.0 +yalexs-ble==3.0.0 # homeassistant.components.august # homeassistant.components.yale From dcad5bbe04e40083d24f78c84005a17a67e11355 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 4 Jul 2025 21:26:36 +0000 Subject: [PATCH 1139/1664] Simplify unnecessary re.findall calls (#147907) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/downloader/services.py | 8 +++----- homeassistant/components/qbus/entity.py | 7 ++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/downloader/services.py b/homeassistant/components/downloader/services.py index cce8c9d65b0..bb1b968dd99 100644 --- a/homeassistant/components/downloader/services.py +++ b/homeassistant/components/downloader/services.py @@ -65,12 +65,10 @@ def download_file(service: ServiceCall) -> None: else: if filename is None and "content-disposition" in req.headers: - match = re.findall( + if match := re.search( r"filename=(\S+)", req.headers["content-disposition"] - ) - - if match: - filename = match[0].strip("'\" ") + ): + filename = match.group(1).strip("'\" ") if not filename: filename = os.path.basename(url).strip() diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index ec800c15afa..91e4d83b548 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -48,11 +48,8 @@ def add_new_outputs( def format_ref_id(ref_id: str) -> str | None: """Format the Qbus ref_id.""" - matches: list[str] = re.findall(_REFID_REGEX, ref_id) - - if len(matches) > 0: - if ref_id := matches[0]: - return ref_id.replace("/", "-") + if match := _REFID_REGEX.search(ref_id): + return match.group(1).replace("/", "-") return None From 528daad8545813129b10adda91ea7cc2415916fa Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 4 Jul 2025 23:42:17 +0200 Subject: [PATCH 1140/1664] Constant polling for Husqvarna Automower (#147957) --- .../husqvarna_automower/coordinator.py | 20 ++++- .../husqvarna_automower/test_init.py | 73 ++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index dc653d8ce80..70af5219d04 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -74,7 +74,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): """Subscribe for websocket and poll data from the API.""" if not self.ws_connected: await self.api.connect() - self.api.register_data_callback(self.callback) + self.api.register_data_callback(self.handle_websocket_updates) self.ws_connected = True try: data = await self.api.get_status() @@ -86,11 +86,27 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): return data @callback - def callback(self, ws_data: MowerDictionary) -> None: + def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" self.async_set_updated_data(ws_data) self._async_add_remove_devices_and_entities(ws_data) + @callback + def async_set_updated_data(self, data: MowerDictionary) -> None: + """Override DataUpdateCoordinator to preserve fixed polling interval. + + The built-in implementation resets the polling timer on every websocket + update. Since websockets do not deliver all required data (e.g. statistics + or work area details), we enforce a constant REST polling cadence. + """ + self.data = data + self.last_update_success = True + self.logger.debug( + "Manually updated %s data", + self.name, + ) + self.async_update_listeners() + async def client_listen( self, hass: HomeAssistant, diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index ecb92bb39cf..f2b468c4faf 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -1,7 +1,9 @@ """Tests for init module.""" from asyncio import Event -from datetime import datetime +from collections.abc import Callable +from copy import deepcopy +from datetime import datetime, timedelta import http import time from unittest.mock import AsyncMock, patch @@ -20,7 +22,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.const import DOMAIN, OAUTH2_TOKEN from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -221,6 +223,73 @@ async def test_device_info( assert reg_device == snapshot +async def test_constant_polling( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + values: dict[str, MowerAttributes], + freezer: FrozenDateTimeFactory, +) -> None: + """Verify that receiving a WebSocket update does not interrupt the regular polling cycle. + + The test simulates a WebSocket update that changes an entity's state, then advances time + to trigger a scheduled poll to confirm polled data also arrives. + """ + test_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) + await setup_integration(hass, mock_config_entry) + await hass.async_block_till_done() + + assert mock_automower_client.register_data_callback.called + assert "cb" in callback_holder + + state = hass.states.get("sensor.test_mower_1_battery") + assert state is not None + assert state.state == "100" + state = hass.states.get("sensor.test_mower_1_front_lawn_progress") + assert state is not None + assert state.state == "40" + + test_values[TEST_MOWER_ID].battery.battery_percent = 77 + + freezer.tick(SCAN_INTERVAL - timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + callback_holder["cb"](test_values) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_mower_1_battery") + assert state is not None + assert state.state == "77" + state = hass.states.get("sensor.test_mower_1_front_lawn_progress") + assert state is not None + assert state.state == "40" + + test_values[TEST_MOWER_ID].work_areas[123456].progress = 50 + mock_automower_client.get_status.return_value = test_values + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + mock_automower_client.get_status.assert_awaited() + state = hass.states.get("sensor.test_mower_1_battery") + assert state is not None + assert state.state == "77" + state = hass.states.get("sensor.test_mower_1_front_lawn_progress") + assert state is not None + assert state.state == "50" + + async def test_coordinator_automatic_registry_cleanup( hass: HomeAssistant, mock_automower_client: AsyncMock, From 76be2fdba1431b2d77ef9e86f2f8b441d3374233 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 5 Jul 2025 00:02:36 +0200 Subject: [PATCH 1141/1664] Improve (and align) deprecation messages (#147948) --- homeassistant/helpers/deprecation.py | 18 +++++++------- tests/common.py | 12 +++++----- tests/components/hassio/test_init.py | 8 +++++-- tests/helpers/test_deprecation.py | 35 ++++++++++++++-------------- tests/helpers/test_json.py | 4 ++-- tests/test_const.py | 4 ++-- tests/util/test_dt.py | 4 ++-- 7 files changed, 44 insertions(+), 41 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 20b5b7ebab9..29d9237de05 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -197,7 +197,7 @@ def _print_deprecation_warning_internal_impl( logger = logging.getLogger(module_name) if breaks_in_ha_version: - breaks_in = f" which will be removed in HA Core {breaks_in_ha_version}" + breaks_in = f" It will be removed in HA Core {breaks_in_ha_version}." else: breaks_in = "" try: @@ -205,9 +205,10 @@ def _print_deprecation_warning_internal_impl( except MissingIntegrationFrame: if log_when_no_integration_is_found: logger.warning( - "%s is a deprecated %s%s. Use %s instead", - obj_name, + "The deprecated %s %s was %s.%s Use %s instead", description, + obj_name, + verb, breaks_in, replacement, ) @@ -219,25 +220,22 @@ def _print_deprecation_warning_internal_impl( module=integration_frame.module, ) logger.warning( - ( - "%s was %s from %s, this is a deprecated %s%s. Use %s instead," - " please %s" - ), + ("The deprecated %s %s was %s from %s.%s Use %s instead, please %s"), + description, obj_name, verb, integration_frame.integration, - description, breaks_in, replacement, report_issue, ) else: logger.warning( - "%s was %s from %s, this is a deprecated %s%s. Use %s instead", + "The deprecated %s %s was %s from %s.%s Use %s instead", + description, obj_name, verb, integration_frame.integration, - description, breaks_in, replacement, ) diff --git a/tests/common.py b/tests/common.py index 7652a020117..e43e4bf5fee 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1826,9 +1826,9 @@ def import_and_test_deprecated_constant( module.__name__, logging.WARNING, ( - f"{constant_name} was used from test_constant_deprecation," - f" this is a deprecated constant which will be removed in HA Core {breaks_in_ha_version}. " - f"Use {replacement_name} instead, please report " + f"The deprecated constant {constant_name} was used from " + "test_constant_deprecation. It will be removed in HA Core " + f"{breaks_in_ha_version}. Use {replacement_name} instead, please report " "it to the author of the 'test_constant_deprecation' custom integration" ), ) in caplog.record_tuples @@ -1860,9 +1860,9 @@ def import_and_test_deprecated_alias( module.__name__, logging.WARNING, ( - f"{alias_name} was used from test_constant_deprecation," - f" this is a deprecated alias which will be removed in HA Core {breaks_in_ha_version}. " - f"Use {replacement_name} instead, please report " + f"The deprecated alias {alias_name} was used from " + "test_constant_deprecation. It will be removed in HA Core " + f"{breaks_in_ha_version}. Use {replacement_name} instead, please report " "it to the author of the 'test_constant_deprecation' custom integration" ), ) in caplog.record_tuples diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 2874ea726dc..f96ab8aca2a 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1098,7 +1098,9 @@ def test_deprecated_function_is_hassio( ( "homeassistant.components.hassio", logging.WARNING, - "is_hassio is a deprecated function which will be removed in HA Core 2025.11. Use homeassistant.helpers.hassio.is_hassio instead", + "The deprecated function is_hassio was called. It will be " + "removed in HA Core 2025.11. Use homeassistant.helpers" + ".hassio.is_hassio instead", ) ] @@ -1114,7 +1116,9 @@ def test_deprecated_function_get_supervisor_ip( ( "homeassistant.helpers.hassio", logging.WARNING, - "get_supervisor_ip is a deprecated function which will be removed in HA Core 2025.11. Use homeassistant.helpers.hassio.get_supervisor_ip instead", + "The deprecated function get_supervisor_ip was called. It will " + "be removed in HA Core 2025.11. Use homeassistant.helpers" + ".hassio.get_supervisor_ip instead", ) ] diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index a74055c59ec..d45c9ce1546 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -135,7 +135,7 @@ def test_deprecated_class(mock_get_logger) -> None: ("breaks_in_ha_version", "extra_msg"), [ (None, ""), - ("2099.1", " which will be removed in HA Core 2099.1"), + ("2099.1", " It will be removed in HA Core 2099.1."), ], ) def test_deprecated_function( @@ -154,8 +154,9 @@ def test_deprecated_function( mock_deprecated_function() assert ( - f"mock_deprecated_function is a deprecated function{extra_msg}. " - "Use new_function instead" + "The deprecated function mock_deprecated_function was called." + f"{extra_msg}" + " Use new_function instead" ) in caplog.text @@ -163,7 +164,7 @@ def test_deprecated_function( ("breaks_in_ha_version", "extra_msg"), [ (None, ""), - ("2099.1", " which will be removed in HA Core 2099.1"), + ("2099.1", " It will be removed in HA Core 2099.1."), ], ) def test_deprecated_function_called_from_built_in_integration( @@ -210,9 +211,9 @@ def test_deprecated_function_called_from_built_in_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, " - f"this is a deprecated function{extra_msg}. " - "Use new_function instead" + "The deprecated function mock_deprecated_function was called from hue." + f"{extra_msg}" + " Use new_function instead" ) in caplog.text @@ -220,7 +221,7 @@ def test_deprecated_function_called_from_built_in_integration( ("breaks_in_ha_version", "extra_msg"), [ (None, ""), - ("2099.1", " which will be removed in HA Core 2099.1"), + ("2099.1", " It will be removed in HA Core 2099.1."), ], ) def test_deprecated_function_called_from_custom_integration( @@ -270,9 +271,9 @@ def test_deprecated_function_called_from_custom_integration( ): mock_deprecated_function() assert ( - "mock_deprecated_function was called from hue, " - f"this is a deprecated function{extra_msg}. " - "Use new_function instead, please report it to the author of the " + "The deprecated function mock_deprecated_function was called from hue." + f"{extra_msg}" + " Use new_function instead, please report it to the author of the " "'hue' custom integration" ) in caplog.text @@ -316,7 +317,7 @@ def _get_value( ), ( DeprecatedConstant(1, "NEW_CONSTANT", "2099.1"), - " which will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", + ". It will be removed in HA Core 2099.1. Use NEW_CONSTANT instead", "constant", ), ( @@ -326,7 +327,7 @@ def _get_value( ), ( DeprecatedConstantEnum(TestDeprecatedConstantEnum.TEST, "2099.1"), - " which will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", + ". It will be removed in HA Core 2099.1. Use TestDeprecatedConstantEnum.TEST instead", "constant", ), ( @@ -336,7 +337,7 @@ def _get_value( ), ( DeprecatedAlias(1, "new_alias", "2099.1"), - " which will be removed in HA Core 2099.1. Use new_alias instead", + ". It will be removed in HA Core 2099.1. Use new_alias instead", "alias", ), ], @@ -405,7 +406,7 @@ def test_check_if_deprecated_constant( assert ( module_name, logging.WARNING, - f"TEST_CONSTANT was used from hue, this is a deprecated {description}{extra_msg}{extra_extra_msg}", + f"The deprecated {description} TEST_CONSTANT was used from hue{extra_msg}{extra_extra_msg}", ) in caplog.record_tuples @@ -594,7 +595,7 @@ def test_enum_with_deprecated_members( "tests.helpers.test_deprecation", logging.WARNING, ( - "TestEnum.CATS was used from hue, this is a deprecated enum member which " + "The deprecated enum member TestEnum.CATS was used from hue. It " "will be removed in HA Core 2025.11.0. Use TestEnum.CATS_PER_CM instead" f"{extra_extra_msg}" ), @@ -603,7 +604,7 @@ def test_enum_with_deprecated_members( "tests.helpers.test_deprecation", logging.WARNING, ( - "TestEnum.DOGS was used from hue, this is a deprecated enum member. Use " + "The deprecated enum member TestEnum.DOGS was used from hue. Use " f"TestEnum.DOGS_PER_CM instead{extra_extra_msg}" ), ) in caplog.record_tuples diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 94f21da1781..413e7e0dc9d 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -359,8 +359,8 @@ def test_deprecated_json_loads(caplog: pytest.LogCaptureFixture) -> None: """ json_helper.json_loads("{}") assert ( - "json_loads is a deprecated function which will be removed in " - "HA Core 2025.8. Use homeassistant.util.json.json_loads instead" + "The deprecated function json_loads was called. It will be removed " + "in HA Core 2025.8. Use homeassistant.util.json.json_loads instead" ) in caplog.text diff --git a/tests/test_const.py b/tests/test_const.py index a039545a004..f1ceaad6a08 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -166,8 +166,8 @@ def test_deprecated_unit_of_conductivity_members( def deprecation_message(member: str, replacement: str) -> str: return ( - f"UnitOfConductivity.{member} was used from hue, this is a deprecated enum " - "member which will be removed in HA Core 2025.11.0. Use UnitOfConductivity." + f"The deprecated enum member UnitOfConductivity.{member} was used from hue. " + "It will be removed in HA Core 2025.11.0. Use UnitOfConductivity." f"{replacement} instead, please report it to the author of the 'hue' custom" " integration" ) diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py index 3f288962009..c357f5cf39c 100644 --- a/tests/util/test_dt.py +++ b/tests/util/test_dt.py @@ -121,8 +121,8 @@ def test_timestamp_to_utc(caplog: pytest.LogCaptureFixture) -> None: utc_now = dt_util.utcnow() assert dt_util.utc_to_timestamp(utc_now) == utc_now.timestamp() assert ( - "utc_to_timestamp is a deprecated function which will be removed " - "in HA Core 2026.1. Use datetime.timestamp instead" in caplog.text + "The deprecated function utc_to_timestamp was called. It will be " + "removed in HA Core 2026.1. Use datetime.timestamp instead" in caplog.text ) From 12b90f3c8ecbe6d2129fe495ac1543cf93f48cc1 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Sat, 5 Jul 2025 00:14:51 +0200 Subject: [PATCH 1142/1664] Add debug logs to trace enphase auth process at load. (#148117) --- .../components/enphase_envoy/coordinator.py | 8 ++++ tests/components/enphase_envoy/test_init.py | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/homeassistant/components/enphase_envoy/coordinator.py b/homeassistant/components/enphase_envoy/coordinator.py index cfff0777af5..57ce924733c 100644 --- a/homeassistant/components/enphase_envoy/coordinator.py +++ b/homeassistant/components/enphase_envoy/coordinator.py @@ -220,6 +220,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await envoy.setup() assert envoy.serial_number is not None self.envoy_serial_number = envoy.serial_number + _LOGGER.debug("Envoy setup complete for serial: %s", self.envoy_serial_number) if token := self.config_entry.data.get(CONF_TOKEN): with contextlib.suppress(*INVALID_AUTH_ERRORS): # Always set the username and password @@ -227,6 +228,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): await envoy.authenticate( username=self.username, password=self.password, token=token ) + _LOGGER.debug("Authorized, validating token lifetime") # The token is valid, but we still want # to refresh it if it's stale right away self._async_refresh_token_if_needed(dt_util.utcnow()) @@ -234,6 +236,8 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # token likely expired or firmware changed # so we fall through to authenticate with # username/password + _LOGGER.debug("setup and auth got INVALID_AUTH_ERRORS") + _LOGGER.debug("Authenticate with username/password only") await self.envoy.authenticate(username=self.username, password=self.password) # Password auth succeeded, so we can update the token # if we are using EnvoyTokenAuth @@ -262,13 +266,16 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): for tries in range(2): try: if not self._setup_complete: + _LOGGER.debug("update on try %s, setup not complete", tries) await self._async_setup_and_authenticate() self._async_mark_setup_complete() # dump all received data in debug mode to assist troubleshooting envoy_data = await envoy.update() except INVALID_AUTH_ERRORS as err: + _LOGGER.debug("update on try %s, INVALID_AUTH_ERRORS %s", tries, err) if self._setup_complete and tries == 0: # token likely expired or firmware changed, try to re-authenticate + _LOGGER.debug("update on try %s, setup was complete, retry", tries) self._setup_complete = False continue raise ConfigEntryAuthFailed( @@ -280,6 +287,7 @@ class EnphaseUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): }, ) from err except EnvoyError as err: + _LOGGER.debug("update on try %s, EnvoyError %s", tries, err) raise UpdateFailed( translation_domain=DOMAIN, translation_key="envoy_error", diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index a738b31c183..c43be96d8b1 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -229,6 +229,45 @@ async def test_coordinator_token_refresh_error( assert entity_state.state == "116" +@respx.mock +@pytest.mark.freeze_time("2024-07-23 00:00:00+00:00") +async def test_coordinator_first_update_auth_error( + hass: HomeAssistant, + mock_envoy: AsyncMock, +) -> None: + """Test coordinator update error handling.""" + current_token = encode( + # some time in future + payload={"name": "envoy", "exp": 1927314600}, + key="secret", + algorithm="HS256", + ) + + # mock envoy with expired token in config + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title="Envoy 1234", + unique_id="1234", + data={ + CONF_HOST: "1.1.1.1", + CONF_NAME: "Envoy 1234", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + CONF_TOKEN: current_token, + }, + ) + mock_envoy.auth = EnvoyTokenAuth( + "127.0.0.1", + token=current_token, + envoy_serial="1234", + cloud_username="test_username", + cloud_password="test_password", + ) + mock_envoy.authenticate.side_effect = EnvoyAuthenticationError("Failing test") + await setup_integration(hass, entry, ConfigEntryState.SETUP_ERROR) + + async def test_config_no_unique_id( hass: HomeAssistant, mock_envoy: AsyncMock, From e592e565c0947c291abd6df7d5ccba42bfcd9ec7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 5 Jul 2025 07:20:42 +0200 Subject: [PATCH 1143/1664] Make ready time sensors unavailable instead in lamarzocco (#147985) --- homeassistant/components/lamarzocco/sensor.py | 16 +++++++++++++++- .../lamarzocco/snapshots/test_sensor.ambr | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index a432f5b8dae..1f4983a03a8 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -56,6 +56,13 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER] ).ready_start_time ), + available_fn=( + lambda coordinator: cast( + CoffeeBoiler, + coordinator.device.dashboard.config[WidgetType.CM_COFFEE_BOILER], + ).ready_start_time + is not None + ), entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( @@ -67,11 +74,18 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = ( SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL] ).ready_start_time ), - entity_category=EntityCategory.DIAGNOSTIC, supported_fn=( lambda coordinator: coordinator.device.dashboard.model_name in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R) ), + available_fn=( + lambda coordinator: cast( + SteamBoilerLevel, + coordinator.device.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL], + ).ready_start_time + is not None + ), + entity_category=EntityCategory.DIAGNOSTIC, ), LaMarzoccoSensorEntityDescription( key="brewing_start_time", diff --git a/tests/components/lamarzocco/snapshots/test_sensor.ambr b/tests/components/lamarzocco/snapshots/test_sensor.ambr index eea4616d0ff..3dd1ff9b665 100644 --- a/tests/components/lamarzocco/snapshots/test_sensor.ambr +++ b/tests/components/lamarzocco/snapshots/test_sensor.ambr @@ -94,7 +94,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_sensors[sensor.gs012345_last_cleaning_time-entry] From e63e6a6072f024e7f3edd59af6f1693228e42b8e Mon Sep 17 00:00:00 2001 From: Matt Zimmerman Date: Fri, 4 Jul 2025 23:08:52 -0700 Subject: [PATCH 1144/1664] Bump python-smarttub to 0.0.43 (#147317) --- homeassistant/components/smarttub/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json index b8d81db0ea5..086446c4c66 100644 --- a/homeassistant/components/smarttub/manifest.json +++ b/homeassistant/components/smarttub/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smarttub", "iot_class": "cloud_polling", "loggers": ["smarttub"], - "requirements": ["python-smarttub==0.0.39"] + "requirements": ["python-smarttub==0.0.44"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8749d653a2f..80a824cf44f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2502,7 +2502,7 @@ python-ripple-api==0.0.3 python-roborock==2.18.2 # homeassistant.components.smarttub -python-smarttub==0.0.39 +python-smarttub==0.0.44 # homeassistant.components.snoo python-snoo==0.6.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e98ca26b18..88dc21a0e07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2072,7 +2072,7 @@ python-rabbitair==0.0.8 python-roborock==2.18.2 # homeassistant.components.smarttub -python-smarttub==0.0.39 +python-smarttub==0.0.44 # homeassistant.components.snoo python-snoo==0.6.6 From 275d390a6c9d4b15edbbc0c0b8d3c02ed1b065ce Mon Sep 17 00:00:00 2001 From: Andrey Kupreychik Date: Sat, 5 Jul 2025 10:52:43 +0400 Subject: [PATCH 1145/1664] Add reconfiguration support for keenetic_ndms2 integration (#142191) Co-authored-by: Franck Nijhof Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Franck Nijhof --- .../components/keenetic_ndms2/config_flow.py | 32 +++++++++++++--- .../components/keenetic_ndms2/strings.json | 3 +- tests/components/keenetic_ndms2/__init__.py | 6 +++ .../keenetic_ndms2/test_config_flow.py | 37 ++++++++++++++++++- 4 files changed, 70 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index 3862d34398f..c6095968c07 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -8,7 +8,12 @@ from urllib.parse import urlparse from ndms2_client import Client, ConnectionException, InterfaceInfo, TelnetConnection import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -45,7 +50,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str | bytes | None = None + _host: str | bytes | None = None @staticmethod @callback @@ -61,8 +66,9 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" errors = {} if user_input is not None: - host = self.host or user_input[CONF_HOST] - self._async_abort_entries_match({CONF_HOST: host}) + host = self._host or user_input[CONF_HOST] + if self.source != SOURCE_RECONFIGURE: + self._async_abort_entries_match({CONF_HOST: host}) _client = Client( TelnetConnection( @@ -81,12 +87,17 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): except ConnectionException: errors["base"] = "cannot_connect" else: + if self.source == SOURCE_RECONFIGURE: + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={CONF_HOST: host, **user_input}, + ) return self.async_create_entry( title=router_info.name, data={CONF_HOST: host, **user_input} ) host_schema: VolDictType = ( - {vol.Required(CONF_HOST): str} if not self.host else {} + {vol.Required(CONF_HOST): str} if not self._host else {} ) return self.async_show_form( @@ -102,6 +113,15 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + existing_entry_data = dict(self._get_reconfigure_entry().data) + self._host = existing_entry_data[CONF_HOST] + + return await self.async_step_user(user_input) + async def async_step_ssdp( self, discovery_info: SsdpServiceInfo ) -> ConfigFlowResult: @@ -124,7 +144,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: host}) - self.host = host + self._host = host self.context["title_placeholders"] = { "name": friendly_name, "host": host, diff --git a/homeassistant/components/keenetic_ndms2/strings.json b/homeassistant/components/keenetic_ndms2/strings.json index 93b59be122d..3098996d48f 100644 --- a/homeassistant/components/keenetic_ndms2/strings.json +++ b/homeassistant/components/keenetic_ndms2/strings.json @@ -21,7 +21,8 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "no_udn": "SSDP discovery info has no UDN", - "not_keenetic_ndms2": "Discovered device is not a Keenetic router" + "not_keenetic_ndms2": "Discovered device is not a Keenetic router", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "options": { diff --git a/tests/components/keenetic_ndms2/__init__.py b/tests/components/keenetic_ndms2/__init__.py index dc0c89e8ea6..dc812af6d01 100644 --- a/tests/components/keenetic_ndms2/__init__.py +++ b/tests/components/keenetic_ndms2/__init__.py @@ -25,6 +25,12 @@ MOCK_DATA = { CONF_PORT: 23, } +MOCK_RECONFIGURE = { + CONF_USERNAME: "user1", + CONF_PASSWORD: "pass1", + CONF_PORT: 123, +} + MOCK_OPTIONS = { CONF_SCAN_INTERVAL: 15, const.CONF_CONSIDER_HOME: 150, diff --git a/tests/components/keenetic_ndms2/test_config_flow.py b/tests/components/keenetic_ndms2/test_config_flow.py index 3293bd3d4da..1b86e6c265c 100644 --- a/tests/components/keenetic_ndms2/test_config_flow.py +++ b/tests/components/keenetic_ndms2/test_config_flow.py @@ -19,7 +19,14 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, ) -from . import MOCK_DATA, MOCK_NAME, MOCK_OPTIONS, MOCK_SSDP_DISCOVERY_INFO +from . import ( + MOCK_DATA, + MOCK_IP, + MOCK_NAME, + MOCK_OPTIONS, + MOCK_RECONFIGURE, + MOCK_SSDP_DISCOVERY_INFO, +) from tests.common import MockConfigEntry @@ -75,6 +82,34 @@ async def test_flow_works(hass: HomeAssistant, connect) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_reconfigure(hass: HomeAssistant, connect) -> None: + """Test reconfigure flow.""" + entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) + entry.add_to_hass(hass) + + result = await entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.keenetic_ndms2.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_RECONFIGURE, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + assert entry.data == { + CONF_HOST: MOCK_IP, + **MOCK_RECONFIGURE, + } + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry(domain=keenetic.DOMAIN, data=MOCK_DATA) From 3cfff4de3a4846f07a73f1294506fe86b7735cb4 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sat, 5 Jul 2025 00:09:02 -0700 Subject: [PATCH 1146/1664] Add a preview to history_stats options flow (#145721) --- .../components/history_stats/config_flow.py | 130 ++++++- .../components/history_stats/coordinator.py | 7 + .../components/history_stats/data.py | 6 +- .../components/history_stats/helpers.py | 13 +- .../components/history_stats/sensor.py | 32 +- .../history_stats/test_config_flow.py | 358 +++++++++++++++++- 6 files changed, 536 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index ca3d5229b6b..996c7ba0d0c 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -3,11 +3,15 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import timedelta from typing import Any, cast import voluptuous as vol +from homeassistant.components import websocket_api from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -26,6 +30,7 @@ from homeassistant.helpers.selector import ( TextSelector, TextSelectorConfig, ) +from homeassistant.helpers.template import Template from .const import ( CONF_DURATION, @@ -37,14 +42,21 @@ from .const import ( DEFAULT_NAME, DOMAIN, ) +from .coordinator import HistoryStatsUpdateCoordinator +from .data import HistoryStats +from .sensor import HistoryStatsSensor + + +def _validate_two_period_keys(user_input: dict[str, Any]) -> None: + if sum(param in user_input for param in CONF_PERIOD_KEYS) != 2: + raise SchemaFlowError("only_two_keys_allowed") async def validate_options( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate options selected.""" - if sum(param in user_input for param in CONF_PERIOD_KEYS) != 2: - raise SchemaFlowError("only_two_keys_allowed") + _validate_two_period_keys(user_input) handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001 @@ -97,12 +109,14 @@ CONFIG_FLOW = { "options": SchemaFlowFormStep( schema=DATA_SCHEMA_OPTIONS, validate_user_input=validate_options, + preview="history_stats", ), } OPTIONS_FLOW = { "init": SchemaFlowFormStep( DATA_SCHEMA_OPTIONS, validate_user_input=validate_options, + preview="history_stats", ), } @@ -116,3 +130,115 @@ class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "history_stats/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + if msg["flow_type"] == "config_flow": + flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) + flow_sets = hass.config_entries.flow._handler_progress_index.get( # noqa: SLF001 + flow_status["handler"] + ) + options = {} + assert flow_sets + for active_flow in flow_sets: + options = active_flow._common_handler.options # type: ignore [attr-defined] # noqa: SLF001 + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + entity_id = options[CONF_ENTITY_ID] + name = options[CONF_NAME] + else: + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + if not config_entry: + raise HomeAssistantError("Config entry not found") + entity_id = config_entry.options[CONF_ENTITY_ID] + name = config_entry.options[CONF_NAME] + + @callback + def async_preview_updated( + last_exception: Exception | None, state: str, attributes: Mapping[str, Any] + ) -> None: + """Forward config entry state events to websocket.""" + if last_exception: + connection.send_message( + websocket_api.event_message( + msg["id"], {"error": str(last_exception) or "Unknown error"} + ) + ) + else: + connection.send_message( + websocket_api.event_message( + msg["id"], {"attributes": attributes, "state": state} + ) + ) + + for param in CONF_PERIOD_KEYS: + if param in msg["user_input"] and not bool(msg["user_input"][param]): + del msg["user_input"][param] # Remove falsy values before counting keys + + validated_data: Any = None + try: + validated_data = DATA_SCHEMA_OPTIONS(msg["user_input"]) + except vol.Invalid as ex: + connection.send_error(msg["id"], "invalid_schema", str(ex)) + return + + try: + _validate_two_period_keys(validated_data) + except SchemaFlowError: + connection.send_error( + msg["id"], + "invalid_schema", + f"Exactly two of {', '.join(CONF_PERIOD_KEYS)} required", + ) + return + + sensor_type = validated_data.get(CONF_TYPE) + entity_states = validated_data.get(CONF_STATE) + start = validated_data.get(CONF_START) + end = validated_data.get(CONF_END) + duration = validated_data.get(CONF_DURATION) + + history_stats = HistoryStats( + hass, + entity_id, + entity_states, + Template(start, hass) if start else None, + Template(end, hass) if end else None, + timedelta(**duration) if duration else None, + True, + ) + coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True) + await coordinator.async_refresh() + preview_entity = HistoryStatsSensor( + hass, coordinator, sensor_type, name, None, entity_id + ) + preview_entity.hass = hass + + connection.send_result(msg["id"]) + cancel_listener = coordinator.async_setup_state_listener() + cancel_preview = await preview_entity.async_start_preview(async_preview_updated) + + def unsub() -> None: + cancel_listener() + cancel_preview() + + connection.subscriptions[msg["id"]] = unsub diff --git a/homeassistant/components/history_stats/coordinator.py b/homeassistant/components/history_stats/coordinator.py index fafbb5d3ce0..091e1da6ad8 100644 --- a/homeassistant/components/history_stats/coordinator.py +++ b/homeassistant/components/history_stats/coordinator.py @@ -36,12 +36,14 @@ class HistoryStatsUpdateCoordinator(DataUpdateCoordinator[HistoryStatsState]): history_stats: HistoryStats, config_entry: ConfigEntry | None, name: str, + preview: bool = False, ) -> None: """Initialize DataUpdateCoordinator.""" self._history_stats = history_stats self._subscriber_count = 0 self._at_start_listener: CALLBACK_TYPE | None = None self._track_events_listener: CALLBACK_TYPE | None = None + self._preview = preview super().__init__( hass, _LOGGER, @@ -104,3 +106,8 @@ class HistoryStatsUpdateCoordinator(DataUpdateCoordinator[HistoryStatsState]): return await self._history_stats.async_update(None) except (TemplateError, TypeError, ValueError) as ex: raise UpdateFailed(ex) from ex + + async def async_refresh(self) -> None: + """Refresh data and log errors.""" + log_failures = not self._preview + await self._async_refresh(log_failures) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index fd950dbba23..569483df687 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -47,6 +47,7 @@ class HistoryStats: start: Template | None, end: Template | None, duration: datetime.timedelta | None, + preview: bool = False, ) -> None: """Init the history stats manager.""" self.hass = hass @@ -59,6 +60,7 @@ class HistoryStats: self._duration = duration self._start = start self._end = end + self._preview = preview self._pending_events: list[Event[EventStateChangedData]] = [] self._query_count = 0 @@ -70,7 +72,9 @@ class HistoryStats: # Get previous values of start and end previous_period_start, previous_period_end = self._period # Parse templates - self._period = async_calculate_period(self._duration, self._start, self._end) + self._period = async_calculate_period( + self._duration, self._start, self._end, log_errors=not self._preview + ) # Get the current period current_period_start, current_period_end = self._period diff --git a/homeassistant/components/history_stats/helpers.py b/homeassistant/components/history_stats/helpers.py index 99214a51369..b0ed132c1ef 100644 --- a/homeassistant/components/history_stats/helpers.py +++ b/homeassistant/components/history_stats/helpers.py @@ -23,6 +23,7 @@ def async_calculate_period( duration: datetime.timedelta | None, start_template: Template | None, end_template: Template | None, + log_errors: bool = True, ) -> tuple[datetime.datetime, datetime.datetime]: """Parse the templates and return the period.""" bounds: dict[str, datetime.datetime | None] = { @@ -37,13 +38,17 @@ def async_calculate_period( if template is None: continue try: - rendered = template.async_render() + rendered = template.async_render( + log_fn=None if log_errors else lambda *args, **kwargs: None + ) except (TemplateError, TypeError) as ex: - if ex.args and not ex.args[0].startswith( - "UndefinedError: 'None' has no attribute" + if ( + log_errors + and ex.args + and not ex.args[0].startswith("UndefinedError: 'None' has no attribute") ): _LOGGER.error("Error parsing template for field %s", bound, exc_info=ex) - raise + raise type(ex)(f"Error parsing template for field {bound}: {ex}") from ex if isinstance(rendered, str): bounds[bound] = dt_util.parse_datetime(rendered) if bounds[bound] is not None: diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 6935b13bc3d..780bff14eb1 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from abc import abstractmethod +from collections.abc import Callable, Mapping import datetime from typing import Any @@ -23,7 +24,7 @@ from homeassistant.const import ( PERCENTAGE, UnitOfTime, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device import async_device_info_to_link_from_entity @@ -183,6 +184,9 @@ class HistoryStatsSensor(HistoryStatsSensorBase): ) -> None: """Initialize the HistoryStats sensor.""" super().__init__(coordinator, name) + self._preview_callback: ( + Callable[[Exception | None, str, Mapping[str, Any]], None] | None + ) = None self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type self._attr_unique_id = unique_id @@ -212,3 +216,29 @@ class HistoryStatsSensor(HistoryStatsSensorBase): self._attr_native_value = pretty_ratio(state.seconds_matched, state.period) elif self._type == CONF_TYPE_COUNT: self._attr_native_value = state.match_count + + if self._preview_callback: + calculated_state = self._async_calculate_state() + self._preview_callback( + None, calculated_state.state, calculated_state.attributes + ) + + async def async_start_preview( + self, + preview_callback: Callable[[Exception | None, str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + self.async_on_remove( + self.coordinator.async_add_listener(self._process_update, None) + ) + + self._preview_callback = preview_callback + calculated_state = self._async_calculate_state() + preview_callback( + self.coordinator.last_exception, + calculated_state.state, + calculated_state.attributes, + ) + + return self._call_on_remove_callbacks diff --git a/tests/components/history_stats/test_config_flow.py b/tests/components/history_stats/test_config_flow.py index a695a06995e..a1f0a080b8a 100644 --- a/tests/components/history_stats/test_config_flow.py +++ b/tests/components/history_stats/test_config_flow.py @@ -2,22 +2,28 @@ from __future__ import annotations -from unittest.mock import AsyncMock +import logging +from unittest.mock import AsyncMock, patch + +from freezegun import freeze_time from homeassistant import config_entries from homeassistant.components.history_stats.const import ( CONF_DURATION, CONF_END, CONF_START, + CONF_TYPE_COUNT, DEFAULT_NAME, DOMAIN, ) from homeassistant.components.recorder import Recorder from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State from homeassistant.data_entry_flow import FlowResultType +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_form( @@ -193,3 +199,351 @@ async def test_entry_already_exist( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_config_flow_preview_success( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + # add state for the tests + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + t1 = start_time.replace(hour=3) + t2 = start_time.replace(hour=4) + t3 = start_time.replace(hour=5) + + monitored_entity = "binary_sensor.state" + + def _fake_states(*args, **kwargs): + return { + monitored_entity: [ + State( + monitored_entity, + "on", + last_changed=start_time, + last_updated=start_time, + ), + State( + monitored_entity, + "off", + last_changed=t1, + last_updated=t1, + ), + State( + monitored_entity, + "on", + last_changed=t2, + last_updated=t2, + ), + ] + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "options" + assert result["errors"] is None + assert result["preview"] == "history_stats" + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(t3), + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{now()}}", + CONF_START: "{{ today_at() }}", + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == "2" + + +async def test_options_flow_preview( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the options flow preview.""" + logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR) + client = await hass_ws_client(hass) + + # add state for the tests + await hass.config.async_set_time_zone("UTC") + utcnow = dt_util.utcnow() + start_time = utcnow.replace(hour=0, minute=0, second=0, microsecond=0) + t1 = start_time.replace(hour=3) + t2 = start_time.replace(hour=4) + t3 = start_time.replace(hour=5) + + monitored_entity = "binary_sensor.state" + + def _fake_states(*args, **kwargs): + return { + monitored_entity: [ + State( + monitored_entity, + "on", + last_changed=start_time, + last_updated=start_time, + ), + State( + monitored_entity, + "off", + last_changed=t1, + last_updated=t1, + ), + State( + monitored_entity, + "on", + last_changed=t2, + last_updated=t2, + ), + State( + monitored_entity, + "off", + last_changed=t2, + last_updated=t2, + ), + ] + } + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + }, + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "history_stats" + + with ( + patch( + "homeassistant.components.recorder.history.state_changes_during_period", + _fake_states, + ), + freeze_time(t3), + ): + for end, exp_count in ( + ("{{now()}}", "2"), + ("{{today_at('2:00')}}", "1"), + ("{{today_at('23:00')}}", "2"), + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: end, + CONF_START: "{{ today_at() }}", + }, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["state"] == exp_count + + hass.states.async_set(monitored_entity, "on") + + msg = await client.receive_json() + assert msg["event"]["state"] == "3" + + +async def test_options_flow_preview_errors( + recorder_mock: Recorder, + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the options flow preview.""" + logging.getLogger("sqlalchemy.engine").setLevel(logging.ERROR) + client = await hass_ws_client(hass) + + # add state for the tests + monitored_entity = "binary_sensor.state" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + }, + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "history_stats" + + for schema in ( + {CONF_END: "{{ now() }"}, # Missing '}' at end of template + {CONF_START: "{{ today_at( }}"}, # Missing ')' in template function + {CONF_DURATION: {"hours": 1}}, # Specified 3 period keys (1 too many) + {CONF_START: ""}, # Specified 1 period keys (1 too few) + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + **schema, + }, + } + ) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "invalid_schema" + + for schema in ( + {CONF_END: "{{ nowwww() }}"}, # Unknown jinja function + {CONF_START: "{{ today_at('abcde') }}"}, # Invalid value passed to today_at + {CONF_END: '"{{ now() }}"'}, # Invalid quotes around template + ): + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: monitored_entity, + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_END: "{{ now() }}", + CONF_START: "{{ today_at() }}", + **schema, + }, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"]["error"] + + +async def test_options_flow_sensor_preview_config_entry_removed( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_START: "0", + CONF_END: "1", + }, + title=DEFAULT_NAME, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "history_stats" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "history_stats/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": { + CONF_ENTITY_ID: "sensor.test_monitored", + CONF_TYPE: CONF_TYPE_COUNT, + CONF_STATE: ["on"], + CONF_START: "0", + CONF_END: "1", + }, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == { + "code": "home_assistant_error", + "message": "Config entry not found", + } From 0d54e759400241184f998659749c0a95834454cc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Jul 2025 09:34:24 +0200 Subject: [PATCH 1147/1664] Fix spelling of "auto" prefixes in `zha` (#148022) --- homeassistant/components/zha/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 9694388e784..48bdfc6bb62 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1118,7 +1118,7 @@ "name": "Comfort temperature" }, "valve_state_auto_shutdown": { - "name": "Valve state auto shutdown" + "name": "Valve state autoshutdown" }, "shutdown_timer": { "name": "Shutdown timer" @@ -1996,7 +1996,7 @@ "name": "Schedule mode" }, "auto_clean": { - "name": "Auto clean" + "name": "Autoclean" }, "test_mode": { "name": "Test mode" @@ -2005,7 +2005,7 @@ "name": "External temperature sensor" }, "auto_relock": { - "name": "Auto relock" + "name": "Autorelock" } } } From 7898e3f0fbe7baad71f3eadac2e6367210cde89c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 5 Jul 2025 09:54:54 +0200 Subject: [PATCH 1148/1664] Add initial tuya snapshot tests (#148034) Co-authored-by: Franck Nijhof --- tests/components/tuya/__init__.py | 32 ++++++ tests/components/tuya/conftest.py | 93 ++++++++++++++- ...ete_two_12l_dehumidifier_air_purifier.json | 53 +++++++++ .../tuya/fixtures/mcs_door_sensor.json | 20 ++++ .../tuya/snapshots/test_config_flow.ambr | 4 +- tests/components/tuya/snapshots/test_fan.ambr | 51 +++++++++ .../tuya/snapshots/test_humidifier.ambr | 58 ++++++++++ .../tuya/snapshots/test_select.ambr | 62 ++++++++++ .../tuya/snapshots/test_sensor.ambr | 107 ++++++++++++++++++ tests/components/tuya/test_fan.py | 36 ++++++ tests/components/tuya/test_humidifier.py | 36 ++++++ tests/components/tuya/test_select.py | 36 ++++++ tests/components/tuya/test_sensor.py | 37 ++++++ 13 files changed, 618 insertions(+), 7 deletions(-) create mode 100644 tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json create mode 100644 tests/components/tuya/fixtures/mcs_door_sensor.json create mode 100644 tests/components/tuya/snapshots/test_fan.ambr create mode 100644 tests/components/tuya/snapshots/test_humidifier.ambr create mode 100644 tests/components/tuya/snapshots/test_select.ambr create mode 100644 tests/components/tuya/snapshots/test_sensor.ambr create mode 100644 tests/components/tuya/test_fan.py create mode 100644 tests/components/tuya/test_humidifier.py create mode 100644 tests/components/tuya/test_select.py create mode 100644 tests/components/tuya/test_sensor.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 56bfc0867c6..1d468a46814 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -1 +1,33 @@ """Tests for the Tuya component.""" + +from __future__ import annotations + +from unittest.mock import patch + +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def initialize_entry( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Initialize the Tuya component with a mock manager and config entry.""" + # Setup + mock_manager.device_map = { + mock_device.id: mock_device, + } + mock_config_entry.add_to_hass(hass) + + # Initialize the component + with patch( + "homeassistant.components.tuya.ManagerCompat", return_value=mock_manager + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 4fffb3ae389..017c6f00241 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -6,10 +6,20 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from tuya_sharing import CustomerDevice -from homeassistant.components.tuya.const import CONF_APP_TYPE, CONF_USER_CODE, DOMAIN +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import ( + CONF_APP_TYPE, + CONF_ENDPOINT, + CONF_TERMINAL_ID, + CONF_TOKEN_INFO, + CONF_USER_CODE, + DOMAIN, +) +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -25,15 +35,44 @@ def mock_old_config_entry() -> MockConfigEntry: @pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Mock an config entry.""" + """Mock a config entry.""" return MockConfigEntry( - title="12345", + title="Test Tuya entry", domain=DOMAIN, - data={CONF_USER_CODE: "12345"}, + data={ + CONF_ENDPOINT: "test_endpoint", + CONF_TERMINAL_ID: "test_terminal", + CONF_TOKEN_INFO: "test_token", + CONF_USER_CODE: "test_user_code", + }, unique_id="12345", ) +@pytest.fixture +async def mock_loaded_entry( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> MockConfigEntry: + """Mock a config entry.""" + # Setup + mock_manager.device_map = { + mock_device.id: mock_device, + } + mock_config_entry.add_to_hass(hass) + + # Initialize the component + with ( + patch("homeassistant.components.tuya.ManagerCompat", return_value=mock_manager), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + @pytest.fixture def mock_setup_entry() -> Generator[None]: """Mock setting up a config entry.""" @@ -68,3 +107,47 @@ def mock_tuya_login_control() -> Generator[MagicMock]: }, ) yield login_control + + +@pytest.fixture +def mock_manager() -> ManagerCompat: + """Mock Tuya Manager.""" + manager = MagicMock(spec=ManagerCompat) + manager.device_map = {} + manager.mq = MagicMock() + return manager + + +@pytest.fixture +def mock_device_code() -> str: + """Fixture to parametrize the type of the mock device. + + To set a configuration, tests can be marked with: + @pytest.mark.parametrize("mock_device_code", ["device_code_1", "device_code_2"]) + """ + return None + + +@pytest.fixture +async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDevice: + """Mock a Tuya CustomerDevice.""" + details = await async_load_json_object_fixture( + hass, f"{mock_device_code}.json", DOMAIN + ) + device = MagicMock(spec=CustomerDevice) + device.id = details["id"] + device.name = details["name"] + device.category = details["category"] + device.product_id = details["product_id"] + device.product_name = details["product_name"] + device.online = details["online"] + device.function = { + key: MagicMock(type=value["type"], values=value["values"]) + for key, value in details["function"].items() + } + device.status_range = { + key: MagicMock(type=value["type"], values=value["values"]) + for key, value in details["status_range"].items() + } + device.status = details["status"] + return device diff --git a/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json b/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json new file mode 100644 index 00000000000..1e50e7e3fec --- /dev/null +++ b/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json @@ -0,0 +1,53 @@ +{ + "id": "bf3fce6af592f12df3gbgq", + "name": "Dehumidifier", + "category": "cs", + "product_id": "zibqa9dutqyaxym2", + "product_name": "Arete\u00ae Two 12L Dehumidifier/Air Purifier", + "online": true, + "function": { + "switch": { "type": "Boolean", "values": "{}" }, + "dehumidify_set_value": { + "type": "Integer", + "values": "{\"unit\": \"%\", \"min\": 35, \"max\": 70, \"scale\": 0, \"step\": 5}" + }, + "child_lock": { "type": "Boolean", "values": "{}" }, + "countdown_set": { + "type": "Enum", + "values": "{\"range\": [\"cancel\", \"1h\", \"2h\", \"3h\"]}" + } + }, + "status_range": { + "switch": { "type": "Boolean", "values": "{}" }, + "dehumidify_set_value": { + "type": "Integer", + "values": "{\"unit\": \"%\", \"min\": 35, \"max\": 70, \"scale\": 0, \"step\": 5}" + }, + "child_lock": { "type": "Boolean", "values": "{}" }, + "humidity_indoor": { + "type": "Integer", + "values": "{\"unit\": \"%\", \"min\": 0, \"max\": 100, \"scale\": 0, \"step\": 1}" + }, + "countdown_set": { + "type": "Enum", + "values": "{\"range\": [\"cancel\", \"1h\", \"2h\", \"3h\"]}" + }, + "countdown_left": { + "type": "Integer", + "values": "{\"unit\": \"h\", \"min\": 0, \"max\": 24, \"scale\": 0, \"step\": 1}" + }, + "fault": { + "type": "Bitmap", + "values": "{\"label\": [\"tankfull\", \"defrost\", \"E1\", \"E2\", \"L2\", \"L3\", \"L4\", \"wet\"]}" + } + }, + "status": { + "switch": true, + "dehumidify_set_value": 50, + "child_lock": false, + "humidity_indoor": 47, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + } +} diff --git a/tests/components/tuya/fixtures/mcs_door_sensor.json b/tests/components/tuya/fixtures/mcs_door_sensor.json new file mode 100644 index 00000000000..cec9547c2ea --- /dev/null +++ b/tests/components/tuya/fixtures/mcs_door_sensor.json @@ -0,0 +1,20 @@ +{ + "id": "bf5cccf9027080e2dbb9w3", + "name": "Door Sensor", + "category": "mcs", + "product_id": "7jIGJAymiH8OsFFb", + "product_name": "Door Sensor", + "online": true, + "function": {}, + "status_range": { + "switch": { "type": "Boolean", "values": "{}" }, + "battery": { + "type": "Integer", + "values": "{\"unit\": \"\", \"min\": 0, \"max\": 500, \"scale\": 0, \"step\": 1}" + } + }, + "status": { + "switch": false, + "battery": 100 + } +} diff --git a/tests/components/tuya/snapshots/test_config_flow.ambr b/tests/components/tuya/snapshots/test_config_flow.ambr index 90d83d69814..ba5b4f4bb8d 100644 --- a/tests/components/tuya/snapshots/test_config_flow.ambr +++ b/tests/components/tuya/snapshots/test_config_flow.ambr @@ -11,7 +11,7 @@ 't': 'mocked_t', 'uid': 'mocked_uid', }), - 'user_code': '12345', + 'user_code': 'test_user_code', }), 'disabled_by': None, 'discovery_keys': dict({ @@ -26,7 +26,7 @@ 'source': 'user', 'subentries': list([ ]), - 'title': '12345', + 'title': 'Test Tuya entry', 'unique_id': '12345', 'version': 1, }) diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr new file mode 100644 index 00000000000..399056e7665 --- /dev/null +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + '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.bf3fce6af592f12df3gbgq', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr new file mode 100644 index 00000000000..c22005e123d --- /dev/null +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][humidifier.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 70, + 'min_humidity': 35, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][humidifier.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 47, + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifier', + 'humidity': 50, + 'max_humidity': 70, + 'min_humidity': 35, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr new file mode 100644 index 00000000000..a9daca637b5 --- /dev/null +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifier_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifier_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..47709b03a5e --- /dev/null +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -0,0 +1,107 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_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.dehumidifier_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqhumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifier Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifier_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_sensor_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.door_sensor_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf5cccf9027080e2dbb9w3battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_sensor_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Door Sensor Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.door_sensor_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py new file mode 100644 index 00000000000..f8a2c5bbee8 --- /dev/null +++ b/tests/components/tuya/test_fan.py @@ -0,0 +1,36 @@ +"""Test Tuya fan platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", ["cs_arete_two_12l_dehumidifier_air_purifier"] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py new file mode 100644 index 00000000000..aad5782ee13 --- /dev/null +++ b/tests/components/tuya/test_humidifier.py @@ -0,0 +1,36 @@ +"""Test Tuya humidifier platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", ["cs_arete_two_12l_dehumidifier_air_purifier"] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py new file mode 100644 index 00000000000..5f1111a0fd3 --- /dev/null +++ b/tests/components/tuya/test_select.py @@ -0,0 +1,36 @@ +"""Test Tuya select platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", ["cs_arete_two_12l_dehumidifier_air_purifier"] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py new file mode 100644 index 00000000000..bf424e289ef --- /dev/null +++ b/tests/components/tuya/test_sensor.py @@ -0,0 +1,37 @@ +"""Test Tuya sensor platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + ["cs_arete_two_12l_dehumidifier_air_purifier", "mcs_door_sensor"], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 1e164c94b152178de4dce4ac5ba6c5683952e42e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 5 Jul 2025 10:14:52 +0200 Subject: [PATCH 1149/1664] Include path when media source file can be accessed on disk (#148180) --- .../components/media_source/local_source.py | 2 +- homeassistant/components/media_source/models.py | 6 +++++- tests/components/media_source/test_local_source.py | 14 ++++++++++++-- .../system_bridge/snapshots/test_media_source.ambr | 2 ++ 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index c9b81e6534e..fa30dc9baf3 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -80,7 +80,7 @@ class LocalSource(MediaSource): path = self.async_full_path(source_dir_id, location) mime_type, _ = mimetypes.guess_type(str(path)) assert isinstance(mime_type, str) - return PlayMedia(f"/media/{item.identifier}", mime_type) + return PlayMedia(f"/media/{item.identifier}", mime_type, path=path) async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: """Return media.""" diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index 8588c5bcacc..2cf5d231741 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType @@ -10,6 +10,9 @@ from homeassistant.core import HomeAssistant, callback from .const import MEDIA_SOURCE_DATA, URI_SCHEME, URI_SCHEME_REGEX +if TYPE_CHECKING: + from pathlib import Path + @dataclass(slots=True) class PlayMedia: @@ -17,6 +20,7 @@ class PlayMedia: url: str mime_type: str + path: Path | None = field(kw_only=True, default=None) class BrowseMediaSource(BrowseMedia): diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index 1823165d906..259407bfb5a 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -167,13 +167,23 @@ async def test_upload_view( res = await client.post( "/api/media_source/local_source/upload", data={ - "media_content_id": "media-source://media_source/test_dir/.", + "media_content_id": "media-source://media_source/test_dir", "file": get_file("logo.png"), }, ) assert res.status == 200 - assert (Path(temp_dir) / "logo.png").is_file() + data = await res.json() + assert data["media_content_id"] == "media-source://media_source/test_dir/logo.png" + uploaded_path = Path(temp_dir) / "logo.png" + assert uploaded_path.is_file() + + resolved = await media_source.async_resolve_media( + hass, data["media_content_id"], target_media_player=None + ) + assert resolved.url == "/media/test_dir/logo.png" + assert resolved.mime_type == "image/png" + assert resolved.path == uploaded_path # Test with bad media source ID for bad_id in ( diff --git a/tests/components/system_bridge/snapshots/test_media_source.ambr b/tests/components/system_bridge/snapshots/test_media_source.ambr index 954332c932a..695a35f17d9 100644 --- a/tests/components/system_bridge/snapshots/test_media_source.ambr +++ b/tests/components/system_bridge/snapshots/test_media_source.ambr @@ -28,12 +28,14 @@ # name: test_file[system_bridge_media_source_file_image] dict({ 'mime_type': 'image/jpeg', + 'path': None, 'url': 'http://127.0.0.1:9170/api/media/file/data?token=abc-123-def-456-ghi&base=documents&path=testimage.jpg', }) # --- # name: test_file[system_bridge_media_source_file_text] dict({ 'mime_type': 'text/plain', + 'path': None, 'url': 'http://127.0.0.1:9170/api/media/file/data?token=abc-123-def-456-ghi&base=documents&path=testfile.txt', }) # --- From 1b21c986e8a4550a000cafcb763c20cfc9c01665 Mon Sep 17 00:00:00 2001 From: HarvsG <11440490+HarvsG@users.noreply.github.com> Date: Sat, 5 Jul 2025 09:21:32 +0100 Subject: [PATCH 1150/1664] Enable Pihole API v6 (#145890) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Josef Zweck --- homeassistant/components/pi_hole/__init__.py | 143 ++++++++++-- .../components/pi_hole/binary_sensor.py | 2 +- .../components/pi_hole/config_flow.py | 88 ++++--- homeassistant/components/pi_hole/const.py | 7 + homeassistant/components/pi_hole/entity.py | 5 +- homeassistant/components/pi_hole/icons.json | 9 + .../components/pi_hole/manifest.json | 2 +- homeassistant/components/pi_hole/sensor.py | 108 ++++++++- homeassistant/components/pi_hole/strings.json | 27 ++- homeassistant/components/pi_hole/switch.py | 2 +- homeassistant/components/pi_hole/update.py | 30 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 1 - tests/components/pi_hole/__init__.py | 215 ++++++++++++++++-- .../pi_hole/snapshots/test_diagnostics.ambr | 1 + tests/components/pi_hole/test_config_flow.py | 129 ++++++++--- tests/components/pi_hole/test_diagnostics.py | 5 +- tests/components/pi_hole/test_init.py | 155 +++++++++++-- tests/components/pi_hole/test_repairs.py | 136 +++++++++++ tests/components/pi_hole/test_sensor.py | 79 +++++++ tests/components/pi_hole/test_update.py | 8 +- 22 files changed, 979 insertions(+), 177 deletions(-) create mode 100644 tests/components/pi_hole/test_repairs.py create mode 100644 tests/components/pi_hole/test_sensor.py diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 5cc21cef3a9..f211d646c0b 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -4,13 +4,15 @@ from __future__ import annotations from dataclasses import dataclass import logging +from typing import Any, Literal -from hole import Hole -from hole.exceptions import HoleError +from hole import Hole, HoleV5, HoleV6 +from hole.exceptions import HoleConnectionError, HoleError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, + CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -24,7 +26,12 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_STATISTICS_ONLY, DOMAIN, MIN_TIME_BETWEEN_UPDATES +from .const import ( + CONF_STATISTICS_ONLY, + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, + VERSION_6_RESPONSE_TO_5_ERROR, +) _LOGGER = logging.getLogger(__name__) @@ -51,10 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo """Set up Pi-hole entry.""" name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] - use_tls = entry.data[CONF_SSL] - verify_tls = entry.data[CONF_VERIFY_SSL] - location = entry.data[CONF_LOCATION] - api_key = entry.data.get(CONF_API_KEY, "") + version = entry.data.get(CONF_API_VERSION) # remove obsolet CONF_STATISTICS_ONLY from entry.data if CONF_STATISTICS_ONLY in entry.data: @@ -96,21 +100,42 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) - session = async_get_clientsession(hass, verify_tls) - api = Hole( - host, - session, - location=location, - tls=use_tls, - api_token=api_key, - ) + if version is None: + _LOGGER.debug( + "No API version specified, determining Pi-hole API version for %s", host + ) + version = await determine_api_version(hass, dict(entry.data)) + _LOGGER.debug("Pi-hole API version determined: %s", version) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_API_VERSION: version} + ) + # Once API version 5 is deprecated we should instantiate Hole directly + api = api_by_version(hass, dict(entry.data), version) async def async_update_data() -> None: """Fetch data from API endpoint.""" try: await api.get_data() await api.get_versions() + if "error" in (response := api.data): + match response["error"]: + case { + "key": key, + "message": message, + "hint": hint, + } if ( + key == VERSION_6_RESPONSE_TO_5_ERROR["key"] + and message == VERSION_6_RESPONSE_TO_5_ERROR["message"] + and hint.startswith("The API is hosted at ") + and "/admin/api" in hint + ): + _LOGGER.warning( + "Pi-hole API v6 returned an error that is expected when using v5 endpoints please re-configure your authentication" + ) + raise ConfigEntryAuthFailed except HoleError as err: + if str(err) == "Authentication failed: Invalid password": + raise ConfigEntryAuthFailed from err raise UpdateFailed(f"Failed to communicate with API: {err}") from err if not isinstance(api.data, dict): raise ConfigEntryAuthFailed @@ -136,3 +161,91 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Pi-hole entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +def api_by_version( + hass: HomeAssistant, + entry: dict[str, Any], + version: int, + password: str | None = None, +) -> HoleV5 | HoleV6: + """Create a pi-hole API object by API version number. Once V5 is deprecated this function can be removed.""" + + if password is None: + password = entry.get(CONF_API_KEY, "") + session = async_get_clientsession(hass, entry[CONF_VERIFY_SSL]) + hole_kwargs = { + "host": entry[CONF_HOST], + "session": session, + "location": entry[CONF_LOCATION], + "verify_tls": entry[CONF_VERIFY_SSL], + "version": version, + } + if version == 5: + hole_kwargs["tls"] = entry.get(CONF_SSL) + hole_kwargs["api_token"] = password + elif version == 6: + hole_kwargs["protocol"] = "https" if entry.get(CONF_SSL) else "http" + hole_kwargs["password"] = password + + return Hole(**hole_kwargs) + + +async def determine_api_version( + hass: HomeAssistant, entry: dict[str, Any] +) -> Literal[5, 6]: + """Determine the API version of the Pi-hole instance without requiring authentication. + + Neither API v5 or v6 provides an endpoint to check the version without authentication. + Version 6 provides other enddpoints that do not require authentication, so we can use those to determine the version + version 5 returns an empty list in response to unauthenticated requests. + Because we are using endpoints that are not designed for this purpose, we should log liberally to help with debugging. + """ + + holeV6 = api_by_version(hass, entry, 6, password="wrong_password") + try: + await holeV6.authenticate() + except HoleConnectionError as err: + _LOGGER.error( + "Unexpected error connecting to Pi-hole v6 API at %s: %s. Trying version 5 API", + holeV6.base_url, + err, + ) + # Ideally python-hole would raise a specific exception for authentication failures + except HoleError as ex_v6: + if str(ex_v6) == "Authentication failed: Invalid password": + _LOGGER.debug( + "Success connecting to Pi-hole at %s without auth, API version is : %s", + holeV6.base_url, + 6, + ) + return 6 + _LOGGER.debug( + "Connection to %s failed: %s, trying API version 5", holeV6.base_url, ex_v6 + ) + holeV5 = api_by_version(hass, entry, 5, password="wrong_token") + try: + await holeV5.get_data() + + except HoleConnectionError as err: + _LOGGER.error( + "Failed to connect to Pi-hole v5 API at %s: %s", holeV5.base_url, err + ) + else: + # V5 API returns [] to unauthenticated requests + if not holeV5.data: + _LOGGER.debug( + "Response '[]' from API without auth, pihole API version 5 probably detected at %s", + holeV5.base_url, + ) + return 5 + _LOGGER.debug( + "Unexpected response from Pi-hole API at %s: %s", + holeV5.base_url, + str(holeV5.data), + ) + _LOGGER.debug( + "Could not determine pi-hole API version at: %s", + holeV6.base_url, + ) + raise HoleError("Could not determine Pi-hole API version") diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py index 1d12307b6e5..049195d01b1 100644 --- a/homeassistant/components/pi_hole/binary_sensor.py +++ b/homeassistant/components/pi_hole/binary_sensor.py @@ -33,7 +33,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = ( PiHoleBinarySensorEntityDescription( key="status", translation_key="status", - state_value=lambda api: bool(api.data.get("status") == "enabled"), + state_value=lambda api: bool(api.status == "enabled"), ), ) diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index e50b018caa4..da994b74e6d 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -6,13 +6,13 @@ from collections.abc import Mapping import logging from typing import Any -from hole import Hole from hole.exceptions import HoleError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, + CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -20,8 +20,8 @@ from homeassistant.const import ( CONF_SSL, CONF_VERIFY_SSL, ) -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import Hole, api_by_version, determine_api_version from .const import ( DEFAULT_LOCATION, DEFAULT_NAME, @@ -55,6 +55,7 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): CONF_LOCATION: user_input[CONF_LOCATION], CONF_SSL: user_input[CONF_SSL], CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL], + CONF_API_KEY: user_input[CONF_API_KEY], } self._async_abort_entries_match( @@ -69,9 +70,6 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): title=user_input[CONF_NAME], data=self._config ) - if CONF_API_KEY in errors: - return await self.async_step_api_key() - user_input = user_input or {} return self.async_show_form( step_id="user", @@ -88,6 +86,10 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): CONF_LOCATION, default=user_input.get(CONF_LOCATION, DEFAULT_LOCATION), ): str, + vol.Required( + CONF_API_KEY, + default=user_input.get(CONF_API_KEY), + ): str, vol.Required( CONF_SSL, default=user_input.get(CONF_SSL, DEFAULT_SSL), @@ -101,25 +103,6 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_api_key( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Handle step to setup API key.""" - errors = {} - if user_input is not None: - self._config[CONF_API_KEY] = user_input[CONF_API_KEY] - if not (errors := await self._async_try_connect()): - return self.async_create_entry( - title=self._config[CONF_NAME], - data=self._config, - ) - - return self.async_show_form( - step_id="api_key", - data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}), - errors=errors, - ) - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: @@ -151,19 +134,50 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): ) async def _async_try_connect(self) -> dict[str, str]: - session = async_get_clientsession(self.hass, self._config[CONF_VERIFY_SSL]) - pi_hole = Hole( - self._config[CONF_HOST], - session, - location=self._config[CONF_LOCATION], - tls=self._config[CONF_SSL], - api_token=self._config.get(CONF_API_KEY), - ) + """Try to connect to the Pi-hole API and determine the version.""" try: - await pi_hole.get_data() - except HoleError as ex: - _LOGGER.debug("Connection failed: %s", ex) + version = await determine_api_version(hass=self.hass, entry=self._config) + except HoleError: return {"base": "cannot_connect"} - if not isinstance(pi_hole.data, dict): - return {CONF_API_KEY: "invalid_auth"} + pi_hole: Hole = api_by_version(self.hass, self._config, version) + + if version == 6: + try: + await pi_hole.authenticate() + _LOGGER.debug("Success authenticating with pihole API version: %s", 6) + self._config[CONF_API_VERSION] = 6 + except HoleError: + _LOGGER.debug("Failed authenticating with pihole API version: %s", 6) + return {CONF_API_KEY: "invalid_auth"} + + elif version == 5: + try: + await pi_hole.get_data() + if pi_hole.data is not None and "error" in pi_hole.data: + _LOGGER.debug( + "API version %s returned an unexpected error: %s", + 5, + str(pi_hole.data), + ) + raise HoleError(pi_hole.data) # noqa: TRY301 + except HoleError as ex_v5: + _LOGGER.error( + "Connection to API version 5 failed: %s", + ex_v5, + ) + return {"base": "cannot_connect"} + else: + _LOGGER.debug( + "Success connecting to, but necessarily authenticating with, pihole, API version is: %s", + 5, + ) + self._config[CONF_API_VERSION] = 5 + # the v5 API returns an empty list to unauthenticated requests. + if not isinstance(pi_hole.data, dict): + _LOGGER.debug( + "API version %s returned %s, '[]' is expected for unauthenticated requests", + 5, + pi_hole.data, + ) + return {CONF_API_KEY: "invalid_auth"} return {} diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index c81e6504dff..5e91f348ce9 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -17,3 +17,10 @@ SERVICE_DISABLE = "disable" SERVICE_DISABLE_ATTR_DURATION = "duration" MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) + +# See https://github.com/pi-hole/FTL/blob/88737f6248cd3df3202eed72aeec89b9fb572631/src/webserver/lua_web.c#L83 +VERSION_6_RESPONSE_TO_5_ERROR = { + "key": "bad_request", + "message": "Bad request", + "hint": "The API is hosted at pi.hole/api, not pi.hole/admin/api", +} diff --git a/homeassistant/components/pi_hole/entity.py b/homeassistant/components/pi_hole/entity.py index 0f5c6039232..f29aa819139 100644 --- a/homeassistant/components/pi_hole/entity.py +++ b/homeassistant/components/pi_hole/entity.py @@ -32,7 +32,10 @@ class PiHoleEntity(CoordinatorEntity[DataUpdateCoordinator[None]]): @property def device_info(self) -> DeviceInfo: """Return the device information of the entity.""" - if self.api.tls: + if ( + getattr(self.api, "tls", None) # API version 5 + or getattr(self.api, "protocol", None) == "https" # API version 6 + ): config_url = f"https://{self.api.host}/{self.api.location}" else: config_url = f"http://{self.api.host}/{self.api.location}" diff --git a/homeassistant/components/pi_hole/icons.json b/homeassistant/components/pi_hole/icons.json index 3a45f8ab454..d5c2e9a2d43 100644 --- a/homeassistant/components/pi_hole/icons.json +++ b/homeassistant/components/pi_hole/icons.json @@ -9,15 +9,24 @@ "ads_blocked_today": { "default": "mdi:close-octagon-outline" }, + "ads_blocked": { + "default": "mdi:close-octagon-outline" + }, "ads_percentage_today": { "default": "mdi:close-octagon-outline" }, + "percent_ads_blocked": { + "default": "mdi:close-octagon-outline" + }, "clients_ever_seen": { "default": "mdi:account-outline" }, "dns_queries_today": { "default": "mdi:comment-question-outline" }, + "dns_queries": { + "default": "mdi:comment-question-outline" + }, "domains_being_blocked": { "default": "mdi:block-helper" }, diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json index 975d8a1494c..aa8af024c5a 100644 --- a/homeassistant/components/pi_hole/manifest.json +++ b/homeassistant/components/pi_hole/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pi_hole", "iot_class": "local_polling", "loggers": ["hole"], - "requirements": ["hole==0.8.0"] + "requirements": ["hole==0.9.0"] } diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index 54a9cb23d02..aa79805cc2d 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -2,10 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import CONF_NAME, PERCENTAGE +from homeassistant.const import CONF_API_VERSION, CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -18,29 +21,98 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="ads_blocked_today", translation_key="ads_blocked_today", + suggested_display_precision=0, ), SensorEntityDescription( key="ads_percentage_today", translation_key="ads_percentage_today", native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, ), SensorEntityDescription( key="clients_ever_seen", translation_key="clients_ever_seen", + suggested_display_precision=0, ), SensorEntityDescription( - key="dns_queries_today", translation_key="dns_queries_today" + key="dns_queries_today", + translation_key="dns_queries_today", + suggested_display_precision=0, ), SensorEntityDescription( key="domains_being_blocked", translation_key="domains_being_blocked", + suggested_display_precision=0, ), - SensorEntityDescription(key="queries_cached", translation_key="queries_cached"), SensorEntityDescription( - key="queries_forwarded", translation_key="queries_forwarded" + key="queries_cached", + translation_key="queries_cached", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries_forwarded", + translation_key="queries_forwarded", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="unique_clients", + translation_key="unique_clients", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="unique_domains", + translation_key="unique_domains", + suggested_display_precision=0, + ), +) + +SENSOR_TYPES_V6: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="queries.blocked", + translation_key="ads_blocked", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.percent_blocked", + translation_key="percent_ads_blocked", + native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, + ), + SensorEntityDescription( + key="clients.total", + translation_key="clients_ever_seen", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.total", + translation_key="dns_queries", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="gravity.domains_being_blocked", + translation_key="domains_being_blocked", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.cached", + translation_key="queries_cached", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.forwarded", + translation_key="queries_forwarded", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="clients.active", + translation_key="unique_clients", + suggested_display_precision=0, + ), + SensorEntityDescription( + key="queries.unique_domains", + translation_key="unique_domains", + suggested_display_precision=0, ), - SensorEntityDescription(key="unique_clients", translation_key="unique_clients"), - SensorEntityDescription(key="unique_domains", translation_key="unique_domains"), ) @@ -60,7 +132,9 @@ async def async_setup_entry( entry.entry_id, description, ) - for description in SENSOR_TYPES + for description in ( + SENSOR_TYPES if entry.data[CONF_API_VERSION] == 5 else SENSOR_TYPES_V6 + ) ] async_add_entities(sensors, True) @@ -88,7 +162,19 @@ class PiHoleSensor(PiHoleEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the device.""" - try: - return round(self.api.data[self.entity_description.key], 2) # type: ignore[no-any-return] - except TypeError: - return self.api.data[self.entity_description.key] # type: ignore[no-any-return] + return get_nested(self.api.data, self.entity_description.key) + + +def get_nested(data: Mapping[str, Any], key: str) -> float | int: + """Get a value from a nested dictionary using a dot-separated key. + + Ensures type safety as it iterates into the dict. + """ + current: Any = data + for part in key.split("."): + if not isinstance(current, Mapping): + raise KeyError(f"Cannot access '{part}' in non-dict {current!r}") + current = current[part] + if not isinstance(current, (float, int)): + raise TypeError(f"Value at '{key}' is not a float or int: {current!r}") + return current diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 504be7a62dd..069f8a576d4 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -8,14 +8,11 @@ "name": "[%key:common::config_flow::data::name%]", "location": "[%key:common::config_flow::data::location%]", "ssl": "[%key:common::config_flow::data::ssl%]", - "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" - } - }, - "api_key": { - "data": { - "api_key": "[%key:common::config_flow::data::api_key%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "App Password or API Key" } }, + "reauth_confirm": { "title": "Reauthenticate Pi-hole", "description": "Please enter a new API key for Pi-hole at {host}/{location}", @@ -33,6 +30,12 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "issues": { + "v5_to_v6_migration": { + "title": "Recent migration from Pi-hole API v5 to v6", + "description": "You've likely updated your Pi-hole to API v6 from v5. Some sensors changed in the new API, the daily sensors were removed, and your old API token is invalid. Provide your new app password by re-authenticating in repairs or in **Settings -> Devices & services -> Pi-hole**." + } + }, "entity": { "binary_sensor": { "status": { @@ -44,9 +47,17 @@ "name": "Ads blocked today", "unit_of_measurement": "ads" }, + "ads_blocked": { + "name": "Ads blocked", + "unit_of_measurement": "ads" + }, "ads_percentage_today": { "name": "Ads percentage blocked today" }, + + "percent_ads_blocked": { + "name": "Ads percentage blocked" + }, "clients_ever_seen": { "name": "Seen clients", "unit_of_measurement": "clients" @@ -55,6 +66,10 @@ "name": "DNS queries today", "unit_of_measurement": "queries" }, + "dns_queries": { + "name": "DNS queries", + "unit_of_measurement": "queries" + }, "domains_being_blocked": { "name": "Domains blocked", "unit_of_measurement": "domains" diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py index 84ffe7e51a4..5fdb39bf9eb 100644 --- a/homeassistant/components/pi_hole/switch.py +++ b/homeassistant/components/pi_hole/switch.py @@ -70,7 +70,7 @@ class PiHoleSwitch(PiHoleEntity, SwitchEntity): @property def is_on(self) -> bool: """Return if the service is on.""" - return self.api.data.get("status") == "enabled" # type: ignore[no-any-return] + return self.api.status == "enabled" # type: ignore[no-any-return] async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the service.""" diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py index 56e92b47289..90fdefd306b 100644 --- a/homeassistant/components/pi_hole/update.py +++ b/homeassistant/components/pi_hole/update.py @@ -21,9 +21,9 @@ from .entity import PiHoleEntity class PiHoleUpdateEntityDescription(UpdateEntityDescription): """Describes PiHole update entity.""" - installed_version: Callable[[dict], str | None] = lambda api: None - latest_version: Callable[[dict], str | None] = lambda api: None - has_update: Callable[[dict], bool | None] = lambda api: None + installed_version: Callable[[Hole], str | None] = lambda api: None + latest_version: Callable[[Hole], str | None] = lambda api: None + has_update: Callable[[Hole], bool | None] = lambda api: None release_base_url: str | None = None title: str | None = None @@ -34,9 +34,9 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( translation_key="core_update_available", title="Pi-hole Core", entity_category=EntityCategory.DIAGNOSTIC, - installed_version=lambda versions: versions.get("core_current"), - latest_version=lambda versions: versions.get("core_latest"), - has_update=lambda versions: versions.get("core_update"), + installed_version=lambda api: api.core_current, + latest_version=lambda api: api.core_latest, + has_update=lambda api: api.core_update, release_base_url="https://github.com/pi-hole/pi-hole/releases/tag", ), PiHoleUpdateEntityDescription( @@ -44,9 +44,9 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( translation_key="web_update_available", title="Pi-hole Web interface", entity_category=EntityCategory.DIAGNOSTIC, - installed_version=lambda versions: versions.get("web_current"), - latest_version=lambda versions: versions.get("web_latest"), - has_update=lambda versions: versions.get("web_update"), + installed_version=lambda api: api.web_current, + latest_version=lambda api: api.web_latest, + has_update=lambda api: api.web_update, release_base_url="https://github.com/pi-hole/AdminLTE/releases/tag", ), PiHoleUpdateEntityDescription( @@ -54,9 +54,9 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = ( translation_key="ftl_update_available", title="Pi-hole FTL DNS", entity_category=EntityCategory.DIAGNOSTIC, - installed_version=lambda versions: versions.get("FTL_current"), - latest_version=lambda versions: versions.get("FTL_latest"), - has_update=lambda versions: versions.get("FTL_update"), + installed_version=lambda api: api.ftl_current, + latest_version=lambda api: api.ftl_latest, + has_update=lambda api: api.ftl_update, release_base_url="https://github.com/pi-hole/FTL/releases/tag", ), ) @@ -108,15 +108,15 @@ class PiHoleUpdateEntity(PiHoleEntity, UpdateEntity): def installed_version(self) -> str | None: """Version installed and in use.""" if isinstance(self.api.versions, dict): - return self.entity_description.installed_version(self.api.versions) + return self.entity_description.installed_version(self.api) return None @property def latest_version(self) -> str | None: """Latest version available for install.""" if isinstance(self.api.versions, dict): - if self.entity_description.has_update(self.api.versions): - return self.entity_description.latest_version(self.api.versions) + if self.entity_description.has_update(self.api): + return self.entity_description.latest_version(self.api) return self.installed_version return None diff --git a/requirements_all.txt b/requirements_all.txt index 80a824cf44f..2655e6f9a90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1161,7 +1161,7 @@ hko==0.3.2 hlk-sw16==0.0.9 # homeassistant.components.pi_hole -hole==0.8.0 +hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 88dc21a0e07..3ec700ccf1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1010,7 +1010,7 @@ hko==0.3.2 hlk-sw16==0.0.9 # homeassistant.components.pi_hole -hole==0.8.0 +hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index a2115ae5591..d7d064fff28 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -246,7 +246,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # opower > arrow > types-python-dateutil "arrow": {"types-python-dateutil"} }, - "pi_hole": {"hole": {"async-timeout"}}, "pvpc_hourly_pricing": {"aiopvpc": {"async-timeout"}}, "remote_rpi_gpio": { # https://github.com/waveform80/colorzero/issues/9 diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index 993f6a2571c..36ee963a16f 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -1,8 +1,9 @@ """Tests for the pi_hole component.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from hole.exceptions import HoleError +from hole.exceptions import HoleConnectionError, HoleError from homeassistant.components.pi_hole.const import ( DEFAULT_LOCATION, @@ -12,6 +13,7 @@ from homeassistant.components.pi_hole.const import ( ) from homeassistant.const import ( CONF_API_KEY, + CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -32,6 +34,82 @@ ZERO_DATA = { "unique_clients": 0, "unique_domains": 0, } +ZERO_DATA_V6 = { + "queries": { + "total": 0, + "blocked": 0, + "percent_blocked": 0, + "unique_domains": 0, + "forwarded": 0, + "cached": 0, + "frequency": 0, + "types": { + "A": 0, + "AAAA": 0, + "ANY": 0, + "SRV": 0, + "SOA": 0, + "PTR": 0, + "TXT": 0, + "NAPTR": 0, + "MX": 0, + "DS": 0, + "RRSIG": 0, + "DNSKEY": 0, + "NS": 0, + "SVCB": 0, + "HTTPS": 0, + "OTHER": 0, + }, + "status": { + "UNKNOWN": 0, + "GRAVITY": 0, + "FORWARDED": 0, + "CACHE": 0, + "REGEX": 0, + "DENYLIST": 0, + "EXTERNAL_BLOCKED_IP": 0, + "EXTERNAL_BLOCKED_NULL": 0, + "EXTERNAL_BLOCKED_NXRA": 0, + "GRAVITY_CNAME": 0, + "REGEX_CNAME": 0, + "DENYLIST_CNAME": 0, + "RETRIED": 0, + "RETRIED_DNSSEC": 0, + "IN_PROGRESS": 0, + "DBBUSY": 0, + "SPECIAL_DOMAIN": 0, + "CACHE_STALE": 0, + "EXTERNAL_BLOCKED_EDE15": 0, + }, + "replies": { + "UNKNOWN": 0, + "NODATA": 0, + "NXDOMAIN": 0, + "CNAME": 0, + "IP": 0, + "DOMAIN": 0, + "RRNAME": 0, + "SERVFAIL": 0, + "REFUSED": 0, + "NOTIMP": 0, + "OTHER": 0, + "DNSSEC": 0, + "NONE": 0, + "BLOB": 0, + }, + }, + "clients": {"active": 0, "total": 0}, + "gravity": {"domains_being_blocked": 0, "last_update": 0}, + "took": 0, +} + +FTL_ERROR = { + "error": { + "key": "FTLnotrunning", + "message": "FTL not running", + } +} SAMPLE_VERSIONS_WITH_UPDATES = { "core_current": "v5.5", @@ -62,6 +140,7 @@ PORT = 80 LOCATION = "location" NAME = "Pi hole" API_KEY = "apikey" +API_VERSION = 6 SSL = False VERIFY_SSL = True @@ -72,6 +151,7 @@ CONFIG_DATA_DEFAULTS = { CONF_SSL: DEFAULT_SSL, CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, CONF_API_KEY: API_KEY, + CONF_API_VERSION: API_VERSION, } CONFIG_DATA = { @@ -81,12 +161,14 @@ CONFIG_DATA = { CONF_API_KEY: API_KEY, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, + CONF_API_VERSION: API_VERSION, } CONFIG_FLOW_USER = { CONF_HOST: HOST, CONF_PORT: PORT, CONF_LOCATION: LOCATION, + CONF_API_KEY: API_KEY, CONF_NAME: NAME, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, @@ -103,6 +185,7 @@ CONFIG_ENTRY_WITH_API_KEY = { CONF_API_KEY: API_KEY, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, + CONF_API_VERSION: API_VERSION, } CONFIG_ENTRY_WITHOUT_API_KEY = { @@ -111,47 +194,129 @@ CONFIG_ENTRY_WITHOUT_API_KEY = { CONF_NAME: NAME, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, + CONF_API_VERSION: API_VERSION, } SWITCH_ENTITY_ID = "switch.pi_hole" def _create_mocked_hole( - raise_exception=False, has_versions=True, has_update=True, has_data=True -): - mocked_hole = MagicMock() - type(mocked_hole).get_data = AsyncMock( - side_effect=HoleError("") if raise_exception else None - ) - type(mocked_hole).get_versions = AsyncMock( - side_effect=HoleError("") if raise_exception else None - ) - type(mocked_hole).enable = AsyncMock() - type(mocked_hole).disable = AsyncMock() - if has_data: - mocked_hole.data = ZERO_DATA - else: - mocked_hole.data = [] - if has_versions: - if has_update: - mocked_hole.versions = SAMPLE_VERSIONS_WITH_UPDATES + raise_exception: bool = False, + has_versions: bool = True, + has_update: bool = True, + has_data: bool = True, + api_version: int = 5, + incorrect_app_password: bool = False, + wrong_host: bool = False, + ftl_error: bool = False, +) -> MagicMock: + """Return a mocked Hole API object with side effects based on constructor args.""" + + instances = [] + + def make_mock(**kwargs: Any) -> MagicMock: + mocked_hole = MagicMock() + # Set constructor kwargs as attributes + for key, value in kwargs.items(): + setattr(mocked_hole, key, value) + + async def authenticate_side_effect(*_args, **_kwargs): + if wrong_host: + raise HoleConnectionError("Cannot authenticate with Pi-hole: err") + password = getattr(mocked_hole, "password", None) + if ( + raise_exception + or incorrect_app_password + or (api_version == 6 and password not in ["newkey", "apikey"]) + ): + if api_version == 6: + raise HoleError("Authentication failed: Invalid password") + raise HoleConnectionError + + async def get_data_side_effect(*_args, **_kwargs): + """Return data based on the mocked Hole instance state.""" + if wrong_host: + raise HoleConnectionError("Cannot fetch data from Pi-hole: err") + password = getattr(mocked_hole, "password", None) + api_token = getattr(mocked_hole, "api_token", None) + if ( + raise_exception + or incorrect_app_password + or (api_version == 5 and (not api_token or api_token == "wrong_token")) + or (api_version == 6 and password not in ["newkey", "apikey"]) + ): + mocked_hole.data = [] if api_version == 5 else {} + elif password in ["newkey", "apikey"] or api_token in ["newkey", "apikey"]: + mocked_hole.data = ZERO_DATA_V6 if api_version == 6 else ZERO_DATA + + async def ftl_side_effect(): + mocked_hole.data = FTL_ERROR + + mocked_hole.authenticate = AsyncMock(side_effect=authenticate_side_effect) + mocked_hole.get_data = AsyncMock(side_effect=get_data_side_effect) + + if ftl_error: + # two unauthenticated instances are created in `determine_api_version` before aync_try_connect is called + if len(instances) > 1: + mocked_hole.get_data = AsyncMock(side_effect=ftl_side_effect) + mocked_hole.get_versions = AsyncMock(return_value=None) + mocked_hole.enable = AsyncMock() + mocked_hole.disable = AsyncMock() + + # Set versions and version properties + if has_versions: + versions = ( + SAMPLE_VERSIONS_WITH_UPDATES + if has_update + else SAMPLE_VERSIONS_NO_UPDATES + ) + mocked_hole.versions = versions + mocked_hole.ftl_current = versions["FTL_current"] + mocked_hole.ftl_latest = versions["FTL_latest"] + mocked_hole.ftl_update = versions["FTL_update"] + mocked_hole.core_current = versions["core_current"] + mocked_hole.core_latest = versions["core_latest"] + mocked_hole.core_update = versions["core_update"] + mocked_hole.web_current = versions["web_current"] + mocked_hole.web_latest = versions["web_latest"] + mocked_hole.web_update = versions["web_update"] else: - mocked_hole.versions = SAMPLE_VERSIONS_NO_UPDATES - else: - mocked_hole.versions = None - return mocked_hole + mocked_hole.versions = None + + # Set initial data + if has_data: + mocked_hole.data = ZERO_DATA_V6 if api_version == 6 else ZERO_DATA + else: + mocked_hole.data = [] if api_version == 5 else {} + instances.append(mocked_hole) + return mocked_hole + + # Return a factory function for patching + make_mock.instances = instances + return make_mock def _patch_init_hole(mocked_hole): - return patch("homeassistant.components.pi_hole.Hole", return_value=mocked_hole) + """Patch the Hole class in the main integration.""" + + def side_effect(*args, **kwargs): + return mocked_hole(**kwargs) + + return patch("homeassistant.components.pi_hole.Hole", side_effect=side_effect) def _patch_config_flow_hole(mocked_hole): + """Patch the Hole class in the config flow.""" + + def side_effect(*args, **kwargs): + return mocked_hole(**kwargs) + return patch( - "homeassistant.components.pi_hole.config_flow.Hole", return_value=mocked_hole + "homeassistant.components.pi_hole.config_flow.Hole", side_effect=side_effect ) def _patch_setup_hole(): + """Patch async_setup_entry for the integration.""" return patch( "homeassistant.components.pi_hole.async_setup_entry", return_value=True ) diff --git a/tests/components/pi_hole/snapshots/test_diagnostics.ambr b/tests/components/pi_hole/snapshots/test_diagnostics.ambr index 2d6f6687d04..58f4302f226 100644 --- a/tests/components/pi_hole/snapshots/test_diagnostics.ambr +++ b/tests/components/pi_hole/snapshots/test_diagnostics.ambr @@ -16,6 +16,7 @@ 'entry': dict({ 'data': dict({ 'api_key': '**REDACTED**', + 'api_version': 5, 'host': '1.2.3.4:80', 'location': 'admin', 'name': 'Pi-Hole', diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index d13712d6f76..e92a845ce1e 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -3,16 +3,15 @@ from homeassistant.components import pi_hole from homeassistant.components.pi_hole.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY +from homeassistant.const import CONF_API_KEY, CONF_API_VERSION from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import ( CONFIG_DATA_DEFAULTS, CONFIG_ENTRY_WITH_API_KEY, - CONFIG_ENTRY_WITHOUT_API_KEY, - CONFIG_FLOW_API_KEY, CONFIG_FLOW_USER, + FTL_ERROR, NAME, ZERO_DATA, _create_mocked_hole, @@ -24,10 +23,14 @@ from . import ( from tests.common import MockConfigEntry -async def test_flow_user_with_api_key(hass: HomeAssistant) -> None: +async def test_flow_user_with_api_key_v6(hass: HomeAssistant) -> None: """Test user initialized flow with api key needed.""" - mocked_hole = _create_mocked_hole(has_data=False) - with _patch_config_flow_hole(mocked_hole), _patch_setup_hole() as mock_setup: + mocked_hole = _create_mocked_hole(has_data=False, api_version=6) + with ( + _patch_init_hole(mocked_hole), + _patch_config_flow_hole(mocked_hole), + _patch_setup_hole() as mock_setup, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -38,27 +41,19 @@ async def test_flow_user_with_api_key(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONFIG_FLOW_USER, + user_input={**CONFIG_FLOW_USER, CONF_API_KEY: "invalid_password"}, ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "api_key" - assert result["errors"] == {} - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={CONF_API_KEY: "some_key"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "api_key" + # we have had no response from the server yet, so we expect an error assert result["errors"] == {CONF_API_KEY: "invalid_auth"} - mocked_hole.data = ZERO_DATA + # now we have a valid passiword result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input=CONFIG_FLOW_API_KEY, + user_input=CONFIG_FLOW_USER, ) + + # form should be complete with a valid config entry assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == NAME assert result["data"] == CONFIG_ENTRY_WITH_API_KEY mock_setup.assert_called_once() @@ -72,10 +67,15 @@ async def test_flow_user_with_api_key(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_flow_user_without_api_key(hass: HomeAssistant) -> None: - """Test user initialized flow without api key needed.""" - mocked_hole = _create_mocked_hole() - with _patch_config_flow_hole(mocked_hole), _patch_setup_hole() as mock_setup: +async def test_flow_user_with_api_key_v5(hass: HomeAssistant) -> None: + """Test user initialized flow with api key needed.""" + mocked_hole = _create_mocked_hole(api_version=5) + with ( + _patch_init_hole(mocked_hole), + _patch_config_flow_hole(mocked_hole), + _patch_setup_hole() as mock_setup, + ): + # start the flow as a user initiated flow result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -84,32 +84,72 @@ async def test_flow_user_without_api_key(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} + # configure the flow with an invalid api key + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={**CONFIG_FLOW_USER, CONF_API_KEY: "wrong_token"}, + ) + + # confirm an invalid authentication error + assert result["errors"] == {CONF_API_KEY: "invalid_auth"} + + # configure the flow with a valid api key result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=CONFIG_FLOW_USER, ) + + # in API V5 we get data to confirm authentication + assert mocked_hole.instances[-1].data == ZERO_DATA + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == NAME - assert result["data"] == CONFIG_ENTRY_WITHOUT_API_KEY + assert result["data"] == {**CONFIG_ENTRY_WITH_API_KEY, CONF_API_VERSION: 5} mock_setup.assert_called_once() + # duplicated server + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=CONFIG_FLOW_USER, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + async def test_flow_user_invalid(hass: HomeAssistant) -> None: - """Test user initialized flow with invalid server.""" + """Test user initialized flow with completely invalid server.""" mocked_hole = _create_mocked_hole(raise_exception=True) - with _patch_config_flow_hole(mocked_hole): + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"api_key": "invalid_auth"} + + +async def test_flow_user_invalid_v6(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid server - typically a V6 API and a incorrect app password.""" + mocked_hole = _create_mocked_hole( + has_data=True, api_version=6, incorrect_app_password=True + ) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"api_key": "invalid_auth"} async def test_flow_reauth(hass: HomeAssistant) -> None: """Test reauth flow.""" - mocked_hole = _create_mocked_hole(has_data=False) - entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) + mocked_hole = _create_mocked_hole(has_data=False, api_version=5) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, + data={**CONFIG_DATA_DEFAULTS, CONF_API_VERSION: 5, CONF_API_KEY: "oldkey"}, + ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole), _patch_config_flow_hole(mocked_hole): assert not await hass.config_entries.async_setup(entry.entry_id) @@ -120,9 +160,7 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" assert flows[0]["context"]["entry_id"] == entry.entry_id - - mocked_hole.data = ZERO_DATA - + mocked_hole.instances[-1].api_token = "newkey" result = await hass.config_entries.flow.async_configure( flows[0]["flow_id"], user_input={CONF_API_KEY: "newkey"}, @@ -131,3 +169,28 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" assert entry.data[CONF_API_KEY] == "newkey" + + +async def test_flow_user_invalid_host(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid server host address.""" + mocked_hole = _create_mocked_hole(api_version=6, wrong_host=True) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_error_response(hass: HomeAssistant) -> None: + """Test user initialized flow but dataotherbase errors occur.""" + mocked_hole = _create_mocked_hole(api_version=5, ftl_error=True, has_data=False) + with _patch_config_flow_hole(mocked_hole), _patch_init_hole(mocked_hole): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG_FLOW_USER + ) + assert mocked_hole.instances[-1].data == FTL_ERROR + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/pi_hole/test_diagnostics.py b/tests/components/pi_hole/test_diagnostics.py index 8d5a83e4622..678efdf078e 100644 --- a/tests/components/pi_hole/test_diagnostics.py +++ b/tests/components/pi_hole/test_diagnostics.py @@ -19,9 +19,10 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Tests diagnostics.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=5) + config_entry = {**CONFIG_DATA_DEFAULTS, "api_version": 5} entry = MockConfigEntry( - domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS, entry_id="pi_hole_mock_entry" + domain=pi_hole.DOMAIN, data=config_entry, entry_id="pi_hole_mock_entry" ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index 72b48e3d572..b4cc11529d9 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -16,6 +16,7 @@ from homeassistant.components.pi_hole.const import ( from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -27,7 +28,7 @@ from . import ( API_KEY, CONFIG_DATA, CONFIG_DATA_DEFAULTS, - CONFIG_ENTRY_WITHOUT_API_KEY, + DEFAULT_VERIFY_SSL, SWITCH_ENTITY_ID, _create_mocked_hole, _patch_init_hole, @@ -38,32 +39,62 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( ("config_entry_data", "expected_api_token"), - [(CONFIG_DATA_DEFAULTS, API_KEY), (CONFIG_ENTRY_WITHOUT_API_KEY, "")], + [(CONFIG_DATA_DEFAULTS, API_KEY)], ) -async def test_setup_api( +async def test_setup_api_v6( hass: HomeAssistant, config_entry_data: dict, expected_api_token: str ) -> None: """Tests the API object is created with the expected parameters.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) + config_entry_data = {**config_entry_data} + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole) as patched_init_hole: + assert await hass.config_entries.async_setup(entry.entry_id) + patched_init_hole.assert_called_once_with( + host=config_entry_data[CONF_HOST], + session=ANY, + password=expected_api_token, + location=config_entry_data[CONF_LOCATION], + protocol="http", + version=6, + verify_tls=DEFAULT_VERIFY_SSL, + ) + + +@pytest.mark.parametrize( + ("config_entry_data", "expected_api_token"), + [({**CONFIG_DATA_DEFAULTS}, API_KEY)], +) +async def test_setup_api_v5( + hass: HomeAssistant, config_entry_data: dict, expected_api_token: str +) -> None: + """Tests the API object is created with the expected parameters.""" + mocked_hole = _create_mocked_hole(api_version=5) + config_entry_data = {**config_entry_data} + config_entry_data[CONF_API_VERSION] = 5 config_entry_data = {**config_entry_data, CONF_STATISTICS_ONLY: True} entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole) as patched_init_hole: assert await hass.config_entries.async_setup(entry.entry_id) patched_init_hole.assert_called_once_with( - config_entry_data[CONF_HOST], - ANY, + host=config_entry_data[CONF_HOST], + session=ANY, api_token=expected_api_token, location=config_entry_data[CONF_LOCATION], tls=config_entry_data[CONF_SSL], + version=5, + verify_tls=DEFAULT_VERIFY_SSL, ) -async def test_setup_with_defaults(hass: HomeAssistant) -> None: +async def test_setup_with_defaults_v5(hass: HomeAssistant) -> None: """Tests component setup with default config.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=5) entry = MockConfigEntry( - domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + domain=pi_hole.DOMAIN, + data={**CONFIG_DATA_DEFAULTS, CONF_API_VERSION: 5, CONF_STATISTICS_ONLY: True}, ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -110,9 +141,87 @@ async def test_setup_with_defaults(hass: HomeAssistant) -> None: assert state.state == "off" +async def test_setup_with_defaults_v6(hass: HomeAssistant) -> None: + """Tests component setup with default config.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state is not None + assert state.name == "Pi-Hole Ads blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_ads_percentage_blocked") + assert state.name == "Pi-Hole Ads percentage blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_queries_cached") + assert state.name == "Pi-Hole DNS queries cached" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_queries_forwarded") + assert state.name == "Pi-Hole DNS queries forwarded" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_queries") + assert state.name == "Pi-Hole DNS queries" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_unique_clients") + assert state.name == "Pi-Hole DNS unique clients" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_dns_unique_domains") + assert state.name == "Pi-Hole DNS unique domains" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_domains_blocked") + assert state.name == "Pi-Hole Domains blocked" + assert state.state == "0" + + state = hass.states.get("sensor.pi_hole_seen_clients") + assert state.name == "Pi-Hole Seen clients" + assert state.state == "0" + + state = hass.states.get("binary_sensor.pi_hole_status") + assert state.name == "Pi-Hole Status" + assert state.state == "off" + + +async def test_setup_without_api_version(hass: HomeAssistant) -> None: + """Tests component setup without API version.""" + + mocked_hole = _create_mocked_hole(api_version=6) + config = {**CONFIG_DATA_DEFAULTS} + config.pop(CONF_API_VERSION) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.data[CONF_API_VERSION] == 6 + + mocked_hole = _create_mocked_hole(api_version=5) + config = {**CONFIG_DATA_DEFAULTS} + config.pop(CONF_API_VERSION) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert entry.data[CONF_API_VERSION] == 5 + + async def test_setup_name_config(hass: HomeAssistant) -> None: """Tests component setup with a custom name.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) entry = MockConfigEntry( domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_NAME: "Custom"} ) @@ -122,16 +231,15 @@ async def test_setup_name_config(hass: HomeAssistant) -> None: await hass.async_block_till_done() - assert ( - hass.states.get("sensor.custom_ads_blocked_today").name - == "Custom Ads blocked today" - ) + assert hass.states.get("sensor.custom_ads_blocked").name == "Custom Ads blocked" async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: """Test Pi-hole switch.""" mocked_hole = _create_mocked_hole() - entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA, CONF_API_VERSION: 5} + ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -145,7 +253,7 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> {"entity_id": SWITCH_ENTITY_ID}, blocking=True, ) - mocked_hole.enable.assert_called_once() + mocked_hole.instances[-1].enable.assert_called_once() await hass.services.async_call( switch.DOMAIN, @@ -153,17 +261,17 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> {"entity_id": SWITCH_ENTITY_ID}, blocking=True, ) - mocked_hole.disable.assert_called_once_with(True) + mocked_hole.instances[-1].disable.assert_called_once_with(True) # Failed calls - type(mocked_hole).enable = AsyncMock(side_effect=HoleError("Error1")) + mocked_hole.instances[-1].enable = AsyncMock(side_effect=HoleError("Error1")) await hass.services.async_call( switch.DOMAIN, switch.SERVICE_TURN_ON, {"entity_id": SWITCH_ENTITY_ID}, blocking=True, ) - type(mocked_hole).disable = AsyncMock(side_effect=HoleError("Error2")) + mocked_hole.instances[-1].disable = AsyncMock(side_effect=HoleError("Error2")) await hass.services.async_call( switch.DOMAIN, switch.SERVICE_TURN_OFF, @@ -171,6 +279,7 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> blocking=True, ) errors = [x for x in caplog.records if x.levelno == logging.ERROR] + assert errors[-2].message == "Unable to enable Pi-hole: Error1" assert errors[-1].message == "Unable to disable Pi-hole: Error2" @@ -178,7 +287,7 @@ async def test_switch(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> async def test_disable_service_call(hass: HomeAssistant) -> None: """Test disable service call with no Pi-hole named.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) with _patch_init_hole(mocked_hole): entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA) entry.add_to_hass(hass) @@ -199,7 +308,7 @@ async def test_disable_service_call(hass: HomeAssistant) -> None: blocking=True, ) - mocked_hole.disable.assert_called_with(1) + mocked_hole.instances[-1].disable.assert_called_with(1) async def test_unload(hass: HomeAssistant) -> None: @@ -209,7 +318,7 @@ async def test_unload(hass: HomeAssistant) -> None: data={**CONFIG_DATA_DEFAULTS, CONF_HOST: "pi.hole"}, ) entry.add_to_hass(hass) - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) with _patch_init_hole(mocked_hole): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -222,7 +331,7 @@ async def test_unload(hass: HomeAssistant) -> None: async def test_remove_obsolete(hass: HomeAssistant) -> None: """Test removing obsolete config entry parameters.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) entry = MockConfigEntry( domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} ) diff --git a/tests/components/pi_hole/test_repairs.py b/tests/components/pi_hole/test_repairs.py new file mode 100644 index 00000000000..4982b1544c7 --- /dev/null +++ b/tests/components/pi_hole/test_repairs.py @@ -0,0 +1,136 @@ +"""Test pi_hole component.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from hole.exceptions import HoleConnectionError, HoleError +import pytest + +import homeassistant +from homeassistant.components import pi_hole +from homeassistant.components.pi_hole.const import VERSION_6_RESPONSE_TO_5_ERROR +from homeassistant.const import CONF_API_VERSION, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util + +from . import CONFIG_DATA_DEFAULTS, ZERO_DATA, _create_mocked_hole, _patch_init_hole + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_change_api_5_to_6( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Tests a user with an API version 5 config entry that is updated to API version 6.""" + mocked_hole = _create_mocked_hole(api_version=5) + + # setu up a valid API version 5 config entry + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, + data={**CONFIG_DATA_DEFAULTS, CONF_API_VERSION: 5}, + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + assert mocked_hole.instances[-1].data == ZERO_DATA + # Change the mock's state after setup + mocked_hole.instances[-1].hole_version = 6 + mocked_hole.instances[-1].api_token = "wrong_token" + + # Patch the method on the coordinator's api reference directly + pihole_data = entry.runtime_data + assert pihole_data.api == mocked_hole.instances[-1] + pihole_data.api.get_data = AsyncMock( + side_effect=lambda: setattr( + pihole_data.api, + "data", + {"error": VERSION_6_RESPONSE_TO_5_ERROR, "took": 0.0001430511474609375}, + ) + ) + + # Now trigger the update + with pytest.raises(homeassistant.exceptions.ConfigEntryAuthFailed): + await pihole_data.coordinator.update_method() + assert pihole_data.api.data == { + "error": VERSION_6_RESPONSE_TO_5_ERROR, + "took": 0.0001430511474609375, + } + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + # ensure a re-auth flow is created + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + assert flows[0]["context"]["entry_id"] == entry.entry_id + + +async def test_app_password_changing( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Tests a user with an API version 5 config entry that is updated to API version 6.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS}) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state is not None + assert state.name == "Pi-Hole Ads blocked" + assert state.state == "0" + + # Test app password changing + async def fail_auth(): + """Set mocked data to bad_data.""" + raise HoleError("Authentication failed: Invalid password") + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=fail_auth) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + assert flows[0]["context"]["entry_id"] == entry.entry_id + + # Test app password changing + async def fail_fetch(): + """Set mocked data to bad_data.""" + raise HoleConnectionError("Cannot fetch data from Pi-hole: 200") + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=fail_fetch) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + +async def test_app_failed_fetch( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Tests a user with an API version 5 config entry that is updated to API version 6.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry(domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS}) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state.state == "0" + + # Test fetch failing changing + async def fail_fetch(): + """Set mocked data to bad_data.""" + raise HoleConnectionError("Cannot fetch data from Pi-hole: 200") + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=fail_fetch) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + state = hass.states.get("sensor.pi_hole_ads_blocked") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/pi_hole/test_sensor.py b/tests/components/pi_hole/test_sensor.py new file mode 100644 index 00000000000..7d3efd938fe --- /dev/null +++ b/tests/components/pi_hole/test_sensor.py @@ -0,0 +1,79 @@ +"""Test pi_hole component.""" + +import copy +from datetime import timedelta +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components import pi_hole +from homeassistant.components.pi_hole.const import CONF_STATISTICS_ONLY +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import CONFIG_DATA_DEFAULTS, ZERO_DATA_V6, _create_mocked_hole, _patch_init_hole + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_bad_data_type( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling of bad data. Mostly for code coverage, rather than simulating known error states.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + bad_data = copy.deepcopy(ZERO_DATA_V6) + bad_data["queries"]["total"] = "error string" + assert bad_data != ZERO_DATA_V6 + + async def set_bad_data(): + """Set mocked data to bad_data.""" + mocked_hole.instances[-1].data = bad_data + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=set_bad_data) + + # Wait a minute + future = dt_util.utcnow() + timedelta(minutes=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert "TypeError" in caplog.text + + +async def test_bad_data_key( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling of bad data. Mostly for code coverage, rather than simulating known error states.""" + mocked_hole = _create_mocked_hole( + api_version=6, has_data=True, incorrect_app_password=False + ) + entry = MockConfigEntry( + domain=pi_hole.DOMAIN, data={**CONFIG_DATA_DEFAULTS, CONF_STATISTICS_ONLY: True} + ) + entry.add_to_hass(hass) + with _patch_init_hole(mocked_hole): + assert await hass.config_entries.async_setup(entry.entry_id) + + bad_data = copy.deepcopy(ZERO_DATA_V6) + # remove a whole part of the dict tree now + bad_data["queries"] = "error string" + assert bad_data != ZERO_DATA_V6 + + async def set_bad_data(): + """Set mocked data to bad_data.""" + mocked_hole.instances[-1].data = bad_data + + mocked_hole.instances[-1].get_data = AsyncMock(side_effect=set_bad_data) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + assert mocked_hole.instances[-1].data != ZERO_DATA_V6 + + assert "KeyError" in caplog.text diff --git a/tests/components/pi_hole/test_update.py b/tests/components/pi_hole/test_update.py index 705e9f9c08d..5e81d91b5bd 100644 --- a/tests/components/pi_hole/test_update.py +++ b/tests/components/pi_hole/test_update.py @@ -11,7 +11,7 @@ from tests.common import MockConfigEntry async def test_update(hass: HomeAssistant) -> None: """Tests update entity.""" - mocked_hole = _create_mocked_hole() + mocked_hole = _create_mocked_hole(api_version=6) entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -52,7 +52,7 @@ async def test_update(hass: HomeAssistant) -> None: async def test_update_no_versions(hass: HomeAssistant) -> None: """Tests update entity when no version data available.""" - mocked_hole = _create_mocked_hole(has_versions=False) + mocked_hole = _create_mocked_hole(has_versions=False, api_version=6) entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): @@ -84,7 +84,9 @@ async def test_update_no_versions(hass: HomeAssistant) -> None: async def test_update_no_updates(hass: HomeAssistant) -> None: """Tests update entity when no latest data available.""" - mocked_hole = _create_mocked_hole(has_versions=True, has_update=False) + mocked_hole = _create_mocked_hole( + has_versions=True, has_update=False, api_version=6 + ) entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=CONFIG_DATA_DEFAULTS) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole): From f1698cdb75d45fff7621cc368740261d8d50988f Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sat, 5 Jul 2025 10:26:04 +0200 Subject: [PATCH 1151/1664] Add reauth flow to homee (#147258) --- homeassistant/components/homee/__init__.py | 10 +- homeassistant/components/homee/config_flow.py | 60 ++++++++ homeassistant/components/homee/strings.json | 16 ++- tests/components/homee/conftest.py | 2 + tests/components/homee/test_config_flow.py | 136 +++++++++++++++++- 5 files changed, 214 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 0f90752733d..d748d1dd809 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -7,7 +7,7 @@ from pyHomee import Homee, HomeeAuthFailedException, HomeeConnectionFailedExcept from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .const import DOMAIN @@ -53,12 +53,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeeConfigEntry) -> boo try: await homee.get_access_token() except HomeeConnectionFailedException as exc: - raise ConfigEntryNotReady( - f"Connection to Homee failed: {exc.__cause__}" - ) from exc + raise ConfigEntryNotReady(f"Connection to Homee failed: {exc.reason}") from exc except HomeeAuthFailedException as exc: - raise ConfigEntryNotReady( - f"Authentication to Homee failed: {exc.__cause__}" + raise ConfigEntryAuthFailed( + f"Authentication to Homee failed: {exc.reason}" ) from exc hass.loop.create_task(homee.run()) diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py index fcf03322d0d..7030752f4c3 100644 --- a/homeassistant/components/homee/config_flow.py +++ b/homeassistant/components/homee/config_flow.py @@ -1,5 +1,6 @@ """Config flow for homee integration.""" +from collections.abc import Mapping import logging from typing import Any @@ -32,6 +33,8 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 homee: Homee + _reauth_host: str + _reauth_username: str async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -84,6 +87,63 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauthentication upon an API authentication error.""" + self._reauth_host = entry_data[CONF_HOST] + self._reauth_username = entry_data[CONF_USERNAME] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + if user_input: + self.homee = Homee( + self._reauth_host, user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + try: + await self.homee.get_access_token() + except HomeeConnectionFailedException: + errors["base"] = "cannot_connect" + except HomeeAuthenticationFailedException: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.hass.loop.create_task(self.homee.run()) + await self.homee.wait_until_connected() + self.homee.disconnect() + await self.homee.wait_until_disconnected() + + await self.async_set_unique_id(self.homee.settings.uid) + self._abort_if_unique_id_mismatch(reason="wrong_hub") + + _LOGGER.debug( + "Reauthenticated homee entry with ID %s", self.homee.settings.uid + ) + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data_updates=user_input + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=self._reauth_username): str, + vol.Required(CONF_PASSWORD): str, + } + ), + description_placeholders={ + "host": self._reauth_host, + }, + errors=errors, + ) + async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 8b10b3ebb8a..9523d62c671 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -3,8 +3,9 @@ "flow_title": "homee {name} ({host})", "abort": { "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_hub": "Address belongs to a different homee." + "wrong_hub": "IP-Address belongs to a different homee than the configured one." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -25,6 +26,17 @@ "password": "The password for your homee." } }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::homee::config::step::user::data_description::username%]", + "password": "[%key:component::homee::config::step::user::data_description::password%]" + } + }, "reconfigure": { "title": "Reconfigure homee {name}", "description": "Reconfigure the IP address of your homee.", @@ -32,7 +44,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The IP address of your homee." + "host": "[%key:component::homee::config::step::user::data_description::host%]" } } } diff --git a/tests/components/homee/conftest.py b/tests/components/homee/conftest.py index f9fa95c593f..3db3e809374 100644 --- a/tests/components/homee/conftest.py +++ b/tests/components/homee/conftest.py @@ -15,7 +15,9 @@ HOMEE_IP = "192.168.1.11" NEW_HOMEE_IP = "192.168.1.12" HOMEE_NAME = "TestHomee" TESTUSER = "testuser" +NEW_TESTUSER = "testuser2" TESTPASS = "testpass" +NEW_TESTPASS = "testpass2" @pytest.fixture diff --git a/tests/components/homee/test_config_flow.py b/tests/components/homee/test_config_flow.py index 70d34ced91c..6f45dcbdb0d 100644 --- a/tests/components/homee/test_config_flow.py +++ b/tests/components/homee/test_config_flow.py @@ -11,7 +11,16 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import HOMEE_ID, HOMEE_IP, HOMEE_NAME, NEW_HOMEE_IP, TESTPASS, TESTUSER +from .conftest import ( + HOMEE_ID, + HOMEE_IP, + HOMEE_NAME, + NEW_HOMEE_IP, + NEW_TESTPASS, + NEW_TESTUSER, + TESTPASS, + TESTUSER, +) from tests.common import MockConfigEntry @@ -113,7 +122,6 @@ async def test_flow_already_configured( ) -> None: """Test config flow aborts when already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -132,6 +140,130 @@ async def test_flow_already_configured( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_homee", "mock_setup_entry") +async def test_reauth_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the reauth flow.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["handler"] == DOMAIN + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + # Confirm that the config entry has been updated + assert mock_config_entry.data[CONF_HOST] == HOMEE_IP + assert mock_config_entry.data[CONF_USERNAME] == NEW_TESTUSER + assert mock_config_entry.data[CONF_PASSWORD] == NEW_TESTPASS + + +@pytest.mark.parametrize( + ("side_eff", "error"), + [ + ( + HomeeConnectionFailedException("connection timed out"), + {"base": "cannot_connect"}, + ), + ( + HomeeAuthFailedException("wrong username or password"), + {"base": "invalid_auth"}, + ), + ( + Exception, + {"base": "unknown"}, + ), + ], +) +async def test_reauth_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: AsyncMock, + side_eff: Exception, + error: dict[str, str], +) -> None: + """Test reconfigure flow errors.""" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_homee.get_access_token.side_effect = side_eff + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == error + + # Confirm that the config entry is unchanged + assert mock_config_entry.data[CONF_USERNAME] == TESTUSER + assert mock_config_entry.data[CONF_PASSWORD] == TESTPASS + + mock_homee.get_access_token.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Confirm that the config entry has been updated + assert mock_config_entry.data[CONF_HOST] == HOMEE_IP + assert mock_config_entry.data[CONF_USERNAME] == NEW_TESTUSER + assert mock_config_entry.data[CONF_PASSWORD] == NEW_TESTPASS + + +async def test_reauth_wrong_uid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homee: AsyncMock, +) -> None: + """Test reauth flow with wrong UID.""" + mock_homee.settings.uid = "wrong_uid" + mock_config_entry.add_to_hass(hass) + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USERNAME: NEW_TESTUSER, + CONF_PASSWORD: NEW_TESTPASS, + }, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "wrong_hub" + + # Confirm that the config entry is unchanged + assert mock_config_entry.data[CONF_HOST] == HOMEE_IP + + @pytest.mark.usefixtures("mock_setup_entry") async def test_reconfigure_success( hass: HomeAssistant, From fea7dc7eba402c42b31d104707eaa3097217830a Mon Sep 17 00:00:00 2001 From: tronikos Date: Sat, 5 Jul 2025 01:26:15 -0700 Subject: [PATCH 1152/1664] Remember Opower utility and username on config flow errors (#148097) --- homeassistant/components/opower/config_flow.py | 11 +++++++++-- tests/components/opower/test_config_flow.py | 11 ++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index 4753a77894e..e7f2534e1ad 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -26,6 +26,7 @@ from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) + STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), @@ -88,9 +89,15 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): errors = await _validate_login(self.hass, user_input) if not errors: return self._async_create_opower_entry(user_input) - + else: + user_input = {} + user_input.pop(CONF_PASSWORD, None) return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="user", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, user_input + ), + errors=errors, ) async def async_step_mfa( diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 8134539b0a5..c9edfc6808f 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value @pytest.fixture(autouse=True, name="mock_setup_entry") @@ -203,6 +203,15 @@ async def test_form_exceptions( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": expected_error} + # On error, the form should have the previous user input, except password, + # as suggested values. + data_schema = result2["data_schema"].schema + assert ( + get_schema_suggested_value(data_schema, "utility") + == "Pacific Gas and Electric Company (PG&E)" + ) + assert get_schema_suggested_value(data_schema, "username") == "test-username" + assert get_schema_suggested_value(data_schema, "password") is None assert mock_login.call_count == 1 From b72536acfa81a5e7330a9f510856f0414b03ca6a Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Jul 2025 10:59:57 +0200 Subject: [PATCH 1153/1664] Make "autorelock" consistent across integrations in `matter` (#148023) --- homeassistant/components/matter/strings.json | 2 +- .../matter/snapshots/test_number.ambr | 24 +++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index df1cbc5adb0..0ac44c006ab 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -193,7 +193,7 @@ "name": "Occupied to unoccupied delay" }, "auto_relock_timer": { - "name": "Automatic relock timer" + "name": "Autorelock time" } }, "light": { diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index c1d08dba8a1..8d27c4b4691 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -402,7 +402,7 @@ 'state': '1.0', }) # --- -# name: test_numbers[door_lock][number.mock_door_lock_automatic_relock_timer-entry] +# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -420,7 +420,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -432,7 +432,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Automatic relock timer', + 'original_name': 'Autorelock time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -442,10 +442,10 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[door_lock][number.mock_door_lock_automatic_relock_timer-state] +# name: test_numbers[door_lock][number.mock_door_lock_autorelock_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Automatic relock timer', + 'friendly_name': 'Mock Door Lock Autorelock time', 'max': 65534, 'min': 0, 'mode': , @@ -453,14 +453,14 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'last_changed': , 'last_reported': , 'last_updated': , 'state': '60', }) # --- -# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_automatic_relock_timer-entry] +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_autorelock_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -478,7 +478,7 @@ 'disabled_by': None, 'domain': 'number', 'entity_category': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -490,7 +490,7 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Automatic relock timer', + 'original_name': 'Autorelock time', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, @@ -500,10 +500,10 @@ 'unit_of_measurement': , }) # --- -# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_automatic_relock_timer-state] +# name: test_numbers[door_lock_with_unbolt][number.mock_door_lock_autorelock_time-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'Mock Door Lock Automatic relock timer', + 'friendly_name': 'Mock Door Lock Autorelock time', 'max': 65534, 'min': 0, 'mode': , @@ -511,7 +511,7 @@ 'unit_of_measurement': , }), 'context': , - 'entity_id': 'number.mock_door_lock_automatic_relock_timer', + 'entity_id': 'number.mock_door_lock_autorelock_time', 'last_changed': , 'last_reported': , 'last_updated': , From ef255788d2a593d1ca95de851048b2a9433a4ccf Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sat, 5 Jul 2025 11:01:27 +0200 Subject: [PATCH 1154/1664] Make lat/long attribute names localizable in `dwd_weather_warnings` (#147988) --- homeassistant/components/dwd_weather_warnings/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json index 3f421d338a7..4e0ee2d2016 100644 --- a/homeassistant/components/dwd_weather_warnings/strings.json +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -2,7 +2,7 @@ "config": { "step": { "user": { - "description": "To identify the desired region, either the warncell ID / name or device tracker is required. The provided device tracker has to contain the attributes 'latitude' and 'longitude'.", + "description": "To identify the desired region, either the warncell ID / name or device tracker is required. The provided device tracker has to contain the attributes 'Latitude' and 'Longitude'.", "data": { "region_identifier": "Warncell ID or name", "region_device_tracker": "Device tracker entity" @@ -14,7 +14,7 @@ "ambiguous_identifier": "The region identifier and device tracker can not be specified together.", "invalid_identifier": "The specified region identifier / device tracker is invalid.", "entity_not_found": "The specified device tracker entity was not found.", - "attribute_not_found": "The required `latitude` or `longitude` attribute was not found in the specified device tracker." + "attribute_not_found": "The required attributes 'Latitude' and 'Longitude' were not found in the specified device tracker." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", From 23773759ea3b9b59662cb114943dd60e12893802 Mon Sep 17 00:00:00 2001 From: David Rapan Date: Sat, 5 Jul 2025 11:18:54 +0200 Subject: [PATCH 1155/1664] Starlink's last boot time occasional, back and forth changes by 1 s fix (#147969) --- homeassistant/components/starlink/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py index 14cbf6fe876..b353051a074 100644 --- a/homeassistant/components/starlink/sensor.py +++ b/homeassistant/components/starlink/sensor.py @@ -114,7 +114,7 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: ( - now() - timedelta(seconds=data.status["uptime"]) + now() - timedelta(seconds=data.status["uptime"], milliseconds=-500) ).replace(microsecond=0), ), StarlinkSensorEntityDescription( From 3151713a346e169614b4bb3523db72542719c1f8 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sat, 5 Jul 2025 12:27:27 +0300 Subject: [PATCH 1156/1664] Replace dot with underscores for NamespacedTool and ActionTool (#147764) --- homeassistant/helpers/llm.py | 4 ++-- tests/helpers/test_llm.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index bf89e693870..a8a598c79f8 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -331,7 +331,7 @@ class NamespacedTool(Tool): def __init__(self, namespace: str, tool: Tool) -> None: """Init the class.""" self.namespace = namespace - self.name = f"{namespace}.{tool.name}" + self.name = f"{namespace}__{tool.name}" self.description = tool.description self.parameters = tool.parameters self.tool = tool @@ -915,7 +915,7 @@ class ActionTool(Tool): """Init the class.""" self._domain = domain self._action = action - self.name = f"{domain}.{action}" + self.name = f"{domain}__{action}" # Note: _get_cached_action_parameters only works for services which # add their description directly to the service description cache. # This is not the case for most services, but it is for scripts. diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index b978559130c..78ff675f0b6 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1542,18 +1542,18 @@ This is prompt 2 """ ) assert [(tool.name, tool.description) for tool in instance.tools] == [ - ("api-1.Tool_1", "Description 1"), - ("api-2.Tool_2", "Description 2"), + ("api-1__Tool_1", "Description 1"), + ("api-2__Tool_2", "Description 2"), ] # The test tool returns back the provided arguments so we can verify # the original tool is invoked with the correct tool name and args. result = await instance.async_call_tool( - llm.ToolInput(tool_name="api-1.Tool_1", tool_args={"arg1": "value1"}) + llm.ToolInput(tool_name="api-1__Tool_1", tool_args={"arg1": "value1"}) ) assert result == {"result": {"Tool_1": {"arg1": "value1"}}} result = await instance.async_call_tool( - llm.ToolInput(tool_name="api-2.Tool_2", tool_args={"arg2": "value2"}) + llm.ToolInput(tool_name="api-2__Tool_2", tool_args={"arg2": "value2"}) ) assert result == {"result": {"Tool_2": {"arg2": "value2"}}} From 676567f471f163449e90ab878be02eb2f387537f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Beye?= Date: Sat, 5 Jul 2025 11:31:30 +0200 Subject: [PATCH 1157/1664] Squeezebox: Fix tracks not having thumbnails (#147187) --- homeassistant/components/squeezebox/browse_media.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 03df289a2fd..03dcd116a6d 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -221,12 +221,16 @@ def _get_item_thumbnail( ) -> str | None: """Construct path to thumbnail image.""" item_thumbnail: str | None = None - if artwork_track_id := item.get("artwork_track_id"): + track_id = item.get("artwork_track_id") or ( + item.get("id") if item_type == "track" else None + ) + + if track_id: if internal_request: - item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id) + item_thumbnail = player.generate_image_url_from_track_id(track_id) elif item_type is not None: item_thumbnail = entity.get_browse_image_url( - item_type, item["id"], artwork_track_id + item_type, item["id"], track_id ) elif search_type in ["apps", "radios"]: From 2ea09ff37a77069c98d411d538a4deba985938b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Beye?= Date: Sat, 5 Jul 2025 11:36:45 +0200 Subject: [PATCH 1158/1664] Squeezebox: Fix track selection in media browser (#147185) --- homeassistant/components/squeezebox/browse_media.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index 03dcd116a6d..bab4f90c6d1 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -315,8 +315,7 @@ async def build_item_response( title=item["title"], media_content_type=item_type, media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"], - can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"] - is not None, + can_expand=bool(CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]), can_play=True, ) From 8d82e34ba55462808594107ef87946b7c8a3264b Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sat, 5 Jul 2025 11:42:15 +0200 Subject: [PATCH 1159/1664] Make connected stations coordinator a dict in devolo Home Network (#147042) --- .../devolo_home_network/coordinator.py | 7 ++-- .../devolo_home_network/device_tracker.py | 35 +++++++------------ .../components/devolo_home_network/entity.py | 2 +- .../components/devolo_home_network/sensor.py | 8 +++-- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/devolo_home_network/coordinator.py b/homeassistant/components/devolo_home_network/coordinator.py index d23aa0e935e..5af9afb12ae 100644 --- a/homeassistant/components/devolo_home_network/coordinator.py +++ b/homeassistant/components/devolo_home_network/coordinator.py @@ -207,7 +207,7 @@ class DevoloUptimeGetCoordinator(DevoloDataUpdateCoordinator[int]): class DevoloWifiConnectedStationsGetCoordinator( - DevoloDataUpdateCoordinator[list[ConnectedStationInfo]] + DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] ): """Class to manage fetching data from the WifiGuestAccessGet endpoint.""" @@ -230,10 +230,11 @@ class DevoloWifiConnectedStationsGetCoordinator( ) self.update_method = self.async_get_wifi_connected_station - async def async_get_wifi_connected_station(self) -> list[ConnectedStationInfo]: + async def async_get_wifi_connected_station(self) -> dict[str, ConnectedStationInfo]: """Fetch data from API endpoint.""" assert self.device.device - return await self.device.device.async_get_wifi_connected_station() + clients = await self.device.device.async_get_wifi_connected_station() + return {client.mac_address: client for client in clients} class DevoloWifiGuestAccessGetCoordinator( diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index ad3d3e1cffa..a0cdd381261 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -28,9 +28,9 @@ async def async_setup_entry( ) -> None: """Get all devices and sensors and setup them via config entry.""" device = entry.runtime_data.device - coordinators: dict[str, DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]] = ( - entry.runtime_data.coordinators - ) + coordinators: dict[ + str, DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]] + ] = entry.runtime_data.coordinators registry = er.async_get(hass) tracked = set() @@ -38,16 +38,16 @@ async def async_setup_entry( def new_device_callback() -> None: """Add new devices if needed.""" new_entities = [] - for station in coordinators[CONNECTED_WIFI_CLIENTS].data: - if station.mac_address in tracked: + for mac_address in coordinators[CONNECTED_WIFI_CLIENTS].data: + if mac_address in tracked: continue new_entities.append( DevoloScannerEntity( - coordinators[CONNECTED_WIFI_CLIENTS], device, station.mac_address + coordinators[CONNECTED_WIFI_CLIENTS], device, mac_address ) ) - tracked.add(station.mac_address) + tracked.add(mac_address) async_add_entities(new_entities) @callback @@ -82,7 +82,7 @@ async def async_setup_entry( # The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138 class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module - CoordinatorEntity[DevoloDataUpdateCoordinator[list[ConnectedStationInfo]]], + CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]], ScannerEntity, ): """Representation of a devolo device tracker.""" @@ -92,7 +92,7 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module def __init__( self, - coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]], + coordinator: DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]], device: Device, mac: str, ) -> None: @@ -109,14 +109,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module if not self.coordinator.data: return {} - station = next( - ( - station - for station in self.coordinator.data - if station.mac_address == self.mac_address - ), - None, - ) + assert self.mac_address + station = self.coordinator.data.get(self.mac_address) if station: attrs["wifi"] = WIFI_APTYPE.get(station.vap_type, STATE_UNKNOWN) attrs["band"] = ( @@ -129,11 +123,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module @property def is_connected(self) -> bool: """Return true if the device is connected to the network.""" - return any( - station - for station in self.coordinator.data - if station.mac_address == self.mac_address - ) + assert self.mac_address + return self.coordinator.data.get(self.mac_address) is not None @property def unique_id(self) -> str: diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index be437314ae4..79b9b846463 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -21,7 +21,7 @@ from .coordinator import DevoloDataUpdateCoordinator, DevoloHomeNetworkConfigEnt type _DataType = ( LogicalNetwork | DataRate - | list[ConnectedStationInfo] + | dict[str, ConnectedStationInfo] | list[NeighborAPInfo] | WifiGuestAccessGet | bool diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index f4c911bf787..941eec4215d 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -47,7 +47,11 @@ def _last_restart(runtime: int) -> datetime: type _CoordinatorDataType = ( - LogicalNetwork | DataRate | list[ConnectedStationInfo] | list[NeighborAPInfo] | int + LogicalNetwork + | DataRate + | dict[str, ConnectedStationInfo] + | list[NeighborAPInfo] + | int ) type _SensorDataType = int | float | datetime @@ -79,7 +83,7 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any, Any]] = { ), ), CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[ - list[ConnectedStationInfo], int + dict[str, ConnectedStationInfo], int ]( key=CONNECTED_WIFI_CLIENTS, state_class=SensorStateClass.MEASUREMENT, From 33d05d99ebfa81503bc114f9b43602e9343c8ef1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luka=20Matijevi=C4=87?= Date: Sat, 5 Jul 2025 16:44:41 +0200 Subject: [PATCH 1160/1664] Fix Miele hob plate power step typo (#148214) --- homeassistant/components/miele/const.py | 2 +- tests/components/miele/snapshots/test_sensor.ambr | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py index fd2f8631cd2..a40df909e14 100644 --- a/homeassistant/components/miele/const.py +++ b/homeassistant/components/miele/const.py @@ -1314,7 +1314,7 @@ class PlatePowerStep(MieleEnum): plate_step_11 = 11 plate_step_12 = 12 plate_step_13 = 13 - plate_step_14 = 4 + plate_step_14 = 14 plate_step_15 = 15 plate_step_16 = 16 plate_step_17 = 17 diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index b1691c28b19..dfc12a52c08 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -103,6 +103,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -160,6 +161,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -197,6 +199,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -254,6 +257,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -291,6 +295,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -348,6 +353,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -385,6 +391,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -442,6 +449,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -479,6 +487,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', @@ -536,6 +545,7 @@ 'plate_step_11', 'plate_step_12', 'plate_step_13', + 'plate_step_14', 'plate_step_15', 'plate_step_16', 'plate_step_17', From 4f4ec6f41a11e1868b8ff44e0f3a08eddc567ef1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 5 Jul 2025 17:22:17 +0200 Subject: [PATCH 1161/1664] Add Google Gen AI structured data support (#148143) --- .../ai_task.py | 25 ++++++- .../entity.py | 14 ++++ homeassistant/helpers/llm.py | 6 +- .../conftest.py | 25 ++++--- .../test_ai_task.py | 74 ++++++++++++++++++- 5 files changed, 127 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index ab34af71ebe..b4f9d73e38d 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -2,11 +2,14 @@ from __future__ import annotations +from json import JSONDecodeError + from homeassistant.components import ai_task, conversation from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads from .const import LOGGER from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity @@ -42,7 +45,7 @@ class GoogleGenerativeAITaskEntity( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log) + await self._async_handle_chat_log(chat_log, task.structure) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( @@ -51,7 +54,25 @@ class GoogleGenerativeAITaskEntity( ) raise HomeAssistantError(ERROR_GETTING_RESPONSE) + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + + try: + data = json_loads(text) + except JSONDecodeError as err: + LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) from err + return ai_task.GenDataTaskResult( conversation_id=chat_log.conversation_id, - data=chat_log.content[-1].content or "", + data=data, ) diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index dea875212ef..d471da36a8c 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -21,6 +21,7 @@ from google.genai.types import ( Schema, Tool, ) +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -324,6 +325,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -402,6 +404,18 @@ class GoogleGenerativeAILLMBaseEntity(Entity): generateContentConfig.automatic_function_calling = ( AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None) ) + if structure: + generateContentConfig.response_mime_type = "application/json" + generateContentConfig.response_schema = _format_schema( + convert( + structure, + custom_serializer=( + chat_log.llm_api.custom_serializer + if chat_log.llm_api + else llm.selector_serializer + ), + ) + ) if not supports_system_instruction: messages = [ diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index a8a598c79f8..b239ad99119 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -458,7 +458,7 @@ class AssistAPI(API): api_prompt=self._async_get_api_prompt(llm_context, exposed_entities), llm_context=llm_context, tools=self._async_get_tools(llm_context, exposed_entities), - custom_serializer=_selector_serializer, + custom_serializer=selector_serializer, ) @callback @@ -701,7 +701,7 @@ def _get_exposed_entities( return data -def _selector_serializer(schema: Any) -> Any: # noqa: C901 +def selector_serializer(schema: Any) -> Any: # noqa: C901 """Convert selectors into OpenAPI schema.""" if not isinstance(schema, selector.Selector): return UNSUPPORTED @@ -782,7 +782,7 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 result["properties"] = { field: convert( selector.selector(field_schema["selector"]), - custom_serializer=_selector_serializer, + custom_serializer=selector_serializer, ) for field, field_schema in fields.items() } diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 244ac518fbd..da5976f46c4 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -112,19 +112,26 @@ async def setup_ha(hass: HomeAssistant) -> None: @pytest.fixture -def mock_send_message_stream() -> Generator[AsyncMock]: +def mock_chat_create() -> Generator[AsyncMock]: """Mock stream response.""" async def mock_generator(stream): for value in stream: yield value - with patch( - "google.genai.chats.AsyncChat.send_message_stream", - AsyncMock(), - ) as mock_send_message_stream: - mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( - mock_send_message_stream.return_value.pop(0) - ) + mock_send_message_stream = AsyncMock() + mock_send_message_stream.side_effect = lambda **kwargs: mock_generator( + mock_send_message_stream.return_value.pop(0) + ) - yield mock_send_message_stream + with patch( + "google.genai.chats.AsyncChats.create", + return_value=AsyncMock(send_message_stream=mock_send_message_stream), + ) as mock_create: + yield mock_create + + +@pytest.fixture +def mock_send_message_stream(mock_chat_create) -> Generator[AsyncMock]: + """Mock stream response.""" + return mock_chat_create.return_value.send_message_stream diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index 72b62b64615..b2b44aa1cd6 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -4,10 +4,12 @@ from unittest.mock import AsyncMock from google.genai.types import GenerateContentResponse import pytest +import voluptuous as vol from homeassistant.components import ai_task from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector from tests.common import MockConfigEntry from tests.components.conversation import ( @@ -17,14 +19,15 @@ from tests.components.conversation import ( @pytest.mark.usefixtures("mock_init_component") -async def test_run_task( +async def test_generate_data( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_chat_log: MockChatLog, # noqa: F811 mock_send_message_stream: AsyncMock, + mock_chat_create: AsyncMock, entity_registry: er.EntityRegistry, ) -> None: - """Test empty response.""" + """Test generating data.""" entity_id = "ai_task.google_ai_task" # Ensure it's linked to the subentry @@ -60,3 +63,68 @@ async def test_run_task( instructions="Test prompt", ) assert result.data == "Hi there!" + + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": '{"characters": ["Mario", "Luigi"]}'}], + "role": "model", + }, + } + ], + ), + ], + ] + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Give me 2 mario characters", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + assert result.data == {"characters": ["Mario", "Luigi"]} + + assert len(mock_chat_create.mock_calls) == 2 + config = mock_chat_create.mock_calls[-1][2]["config"] + assert config.response_mime_type == "application/json" + assert config.response_schema == { + "properties": {"characters": {"items": {"type": "STRING"}, "type": "ARRAY"}}, + "required": ["characters"], + "type": "OBJECT", + } + # Raise error on invalid JSON response + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "INVALID JSON RESPONSE"}], + "role": "model", + }, + } + ], + ), + ], + ] + with pytest.raises(HomeAssistantError): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + structure=vol.Schema({vol.Required("bla"): str}), + ) From 736865c130ee33c68464667524c74d68f976b8dc Mon Sep 17 00:00:00 2001 From: Jack Powell Date: Sat, 5 Jul 2025 11:27:23 -0400 Subject: [PATCH 1162/1664] Add binary sensor platform to PlayStation Network Integration (#147639) --- .../playstation_network/__init__.py | 6 +- .../playstation_network/binary_sensor.py | 71 +++++++++++++++++++ .../components/playstation_network/entity.py | 36 ++++++++++ .../components/playstation_network/icons.json | 5 ++ .../components/playstation_network/sensor.py | 35 ++------- .../playstation_network/strings.json | 5 ++ .../snapshots/test_binary_sensor.ambr | 49 +++++++++++++ .../playstation_network/test_binary_sensor.py | 42 +++++++++++ 8 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/playstation_network/binary_sensor.py create mode 100644 homeassistant/components/playstation_network/entity.py create mode 100644 tests/components/playstation_network/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/playstation_network/test_binary_sensor.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index 72ce0b9cfc2..feb598a646a 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -9,7 +9,11 @@ from .const import CONF_NPSSO from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator from .helpers import PlaystationNetwork -PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.SENSOR] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.MEDIA_PLAYER, + Platform.SENSOR, +] async def async_setup_entry( diff --git a/homeassistant/components/playstation_network/binary_sensor.py b/homeassistant/components/playstation_network/binary_sensor.py new file mode 100644 index 00000000000..fcecd1d1ee1 --- /dev/null +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -0,0 +1,71 @@ +"""Binary Sensor platform for PlayStation Network integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 0 + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkBinarySensorEntityDescription(BinarySensorEntityDescription): + """PlayStation Network binary sensor description.""" + + is_on_fn: Callable[[PlaystationNetworkData], bool] + + +class PlaystationNetworkBinarySensor(StrEnum): + """PlayStation Network binary sensors.""" + + PS_PLUS_STATUS = "ps_plus_status" + + +BINARY_SENSOR_DESCRIPTIONS: tuple[ + PlaystationNetworkBinarySensorEntityDescription, ... +] = ( + PlaystationNetworkBinarySensorEntityDescription( + key=PlaystationNetworkBinarySensor.PS_PLUS_STATUS, + translation_key=PlaystationNetworkBinarySensor.PS_PLUS_STATUS, + is_on_fn=lambda psn: psn.profile["isPlus"], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the binary sensor platform.""" + coordinator = config_entry.runtime_data + async_add_entities( + PlaystationNetworkBinarySensorEntity(coordinator, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ) + + +class PlaystationNetworkBinarySensorEntity( + PlaystationNetworkServiceEntity, + BinarySensorEntity, +): + """Representation of a PlayStation Network binary sensor entity.""" + + entity_description: PlaystationNetworkBinarySensorEntityDescription + + @property + def is_on(self) -> bool: + """Return the state of the binary sensor.""" + + return self.entity_description.is_on_fn(self.coordinator.data) diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py new file mode 100644 index 00000000000..54f5fd5db70 --- /dev/null +++ b/homeassistant/components/playstation_network/entity.py @@ -0,0 +1,36 @@ +"""Base entity for PlayStation Network Integration.""" + +from typing import TYPE_CHECKING + +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PlaystationNetworkCoordinator + + +class PlaystationNetworkServiceEntity(CoordinatorEntity[PlaystationNetworkCoordinator]): + """Common entity class for PlayStationNetwork Service entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PlaystationNetworkCoordinator, + entity_description: EntityDescription, + ) -> None: + """Initialize PlayStation Network Service Entity.""" + super().__init__(coordinator) + if TYPE_CHECKING: + assert coordinator.config_entry.unique_id + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, + name=coordinator.data.username, + 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 612427c9a1d..2742ab1c989 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -5,6 +5,11 @@ "default": "mdi:sony-playstation" } }, + "binary_sensor": { + "ps_plus_status": { + "default": "mdi:shape-plus-outline" + } + }, "sensor": { "trophy_level": { "default": "mdi:trophy-award" diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index 305f252f31d..f4a634d5fb5 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -6,7 +6,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime from enum import StrEnum -from typing import TYPE_CHECKING from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,18 +14,12 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from .const import DOMAIN -from .coordinator import ( - PlaystationNetworkConfigEntry, - PlaystationNetworkCoordinator, - PlaystationNetworkData, -) +from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkData +from .entity import PlaystationNetworkServiceEntity PARALLEL_UPDATES = 0 @@ -146,32 +139,12 @@ async def async_setup_entry( class PlaystationNetworkSensorEntity( - CoordinatorEntity[PlaystationNetworkCoordinator], SensorEntity + PlaystationNetworkServiceEntity, + SensorEntity, ): """Representation of a PlayStation Network sensor entity.""" entity_description: PlaystationNetworkSensorEntityDescription - coordinator: PlaystationNetworkCoordinator - - _attr_has_entity_name = True - - def __init__( - self, - coordinator: PlaystationNetworkCoordinator, - description: PlaystationNetworkSensorEntityDescription, - ) -> None: - """Initialize a sensor entity.""" - super().__init__(coordinator) - self.entity_description = description - if TYPE_CHECKING: - assert coordinator.config_entry.unique_id - self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.unique_id)}, - name=coordinator.data.username, - entry_type=DeviceEntryType.SERVICE, - manufacturer="Sony Interactive Entertainment", - ) @property def native_value(self) -> StateType | datetime: diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index f68d69417fb..360687f97c8 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -53,6 +53,11 @@ } }, "entity": { + "binary_sensor": { + "ps_plus_status": { + "name": "Subscribed to PlayStation Plus" + } + }, "sensor": { "trophy_level": { "name": "Trophy level" diff --git a/tests/components/playstation_network/snapshots/test_binary_sensor.ambr b/tests/components/playstation_network/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..f380f91e9b9 --- /dev/null +++ b/tests/components/playstation_network/snapshots/test_binary_sensor.ambr @@ -0,0 +1,49 @@ +# serializer version: 1 +# name: test_sensors[binary_sensor.testuser_subscribed_to_playstation_plus-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.testuser_subscribed_to_playstation_plus', + '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': 'Subscribed to PlayStation Plus', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_ps_plus_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[binary_sensor.testuser_subscribed_to_playstation_plus-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'testuser Subscribed to PlayStation Plus', + }), + 'context': , + 'entity_id': 'binary_sensor.testuser_subscribed_to_playstation_plus', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/playstation_network/test_binary_sensor.py b/tests/components/playstation_network/test_binary_sensor.py new file mode 100644 index 00000000000..de7ef630b76 --- /dev/null +++ b/tests/components/playstation_network/test_binary_sensor.py @@ -0,0 +1,42 @@ +"""Test the Playstation Network binary sensor platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def binary_sensor_only() -> Generator[None]: + """Enable only the binary sensor platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.BINARY_SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the PlayStation Network binary sensor 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) From d997efc500efba308c780e4c8f7c0d316a15555d Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Sat, 5 Jul 2025 11:39:52 -0400 Subject: [PATCH 1163/1664] Add tests for Sonos Alarms (#146308) --- tests/components/sonos/conftest.py | 28 +++++++++++++- tests/components/sonos/test_init.py | 5 +++ tests/components/sonos/test_switch.py | 54 ++++++++++++++++++++++++++- 3 files changed, 84 insertions(+), 3 deletions(-) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d3de2a889d5..a2a4e53cae4 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -214,12 +214,25 @@ class MockSoCo(MagicMock): surround_level = 3 music_surround_level = 4 soundbar_audio_input_format = "Dolby 5.1" + factory: SoCoMockFactory | None = None @property def visible_zones(self): """Return visible zones and allow property to be overridden by device classes.""" return {self} + @property + def all_zones(self) -> set[MockSoCo]: + """Return a set of all mock zones, or just self if no factory or zones.""" + if self.factory is not None: + if zones := self.factory.mock_all_zones: + return zones + return {self} + + def set_factory(self, factory: SoCoMockFactory) -> None: + """Set the factory for this mock.""" + self.factory = factory + class SoCoMockFactory: """Factory for creating SoCo Mocks.""" @@ -244,11 +257,19 @@ class SoCoMockFactory: self.sonos_playlists = sonos_playlists self.sonos_queue = sonos_queue + @property + def mock_all_zones(self) -> set[MockSoCo]: + """Return a set of all mock zones.""" + return { + mock for mock in self.mock_list.values() if mock.mock_include_in_all_zones + } + def cache_mock( self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A" ) -> MockSoCo: """Put a user created mock into the cache.""" mock_soco.mock_add_spec(SoCo) + mock_soco.set_factory(self) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": mock_soco.uid += f"_{ip_address}" @@ -260,6 +281,11 @@ class SoCoMockFactory: my_speaker_info = self.speaker_info.copy() my_speaker_info["zone_name"] = name my_speaker_info["uid"] = mock_soco.uid + # Generate a different MAC for the non-default speakers. + # otherwise new devices will not be created. + if ip_address != "192.168.42.2": + last_octet = ip_address.split(".")[-1] + my_speaker_info["mac_address"] = f"00-00-00-00-00-{last_octet.zfill(2)}" mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) mock_soco.add_to_queue = Mock(return_value=10) mock_soco.add_uri_to_queue = Mock(return_value=10) @@ -278,7 +304,7 @@ class SoCoMockFactory: mock_soco.alarmClock = self.alarm_clock mock_soco.get_battery_info.return_value = self.battery_info - mock_soco.all_zones = {mock_soco} + mock_soco.mock_include_in_all_zones = True mock_soco.group.coordinator = mock_soco mock_soco.household_id = "test_household_id" self.mock_list[ip_address] = mock_soco diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index c1b98b2ec60..901ae359917 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -324,10 +324,15 @@ async def test_async_poll_manual_hosts_5( soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_1.renderingControl = Mock() soco_1.renderingControl.GetVolume = Mock() + # Unavailable speakers should not be included in all zones + soco_1.mock_include_in_all_zones = False + speaker_1_activity = SpeakerActivity(hass, soco_1) soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") soco_2.renderingControl = Mock() soco_2.renderingControl.GetVolume = Mock() + soco_2.mock_include_in_all_zones = False + speaker_2_activity = SpeakerActivity(hass, soco_2) with caplog.at_level(logging.DEBUG): diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 04457ee95c7..56dd96b0caf 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -26,10 +26,10 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent +from .conftest import MockSoCo, SonosMockEvent, SonosMockService from tests.common import async_fire_time_changed @@ -211,3 +211,53 @@ async def test_alarm_create_delete( assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities + + +async def test_alarm_change_device( + hass: HomeAssistant, + async_setup_sonos, + soco: MockSoCo, + alarm_clock: SonosMockService, + alarm_clock_extended: SonosMockService, + alarm_event: SonosMockEvent, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + sonos_setup_two_speakers: list[MockSoCo], +) -> None: + """Test Sonos Alarm being moved to a different speaker. + + This test simulates a scenario where an alarm is created on one speaker + and then moved to another speaker. It checks that the entity is correctly + created on the new speaker and removed from the old one. + """ + entity_id = "switch.sonos_alarm_14" + soco_lr = sonos_setup_two_speakers[0] + + await async_setup_sonos() + + # Initially, the alarm is created on the soco mock + assert entity_id in entity_registry.entities + entity = entity_registry.async_get(entity_id) + device = device_registry.async_get(entity.device_id) + assert device.name == soco.get_speaker_info()["zone_name"] + + # Simulate the alarm being moved to the soco_lr speaker + alarm_update = copy(alarm_clock_extended.ListAlarms.return_value) + alarm_update["CurrentAlarmList"] = alarm_update["CurrentAlarmList"].replace( + "RINCON_test", f"{soco_lr.uid}" + ) + alarm_clock.ListAlarms.return_value = alarm_update + + # Update the alarm_list_version so it gets processed. + alarm_event.variables["alarm_list_version"] = f"{soco_lr.uid}:1000" + alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable( + "alarm_list_version" + ) + + alarm_clock.subscribe.return_value.callback(event=alarm_event) + await hass.async_block_till_done(wait_background_tasks=True) + + assert entity_id in entity_registry.entities + alarm_14 = entity_registry.async_get(entity_id) + device = device_registry.async_get(alarm_14.device_id) + assert device.name == soco_lr.get_speaker_info()["zone_name"] From 295b15ace928bb8cce046dca7b8cb1b9547447d8 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Sat, 5 Jul 2025 20:23:03 +0200 Subject: [PATCH 1164/1664] Change ZHA string "autoshutdown" to "auto-shutdown" (#148230) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 48bdfc6bb62..87c3903b342 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1118,7 +1118,7 @@ "name": "Comfort temperature" }, "valve_state_auto_shutdown": { - "name": "Valve state autoshutdown" + "name": "Valve state auto-shutdown" }, "shutdown_timer": { "name": "Shutdown timer" From eb0f11a8597488fb8249646b49f8dce37f9d6734 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Sat, 5 Jul 2025 14:13:48 -0500 Subject: [PATCH 1165/1664] Bump aiorussound to 4.8.0 (#148235) --- homeassistant/components/russound_rio/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 955ab451d3d..aad9b9425aa 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.7.0"], + "requirements": ["aiorussound==4.8.0"], "zeroconf": ["_rio._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 2655e6f9a90..83e7693822e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.7.0 +aiorussound==4.8.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ec700ccf1e..a7fb3353938 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -354,7 +354,7 @@ aioridwell==2024.01.0 aioruckus==0.42 # homeassistant.components.russound_rio -aiorussound==4.7.0 +aiorussound==4.8.0 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 From 160e4e4d054a3266204a5ccb6fe0e77331d27a5f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 5 Jul 2025 21:36:15 +0200 Subject: [PATCH 1166/1664] Block options flow for default hostname in dnsip (#148221) --- homeassistant/components/dnsip/config_flow.py | 3 ++ homeassistant/components/dnsip/strings.json | 3 +- tests/components/dnsip/test_config_flow.py | 34 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index 6b86f1627bc..ab1ca42acd3 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -172,6 +172,9 @@ class DnsIPOptionsFlowHandler(OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" + if self.config_entry.data[CONF_HOSTNAME] == DEFAULT_HOSTNAME: + return self.async_abort(reason="no_options") + errors = {} if user_input is not None: resolver = user_input.get(CONF_RESOLVER, DEFAULT_RESOLVER) diff --git a/homeassistant/components/dnsip/strings.json b/homeassistant/components/dnsip/strings.json index 39a0fbf7cd3..70472d37917 100644 --- a/homeassistant/components/dnsip/strings.json +++ b/homeassistant/components/dnsip/strings.json @@ -30,7 +30,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "no_options": "The myip hostname requires the default resolvers and therefore cannot be configured." }, "error": { "invalid_resolver": "Invalid IP address or port for resolver" diff --git a/tests/components/dnsip/test_config_flow.py b/tests/components/dnsip/test_config_flow.py index 1a565345275..d9420afaa8c 100644 --- a/tests/components/dnsip/test_config_flow.py +++ b/tests/components/dnsip/test_config_flow.py @@ -16,6 +16,7 @@ from homeassistant.components.dnsip.const import ( CONF_PORT_IPV6, CONF_RESOLVER, CONF_RESOLVER_IPV6, + DEFAULT_HOSTNAME, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState @@ -379,3 +380,36 @@ async def test_options_error(hass: HomeAssistant, p_input: dict[str, str]) -> No assert result2["errors"] == {"resolver": "invalid_resolver"} if p_input[CONF_IPV6]: assert result2["errors"] == {"resolver_ipv6": "invalid_resolver"} + + +async def test_cannot_configure_options_for_myip(hass: HomeAssistant) -> None: + """Test options config flow aborts for default myip hostname.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="12345", + data={ + CONF_HOSTNAME: DEFAULT_HOSTNAME, + CONF_NAME: "myip", + CONF_IPV4: True, + CONF_IPV6: False, + }, + options={ + CONF_RESOLVER: "208.67.222.222", + CONF_RESOLVER_IPV6: "2620:119:53::5", + CONF_PORT: 53, + CONF_PORT_IPV6: 53, + }, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.dnsip.config_flow.aiodns.DNSResolver", + return_value=RetrieveDNS(), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_options" From e304022560bdcc79089d728a7c1b72cd1f16b557 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 5 Jul 2025 21:39:48 +0200 Subject: [PATCH 1167/1664] Add service in Nord Pool for fetching normalized price indices (#147979) --- homeassistant/components/nordpool/const.py | 1 + homeassistant/components/nordpool/icons.json | 3 + homeassistant/components/nordpool/services.py | 79 +- .../components/nordpool/services.yaml | 56 ++ .../components/nordpool/strings.json | 26 + .../nordpool/fixtures/indices_15.json | 689 ++++++++++++++++++ .../nordpool/fixtures/indices_60.json | 185 +++++ .../nordpool/snapshots/test_services.ambr | 612 ++++++++++++++++ tests/components/nordpool/test_services.py | 84 ++- 9 files changed, 1726 insertions(+), 9 deletions(-) create mode 100644 tests/components/nordpool/fixtures/indices_15.json create mode 100644 tests/components/nordpool/fixtures/indices_60.json diff --git a/homeassistant/components/nordpool/const.py b/homeassistant/components/nordpool/const.py index 19a978d946c..1fd3009321b 100644 --- a/homeassistant/components/nordpool/const.py +++ b/homeassistant/components/nordpool/const.py @@ -12,3 +12,4 @@ PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "Nord Pool" CONF_AREAS = "areas" +ATTR_RESOLUTION = "resolution" diff --git a/homeassistant/components/nordpool/icons.json b/homeassistant/components/nordpool/icons.json index 5a1a3df3d92..42449b7a1a5 100644 --- a/homeassistant/components/nordpool/icons.json +++ b/homeassistant/components/nordpool/icons.json @@ -42,6 +42,9 @@ "services": { "get_prices_for_date": { "service": "mdi:cash-multiple" + }, + "get_price_indices_for_date": { + "service": "mdi:cash-multiple" } } } diff --git a/homeassistant/components/nordpool/services.py b/homeassistant/components/nordpool/services.py index 9bb97d0737b..e568764871a 100644 --- a/homeassistant/components/nordpool/services.py +++ b/homeassistant/components/nordpool/services.py @@ -2,16 +2,21 @@ from __future__ import annotations +from collections.abc import Callable from datetime import date, datetime +from functools import partial import logging from typing import TYPE_CHECKING from pynordpool import ( AREAS, Currency, + DeliveryPeriodData, NordPoolAuthenticationError, + NordPoolClient, NordPoolEmptyResponseError, NordPoolError, + PriceIndicesData, ) import voluptuous as vol @@ -32,7 +37,7 @@ from homeassistant.util.json import JsonValueType if TYPE_CHECKING: from . import NordPoolConfigEntry -from .const import DOMAIN +from .const import ATTR_RESOLUTION, DOMAIN _LOGGER = logging.getLogger(__name__) ATTR_CONFIG_ENTRY = "config_entry" @@ -40,6 +45,7 @@ ATTR_AREAS = "areas" ATTR_CURRENCY = "currency" SERVICE_GET_PRICES_FOR_DATE = "get_prices_for_date" +SERVICE_GET_PRICE_INDICES_FOR_DATE = "get_price_indices_for_date" SERVICE_GET_PRICES_SCHEMA = vol.Schema( { vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}), @@ -50,6 +56,13 @@ SERVICE_GET_PRICES_SCHEMA = vol.Schema( ), } ) +SERVICE_GET_PRICE_INDICES_SCHEMA = SERVICE_GET_PRICES_SCHEMA.extend( + { + vol.Optional(ATTR_RESOLUTION, default=60): vol.All( + cv.positive_int, vol.All(vol.Coerce(int), vol.In((15, 30, 60))) + ), + } +) def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: @@ -71,11 +84,13 @@ def get_config_entry(hass: HomeAssistant, entry_id: str) -> NordPoolConfigEntry: def async_setup_services(hass: HomeAssistant) -> None: """Set up services for Nord Pool integration.""" - async def get_prices_for_date(call: ServiceCall) -> ServiceResponse: - """Get price service.""" + def get_service_params( + call: ServiceCall, + ) -> tuple[NordPoolClient, date, str, list[str], int]: + """Return the parameters for the service.""" entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY]) - asked_date: date = call.data[ATTR_DATE] client = entry.runtime_data.client + asked_date: date = call.data[ATTR_DATE] areas: list[str] = entry.data[ATTR_AREAS] if _areas := call.data.get(ATTR_AREAS): @@ -85,14 +100,55 @@ def async_setup_services(hass: HomeAssistant) -> None: if _currency := call.data.get(ATTR_CURRENCY): currency = _currency + resolution: int = 60 + if _resolution := call.data.get(ATTR_RESOLUTION): + resolution = _resolution + areas = [area.upper() for area in areas] currency = currency.upper() + return (client, asked_date, currency, areas, resolution) + + async def get_prices_for_date( + client: NordPoolClient, + asked_date: date, + currency: str, + areas: list[str], + resolution: int, + ) -> DeliveryPeriodData: + """Get prices.""" + return await client.async_get_delivery_period( + datetime.combine(asked_date, dt_util.utcnow().time()), + Currency(currency), + areas, + ) + + async def get_price_indices_for_date( + client: NordPoolClient, + asked_date: date, + currency: str, + areas: list[str], + resolution: int, + ) -> PriceIndicesData: + """Get prices.""" + return await client.async_get_price_indices( + datetime.combine(asked_date, dt_util.utcnow().time()), + Currency(currency), + areas, + resolution=resolution, + ) + + async def get_prices(func: Callable, call: ServiceCall) -> ServiceResponse: + """Get price service.""" + client, asked_date, currency, areas, resolution = get_service_params(call) + try: - price_data = await client.async_get_delivery_period( - datetime.combine(asked_date, dt_util.utcnow().time()), - Currency(currency), + price_data = await func( + client, + asked_date, + currency, areas, + resolution, ) except NordPoolAuthenticationError as error: raise ServiceValidationError( @@ -122,7 +178,14 @@ def async_setup_services(hass: HomeAssistant) -> None: hass.services.async_register( DOMAIN, SERVICE_GET_PRICES_FOR_DATE, - get_prices_for_date, + partial(get_prices, get_prices_for_date), schema=SERVICE_GET_PRICES_SCHEMA, supports_response=SupportsResponse.ONLY, ) + hass.services.async_register( + DOMAIN, + SERVICE_GET_PRICE_INDICES_FOR_DATE, + partial(get_prices, get_price_indices_for_date), + schema=SERVICE_GET_PRICE_INDICES_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/nordpool/services.yaml b/homeassistant/components/nordpool/services.yaml index dded8482c6f..f18d705f54b 100644 --- a/homeassistant/components/nordpool/services.yaml +++ b/homeassistant/components/nordpool/services.yaml @@ -46,3 +46,59 @@ get_prices_for_date: - "PLN" - "SEK" mode: dropdown +get_price_indices_for_date: + fields: + config_entry: + required: true + selector: + config_entry: + integration: nordpool + date: + required: true + selector: + date: + areas: + selector: + select: + options: + - "EE" + - "LT" + - "LV" + - "AT" + - "BE" + - "FR" + - "GER" + - "NL" + - "PL" + - "DK1" + - "DK2" + - "FI" + - "NO1" + - "NO2" + - "NO3" + - "NO4" + - "NO5" + - "SE1" + - "SE2" + - "SE3" + - "SE4" + - "SYS" + mode: dropdown + currency: + selector: + select: + options: + - "DKK" + - "EUR" + - "NOK" + - "PLN" + - "SEK" + mode: dropdown + resolution: + selector: + select: + options: + - "15" + - "30" + - "60" + mode: dropdown diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 73c35673826..06bd74e78a6 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -114,6 +114,32 @@ "description": "Currency to get prices in. If left empty it will use the currency already configured." } } + }, + "get_price_indices_for_date": { + "name": "Get price indices for date", + "description": "Retrieves the price indices for a specific date.", + "fields": { + "config_entry": { + "name": "Config entry", + "description": "The Nord Pool configuration entry for this action." + }, + "date": { + "name": "Date", + "description": "Only dates two months in the past and one day in the future is allowed." + }, + "areas": { + "name": "Areas", + "description": "One or multiple areas to get prices for. If left empty it will use the areas already configured." + }, + "currency": { + "name": "Currency", + "description": "Currency to get prices in. If left empty it will use the currency already configured." + }, + "resolution": { + "name": "Resolution", + "description": "Resolution time for the prices, can be any of 15, 30 and 60 minutes." + } + } } }, "exceptions": { diff --git a/tests/components/nordpool/fixtures/indices_15.json b/tests/components/nordpool/fixtures/indices_15.json new file mode 100644 index 00000000000..63af9840098 --- /dev/null +++ b/tests/components/nordpool/fixtures/indices_15.json @@ -0,0 +1,689 @@ +{ + "deliveryDateCET": "2025-07-06", + "version": 2, + "updatedAt": "2025-07-05T10:56:42.3755929Z", + "market": "DayAhead", + "indexNames": ["SE3"], + "currency": "SEK", + "resolutionInMinutes": 15, + "areaStates": [ + { + "state": "Preliminary", + "areas": ["SE3"] + } + ], + "multiIndexEntries": [ + { + "deliveryStart": "2025-07-05T22:00:00Z", + "deliveryEnd": "2025-07-05T22:15:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T22:15:00Z", + "deliveryEnd": "2025-07-05T22:30:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T22:30:00Z", + "deliveryEnd": "2025-07-05T22:45:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T22:45:00Z", + "deliveryEnd": "2025-07-05T23:00:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T23:00:00Z", + "deliveryEnd": "2025-07-05T23:15:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-05T23:15:00Z", + "deliveryEnd": "2025-07-05T23:30:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-05T23:30:00Z", + "deliveryEnd": "2025-07-05T23:45:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-05T23:45:00Z", + "deliveryEnd": "2025-07-06T00:00:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-06T00:00:00Z", + "deliveryEnd": "2025-07-06T00:15:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T00:15:00Z", + "deliveryEnd": "2025-07-06T00:30:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T00:30:00Z", + "deliveryEnd": "2025-07-06T00:45:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T00:45:00Z", + "deliveryEnd": "2025-07-06T01:00:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T01:00:00Z", + "deliveryEnd": "2025-07-06T01:15:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T01:15:00Z", + "deliveryEnd": "2025-07-06T01:30:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T01:30:00Z", + "deliveryEnd": "2025-07-06T01:45:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T01:45:00Z", + "deliveryEnd": "2025-07-06T02:00:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T02:00:00Z", + "deliveryEnd": "2025-07-06T02:15:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T02:15:00Z", + "deliveryEnd": "2025-07-06T02:30:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T02:30:00Z", + "deliveryEnd": "2025-07-06T02:45:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T02:45:00Z", + "deliveryEnd": "2025-07-06T03:00:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T03:00:00Z", + "deliveryEnd": "2025-07-06T03:15:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T03:15:00Z", + "deliveryEnd": "2025-07-06T03:30:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T03:30:00Z", + "deliveryEnd": "2025-07-06T03:45:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T03:45:00Z", + "deliveryEnd": "2025-07-06T04:00:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T04:00:00Z", + "deliveryEnd": "2025-07-06T04:15:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T04:15:00Z", + "deliveryEnd": "2025-07-06T04:30:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T04:30:00Z", + "deliveryEnd": "2025-07-06T04:45:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T04:45:00Z", + "deliveryEnd": "2025-07-06T05:00:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T05:00:00Z", + "deliveryEnd": "2025-07-06T05:15:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T05:15:00Z", + "deliveryEnd": "2025-07-06T05:30:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T05:30:00Z", + "deliveryEnd": "2025-07-06T05:45:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T05:45:00Z", + "deliveryEnd": "2025-07-06T06:00:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T06:00:00Z", + "deliveryEnd": "2025-07-06T06:15:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T06:15:00Z", + "deliveryEnd": "2025-07-06T06:30:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T06:30:00Z", + "deliveryEnd": "2025-07-06T06:45:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T06:45:00Z", + "deliveryEnd": "2025-07-06T07:00:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T07:00:00Z", + "deliveryEnd": "2025-07-06T07:15:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T07:15:00Z", + "deliveryEnd": "2025-07-06T07:30:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T07:30:00Z", + "deliveryEnd": "2025-07-06T07:45:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T07:45:00Z", + "deliveryEnd": "2025-07-06T08:00:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T08:00:00Z", + "deliveryEnd": "2025-07-06T08:15:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T08:15:00Z", + "deliveryEnd": "2025-07-06T08:30:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T08:30:00Z", + "deliveryEnd": "2025-07-06T08:45:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T08:45:00Z", + "deliveryEnd": "2025-07-06T09:00:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T09:00:00Z", + "deliveryEnd": "2025-07-06T09:15:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T09:15:00Z", + "deliveryEnd": "2025-07-06T09:30:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T09:30:00Z", + "deliveryEnd": "2025-07-06T09:45:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T09:45:00Z", + "deliveryEnd": "2025-07-06T10:00:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T10:00:00Z", + "deliveryEnd": "2025-07-06T10:15:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T10:15:00Z", + "deliveryEnd": "2025-07-06T10:30:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T10:30:00Z", + "deliveryEnd": "2025-07-06T10:45:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T10:45:00Z", + "deliveryEnd": "2025-07-06T11:00:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T11:00:00Z", + "deliveryEnd": "2025-07-06T11:15:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T11:15:00Z", + "deliveryEnd": "2025-07-06T11:30:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T11:30:00Z", + "deliveryEnd": "2025-07-06T11:45:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T11:45:00Z", + "deliveryEnd": "2025-07-06T12:00:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T12:00:00Z", + "deliveryEnd": "2025-07-06T12:15:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T12:15:00Z", + "deliveryEnd": "2025-07-06T12:30:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T12:30:00Z", + "deliveryEnd": "2025-07-06T12:45:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T12:45:00Z", + "deliveryEnd": "2025-07-06T13:00:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T13:00:00Z", + "deliveryEnd": "2025-07-06T13:15:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T13:15:00Z", + "deliveryEnd": "2025-07-06T13:30:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T13:30:00Z", + "deliveryEnd": "2025-07-06T13:45:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T13:45:00Z", + "deliveryEnd": "2025-07-06T14:00:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T14:00:00Z", + "deliveryEnd": "2025-07-06T14:15:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T14:15:00Z", + "deliveryEnd": "2025-07-06T14:30:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T14:30:00Z", + "deliveryEnd": "2025-07-06T14:45:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T14:45:00Z", + "deliveryEnd": "2025-07-06T15:00:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T15:00:00Z", + "deliveryEnd": "2025-07-06T15:15:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T15:15:00Z", + "deliveryEnd": "2025-07-06T15:30:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T15:30:00Z", + "deliveryEnd": "2025-07-06T15:45:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T15:45:00Z", + "deliveryEnd": "2025-07-06T16:00:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T16:00:00Z", + "deliveryEnd": "2025-07-06T16:15:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T16:15:00Z", + "deliveryEnd": "2025-07-06T16:30:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T16:30:00Z", + "deliveryEnd": "2025-07-06T16:45:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T16:45:00Z", + "deliveryEnd": "2025-07-06T17:00:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T17:00:00Z", + "deliveryEnd": "2025-07-06T17:15:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T17:15:00Z", + "deliveryEnd": "2025-07-06T17:30:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T17:30:00Z", + "deliveryEnd": "2025-07-06T17:45:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T17:45:00Z", + "deliveryEnd": "2025-07-06T18:00:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T18:00:00Z", + "deliveryEnd": "2025-07-06T18:15:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T18:15:00Z", + "deliveryEnd": "2025-07-06T18:30:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T18:30:00Z", + "deliveryEnd": "2025-07-06T18:45:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T18:45:00Z", + "deliveryEnd": "2025-07-06T19:00:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T19:00:00Z", + "deliveryEnd": "2025-07-06T19:15:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T19:15:00Z", + "deliveryEnd": "2025-07-06T19:30:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T19:30:00Z", + "deliveryEnd": "2025-07-06T19:45:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T19:45:00Z", + "deliveryEnd": "2025-07-06T20:00:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T20:00:00Z", + "deliveryEnd": "2025-07-06T20:15:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T20:15:00Z", + "deliveryEnd": "2025-07-06T20:30:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T20:30:00Z", + "deliveryEnd": "2025-07-06T20:45:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T20:45:00Z", + "deliveryEnd": "2025-07-06T21:00:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T21:00:00Z", + "deliveryEnd": "2025-07-06T21:15:00Z", + "entryPerArea": { + "SE3": 396.38 + } + }, + { + "deliveryStart": "2025-07-06T21:15:00Z", + "deliveryEnd": "2025-07-06T21:30:00Z", + "entryPerArea": { + "SE3": 396.38 + } + }, + { + "deliveryStart": "2025-07-06T21:30:00Z", + "deliveryEnd": "2025-07-06T21:45:00Z", + "entryPerArea": { + "SE3": 396.38 + } + }, + { + "deliveryStart": "2025-07-06T21:45:00Z", + "deliveryEnd": "2025-07-06T22:00:00Z", + "entryPerArea": { + "SE3": 396.38 + } + } + ] +} diff --git a/tests/components/nordpool/fixtures/indices_60.json b/tests/components/nordpool/fixtures/indices_60.json new file mode 100644 index 00000000000..97bbe554b13 --- /dev/null +++ b/tests/components/nordpool/fixtures/indices_60.json @@ -0,0 +1,185 @@ +{ + "deliveryDateCET": "2025-07-06", + "version": 2, + "updatedAt": "2025-07-05T10:56:44.6936838Z", + "market": "DayAhead", + "indexNames": ["SE3"], + "currency": "SEK", + "resolutionInMinutes": 60, + "areaStates": [ + { + "state": "Preliminary", + "areas": ["SE3"] + } + ], + "multiIndexEntries": [ + { + "deliveryStart": "2025-07-05T22:00:00Z", + "deliveryEnd": "2025-07-05T23:00:00Z", + "entryPerArea": { + "SE3": 43.57 + } + }, + { + "deliveryStart": "2025-07-05T23:00:00Z", + "deliveryEnd": "2025-07-06T00:00:00Z", + "entryPerArea": { + "SE3": 36.47 + } + }, + { + "deliveryStart": "2025-07-06T00:00:00Z", + "deliveryEnd": "2025-07-06T01:00:00Z", + "entryPerArea": { + "SE3": 35.57 + } + }, + { + "deliveryStart": "2025-07-06T01:00:00Z", + "deliveryEnd": "2025-07-06T02:00:00Z", + "entryPerArea": { + "SE3": 30.73 + } + }, + { + "deliveryStart": "2025-07-06T02:00:00Z", + "deliveryEnd": "2025-07-06T03:00:00Z", + "entryPerArea": { + "SE3": 32.42 + } + }, + { + "deliveryStart": "2025-07-06T03:00:00Z", + "deliveryEnd": "2025-07-06T04:00:00Z", + "entryPerArea": { + "SE3": 38.73 + } + }, + { + "deliveryStart": "2025-07-06T04:00:00Z", + "deliveryEnd": "2025-07-06T05:00:00Z", + "entryPerArea": { + "SE3": 42.78 + } + }, + { + "deliveryStart": "2025-07-06T05:00:00Z", + "deliveryEnd": "2025-07-06T06:00:00Z", + "entryPerArea": { + "SE3": 54.71 + } + }, + { + "deliveryStart": "2025-07-06T06:00:00Z", + "deliveryEnd": "2025-07-06T07:00:00Z", + "entryPerArea": { + "SE3": 83.87 + } + }, + { + "deliveryStart": "2025-07-06T07:00:00Z", + "deliveryEnd": "2025-07-06T08:00:00Z", + "entryPerArea": { + "SE3": 78.8 + } + }, + { + "deliveryStart": "2025-07-06T08:00:00Z", + "deliveryEnd": "2025-07-06T09:00:00Z", + "entryPerArea": { + "SE3": 92.09 + } + }, + { + "deliveryStart": "2025-07-06T09:00:00Z", + "deliveryEnd": "2025-07-06T10:00:00Z", + "entryPerArea": { + "SE3": 104.92 + } + }, + { + "deliveryStart": "2025-07-06T10:00:00Z", + "deliveryEnd": "2025-07-06T11:00:00Z", + "entryPerArea": { + "SE3": 72.5 + } + }, + { + "deliveryStart": "2025-07-06T11:00:00Z", + "deliveryEnd": "2025-07-06T12:00:00Z", + "entryPerArea": { + "SE3": 63.49 + } + }, + { + "deliveryStart": "2025-07-06T12:00:00Z", + "deliveryEnd": "2025-07-06T13:00:00Z", + "entryPerArea": { + "SE3": 91.64 + } + }, + { + "deliveryStart": "2025-07-06T13:00:00Z", + "deliveryEnd": "2025-07-06T14:00:00Z", + "entryPerArea": { + "SE3": 111.79 + } + }, + { + "deliveryStart": "2025-07-06T14:00:00Z", + "deliveryEnd": "2025-07-06T15:00:00Z", + "entryPerArea": { + "SE3": 234.04 + } + }, + { + "deliveryStart": "2025-07-06T15:00:00Z", + "deliveryEnd": "2025-07-06T16:00:00Z", + "entryPerArea": { + "SE3": 435.33 + } + }, + { + "deliveryStart": "2025-07-06T16:00:00Z", + "deliveryEnd": "2025-07-06T17:00:00Z", + "entryPerArea": { + "SE3": 431.84 + } + }, + { + "deliveryStart": "2025-07-06T17:00:00Z", + "deliveryEnd": "2025-07-06T18:00:00Z", + "entryPerArea": { + "SE3": 423.73 + } + }, + { + "deliveryStart": "2025-07-06T18:00:00Z", + "deliveryEnd": "2025-07-06T19:00:00Z", + "entryPerArea": { + "SE3": 437.92 + } + }, + { + "deliveryStart": "2025-07-06T19:00:00Z", + "deliveryEnd": "2025-07-06T20:00:00Z", + "entryPerArea": { + "SE3": 416.42 + } + }, + { + "deliveryStart": "2025-07-06T20:00:00Z", + "deliveryEnd": "2025-07-06T21:00:00Z", + "entryPerArea": { + "SE3": 414.39 + } + }, + { + "deliveryStart": "2025-07-06T21:00:00Z", + "deliveryEnd": "2025-07-06T22:00:00Z", + "entryPerArea": { + "SE3": 396.38 + } + } + ] +} diff --git a/tests/components/nordpool/snapshots/test_services.ambr b/tests/components/nordpool/snapshots/test_services.ambr index b271b433061..5e39082f647 100644 --- a/tests/components/nordpool/snapshots/test_services.ambr +++ b/tests/components/nordpool/snapshots/test_services.ambr @@ -131,3 +131,615 @@ ]), }) # --- +# name: test_service_call_for_price_indices[get_price_indices_for_date_15] + dict({ + 'SE3': list([ + dict({ + 'end': '2025-07-05T22:15:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:00:00+00:00', + }), + dict({ + 'end': '2025-07-05T22:30:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:15:00+00:00', + }), + dict({ + 'end': '2025-07-05T22:45:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:30:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:00:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:45:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:15:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:00:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:30:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:15:00+00:00', + }), + dict({ + 'end': '2025-07-05T23:45:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:00:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:15:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:30:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:45:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:00:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:15:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:30:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:45:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:00:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:15:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:30:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:45:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:00:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:15:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:30:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:45:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:00:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:15:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:30:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:45:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:00:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:15:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:30:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:45:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:00:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:15:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:30:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:45:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:00:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:15:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:30:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:45:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:00:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:15:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:30:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:45:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:00:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:15:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:30:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:45:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:00:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:15:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:30:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:45:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:00:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:15:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:30:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:45:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:00:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:15:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:30:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:45:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:00:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:15:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:30:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:45:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:00:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:15:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:30:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:45:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:00:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:15:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:30:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:45:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:00:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:15:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:30:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:45:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:00:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:15:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:30:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:45:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:00:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:15:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:30:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:45:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:00:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:15:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:30:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:45:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:00:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:15:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:30:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:45:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:00:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:45:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:15:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:30:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:15:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:45:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:30:00+00:00', + }), + dict({ + 'end': '2025-07-06T22:00:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:45:00+00:00', + }), + ]), + }) +# --- +# name: test_service_call_for_price_indices[get_price_indices_for_date_60] + dict({ + 'SE3': list([ + dict({ + 'end': '2025-07-05T23:00:00+00:00', + 'price': 43.57, + 'start': '2025-07-05T22:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T00:00:00+00:00', + 'price': 36.47, + 'start': '2025-07-05T23:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T01:00:00+00:00', + 'price': 35.57, + 'start': '2025-07-06T00:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T02:00:00+00:00', + 'price': 30.73, + 'start': '2025-07-06T01:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T03:00:00+00:00', + 'price': 32.42, + 'start': '2025-07-06T02:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T04:00:00+00:00', + 'price': 38.73, + 'start': '2025-07-06T03:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T05:00:00+00:00', + 'price': 42.78, + 'start': '2025-07-06T04:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T06:00:00+00:00', + 'price': 54.71, + 'start': '2025-07-06T05:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T07:00:00+00:00', + 'price': 83.87, + 'start': '2025-07-06T06:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T08:00:00+00:00', + 'price': 78.8, + 'start': '2025-07-06T07:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T09:00:00+00:00', + 'price': 92.09, + 'start': '2025-07-06T08:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T10:00:00+00:00', + 'price': 104.92, + 'start': '2025-07-06T09:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T11:00:00+00:00', + 'price': 72.5, + 'start': '2025-07-06T10:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T12:00:00+00:00', + 'price': 63.49, + 'start': '2025-07-06T11:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T13:00:00+00:00', + 'price': 91.64, + 'start': '2025-07-06T12:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T14:00:00+00:00', + 'price': 111.79, + 'start': '2025-07-06T13:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T15:00:00+00:00', + 'price': 234.04, + 'start': '2025-07-06T14:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T16:00:00+00:00', + 'price': 435.33, + 'start': '2025-07-06T15:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T17:00:00+00:00', + 'price': 431.84, + 'start': '2025-07-06T16:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T18:00:00+00:00', + 'price': 423.73, + 'start': '2025-07-06T17:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T19:00:00+00:00', + 'price': 437.92, + 'start': '2025-07-06T18:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T20:00:00+00:00', + 'price': 416.42, + 'start': '2025-07-06T19:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T21:00:00+00:00', + 'price': 414.39, + 'start': '2025-07-06T20:00:00+00:00', + }), + dict({ + 'end': '2025-07-06T22:00:00+00:00', + 'price': 396.38, + 'start': '2025-07-06T21:00:00+00:00', + }), + ]), + }) +# --- diff --git a/tests/components/nordpool/test_services.py b/tests/components/nordpool/test_services.py index d59ec4712d7..1042783fee8 100644 --- a/tests/components/nordpool/test_services.py +++ b/tests/components/nordpool/test_services.py @@ -1,8 +1,10 @@ """Test services in Nord Pool.""" +import json from unittest.mock import patch from pynordpool import ( + API, NordPoolAuthenticationError, NordPoolEmptyResponseError, NordPoolError, @@ -15,13 +17,16 @@ from homeassistant.components.nordpool.services import ( ATTR_AREAS, ATTR_CONFIG_ENTRY, ATTR_CURRENCY, + ATTR_RESOLUTION, + SERVICE_GET_PRICE_INDICES_FOR_DATE, SERVICE_GET_PRICES_FOR_DATE, ) from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker TEST_SERVICE_DATA = { ATTR_CONFIG_ENTRY: "to_replace", @@ -33,6 +38,20 @@ TEST_SERVICE_DATA_USE_DEFAULTS = { ATTR_CONFIG_ENTRY: "to_replace", ATTR_DATE: "2024-11-05", } +TEST_SERVICE_INDICES_DATA_60 = { + ATTR_CONFIG_ENTRY: "to_replace", + ATTR_DATE: "2025-07-06", + ATTR_AREAS: "SE3", + ATTR_CURRENCY: "SEK", + ATTR_RESOLUTION: 60, +} +TEST_SERVICE_INDICES_DATA_15 = { + ATTR_CONFIG_ENTRY: "to_replace", + ATTR_DATE: "2025-07-06", + ATTR_AREAS: "SE3", + ATTR_CURRENCY: "SEK", + ATTR_RESOLUTION: 15, +} @pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") @@ -163,3 +182,66 @@ async def test_service_call_config_entry_bad_state( return_response=True, ) assert err.value.translation_key == "entry_not_loaded" + + +@pytest.mark.freeze_time("2024-11-05T18:00:00+00:00") +async def test_service_call_for_price_indices( + hass: HomeAssistant, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test get_price_indices_for_date service call.""" + + fixture_60 = json.loads(await async_load_fixture(hass, "indices_60.json", DOMAIN)) + fixture_15 = json.loads(await async_load_fixture(hass, "indices_15.json", DOMAIN)) + + aioclient_mock.request( + "GET", + url=API + "/DayAheadPriceIndices", + params={ + "date": "2025-07-06", + "market": "DayAhead", + "indexNames": "SE3", + "currency": "SEK", + "resolutionInMinutes": "60", + }, + json=fixture_60, + ) + + aioclient_mock.request( + "GET", + url=API + "/DayAheadPriceIndices", + params={ + "date": "2025-07-06", + "market": "DayAhead", + "indexNames": "SE3", + "currency": "SEK", + "resolutionInMinutes": "15", + }, + json=fixture_15, + ) + + service_data = TEST_SERVICE_INDICES_DATA_60.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICE_INDICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert response == snapshot(name="get_price_indices_for_date_60") + + service_data = TEST_SERVICE_INDICES_DATA_15.copy() + service_data[ATTR_CONFIG_ENTRY] = load_int.entry_id + response = await hass.services.async_call( + DOMAIN, + SERVICE_GET_PRICE_INDICES_FOR_DATE, + service_data, + blocking=True, + return_response=True, + ) + + assert response == snapshot(name="get_price_indices_for_date_15") From 3ffcfa42ba34e43f7df32645ad836ba94ca62a8f Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sat, 5 Jul 2025 23:34:23 +0200 Subject: [PATCH 1168/1664] Bump pylamarzocco to 2.0.10 (#148233) --- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 7fdafc4dda1..10cb23146ae 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.9"] + "requirements": ["pylamarzocco==2.0.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index 83e7693822e..01b297fd250 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2100,7 +2100,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.9 +pylamarzocco==2.0.10 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a7fb3353938..29ab2676ee0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.9 +pylamarzocco==2.0.10 # homeassistant.components.lastfm pylast==5.1.0 From 26de1ea37b5baf8d351558558c9d1fc247073210 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Sun, 6 Jul 2025 00:14:59 +0200 Subject: [PATCH 1169/1664] Update strings in pihole (#148234) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/pi_hole/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pi_hole/strings.json b/homeassistant/components/pi_hole/strings.json index 069f8a576d4..b3a634f4420 100644 --- a/homeassistant/components/pi_hole/strings.json +++ b/homeassistant/components/pi_hole/strings.json @@ -9,7 +9,7 @@ "location": "[%key:common::config_flow::data::location%]", "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", - "api_key": "App Password or API Key" + "api_key": "App password or API key" } }, @@ -49,7 +49,7 @@ }, "ads_blocked": { "name": "Ads blocked", - "unit_of_measurement": "ads" + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::ads_blocked_today::unit_of_measurement%]" }, "ads_percentage_today": { "name": "Ads percentage blocked today" @@ -68,7 +68,7 @@ }, "dns_queries": { "name": "DNS queries", - "unit_of_measurement": "queries" + "unit_of_measurement": "[%key:component::pi_hole::entity::sensor::dns_queries_today::unit_of_measurement%]" }, "domains_being_blocked": { "name": "Domains blocked", From 70e9c4e2d0a9f72a3dad22248b2230e2f7cf4f18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 6 Jul 2025 07:09:59 +0100 Subject: [PATCH 1170/1664] Add reauth flow to the Traccar Server integration (#148236) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Josef Zweck --- .../components/traccar_server/config_flow.py | 67 ++++++++++++- .../components/traccar_server/coordinator.py | 6 ++ .../components/traccar_server/strings.json | 13 ++- .../traccar_server/test_config_flow.py | 97 ++++++++++++++++++- 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/traccar_server/config_flow.py b/homeassistant/components/traccar_server/config_flow.py index b186424d32c..ae2f01e698b 100644 --- a/homeassistant/components/traccar_server/config_flow.py +++ b/homeassistant/components/traccar_server/config_flow.py @@ -2,9 +2,15 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any -from pytraccar import ApiClient, ServerModel, TraccarException +from pytraccar import ( + ApiClient, + ServerModel, + TraccarAuthenticationException, + TraccarException, +) import voluptuous as vol from homeassistant import config_entries @@ -160,6 +166,65 @@ class TraccarServerConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth( + self, _entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle reauth flow.""" + reauth_entry = self._get_reauth_entry() + errors: dict[str, str] = {} + + if user_input is not None: + test_data = { + **reauth_entry.data, + **user_input, + } + try: + await self._get_server_info(test_data) + except TraccarAuthenticationException: + LOGGER.error("Invalid credentials for Traccar Server") + errors["base"] = "invalid_auth" + except TraccarException as exception: + LOGGER.error("Unable to connect to Traccar Server: %s", exception) + errors["base"] = "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + reauth_entry, + data_updates=user_input, + ) + username = ( + user_input[CONF_USERNAME] + if user_input + else reauth_entry.data[CONF_USERNAME] + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME, default=username): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } + ), + errors=errors, + description_placeholders={ + CONF_HOST: reauth_entry.data[CONF_HOST], + CONF_PORT: reauth_entry.data[CONF_PORT], + }, + ) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/traccar_server/coordinator.py b/homeassistant/components/traccar_server/coordinator.py index 3a0bfe47e5f..9cb0530356f 100644 --- a/homeassistant/components/traccar_server/coordinator.py +++ b/homeassistant/components/traccar_server/coordinator.py @@ -13,11 +13,13 @@ from pytraccar import ( GeofenceModel, PositionModel, SubscriptionData, + TraccarAuthenticationException, TraccarException, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -90,6 +92,8 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat self.client.get_positions(), self.client.get_geofences(), ) + except TraccarAuthenticationException: + raise ConfigEntryAuthFailed from None except TraccarException as ex: raise UpdateFailed(f"Error while updating device data: {ex}") from ex @@ -236,6 +240,8 @@ class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorDat """Subscribe to events.""" try: await self.client.subscribe(self.handle_subscription_data) + except TraccarAuthenticationException: + raise ConfigEntryAuthFailed from None except TraccarException as ex: if self._should_log_subscription_error: self._should_log_subscription_error = False diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json index a4b57562388..89b7b180346 100644 --- a/homeassistant/components/traccar_server/strings.json +++ b/homeassistant/components/traccar_server/strings.json @@ -14,14 +14,23 @@ "host": "The hostname or IP address of your Traccar Server", "username": "The username (email) you use to log in to your Traccar Server" } + }, + "reauth_confirm": { + "description": "The authentication credentials for {host}:{port} need to be updated.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/tests/components/traccar_server/test_config_flow.py b/tests/components/traccar_server/test_config_flow.py index 0418e4a5a72..7270a77fef1 100644 --- a/tests/components/traccar_server/test_config_flow.py +++ b/tests/components/traccar_server/test_config_flow.py @@ -4,7 +4,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock import pytest -from pytraccar import TraccarException +from pytraccar import TraccarAuthenticationException, TraccarException from homeassistant import config_entries from homeassistant.components.traccar_server.const import ( @@ -175,3 +175,98 @@ async def test_abort_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +async def test_reauth_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_traccar_api_client: Generator[AsyncMock], +) -> None: + """Test reauth flow.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + # Verify the config entry was updated + assert mock_config_entry.data[CONF_USERNAME] == "new-username" + assert mock_config_entry.data[CONF_PASSWORD] == "new-password" + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (TraccarAuthenticationException, "invalid_auth"), + (TraccarException, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_reauth_flow_errors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_traccar_api_client: Generator[AsyncMock], + side_effect: Exception, + error: str, +) -> None: + """Test reauth flow with errors.""" + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + mock_traccar_api_client.get_server.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + # Test recovery after error + mock_traccar_api_client.get_server.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "new-username", + CONF_PASSWORD: "new-password", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" From 8d7e387b46f626e1fd680df7c358e3be2ccb3440 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 6 Jul 2025 11:23:57 +0200 Subject: [PATCH 1171/1664] Deduplicate strings in `nordpool` actions (#148258) --- homeassistant/components/nordpool/strings.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 06bd74e78a6..3494996af01 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -103,7 +103,7 @@ }, "date": { "name": "Date", - "description": "Only dates two months in the past and one day in the future is allowed." + "description": "Only dates in the range from two months in the past to one day in the future are allowed." }, "areas": { "name": "Areas", @@ -120,20 +120,20 @@ "description": "Retrieves the price indices for a specific date.", "fields": { "config_entry": { - "name": "Config entry", - "description": "The Nord Pool configuration entry for this action." + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::config_entry::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::config_entry::description%]" }, "date": { - "name": "Date", - "description": "Only dates two months in the past and one day in the future is allowed." + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::date::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::date::description%]" }, "areas": { - "name": "Areas", - "description": "One or multiple areas to get prices for. If left empty it will use the areas already configured." + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::areas::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::areas::description%]" }, "currency": { - "name": "Currency", - "description": "Currency to get prices in. If left empty it will use the currency already configured." + "name": "[%key:component::nordpool::services::get_prices_for_date::fields::currency::name%]", + "description": "[%key:component::nordpool::services::get_prices_for_date::fields::currency::description%]" }, "resolution": { "name": "Resolution", From 1b11ac912350628bf52c0cb5bd979fc5f8b8a58a Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Sun, 6 Jul 2025 12:05:43 +0200 Subject: [PATCH 1172/1664] Add Homee general tests (#137128) --- .../fixtures/cover_with_position_slats.json | 49 +++++++ .../fixtures/cover_without_position.json | 21 +++ .../homee/snapshots/test_diagnostics.ambr | 49 +++++++ .../components/homee/snapshots/test_init.ambr | 71 ++++++++++ tests/components/homee/test_cover.py | 57 ++++++++ tests/components/homee/test_init.py | 131 ++++++++++++++++++ tests/components/homee/test_sensor.py | 50 ++++++- 7 files changed, 427 insertions(+), 1 deletion(-) create mode 100644 tests/components/homee/snapshots/test_init.ambr create mode 100644 tests/components/homee/test_init.py diff --git a/tests/components/homee/fixtures/cover_with_position_slats.json b/tests/components/homee/fixtures/cover_with_position_slats.json index 8fd0d6f44fe..a61be87ab9f 100644 --- a/tests/components/homee/fixtures/cover_with_position_slats.json +++ b/tests/components/homee/fixtures/cover_with_position_slats.json @@ -96,6 +96,55 @@ "options": { "automations": ["step"] } + }, + { + "id": 4, + "node_id": 3, + "instance": 0, + "minimum": -50, + "maximum": 125, + "current_value": 20.3, + "target_value": 20.3, + "last_value": 20.3, + "unit": "°C", + "step_value": 1.0, + "editable": 0, + "type": 5, + "state": 1, + "last_changed": 1709982925, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "history": { + "day": 1, + "week": 26, + "month": 6 + } + } + }, + { + "id": 5, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 0, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "text", + "step_value": 1.0, + "editable": 0, + "type": 44, + "state": 1, + "last_changed": 0, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "4.54", + "name": "" } ] } diff --git a/tests/components/homee/fixtures/cover_without_position.json b/tests/components/homee/fixtures/cover_without_position.json index e2bc6c7a38d..f6e9ea19c8a 100644 --- a/tests/components/homee/fixtures/cover_without_position.json +++ b/tests/components/homee/fixtures/cover_without_position.json @@ -43,6 +43,27 @@ "observes": [75], "automations": ["toggle"] } + }, + { + "id": 2, + "node_id": 3, + "instance": 0, + "minimum": 0, + "maximum": 0, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "text", + "step_value": 1.0, + "editable": 0, + "type": 45, + "state": 1, + "last_changed": 0, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "1.45", + "name": "" } ] } diff --git a/tests/components/homee/snapshots/test_diagnostics.ambr b/tests/components/homee/snapshots/test_diagnostics.ambr index 76d3f426e17..d934c4e225e 100644 --- a/tests/components/homee/snapshots/test_diagnostics.ambr +++ b/tests/components/homee/snapshots/test_diagnostics.ambr @@ -689,6 +689,55 @@ 'type': 113, 'unit': '°', }), + dict({ + 'based_on': 1, + 'changed_by': 1, + 'changed_by_id': 0, + 'current_value': 20.3, + 'data': '', + 'editable': 0, + 'id': 4, + 'instance': 0, + 'last_changed': 1709982925, + 'last_value': 20.3, + 'maximum': 125, + 'minimum': -50, + 'name': '', + 'node_id': 3, + 'options': dict({ + 'history': dict({ + 'day': 1, + 'month': 6, + 'week': 26, + }), + }), + 'state': 1, + 'step_value': 1.0, + 'target_value': 20.3, + 'type': 5, + 'unit': '°C', + }), + dict({ + 'based_on': 1, + 'changed_by': 1, + 'changed_by_id': 0, + 'current_value': 0.0, + 'data': '4.54', + 'editable': 0, + 'id': 5, + 'instance': 0, + 'last_changed': 0, + 'last_value': 0.0, + 'maximum': 0, + 'minimum': 0, + 'name': '', + 'node_id': 3, + 'state': 1, + 'step_value': 1.0, + 'target_value': 0.0, + 'type': 44, + 'unit': 'text', + }), ]), 'cube_type': 14, 'favorite': 0, diff --git a/tests/components/homee/snapshots/test_init.ambr b/tests/components/homee/snapshots/test_init.ambr new file mode 100644 index 00000000000..664740dbeac --- /dev/null +++ b/tests/components/homee/snapshots/test_init.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_general_data + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + '00:05:55:11:ee:cc', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'homee', + '00055511EECC', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'homee', + 'model': 'homee', + 'model_id': None, + 'name': 'TestHomee', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '1.2.3', + 'via_device_id': None, + }) +# --- +# name: test_general_data.1 + 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( + 'homee', + '00055511EECC-3', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': None, + 'model': 'shutter_position_switch', + 'model_id': None, + 'name': 'Test Cover', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '4.54', + 'via_device_id': , + }) +# --- diff --git a/tests/components/homee/test_cover.py b/tests/components/homee/test_cover.py index a3e26abc52a..4f215c683a2 100644 --- a/tests/components/homee/test_cover.py +++ b/tests/components/homee/test_cover.py @@ -13,6 +13,10 @@ from homeassistant.components.cover import ( CoverEntityFeature, CoverState, ) +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.homee.const import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -23,9 +27,11 @@ from homeassistant.const import ( SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from . import build_mock_node, setup_integration @@ -39,6 +45,7 @@ async def test_open_close_stop_cover( ) -> None: """Test opening the cover.""" mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -73,6 +80,7 @@ async def test_open_close_reverse_cover( ) -> None: """Test opening the cover.""" mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] mock_homee.nodes[0].attributes[0].is_reversed = True await setup_integration(hass, mock_config_entry) @@ -102,6 +110,7 @@ async def test_set_cover_position( ) -> None: """Test setting the cover position.""" mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -246,6 +255,7 @@ async def test_cover_positions( # Cover open, tilt open. # mock_homee.nodes = [cover] mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] cover = mock_homee.nodes[0] await setup_integration(hass, mock_config_entry) @@ -348,3 +358,50 @@ async def test_send_error( assert exc_info.value.translation_domain == DOMAIN assert exc_info.value.translation_key == "connection_closed" + + +async def test_node_entity_connection_listener( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if loss of connection is sensed correctly.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + states = hass.states.get("cover.test_cover") + assert states.state != STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[1][0][0](False) + await hass.async_block_till_done() + + states = hass.states.get("cover.test_cover") + assert states.state == STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[1][0][0](True) + await hass.async_block_till_done() + + states = hass.states.get("cover.test_cover") + assert states.state != STATE_UNAVAILABLE + + +async def test_node_entity_update_action( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update_entity action for a HomeeEntity.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + await async_setup_component(hass, HA_DOMAIN, {}) + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "cover.test_cover"}, + blocking=True, + ) + + mock_homee.update_node.assert_called_once_with(3) diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py new file mode 100644 index 00000000000..0b2ae21a8d0 --- /dev/null +++ b/tests/components/homee/test_init.py @@ -0,0 +1,131 @@ +"""Test Homee initialization.""" + +from unittest.mock import MagicMock + +from pyHomee import HomeeAuthFailedException, HomeeConnectionFailedException +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.homee.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import build_mock_node, setup_integration +from .conftest import HOMEE_ID + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "side_eff", + [ + HomeeConnectionFailedException("connection timed out"), + HomeeAuthFailedException("wrong username or password"), + ], +) +async def test_connection_errors( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + side_eff: Exception, +) -> None: + """Test if connection errors on startup are handled correctly.""" + mock_homee.get_access_token.side_effect = side_eff + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_connection_listener( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test if loss of connection is sensed correctly.""" + mock_homee.nodes = [build_mock_node("homee.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + await mock_homee.add_connection_listener.call_args_list[0][0][0](False) + await hass.async_block_till_done() + assert "Disconnected from Homee" in caplog.text + await mock_homee.add_connection_listener.call_args_list[0][0][0](True) + await hass.async_block_till_done() + assert "Reconnected to Homee" in caplog.text + + +async def test_general_data( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test if data is set correctly.""" + mock_homee.nodes = [ + build_mock_node("cover_with_position_slats.json"), + build_mock_node("homee.json"), + ] + mock_homee.get_node_by_id = ( + lambda node_id: mock_homee.nodes[0] if node_id == 3 else mock_homee.nodes[1] + ) + await setup_integration(hass, mock_config_entry) + + # Verify hub and device created correctly using snapshots. + hub = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}-3")}) + + assert hub == snapshot + assert device == snapshot + + +async def test_software_version( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test sw_version for device with only AttributeType.SOFTWARE_VERSION.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}-3")}) + assert device.sw_version == "1.45" + + +async def test_invalid_profile( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """Test unknown value passed to get_name_for_enum.""" + mock_homee.nodes = [build_mock_node("cover_without_position.json")] + # This is a profile, that does not exist in the enum. + mock_homee.nodes[0].profile = 77 + await setup_integration(hass, mock_config_entry) + + device = device_registry.async_get_device(identifiers={(DOMAIN, f"{HOMEE_ID}-3")}) + assert device.model is None + + +async def test_unload_entry( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test unloading of config entry.""" + mock_homee.nodes = [build_mock_node("cover_with_position_slats.json")] + mock_homee.get_node_by_id.return_value = mock_homee.nodes[0] + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/homee/test_sensor.py b/tests/components/homee/test_sensor.py index 1d4ad4b0f66..b51b3a23b75 100644 --- a/tests/components/homee/test_sensor.py +++ b/tests/components/homee/test_sensor.py @@ -5,6 +5,10 @@ from unittest.mock import MagicMock, patch import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.homee.const import ( DOMAIN, OPEN_CLOSE_MAP, @@ -13,9 +17,10 @@ from homeassistant.components.homee.const import ( WINDOW_MAP_REVERSED, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.setup import async_setup_component from . import async_update_attribute_value, build_mock_node, setup_integration from .conftest import HOMEE_ID @@ -168,6 +173,49 @@ async def test_sensor_deprecation_unused_entity( ) +async def test_entity_connection_listener( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test if loss of connection is sensed correctly.""" + await setup_sensor(hass, mock_homee, mock_config_entry) + + states = hass.states.get("sensor.test_multisensor_energy_1") + assert states.state is not STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[2][0][0](False) + await hass.async_block_till_done() + + states = hass.states.get("sensor.test_multisensor_energy_1") + assert states.state is STATE_UNAVAILABLE + + await mock_homee.add_connection_listener.call_args_list[2][0][0](True) + await hass.async_block_till_done() + + states = hass.states.get("sensor.test_multisensor_energy_1") + assert states.state is not STATE_UNAVAILABLE + + +async def test_entity_update_action( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the update_entity action for a HomeeEntity.""" + await setup_sensor(hass, mock_homee, mock_config_entry) + await async_setup_component(hass, HA_DOMAIN, {}) + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "sensor.test_multisensor_temperature"}, + blocking=True, + ) + + mock_homee.update_attribute.assert_called_once_with(1, 23) + + async def test_sensor_snapshot( hass: HomeAssistant, mock_homee: MagicMock, From 4ee930507d01b7969bc1e8eb2ba056dcba455cd4 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 6 Jul 2025 12:11:44 +0200 Subject: [PATCH 1173/1664] Fix typo in `wrong_hub` abort message of `homee` (#148261) --- homeassistant/components/homee/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index 9523d62c671..267d5553a8c 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -5,7 +5,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_hub": "IP-Address belongs to a different homee than the configured one." + "wrong_hub": "IP address belongs to a different homee than the configured one." }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", From 0e7a4c91bf585899a2089558154ff923aecb6ba7 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 6 Jul 2025 12:38:57 +0200 Subject: [PATCH 1174/1664] bump motionblinds to 0.6.29 (#148265) --- homeassistant/components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index a82da20396f..eca520d8946 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.28"] + "requirements": ["motionblinds==0.6.29"] } diff --git a/requirements_all.txt b/requirements_all.txt index 01b297fd250..dd5376baa03 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1452,7 +1452,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.28 +motionblinds==0.6.29 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29ab2676ee0..58a4f6a221d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,7 +1244,7 @@ monzopy==1.4.2 mopeka-iot-ble==0.8.0 # homeassistant.components.motion_blinds -motionblinds==0.6.28 +motionblinds==0.6.29 # homeassistant.components.motionblinds_ble motionblindsble==0.1.3 From 075efb469a4267ddc2755ef6535012922528de3d Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Sun, 6 Jul 2025 13:08:27 +0200 Subject: [PATCH 1175/1664] Bump sfrbox-api to 0.0.12 (#148259) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/sfr_box/manifest.json | 2 +- homeassistant/components/sfr_box/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sfr_box/manifest.json b/homeassistant/components/sfr_box/manifest.json index a2d65e9819d..1987453a80d 100644 --- a/homeassistant/components/sfr_box/manifest.json +++ b/homeassistant/components/sfr_box/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sfr_box", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["sfrbox-api==0.0.11"] + "requirements": ["sfrbox-api==0.0.12"] } diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json index 35e9b1869ff..5139ec52bad 100644 --- a/homeassistant/components/sfr_box/strings.json +++ b/homeassistant/components/sfr_box/strings.json @@ -27,7 +27,7 @@ "host": "[%key:common::config_flow::data::host%]" }, "data_description": { - "host": "The hostname or IP address of your SFR device." + "host": "The hostname, IP address, or full URL of your SFR device. e.g.: '192.168.1.1' or 'https://sfrbox.example.com'" }, "description": "Setting the credentials is optional, but enables additional functionality." } diff --git a/requirements_all.txt b/requirements_all.txt index dd5376baa03..3e3c701508c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2753,7 +2753,7 @@ sensoterra==2.0.1 sentry-sdk==1.45.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.11 +sfrbox-api==0.0.12 # homeassistant.components.sharkiq sharkiq==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58a4f6a221d..03f77b69b93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2275,7 +2275,7 @@ sensoterra==2.0.1 sentry-sdk==1.45.1 # homeassistant.components.sfr_box -sfrbox-api==0.0.11 +sfrbox-api==0.0.12 # homeassistant.components.sharkiq sharkiq==1.1.0 From 8cb9cadce9b3ceb6ea7054f343ec7a51b5f0467f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 6 Jul 2025 15:15:38 +0200 Subject: [PATCH 1176/1664] Extract files_to_prompt from Gemini action (#148203) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Allen Porter --- .../__init__.py | 57 +++----------- .../entity.py | 74 +++++++++++++++++++ .../snapshots/test_init.ambr | 4 +- .../test_init.py | 11 ++- 4 files changed, 92 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 99e475a376b..a3b87c05e5a 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,15 +2,12 @@ from __future__ import annotations -import asyncio from functools import partial -import mimetypes from pathlib import Path from types import MappingProxyType from google.genai import Client from google.genai.errors import APIError, ClientError -from google.genai.types import File, FileState from requests.exceptions import Timeout import voluptuous as vol @@ -42,13 +39,13 @@ from .const import ( DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, - FILE_POLLING_INTERVAL_SECONDS, LOGGER, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) +from .entity import async_prepare_files_for_prompt SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" @@ -92,58 +89,22 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: client = config_entry.runtime_data - def append_files_to_prompt(): - image_filenames = call.data[CONF_IMAGE_FILENAME] - filenames = call.data[CONF_FILENAMES] - for filename in set(image_filenames + filenames): + files = call.data[CONF_IMAGE_FILENAME] + call.data[CONF_FILENAMES] + + if files: + for filename in files: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( f"Cannot read `{filename}`, no access to path; " "`allowlist_external_dirs` may need to be adjusted in " "`configuration.yaml`" ) - if not Path(filename).exists(): - raise HomeAssistantError(f"`{filename}` does not exist") - mimetype = mimetypes.guess_type(filename)[0] - with open(filename, "rb") as file: - uploaded_file = client.files.upload( - file=file, config={"mime_type": mimetype} - ) - prompt_parts.append(uploaded_file) - async def wait_for_file_processing(uploaded_file: File) -> None: - """Wait for file processing to complete.""" - while True: - uploaded_file = await client.aio.files.get( - name=uploaded_file.name, - config={"http_options": {"timeout": TIMEOUT_MILLIS}}, + prompt_parts.extend( + await async_prepare_files_for_prompt( + hass, client, [Path(filename) for filename in files] ) - if uploaded_file.state not in ( - FileState.STATE_UNSPECIFIED, - FileState.PROCESSING, - ): - break - LOGGER.debug( - "Waiting for file `%s` to be processed, current state: %s", - uploaded_file.name, - uploaded_file.state, - ) - await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) - - if uploaded_file.state == FileState.FAILED: - raise HomeAssistantError( - f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" - ) - - await hass.async_add_executor_job(append_files_to_prompt) - - tasks = [ - asyncio.create_task(wait_for_file_processing(part)) - for part in prompt_parts - if isinstance(part, File) and part.state != FileState.ACTIVE - ] - async with asyncio.timeout(TIMEOUT_MILLIS / 1000): - await asyncio.gather(*tasks) + ) try: response = await client.aio.models.generate_content( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index d471da36a8c..8f8edea18cb 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -2,15 +2,21 @@ from __future__ import annotations +import asyncio import codecs from collections.abc import AsyncGenerator, Callable from dataclasses import replace +import mimetypes +from pathlib import Path from typing import Any, cast +from google.genai import Client from google.genai.errors import APIError, ClientError from google.genai.types import ( AutomaticFunctionCallingConfig, Content, + File, + FileState, FunctionDeclaration, GenerateContentConfig, GenerateContentResponse, @@ -26,6 +32,7 @@ from voluptuous_openapi import convert from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity @@ -42,6 +49,7 @@ from .const import ( CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, DOMAIN, + FILE_POLLING_INTERVAL_SECONDS, LOGGER, RECOMMENDED_CHAT_MODEL, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -49,6 +57,7 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + TIMEOUT_MILLIS, ) # Max number of back and forth with the LLM to generate a response @@ -494,3 +503,68 @@ class GoogleGenerativeAILLMBaseEntity(Entity): ), ], ) + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, client: Client, files: list[Path] +) -> list[File]: + """Append files to a prompt. + + Caller needs to ensure that the files are allowed. + """ + + def upload_files() -> list[File]: + prompt_parts: list[File] = [] + for filename in files: + if not filename.exists(): + raise HomeAssistantError(f"`{filename}` does not exist") + mimetype = mimetypes.guess_type(filename)[0] + prompt_parts.append( + client.files.upload( + file=filename, + config={ + "mime_type": mimetype, + "display_name": filename.name, + }, + ) + ) + return prompt_parts + + async def wait_for_file_processing(uploaded_file: File) -> None: + """Wait for file processing to complete.""" + first = True + while uploaded_file.state in ( + FileState.STATE_UNSPECIFIED, + FileState.PROCESSING, + ): + if first: + first = False + else: + LOGGER.debug( + "Waiting for file `%s` to be processed, current state: %s", + uploaded_file.name, + uploaded_file.state, + ) + await asyncio.sleep(FILE_POLLING_INTERVAL_SECONDS) + + uploaded_file = await client.aio.files.get( + name=uploaded_file.name, + config={"http_options": {"timeout": TIMEOUT_MILLIS}}, + ) + + if uploaded_file.state == FileState.FAILED: + raise HomeAssistantError( + f"File `{uploaded_file.name}` processing failed, reason: {uploaded_file.error.message}" + ) + + prompt_parts = await hass.async_add_executor_job(upload_files) + + tasks = [ + asyncio.create_task(wait_for_file_processing(part)) + for part in prompt_parts + if part.state != FileState.ACTIVE + ] + async with asyncio.timeout(TIMEOUT_MILLIS / 1000): + await asyncio.gather(*tasks) + + return prompt_parts diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index a2603328959..a0d34f49d37 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -122,8 +122,8 @@ dict({ 'contents': list([ 'Describe this image from my doorbell camera', - b'some file', - b'some file', + File(name='doorbell_snapshot.jpg', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), + File(name='context.txt', display_name=None, mime_type=None, size_bytes=None, create_time=None, expiration_time=None, update_time=None, sha256_hash=None, uri=None, download_uri=None, state=, source=None, video_metadata=None, error=None), ]), 'model': 'models/gemini-2.5-flash', }), diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index c0a610f6a0a..351895c89fb 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -80,7 +80,10 @@ async def test_generate_content_service_with_image( ) as mock_generate, patch( "google.genai.files.Files.upload", - return_value=b"some file", + side_effect=[ + File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE), + File(name="context.txt", state=FileState.ACTIVE), + ], ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), @@ -92,7 +95,7 @@ async def test_generate_content_service_with_image( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + "filenames": ["doorbell_snapshot.jpg", "context.txt"], }, blocking=True, return_response=True, @@ -146,7 +149,7 @@ async def test_generate_content_file_processing_succeeds( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + "filenames": ["doorbell_snapshot.jpg", "context.txt"], }, blocking=True, return_response=True, @@ -208,7 +211,7 @@ async def test_generate_content_file_processing_fails( "generate_content", { "prompt": "Describe this image from my doorbell camera", - "filenames": ["doorbell_snapshot.jpg", "context.txt", "context.txt"], + "filenames": ["doorbell_snapshot.jpg", "context.txt"], }, blocking=True, return_response=True, From 4b5c04b2f03e1052f061583c959a07305bf4ba7f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 6 Jul 2025 07:56:37 -0700 Subject: [PATCH 1177/1664] Add AI Task support in Ollama (#148226) Co-authored-by: Paulus Schoutsen --- homeassistant/components/ollama/__init__.py | 29 ++- homeassistant/components/ollama/ai_task.py | 77 ++++++ .../components/ollama/config_flow.py | 80 ++++-- homeassistant/components/ollama/const.py | 7 + homeassistant/components/ollama/entity.py | 14 + homeassistant/components/ollama/strings.json | 38 +++ tests/components/ollama/__init__.py | 5 + tests/components/ollama/conftest.py | 12 +- tests/components/ollama/test_ai_task.py | 245 ++++++++++++++++++ tests/components/ollama/test_config_flow.py | 75 ++++++ tests/components/ollama/test_init.py | 98 ++++++- 11 files changed, 635 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/ollama/ai_task.py create mode 100644 tests/components/ollama/test_ai_task.py diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 6fe4720d13f..e16550c1e94 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -28,6 +28,7 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_AI_TASK_NAME, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN, @@ -47,7 +48,7 @@ __all__ = [ ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) type OllamaConfigEntry = ConfigEntry[ollama.AsyncClient] @@ -118,6 +119,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: parent_entry = api_keys_entries[entry.data[CONF_URL]] hass.config_entries.async_add_subentry(parent_entry, subentry) + conversation_entity = entity_registry.async_get_entity_id( "conversation", DOMAIN, @@ -208,6 +210,31 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> minor_version=1, ) + if entry.version == 3 and entry.minor_version == 1: + # Add AI Task subentry with default options. We can only create a new + # subentry if we can find an existing model in the entry. The model + # was removed in the previous migration step, so we need to + # check the subentries for an existing model. + existing_model = next( + iter( + model + for subentry in entry.subentries.values() + if (model := subentry.data.get(CONF_MODEL)) is not None + ), + None, + ) + if existing_model: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType({CONF_MODEL: existing_model}), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) + hass.config_entries.async_update_entry(entry, minor_version=2) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/ollama/ai_task.py b/homeassistant/components/ollama/ai_task.py new file mode 100644 index 00000000000..d796b28aac8 --- /dev/null +++ b/homeassistant/components/ollama/ai_task.py @@ -0,0 +1,77 @@ +"""AI Task integration for Ollama.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads + +from .entity import OllamaBaseLLMEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OllamaTaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OllamaTaskEntity( + ai_task.AITaskEntity, + OllamaBaseLLMEntity, +): + """Ollama AI Task entity.""" + + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + _LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError("Error with Ollama structured response") from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index 49eb12a5c23..cca917f6c29 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -46,6 +46,8 @@ from .const import ( CONF_NUM_CTX, CONF_PROMPT, CONF_THINK, + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DEFAULT_KEEP_ALIVE, DEFAULT_MAX_HISTORY, DEFAULT_MODEL, @@ -74,7 +76,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 3 - MINOR_VERSION = 1 + MINOR_VERSION = 2 def __init__(self) -> None: """Initialize config flow.""" @@ -136,11 +138,14 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": OllamaSubentryFlowHandler, + "ai_task_data": OllamaSubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" +class OllamaSubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing Ollama subentries.""" def __init__(self) -> None: """Initialize the subentry flow.""" @@ -201,7 +206,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): step_id="set_options", data_schema=vol.Schema( ollama_config_option_schema( - self.hass, self._is_new, options, models_to_list + self.hass, + self._is_new, + self._subentry_type, + options, + models_to_list, ) ), ) @@ -300,13 +309,19 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): def ollama_config_option_schema( hass: HomeAssistant, is_new: bool, + subentry_type: str, options: Mapping[str, Any], models_to_list: list[SelectOptionDict], ) -> dict: """Ollama options schema.""" if is_new: + if subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME + schema: dict = { - vol.Required(CONF_NAME, default="Ollama Conversation"): str, + vol.Required(CONF_NAME, default=default_name): str, } else: schema = {} @@ -319,29 +334,38 @@ def ollama_config_option_schema( ): SelectSelector( SelectSelectorConfig(options=models_to_list, custom_value=True) ), - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional( - CONF_LLM_HASS_API, - description={"suggested_value": options.get(CONF_LLM_HASS_API)}, - ): SelectSelector( - SelectSelectorConfig( - options=[ - SelectOptionDict( - label=api.name, - value=api.id, + } + ) + if subentry_type == "conversation": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT ) - for api in llm.async_get_apis(hass) - ], - multiple=True, - ) - ), + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + description={"suggested_value": options.get(CONF_LLM_HASS_API)}, + ): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict( + label=api.name, + value=api.id, + ) + for api in llm.async_get_apis(hass) + ], + multiple=True, + ) + ), + } + ) + schema.update( + { vol.Optional( CONF_NUM_CTX, description={ diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 3175525c70d..7e80570bd5e 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -159,3 +159,10 @@ MODEL_NAMES = [ # https://ollama.com/library "zephyr", ] DEFAULT_MODEL = "llama3.2:latest" + +DEFAULT_CONVERSATION_NAME = "Ollama Conversation" +DEFAULT_AI_TASK_NAME = "Ollama AI Task" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_MAX_HISTORY: DEFAULT_MAX_HISTORY, +} diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 7b63b1dff00..4122d0c67d8 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -8,6 +8,7 @@ import logging from typing import Any import ollama +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -180,6 +181,7 @@ class OllamaBaseLLMEntity(Entity): async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" settings = {**self.entry.data, **self.subentry.data} @@ -200,6 +202,17 @@ class OllamaBaseLLMEntity(Entity): max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) self._trim_history(message_history, max_messages) + output_format: dict[str, Any] | None = None + if structure: + output_format = convert( + structure, + custom_serializer=( + chat_log.llm_api.custom_serializer + if chat_log.llm_api + else llm.selector_serializer + ), + ) + # Get response # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): @@ -214,6 +227,7 @@ class OllamaBaseLLMEntity(Entity): keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, think=settings.get(CONF_THINK), + format=output_format, ) except (ollama.RequestError, ollama.ResponseError) as err: _LOGGER.error("Unexpected error talking to Ollama server: %s", err) diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index bb08e4a4684..4261b2286bf 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -55,6 +55,44 @@ "progress": { "download": "Please wait while the model is downloaded, which may take a very long time. Check your Ollama server logs for more details." } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add Generate data with AI service", + "reconfigure": "Reconfigure Generate data with AI service" + }, + "entry_type": "Generate data with AI service", + "step": { + "set_options": { + "data": { + "model": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::model%]", + "name": "[%key:common::config_flow::data::name%]", + "prompt": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::prompt%]", + "max_history": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::max_history%]", + "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::num_ctx%]", + "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::keep_alive%]", + "think": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::think%]" + }, + "data_description": { + "prompt": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::prompt%]", + "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::keep_alive%]", + "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::num_ctx%]", + "think": "[%key:component::ollama::config_subentries::conversation::step::set_options::data_description::think%]" + } + }, + "download": { + "title": "[%key:component::ollama::config_subentries::conversation::step::download::title%]" + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "[%key:component::ollama::config_subentries::conversation::abort::entry_not_loaded%]", + "download_failed": "[%key:component::ollama::config_subentries::conversation::abort::download_failed%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "progress": { + "download": "[%key:component::ollama::config_subentries::conversation::progress::download%]" + } } } } diff --git a/tests/components/ollama/__init__.py b/tests/components/ollama/__init__.py index 92db3b13304..9e7ae4772d4 100644 --- a/tests/components/ollama/__init__.py +++ b/tests/components/ollama/__init__.py @@ -12,3 +12,8 @@ TEST_OPTIONS = { ollama.CONF_MAX_HISTORY: 2, ollama.CONF_MODEL: "test_model:latest", } + +TEST_AI_TASK_OPTIONS = { + ollama.CONF_MAX_HISTORY: 2, + ollama.CONF_MODEL: "test_model:latest", +} diff --git a/tests/components/ollama/conftest.py b/tests/components/ollama/conftest.py index 552e7dee20a..f3406bf5566 100644 --- a/tests/components/ollama/conftest.py +++ b/tests/components/ollama/conftest.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import llm from homeassistant.setup import async_setup_component -from . import TEST_OPTIONS, TEST_USER_DATA +from . import TEST_AI_TASK_OPTIONS, TEST_OPTIONS, TEST_USER_DATA from tests.common import MockConfigEntry @@ -31,14 +31,20 @@ def mock_config_entry( domain=ollama.DOMAIN, data=TEST_USER_DATA, version=3, - minor_version=1, + minor_version=2, subentries_data=[ { "data": {**TEST_OPTIONS, **mock_config_entry_options}, "subentry_type": "conversation", "title": "Ollama Conversation", "unique_id": None, - } + }, + { + "data": TEST_AI_TASK_OPTIONS, + "subentry_type": "ai_task_data", + "title": "Ollama AI Task", + "unique_id": None, + }, ], ) entry.add_to_hass(hass) diff --git a/tests/components/ollama/test_ai_task.py b/tests/components/ollama/test_ai_task.py new file mode 100644 index 00000000000..ee812e7b316 --- /dev/null +++ b/tests/components/ollama/test_ai_task.py @@ -0,0 +1,245 @@ +"""Test AI Task platform of Ollama integration.""" + +from unittest.mock import patch + +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.ollama_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "Generated test data" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task_with_streaming( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with streaming response.""" + entity_id = "ai_task.ollama_ai_task" + + async def mock_stream(): + """Mock streaming response.""" + yield {"message": {"role": "assistant", "content": "Stream "}} + yield { + "message": {"role": "assistant", "content": "response"}, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_stream(), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Streaming Task", + entity_id=entity_id, + instructions="Generate streaming data", + ) + + assert result.data == "Stream response" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with connection error.""" + entity_id = "ai_task.ollama_ai_task" + + with ( + patch( + "ollama.AsyncClient.chat", + side_effect=Exception("Connection failed"), + ), + pytest.raises(Exception, match="Connection failed"), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Error Task", + entity_id=entity_id, + instructions="Generate data that will fail", + ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task_empty_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with empty response.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock response with space (minimally non-empty) + async def mock_minimal_response(): + """Mock minimal streaming response.""" + yield { + "message": {"role": "assistant", "content": " "}, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_minimal_response(), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Minimal Task", + entity_id=entity_id, + instructions="Generate minimal data", + ) + + assert result.data == " " + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": { + "role": "assistant", + "content": '{"characters": ["Mario", "Luigi"]}', + }, + "done": True, + "done_reason": "stop", + } + + with patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ) as mock_chat: + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + assert result.data == {"characters": ["Mario", "Luigi"]} + + assert mock_chat.call_count == 1 + assert mock_chat.call_args[1]["format"] == { + "type": "object", + "properties": {"characters": {"items": {"type": "string"}, "type": "array"}}, + "required": ["characters"], + } + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": { + "role": "assistant", + "content": "INVALID JSON RESPONSE", + }, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ), + pytest.raises(HomeAssistantError), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) diff --git a/tests/components/ollama/test_config_flow.py b/tests/components/ollama/test_config_flow.py index 7372a460c95..1a873c2adb7 100644 --- a/tests/components/ollama/test_config_flow.py +++ b/tests/components/ollama/test_config_flow.py @@ -461,3 +461,78 @@ async def test_subentry_reconfigure_with_download( ollama.CONF_NUM_CTX: 8192.0, ollama.CONF_THINK: True, } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + original ai_task + assert len(mock_config_entry.subentries) == 2 + + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": "test_model:latest"}]}, + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "set_options" + assert not result.get("errors") + + with patch( + "ollama.AsyncClient.list", + return_value={"models": [{"model": "test_model:latest"}]}, + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + ollama.CONF_MODEL: "test_model:latest", + ollama.CONF_MAX_HISTORY: 5, + ollama.CONF_NUM_CTX: 4096, + ollama.CONF_KEEP_ALIVE: 30, + ollama.CONF_THINK: False, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + ollama.CONF_MODEL: "test_model:latest", + ollama.CONF_MAX_HISTORY: 5, + ollama.CONF_NUM_CTX: 4096, + ollama.CONF_KEEP_ALIVE: 30, + ollama.CONF_THINK: False, + } + + assert ( + len(mock_config_entry.subentries) == 3 + ) # Original conversation + original ai_task + new ai_task + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index c7cd78fca9a..1db57302704 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -93,16 +93,23 @@ async def test_migration_from_v1( return_value=True, ): await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 1 + assert mock_config_entry.minor_version == 2 # After migration, parent entry should only have URL assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 - subentry = next(iter(mock_config_entry.subentries.values())) + subentry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "conversation" + ) + ) assert subentry.unique_id is None assert subentry.title == "llama-3.2-8b" assert subentry.subentry_type == "conversation" @@ -110,6 +117,18 @@ async def test_migration_from_v1( expected_subentry_data = TEST_OPTIONS.copy() assert subentry.data == expected_subentry_data + # Find the AI Task subentry + ai_task_subentry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert ai_task_subentry.unique_id is None + assert ai_task_subentry.title == "Ollama AI Task" + assert ai_task_subentry.subentry_type == "ai_task_data" + migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None assert migrated_entity.config_entry_id == mock_config_entry.entry_id @@ -204,10 +223,17 @@ async def test_migration_from_v1_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 3 - assert entry.minor_version == 1 + assert entry.minor_version == 2 assert not entry.options - assert len(entry.subentries) == 1 - subentry = list(entry.subentries.values())[0] + assert len(entry.subentries) == 2 + + subentry = next( + iter( + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ) + ) assert subentry.subentry_type == "conversation" # Subentry should include the model along with the original options expected_subentry_data = TEST_OPTIONS.copy() @@ -215,6 +241,17 @@ async def test_migration_from_v1_with_multiple_urls( assert subentry.data == expected_subentry_data assert subentry.title == f"Ollama {idx + 1}" + # Find the AI Task subentry + ai_task_subentry = next( + iter( + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ) + ) + assert ai_task_subentry.subentry_type == "ai_task_data" + assert ai_task_subentry.title == "Ollama AI Task" + dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} ) @@ -295,9 +332,10 @@ async def test_migration_from_v1_with_same_urls( entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 1 + assert entry.minor_version == 2 assert not entry.options - assert len(entry.subentries) == 2 # Two subentries from the two original entries + # Two conversation subentries from the two original entries and 1 aitask subentry + assert len(entry.subentries) == 3 # Check both subentries exist with correct data subentries = list(entry.subentries.values()) @@ -305,7 +343,11 @@ async def test_migration_from_v1_with_same_urls( assert "Ollama" in titles assert "Ollama 2" in titles - for subentry in subentries: + conversation_subentries = [ + subentry for subentry in subentries if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" # Subentry should include the model along with the original options expected_subentry_data = TEST_OPTIONS.copy() @@ -415,10 +457,10 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 1 + assert entry.minor_version == 2 assert not entry.options assert entry.title == "Ollama" - assert len(entry.subentries) == 2 + assert len(entry.subentries) == 3 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -504,14 +546,44 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None: # Check migration to v3.1 assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 1 + assert mock_config_entry.minor_version == 2 # Check that model was moved from main data to subentry assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 subentry = next(iter(mock_config_entry.subentries.values())) assert subentry.data == { **V21_TEST_USER_DATA, ollama.CONF_MODEL: "test_model:latest", } + + +async def test_migration_from_v3_1_without_subentry(hass: HomeAssistant) -> None: + """Test migration from version 3.1 where there is no existing subentry. + + This exercises the code path where the model is not moved to a subentry + because the subentry does not exist, which is a scenario that can happen + if the user created the config entry without adding a subentry, or + if the user manually removed the subentry after the migration to v3.1. + """ + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + ollama.CONF_MODEL: "test_model:latest", + }, + version=3, + minor_version=1, + ) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 2 + + assert next(iter(mock_config_entry.subentries.values()), None) is None From 404d17efca95e05b7ed5d8e7b65173faf8ea8927 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 6 Jul 2025 09:36:38 -0700 Subject: [PATCH 1178/1664] Translate number selector unit for utility_meter (#148276) --- homeassistant/components/utility_meter/config_flow.py | 1 + homeassistant/components/utility_meter/strings.json | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index e8acca88cbe..db7cea6ecf2 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -94,6 +94,7 @@ CONFIG_SCHEMA = vol.Schema( max=28, mode=selector.NumberSelectorMode.BOX, unit_of_measurement="days", + translation_key=CONF_METER_OFFSET, ), ), vol.Required(CONF_TARIFFS, default=[]): selector.SelectSelector( diff --git a/homeassistant/components/utility_meter/strings.json b/homeassistant/components/utility_meter/strings.json index aadc0f82412..0ba7ad85050 100644 --- a/homeassistant/components/utility_meter/strings.json +++ b/homeassistant/components/utility_meter/strings.json @@ -58,6 +58,11 @@ "quarterly": "Quarterly", "yearly": "Yearly" } + }, + "offset": { + "unit_of_measurement": { + "days": "days" + } } }, "services": { From 699c60f2937168f857e80469ad9dd7a7d2ae9e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 6 Jul 2025 18:06:27 +0100 Subject: [PATCH 1179/1664] Add the current version to the starting log to aid troubleshooting (#148271) --- homeassistant/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index c5d4ca79371..469acd5dae8 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -532,7 +532,7 @@ class HomeAssistant: This method is a coroutine. """ - _LOGGER.info("Starting Home Assistant") + _LOGGER.info("Starting Home Assistant %s", __version__) self.set_state(CoreState.starting) self.bus.async_fire_internal(EVENT_CORE_CONFIG_UPDATE) From 008e2a3d10070e7ad5ac93ff679a9836cbc6ab8d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 6 Jul 2025 19:33:41 +0200 Subject: [PATCH 1180/1664] Add attachment support to AI task (#148120) --- homeassistant/components/ai_task/__init__.py | 7 ++- homeassistant/components/ai_task/const.py | 4 ++ .../components/ai_task/manifest.json | 2 +- .../components/ai_task/services.yaml | 6 ++ homeassistant/components/ai_task/strings.json | 4 ++ homeassistant/components/ai_task/task.py | 45 +++++++++++++- tests/components/ai_task/conftest.py | 4 +- tests/components/ai_task/test_init.py | 59 +++++++++++++++---- tests/components/ai_task/test_task.py | 37 ++++++++++-- 9 files changed, 147 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index 95c080cc472..a472b0db131 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -20,6 +20,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from .const import ( + ATTR_ATTACHMENTS, ATTR_INSTRUCTIONS, ATTR_REQUIRED, ATTR_STRUCTURE, @@ -32,7 +33,7 @@ from .const import ( ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenDataTask, GenDataTaskResult, async_generate_data +from .task import GenDataTask, GenDataTaskResult, PlayMediaWithId, async_generate_data __all__ = [ "DOMAIN", @@ -40,6 +41,7 @@ __all__ = [ "AITaskEntityFeature", "GenDataTask", "GenDataTaskResult", + "PlayMediaWithId", "async_generate_data", "async_setup", "async_setup_entry", @@ -92,6 +94,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.Schema({str: STRUCTURE_FIELD_SCHEMA}), _validate_structure_fields, ), + vol.Optional(ATTR_ATTACHMENTS): vol.All( + cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})] + ), } ), supports_response=SupportsResponse.ONLY, diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index fa8702ed69e..09948e9b673 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -23,6 +23,7 @@ ATTR_INSTRUCTIONS: Final = "instructions" ATTR_TASK_NAME: Final = "task_name" ATTR_STRUCTURE: Final = "structure" ATTR_REQUIRED: Final = "required" +ATTR_ATTACHMENTS: Final = "attachments" DEFAULT_SYSTEM_PROMPT = ( "You are a Home Assistant expert and help users with their tasks." @@ -34,3 +35,6 @@ class AITaskEntityFeature(IntFlag): GENERATE_DATA = 1 """Generate data based on instructions.""" + + SUPPORT_ATTACHMENTS = 2 + """Support attachments with generate data.""" diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index c685410530d..c3e33e7d411 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -2,7 +2,7 @@ "domain": "ai_task", "name": "AI Task", "codeowners": ["@home-assistant/core"], - "dependencies": ["conversation"], + "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", "integration_type": "system", "quality_scale": "internal" diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index d55b0e60fac..4298ab62a07 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -23,3 +23,9 @@ generate_data: example: '{ "name": { "selector": { "text": }, "description": "Name of the user", "required": "True" } } }, "age": { "selector": { "number": }, "description": "Age of the user" } }' selector: object: + attachments: + required: false + selector: + media: + accept: + - "*" diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index 92106c3baca..261381b7c31 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -19,6 +19,10 @@ "structure": { "name": "Structured output", "description": "When set, the AI Task will output fields with this in structure. The structure is a dictionary where the keys are the field names and the values contain a 'description', a 'selector', and an optional 'required' field." + }, + "attachments": { + "name": "Attachments", + "description": "List of files to attach for multi-modal AI analysis." } } } diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index b6defbfad31..72d1018210c 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -2,17 +2,30 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, fields from typing import Any import voluptuous as vol +from homeassistant.components import media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature +@dataclass(slots=True) +class PlayMediaWithId(media_source.PlayMedia): + """Play media with a media content ID.""" + + media_content_id: str + """Media source ID to play.""" + + def __str__(self) -> str: + """Return media source ID as a string.""" + return f"" + + async def async_generate_data( hass: HomeAssistant, *, @@ -20,6 +33,7 @@ async def async_generate_data( entity_id: str | None = None, instructions: str, structure: vol.Schema | None = None, + attachments: list[dict] | None = None, ) -> GenDataTaskResult: """Run a task in the AI Task integration.""" if entity_id is None: @@ -37,11 +51,37 @@ async def async_generate_data( f"AI Task entity {entity_id} does not support generating data" ) + # Resolve attachments + resolved_attachments: list[PlayMediaWithId] | None = None + + if attachments: + if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + resolved_attachments = [] + + for attachment in attachments: + media = await media_source.async_resolve_media( + hass, attachment["media_content_id"], None + ) + resolved_attachments.append( + PlayMediaWithId( + **{ + field.name: getattr(media, field.name) + for field in fields(media) + }, + media_content_id=attachment["media_content_id"], + ) + ) + return await entity.internal_async_generate_data( GenDataTask( name=task_name, instructions=instructions, structure=structure, + attachments=resolved_attachments, ) ) @@ -59,6 +99,9 @@ class GenDataTask: structure: vol.Schema | None = None """Optional structure for the data to be generated.""" + attachments: list[PlayMediaWithId] | None = None + """List of attachments to go along the instructions.""" + def __str__(self) -> str: """Return task as a string.""" return f"" diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index e80e70ddaed..05d34b15ddc 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -35,7 +35,9 @@ class MockAITaskEntity(AITaskEntity): """Mock AI Task entity for testing.""" _attr_name = "Test Task Entity" - _attr_supported_features = AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + AITaskEntityFeature.GENERATE_DATA | AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) def __init__(self) -> None: """Initialize the mock entity.""" diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index d32b09adec5..840285493ac 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -1,11 +1,13 @@ """Test initialization of the AI Task component.""" from typing import Any +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol +from homeassistant.components import media_source from homeassistant.components.ai_task import AITaskPreferences from homeassistant.components.ai_task.const import DATA_PREFERENCES from homeassistant.core import HomeAssistant @@ -58,7 +60,15 @@ async def test_preferences_storage_load( ), ( {}, - {"entity_id": TEST_ENTITY_ID}, + { + "entity_id": TEST_ENTITY_ID, + "attachments": [ + { + "media_content_id": "media-source://mock/blah_blah_blah.mp4", + "media_content_type": "video/mp4", + } + ], + }, ), ], ) @@ -68,25 +78,50 @@ async def test_generate_data_service( freezer: FrozenDateTimeFactory, set_preferences: dict[str, str | None], msg_extra: dict[str, str], + mock_ai_task_entity: MockAITaskEntity, ) -> None: """Test the generate data service.""" preferences = hass.data[DATA_PREFERENCES] preferences.async_set_preferences(**set_preferences) - result = await hass.services.async_call( - "ai_task", - "generate_data", - { - "task_name": "Test Name", - "instructions": "Test prompt", - } - | msg_extra, - blocking=True, - return_response=True, - ) + with patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/media.mp4", + mime_type="video/mp4", + ), + ): + result = await hass.services.async_call( + "ai_task", + "generate_data", + { + "task_name": "Test Name", + "instructions": "Test prompt", + } + | msg_extra, + blocking=True, + return_response=True, + ) assert result["data"] == "Mock result" + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + + assert len(task.attachments or []) == len( + msg_attachments := msg_extra.get("attachments", []) + ) + + for msg_attachment, attachment in zip( + msg_attachments, task.attachments or [], strict=False + ): + assert attachment.url == "http://example.com/media.mp4" + assert attachment.mime_type == "video/mp4" + assert attachment.media_content_id == msg_attachment["media_content_id"] + assert ( + str(attachment) == f"" + ) + async def test_generate_data_service_structure_fields( hass: HomeAssistant, diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index bed760c8a1d..b11d96823cc 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -16,13 +16,13 @@ from .conftest import TEST_ENTITY_ID, MockAITaskEntity from tests.typing import WebSocketGenerator -async def test_run_task_preferred_entity( +async def test_generate_data_preferred_entity( hass: HomeAssistant, init_components: None, mock_ai_task_entity: MockAITaskEntity, hass_ws_client: WebSocketGenerator, ) -> None: - """Test running a task with an unknown entity.""" + """Test generating data with entity via preferences.""" client = await hass_ws_client(hass) with pytest.raises( @@ -90,11 +90,11 @@ async def test_run_task_preferred_entity( ) -async def test_run_data_task_unknown_entity( +async def test_generate_data_unknown_entity( hass: HomeAssistant, init_components: None, ) -> None: - """Test running a data task with an unknown entity.""" + """Test generating data with an unknown entity.""" with pytest.raises( HomeAssistantError, match="AI Task entity ai_task.unknown_entity not found" @@ -113,7 +113,7 @@ async def test_run_data_task_updates_chat_log( init_components: None, snapshot: SnapshotAssertion, ) -> None: - """Test that running a data task updates the chat log.""" + """Test that generating data updates the chat log.""" result = await async_generate_data( hass, task_name="Test Task", @@ -127,3 +127,30 @@ async def test_run_data_task_updates_chat_log( async_get_chat_log(hass, session) as chat_log, ): assert chat_log.content == snapshot + + +async def test_generate_data_attachments_not_supported( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating data with attachments when entity doesn't support them.""" + # Remove attachment support from the entity + mock_ai_task_entity._attr_supported_features = AITaskEntityFeature.GENERATE_DATA + + with pytest.raises( + HomeAssistantError, + match="AI Task entity ai_task.test_task_entity does not support attachments", + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + attachments=[ + { + "media_content_id": "media-source://mock/test.mp4", + "media_content_type": "video/mp4", + } + ], + ) From 2ea20ee2ab892c31ad0f8aecd86b9af5aa9d4784 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 6 Jul 2025 12:40:19 -0500 Subject: [PATCH 1181/1664] Fix UTF-8 encoding for REST basic authentication (#148225) --- homeassistant/components/rest/data.py | 2 +- tests/components/rest/test_binary_sensor.py | 33 +++++++++++++++++++++ tests/test_util/aiohttp.py | 3 ++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 731d1ffe9c3..c5dcd0a73a5 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -49,7 +49,7 @@ class RestData: # Convert auth tuple to aiohttp.BasicAuth if needed if isinstance(auth, tuple) and len(auth) == 2: self._auth: aiohttp.BasicAuth | aiohttp.DigestAuthMiddleware | None = ( - aiohttp.BasicAuth(auth[0], auth[1]) + aiohttp.BasicAuth(auth[0], auth[1], encoding="utf-8") ) else: self._auth = auth diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 315f8113309..af7503a7007 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -667,3 +667,36 @@ async def test_availability_blocks_value_template( await hass.async_block_till_done() assert error in caplog.text + + +async def test_setup_get_basic_auth_utf8( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with basic auth using UTF-8 characters including Unicode char \u2018.""" + # Use a password with the Unicode character \u2018 (left single quotation mark) + aioclient_mock.get("http://localhost", status=HTTPStatus.OK, json={"key": "on"}) + assert await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "resource": "http://localhost", + "method": "GET", + "value_template": "{{ value_json.key }}", + "name": "foo", + "verify_ssl": "true", + "timeout": 30, + "authentication": "basic", + "username": "test_user", + "password": "test\u2018password", # Password with Unicode char + "headers": {"Accept": CONTENT_TYPE_JSON}, + } + }, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all(BINARY_SENSOR_DOMAIN)) == 1 + + state = hass.states.get("binary_sensor.foo") + assert state.state == STATE_ON diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index eea3f4e88b4..fe0de66f44c 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -156,6 +156,9 @@ class AiohttpClientMocker: for response in self._mocks: if response.match_request(method, url, params): + # If auth is provided, try to encode it to trigger any encoding errors + if auth is not None: + auth.encode() self.mock_calls.append((method, url, data, headers)) if response.side_effect: response = await response.side_effect(method, url, data) From 6351c3302e6be434cf9cbf67b538d26cfe4a1483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Sun, 6 Jul 2025 23:40:05 +0200 Subject: [PATCH 1182/1664] Matter OperationalState CountdownTime (#147705) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/sensor.py | 19 ++++++- homeassistant/components/matter/strings.json | 3 ++ .../matter/snapshots/test_sensor.ambr | 49 +++++++++++++++++++ tests/components/matter/test_sensor.py | 16 ++++++ 4 files changed, 85 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 62c70f777e7..f563c246186 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timedelta from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters @@ -44,7 +44,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util import slugify +from homeassistant.util import dt as dt_util, slugify from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -942,6 +942,21 @@ DISCOVERY_SCHEMAS = [ # don't discover this entry if the supported state list is empty secondary_value_is_not=[], ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OperationalStateCountdownTime", + translation_key="estimated_end_time", + device_class=SensorDeviceClass.TIMESTAMP, + state_class=None, + # Add countdown to current datetime to get the estimated end time + device_to_ha=( + lambda x: dt_util.utcnow() + timedelta(seconds=x) if x > 0 else None + ), + ), + entity_class=MatterSensor, + required_attributes=(clusters.OperationalState.Attributes.CountdownTime,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterListSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 0ac44c006ab..2534dd6aa6e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -318,6 +318,9 @@ "docked": "Docked" } }, + "estimated_end_time": { + "name": "Estimated end time" + }, "switch_current_position": { "name": "Current switch position" }, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 8e459c0f573..472799b80ae 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -3443,6 +3443,55 @@ 'state': '1.3', }) # --- +# name: test_sensors[microwave_oven][sensor.microwave_oven_estimated_end_time-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.microwave_oven_estimated_end_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': 'Estimated end time', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'estimated_end_time', + 'unique_id': '00000000000004D2-000000000000009D-MatterNodeDevice-1-OperationalStateCountdownTime-96-2', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[microwave_oven][sensor.microwave_oven_estimated_end_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Microwave Oven Estimated end time', + }), + 'context': , + 'entity_id': 'sensor.microwave_oven_estimated_end_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-01-01T14:00:30+00:00', + }) +# --- # name: test_sensors[microwave_oven][sensor.microwave_oven_operational_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 3e9af4a6e4b..883a976284e 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -17,6 +17,7 @@ from .common import ( ) +@pytest.mark.freeze_time("2025-01-01T14:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default", "matter_devices") async def test_sensors( hass: HomeAssistant, @@ -381,6 +382,21 @@ async def test_draft_electrical_measurement_sensor( assert state.state == "unknown" +@pytest.mark.freeze_time("2025-01-01T14:00:00+00:00") +@pytest.mark.parametrize("node_fixture", ["microwave_oven"]) +async def test_countdown_time_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test CountdownTime sensor.""" + # OperationalState Cluster / CountdownTime (1/96/2) + state = hass.states.get("sensor.microwave_oven_estimated_end_time") + assert state + # 1/96/2 = 30 seconds, so 30 s should be added to the current time. + assert state.state == "2025-01-01T14:00:30+00:00" + + @pytest.mark.parametrize("node_fixture", ["silabs_laundrywasher"]) async def test_list_sensor( hass: HomeAssistant, From 0bce01da0b634ebe1f52ba53f5b584ee0fbc10d0 Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 7 Jul 2025 10:09:07 +0200 Subject: [PATCH 1183/1664] Address some Wallbox quality scale issues (#148200) --- homeassistant/components/wallbox/__init__.py | 14 +- .../components/wallbox/coordinator.py | 12 +- homeassistant/components/wallbox/lock.py | 7 +- homeassistant/components/wallbox/number.py | 7 +- homeassistant/components/wallbox/select.py | 6 +- homeassistant/components/wallbox/sensor.py | 7 +- homeassistant/components/wallbox/strings.json | 8 + homeassistant/components/wallbox/switch.py | 7 +- tests/components/wallbox/conftest.py | 188 +---------------- tests/components/wallbox/const.py | 189 ++++++++++++++++++ tests/components/wallbox/test_config_flow.py | 24 +-- tests/components/wallbox/test_init.py | 88 +++++--- tests/components/wallbox/test_number.py | 11 +- tests/components/wallbox/test_select.py | 26 +-- 14 files changed, 321 insertions(+), 273 deletions(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 9336ab0e36b..c2983d540df 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import DOMAIN, UPDATE_INTERVAL +from .const import UPDATE_INTERVAL from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input PLATFORMS = [ @@ -20,8 +20,10 @@ PLATFORMS = [ Platform.SWITCH, ] +type WallboxConfigEntry = ConfigEntry[WallboxCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool: """Set up Wallbox from a config entry.""" wallbox = Wallbox( entry.data[CONF_USERNAME], @@ -36,7 +38,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wallbox_coordinator = WallboxCoordinator(hass, entry, wallbox) await wallbox_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = wallbox_coordinator + entry.runtime_data = wallbox_coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -45,8 +47,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 69bf3a3af1c..ffd235157ac 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -222,7 +222,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InvalidAuth( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="too_many_requests" @@ -248,7 +250,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InvalidAuth( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="too_many_requests" @@ -303,7 +307,9 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InvalidAuth( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( translation_domain=DOMAIN, translation_key="too_many_requests" diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7b5c99340f8..6ba9058db96 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -13,7 +13,6 @@ from .const import ( CHARGER_DATA_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, - DOMAIN, ) from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -32,7 +31,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox lock entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( WallboxLock(coordinator, description) for ent in coordinator.data @@ -40,6 +39,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxLock(WallboxEntity, LockEntity): """Representation of a wallbox lock.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index e1b044bbdb2..af4fbe2c38b 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -23,7 +23,6 @@ from .const import ( CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PART_NUMBER_KEY, CHARGER_SERIAL_NUMBER_KEY, - DOMAIN, ) from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -84,7 +83,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox number entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( WallboxNumber(coordinator, entry, description) for ent in coordinator.data @@ -92,6 +91,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxNumber(WallboxEntity, NumberEntity): """Representation of the Wallbox portal.""" diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 0048aa35c7c..10ac4e61189 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -62,7 +62,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox select entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data if coordinator.data[CHARGER_ECO_SMART_KEY] != EcoSmartMode.DISABLED: async_add_entities( WallboxSelect(coordinator, description) @@ -74,6 +74,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxSelect(WallboxEntity, SelectEntity): """Representation of the Wallbox portal.""" diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index e19fc2b936a..7d5e5b56309 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -43,7 +43,6 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, CHARGER_STATE_OF_CHARGE_KEY, CHARGER_STATUS_DESCRIPTION_KEY, - DOMAIN, ) from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -174,7 +173,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( WallboxSensor(coordinator, description) @@ -183,6 +182,10 @@ async def async_setup_entry( ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxSensor(WallboxEntity, SensorEntity): """Representation of the Wallbox portal.""" diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index ee98a4855e3..a69251eb832 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -6,6 +6,11 @@ "station": "Station Serial Number", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "station": "Serial number of the charger, this value can be found in the Wallbox App or in the Wallbox Portal.", + "username": "Username for your Wallbox Account.", + "password": "Password for your Wallbox Account." } }, "reauth_confirm": { @@ -115,6 +120,9 @@ }, "too_many_requests": { "message": "Error communicating with Wallbox API, too many requests" + }, + "invalid_auth": { + "message": "Invalid authentication" } } } diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 30275951ab2..7a28f863c4d 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -14,7 +14,6 @@ from .const import ( CHARGER_PAUSE_RESUME_KEY, CHARGER_SERIAL_NUMBER_KEY, CHARGER_STATUS_DESCRIPTION_KEY, - DOMAIN, ChargerStatus, ) from .coordinator import WallboxCoordinator @@ -34,12 +33,16 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" - coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WallboxCoordinator = entry.runtime_data async_add_entities( [WallboxSwitch(coordinator, SWITCH_TYPES[CHARGER_PAUSE_RESUME_KEY])] ) +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + class WallboxSwitch(WallboxEntity, SwitchEntity): """Representation of the Wallbox portal.""" diff --git a/tests/components/wallbox/conftest.py b/tests/components/wallbox/conftest.py index ab1032b3816..c20c6e59da1 100644 --- a/tests/components/wallbox/conftest.py +++ b/tests/components/wallbox/conftest.py @@ -7,165 +7,22 @@ import pytest import requests from homeassistant.components.wallbox.const import ( - CHARGER_ADDED_ENERGY_KEY, - CHARGER_ADDED_RANGE_KEY, - CHARGER_CHARGING_POWER_KEY, - CHARGER_CHARGING_SPEED_KEY, - CHARGER_CURRENCY_KEY, - CHARGER_CURRENT_VERSION_KEY, - CHARGER_DATA_KEY, CHARGER_DATA_POST_L1_KEY, CHARGER_DATA_POST_L2_KEY, - CHARGER_ECO_SMART_KEY, - CHARGER_ECO_SMART_MODE_KEY, - CHARGER_ECO_SMART_STATUS_KEY, CHARGER_ENERGY_PRICE_KEY, - CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, - CHARGER_MAX_AVAILABLE_POWER_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, - CHARGER_NAME_KEY, - CHARGER_PART_NUMBER_KEY, - CHARGER_PLAN_KEY, - CHARGER_POWER_BOOST_KEY, - CHARGER_SERIAL_NUMBER_KEY, - CHARGER_SOFTWARE_KEY, - CHARGER_STATUS_ID_KEY, CONF_STATION, DOMAIN, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import ERROR, REFRESH_TOKEN_TTL, STATUS, TTL, USER_ID +from .const import WALLBOX_AUTHORISATION_RESPONSE, WALLBOX_STATUS_RESPONSE from tests.common import MockConfigEntry -test_response = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: False, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - -test_response_bidir = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: False, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - -test_response_eco_mode = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: True, - CHARGER_ECO_SMART_MODE_KEY: 0, - }, - }, -} - - -test_response_full_solar = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, - CHARGER_ECO_SMART_KEY: { - CHARGER_ECO_SMART_STATUS_KEY: True, - CHARGER_ECO_SMART_MODE_KEY: 1, - }, - }, -} - -test_response_no_power_boost = { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - CHARGER_MAX_ICP_CURRENT_KEY: 20, - CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, - }, -} - - http_403_error = requests.exceptions.HTTPError() http_403_error.response = requests.Response() http_403_error.response.status_code = HTTPStatus.FORBIDDEN @@ -176,45 +33,6 @@ http_429_error = requests.exceptions.HTTPError() http_429_error.response = requests.Response() http_429_error.response.status_code = HTTPStatus.TOO_MANY_REQUESTS -authorisation_response = { - "data": { - "attributes": { - "token": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - REFRESH_TOKEN_TTL: 145756758, - ERROR: "false", - STATUS: 200, - } - } -} - - -authorisation_response_unauthorised = { - "data": { - "attributes": { - "token": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - REFRESH_TOKEN_TTL: 145756758, - ERROR: "false", - STATUS: 404, - } - } -} - -invalid_reauth_response = { - "jwt": "fakekeyhere", - "refresh_token": "refresh_fakekeyhere", - "user_id": 12345, - "ttl": 145656758, - "refresh_token_ttl": 145756758, - "error": False, - "status": 200, -} - @pytest.fixture def entry(hass: HomeAssistant) -> MockConfigEntry: @@ -237,7 +55,7 @@ def mock_wallbox(): """Patch Wallbox class for tests.""" with patch("homeassistant.components.wallbox.Wallbox") as mock: wallbox = MagicMock() - wallbox.authenticate = Mock(return_value=authorisation_response) + wallbox.authenticate = Mock(return_value=WALLBOX_AUTHORISATION_RESPONSE) wallbox.lockCharger = Mock( return_value={ CHARGER_DATA_POST_L1_KEY: { @@ -263,7 +81,7 @@ def mock_wallbox(): } ) wallbox.setIcpMaxCurrent = Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 25}) - wallbox.getChargerStatus = Mock(return_value=test_response) + wallbox.getChargerStatus = Mock(return_value=WALLBOX_STATUS_RESPONSE) mock.return_value = wallbox yield wallbox diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 82c9e5169d5..9650f9d3c61 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -1,5 +1,31 @@ """Provides constants for Wallbox component tests.""" +from homeassistant.components.wallbox.const import ( + CHARGER_ADDED_ENERGY_KEY, + CHARGER_ADDED_RANGE_KEY, + CHARGER_CHARGING_POWER_KEY, + CHARGER_CHARGING_SPEED_KEY, + CHARGER_CURRENCY_KEY, + CHARGER_CURRENT_VERSION_KEY, + CHARGER_DATA_KEY, + CHARGER_ECO_SMART_KEY, + CHARGER_ECO_SMART_MODE_KEY, + CHARGER_ECO_SMART_STATUS_KEY, + CHARGER_ENERGY_PRICE_KEY, + CHARGER_FEATURES_KEY, + CHARGER_LOCKED_UNLOCKED_KEY, + CHARGER_MAX_AVAILABLE_POWER_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_ICP_CURRENT_KEY, + CHARGER_NAME_KEY, + CHARGER_PART_NUMBER_KEY, + CHARGER_PLAN_KEY, + CHARGER_POWER_BOOST_KEY, + CHARGER_SERIAL_NUMBER_KEY, + CHARGER_SOFTWARE_KEY, + CHARGER_STATUS_ID_KEY, +) + JWT = "jwt" USER_ID = "user_id" TTL = "ttl" @@ -7,6 +33,169 @@ REFRESH_TOKEN_TTL = "refresh_token_ttl" ERROR = "error" STATUS = "status" +WALLBOX_STATUS_RESPONSE = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + +WALLBOX_STATUS_RESPONSE_BIDIR = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: False, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + +WALLBOX_STATUS_RESPONSE_ECO_MODE = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 0, + }, + }, +} + + +WALLBOX_STATUS_RESPONSE_FULL_SOLAR = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: [CHARGER_POWER_BOOST_KEY]}, + CHARGER_ECO_SMART_KEY: { + CHARGER_ECO_SMART_STATUS_KEY: True, + CHARGER_ECO_SMART_MODE_KEY: 1, + }, + }, +} + +WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + CHARGER_MAX_ICP_CURRENT_KEY: 20, + CHARGER_PLAN_KEY: {CHARGER_FEATURES_KEY: []}, + }, +} + + +WALLBOX_AUTHORISATION_RESPONSE = { + "data": { + "attributes": { + "token": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + REFRESH_TOKEN_TTL: 145756758, + ERROR: "false", + STATUS: 200, + } + } +} + + +WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED = { + "data": { + "attributes": { + "token": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + REFRESH_TOKEN_TTL: 145756758, + ERROR: "false", + STATUS: 404, + } + } +} + +WALLBOX_INVALID_REAUTH_RESPONSE = { + "jwt": "fakekeyhere", + "refresh_token": "refresh_fakekeyhere", + "user_id": 12345, + "ttl": 145656758, + "refresh_token_ttl": 145756758, + "error": False, + "status": 200, +} + + MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" MOCK_NUMBER_ENTITY_ICP_CURRENT_ID = "number.wallbox_wallboxname_maximum_icp_current" diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index d0c34ae0bce..25265aeda4a 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -3,7 +3,6 @@ from unittest.mock import Mock, patch from homeassistant import config_entries -from homeassistant.components.wallbox import config_flow from homeassistant.components.wallbox.const import ( CHARGER_ADDED_ENERGY_KEY, CHARGER_ADDED_RANGE_KEY, @@ -18,12 +17,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import ( - authorisation_response, - authorisation_response_unauthorised, - http_403_error, - http_404_error, - setup_integration, +from .conftest import http_403_error, http_404_error, setup_integration +from .const import ( + WALLBOX_AUTHORISATION_RESPONSE, + WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, ) from tests.common import MockConfigEntry @@ -40,10 +37,9 @@ test_response = { async def test_show_set_form(hass: HomeAssistant, mock_wallbox) -> None: """Test that the setup form is served.""" - flow = config_flow.WallboxConfigFlow() - flow.hass = hass - result = await flow.async_step_user(user_input=None) - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" @@ -112,7 +108,7 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: with ( patch( "homeassistant.components.wallbox.Wallbox.authenticate", - return_value=authorisation_response, + return_value=WALLBOX_AUTHORISATION_RESPONSE, ), patch( "homeassistant.components.wallbox.Wallbox.pauseChargingSession", @@ -143,7 +139,7 @@ async def test_form_reauth( patch.object( mock_wallbox, "authenticate", - return_value=authorisation_response_unauthorised, + return_value=WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, ), patch.object(mock_wallbox, "getChargerStatus", return_value=test_response), ): @@ -176,7 +172,7 @@ async def test_form_reauth_invalid( patch.object( mock_wallbox, "authenticate", - return_value=authorisation_response_unauthorised, + return_value=WALLBOX_AUTHORISATION_RESPONSE_UNAUTHORISED, ), patch.object(mock_wallbox, "getChargerStatus", return_value=test_response), ): diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index ef73decea8f..4d882da7a6e 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -1,19 +1,23 @@ """Test Wallbox Init Component.""" +from datetime import datetime, timedelta from unittest.mock import patch -from homeassistant.components.wallbox.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +import pytest -from .conftest import ( - http_403_error, - http_429_error, - setup_integration, - test_response_no_power_boost, +from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import http_403_error, http_429_error, setup_integration +from .const import ( + MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_wallbox_setup_unload_entry( @@ -40,24 +44,25 @@ async def test_wallbox_unload_entry_connection_error( assert entry.state is ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_connection_error_auth( +async def test_wallbox_refresh_failed_connection_error_too_many_requests( hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox ) -> None: """Test Wallbox setup with connection error.""" - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED + with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error): + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_RETRY - with patch.object(mock_wallbox, "authenticate", side_effect=http_429_error): - wallbox = hass.data[DOMAIN][entry.entry_id] - await wallbox.async_refresh() + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED -async def test_wallbox_refresh_failed_invalid_auth( - hass: HomeAssistant, entry: MockConfigEntry, mock_wallbox +async def test_wallbox_refresh_failed_error_auth( + hass: HomeAssistant, + entry: MockConfigEntry, + mock_wallbox, ) -> None: """Test Wallbox setup with authentication error.""" @@ -66,11 +71,31 @@ async def test_wallbox_refresh_failed_invalid_auth( with ( patch.object(mock_wallbox, "authenticate", side_effect=http_403_error), - patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error), + pytest.raises(HomeAssistantError), ): - wallbox = hass.data[DOMAIN][entry.entry_id] + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) - await wallbox.async_refresh() + with ( + patch.object(mock_wallbox, "authenticate", side_effect=http_429_error), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED @@ -81,13 +106,10 @@ async def test_wallbox_refresh_failed_http_error( ) -> None: """Test Wallbox setup with authentication error.""" - await setup_integration(hass, entry) - assert entry.state is ConfigEntryState.LOADED - with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_403_error): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() + await setup_integration(hass, entry) + assert entry.state is ConfigEntryState.SETUP_RETRY + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED @@ -102,9 +124,8 @@ async def test_wallbox_refresh_failed_too_many_requests( assert entry.state is ConfigEntryState.LOADED with patch.object(mock_wallbox, "getChargerStatus", side_effect=http_429_error): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() + async_fire_time_changed(hass, datetime.now() + timedelta(seconds=120), True) + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED @@ -119,9 +140,8 @@ async def test_wallbox_refresh_failed_connection_error( assert entry.state is ConfigEntryState.LOADED with patch.object(mock_wallbox, "pauseChargingSession", side_effect=http_403_error): - wallbox = hass.data[DOMAIN][entry.entry_id] - - await wallbox.async_refresh() + async_fire_time_changed(hass, datetime.now() + timedelta(seconds=120), True) + await hass.async_block_till_done() assert await hass.config_entries.async_unload(entry.entry_id) assert entry.state is ConfigEntryState.NOT_LOADED @@ -132,7 +152,9 @@ async def test_wallbox_setup_load_entry_no_eco_mode( ) -> None: """Test Wallbox Unload.""" with patch.object( - mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost + mock_wallbox, + "getChargerStatus", + return_value=WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, ): await setup_integration(hass, entry) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 3aba0792baa..cb332d1cb1e 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -11,17 +11,12 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import ( - http_403_error, - http_404_error, - http_429_error, - setup_integration, - test_response_bidir, -) +from .conftest import http_403_error, http_404_error, http_429_error, setup_integration from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, MOCK_NUMBER_ENTITY_ID, + WALLBOX_STATUS_RESPONSE_BIDIR, ) from tests.common import MockConfigEntry @@ -53,7 +48,7 @@ async def test_wallbox_number_power_class_bidir( ) -> None: """Test wallbox sensor class.""" with patch.object( - mock_wallbox, "getChargerStatus", return_value=test_response_bidir + mock_wallbox, "getChargerStatus", return_value=WALLBOX_STATUS_RESPONSE_BIDIR ): await setup_integration(hass, entry) diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index f194566dbae..c07d0ad5272 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -13,23 +13,21 @@ from homeassistant.components.wallbox.const import EcoSmartMode from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, HomeAssistantError -from .conftest import ( - http_404_error, - http_429_error, - setup_integration, - test_response, - test_response_eco_mode, - test_response_full_solar, - test_response_no_power_boost, +from .conftest import http_404_error, http_429_error, setup_integration +from .const import ( + MOCK_SELECT_ENTITY_ID, + WALLBOX_STATUS_RESPONSE, + WALLBOX_STATUS_RESPONSE_ECO_MODE, + WALLBOX_STATUS_RESPONSE_FULL_SOLAR, + WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, ) -from .const import MOCK_SELECT_ENTITY_ID from tests.common import MockConfigEntry TEST_OPTIONS = [ - (EcoSmartMode.OFF, test_response), - (EcoSmartMode.ECO_MODE, test_response_eco_mode), - (EcoSmartMode.FULL_SOLAR, test_response_full_solar), + (EcoSmartMode.OFF, WALLBOX_STATUS_RESPONSE), + (EcoSmartMode.ECO_MODE, WALLBOX_STATUS_RESPONSE_ECO_MODE), + (EcoSmartMode.FULL_SOLAR, WALLBOX_STATUS_RESPONSE_FULL_SOLAR), ] @@ -61,7 +59,9 @@ async def test_wallbox_select_no_power_boost_class( """Test wallbox select class.""" with patch.object( - mock_wallbox, "getChargerStatus", return_value=test_response_no_power_boost + mock_wallbox, + "getChargerStatus", + return_value=WALLBOX_STATUS_RESPONSE_NO_POWER_BOOST, ): await setup_integration(hass, entry) From 21f6bf39140233e3513f7e7464a5bf0407c09c8e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Jul 2025 10:26:20 +0200 Subject: [PATCH 1184/1664] Improve translation_key of `EnergyEvseSupplyStateSensor` in `matter` (#148280) --- homeassistant/components/matter/binary_sensor.py | 2 +- homeassistant/components/matter/strings.json | 4 ++-- .../matter/snapshots/test_binary_sensor.ambr | 14 +++++++------- tests/components/matter/test_binary_sensor.py | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py index 09321bd33b2..3ce0cc68012 100644 --- a/homeassistant/components/matter/binary_sensor.py +++ b/homeassistant/components/matter/binary_sensor.py @@ -309,7 +309,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.BINARY_SENSOR, entity_description=MatterBinarySensorEntityDescription( key="EnergyEvseSupplyStateSensor", - translation_key="evse_supply_charging_state", + translation_key="evse_supply_state", device_class=BinarySensorDeviceClass.RUNNING, device_to_ha={ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 2534dd6aa6e..f7cec270f54 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -83,8 +83,8 @@ "evse_plug": { "name": "Plug state" }, - "evse_supply_charging_state": { - "name": "Supply charging state" + "evse_supply_state": { + "name": "Charger supply state" }, "boost_state": { "name": "Boost state" diff --git a/tests/components/matter/snapshots/test_binary_sensor.ambr b/tests/components/matter/snapshots/test_binary_sensor.ambr index f6c7780c517..7e2f1e7618e 100644 --- a/tests/components/matter/snapshots/test_binary_sensor.ambr +++ b/tests/components/matter/snapshots/test_binary_sensor.ambr @@ -685,7 +685,7 @@ 'state': 'on', }) # --- -# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-entry] +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charger_supply_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -698,7 +698,7 @@ 'disabled_by': None, 'domain': 'binary_sensor', 'entity_category': None, - 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'entity_id': 'binary_sensor.evse_charger_supply_state', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -710,24 +710,24 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Supply charging state', + 'original_name': 'Charger supply state', 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'evse_supply_charging_state', + 'translation_key': 'evse_supply_state', 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseSupplyStateSensor-153-1', 'unit_of_measurement': None, }) # --- -# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_supply_charging_state-state] +# name: test_binary_sensors[silabs_evse_charging][binary_sensor.evse_charger_supply_state-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'running', - 'friendly_name': 'evse Supply charging state', + 'friendly_name': 'evse Charger supply state', }), 'context': , - 'entity_id': 'binary_sensor.evse_supply_charging_state', + 'entity_id': 'binary_sensor.evse_charger_supply_state', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/matter/test_binary_sensor.py b/tests/components/matter/test_binary_sensor.py index 873d6f17528..fcfd4da84c8 100644 --- a/tests/components/matter/test_binary_sensor.py +++ b/tests/components/matter/test_binary_sensor.py @@ -184,8 +184,8 @@ async def test_evse_sensor( assert state assert state.state == "off" - # Test SupplyStateEnum value with binary_sensor.evse_supply_charging - entity_id = "binary_sensor.evse_supply_charging_state" + # Test SupplyStateEnum value with binary_sensor.evse_charger_supply_state + entity_id = "binary_sensor.evse_charger_supply_state" state = hass.states.get(entity_id) assert state assert state.state == "on" From a5d6bfd1b31a27bbdd9a5a89c63caa2397afe1e6 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Jul 2025 10:30:39 +0200 Subject: [PATCH 1185/1664] Reword option for 'Main' control in `wled` (#148309) --- homeassistant/components/wled/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 50dc0129369..1f15aea979b 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -28,7 +28,7 @@ "step": { "init": { "data": { - "keep_master_light": "Keep main light, even with 1 LED segment." + "keep_master_light": "Add 'Main' control even with single LED segment" } } } From f02c1b0d4ee1e3c29a39fc73da311a7d078812a9 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 7 Jul 2025 12:37:39 +0300 Subject: [PATCH 1186/1664] Bump aiowebostv to 0.7.4 (#148273) --- .../components/webostv/config_flow.py | 5 +++- .../components/webostv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/webostv/test_config_flow.py | 30 ++++++++++++++++++- 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 80c8fb7f8f2..2af38cb3d17 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -98,7 +98,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key} if not self._name: - self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}" + if model_name := client.tv_info.system.get("modelName"): + self._name = f"{DEFAULT_NAME} {model_name}" + else: + self._name = DEFAULT_NAME return self.async_create_entry(title=self._name, data=data) return self.async_show_form(step_id="pairing", errors=errors) diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json index 8ac470ae922..c3c3e9a564f 100644 --- a/homeassistant/components/webostv/manifest.json +++ b/homeassistant/components/webostv/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/webostv", "iot_class": "local_push", "loggers": ["aiowebostv"], - "requirements": ["aiowebostv==0.7.3"], + "requirements": ["aiowebostv==0.7.4"], "ssdp": [ { "st": "urn:lge-com:service:webos-second-screen:1" diff --git a/requirements_all.txt b/requirements_all.txt index 3e3c701508c..9c893b72175 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -435,7 +435,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.3 +aiowebostv==0.7.4 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03f77b69b93..29be48c7f7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -417,7 +417,7 @@ aiowatttime==0.1.1 aiowebdav2==0.4.6 # homeassistant.components.webostv -aiowebostv==0.7.3 +aiowebostv==0.7.4 # homeassistant.components.withings aiowithings==3.1.6 diff --git a/tests/components/webostv/test_config_flow.py b/tests/components/webostv/test_config_flow.py index 564ff9afa9b..2445140aff4 100644 --- a/tests/components/webostv/test_config_flow.py +++ b/tests/components/webostv/test_config_flow.py @@ -4,7 +4,12 @@ from aiowebostv import WebOsTvPairError import pytest from homeassistant import config_entries -from homeassistant.components.webostv.const import CONF_SOURCES, DOMAIN, LIVE_TV_APP_ID +from homeassistant.components.webostv.const import ( + CONF_SOURCES, + DEFAULT_NAME, + DOMAIN, + LIVE_TV_APP_ID, +) from homeassistant.config_entries import SOURCE_SSDP from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -63,6 +68,29 @@ async def test_form(hass: HomeAssistant, client) -> None: assert config_entry.unique_id == FAKE_UUID +async def test_form_no_model_name(hass: HomeAssistant, client) -> None: + """Test successful user flow without model name.""" + client.tv_info.system = {} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: config_entries.SOURCE_USER}, + data=MOCK_USER_CONFIG, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pairing" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + config_entry = result["result"] + assert config_entry.unique_id == FAKE_UUID + + @pytest.mark.parametrize( ("apps", "inputs"), [ From b79e770bcfaf84b6b70e6c81240c5b1df9cc8c70 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Mon, 7 Jul 2025 11:40:48 +0200 Subject: [PATCH 1187/1664] Bump pyenphase to 2.2.1 (#148292) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 8387ecc9c9f..278045001fc 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.0"], + "requirements": ["pyenphase==2.2.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 9c893b72175..dcd43de5872 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.0 +pyenphase==2.2.1 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 29be48c7f7d..8d866c7216e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.0 +pyenphase==2.2.1 # homeassistant.components.everlights pyeverlights==0.1.0 From 991864a8afbf2bfcfcd831cc28fc98b80b0a87e3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 7 Jul 2025 11:43:39 +0200 Subject: [PATCH 1188/1664] Bump `gios` to version 6.1.0 (#148274) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/gios/__init__.py | 33 +++++-- tests/components/gios/fixtures/indexes.json | 63 +++++++----- tests/components/gios/fixtures/sensors.json | 56 +++++------ tests/components/gios/fixtures/station.json | 98 ++++++++----------- .../gios/snapshots/test_diagnostics.ambr | 2 + 8 files changed, 134 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 8deb2eee414..ba87890de03 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.0.0"] + "requirements": ["gios==6.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcd43de5872..95ffd1fcf9e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.0.0 +gios==6.1.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d866c7216e..2298062fb96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.0.0 +gios==6.1.0 # homeassistant.components.glances glances-api==0.8.0 diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py index 49388428805..a4dc0a39be6 100644 --- a/tests/components/gios/__init__.py +++ b/tests/components/gios/__init__.py @@ -1,16 +1,29 @@ """Tests for GIOS.""" -import json from unittest.mock import patch from homeassistant.components.gios.const import DOMAIN from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_load_fixture +from tests.common import ( + MockConfigEntry, + async_load_json_array_fixture, + async_load_json_object_fixture, +) STATIONS = [ - {"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"}, - {"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"}, + { + "Identyfikator stacji": 123, + "Nazwa stacji": "Test Name 1", + "WGS84 φ N": "99.99", + "WGS84 λ E": "88.88", + }, + { + "Identyfikator stacji": 321, + "Nazwa stacji": "Test Name 2", + "WGS84 φ N": "77.77", + "WGS84 λ E": "66.66", + }, ] @@ -26,13 +39,13 @@ async def init_integration( entry_id="86129426118ae32020417a53712d6eef", ) - indexes = json.loads(await async_load_fixture(hass, "indexes.json", DOMAIN)) - station = json.loads(await async_load_fixture(hass, "station.json", DOMAIN)) - sensors = json.loads(await async_load_fixture(hass, "sensors.json", DOMAIN)) + indexes = await async_load_json_object_fixture(hass, "indexes.json", DOMAIN) + station = await async_load_json_array_fixture(hass, "station.json", DOMAIN) + sensors = await async_load_json_object_fixture(hass, "sensors.json", DOMAIN) if incomplete_data: - indexes["stIndexLevel"]["indexLevelName"] = "foo" - sensors["pm10"]["values"][0]["value"] = None - sensors["pm10"]["values"][1]["value"] = None + indexes["AqIndex"] = "foo" + sensors["pm10"]["Lista danych pomiarowych"][0]["Wartość"] = None + sensors["pm10"]["Lista danych pomiarowych"][1]["Wartość"] = None if invalid_indexes: indexes = {} diff --git a/tests/components/gios/fixtures/indexes.json b/tests/components/gios/fixtures/indexes.json index c53d1c78f6e..1fb46e9a4d8 100644 --- a/tests/components/gios/fixtures/indexes.json +++ b/tests/components/gios/fixtures/indexes.json @@ -1,29 +1,38 @@ { - "id": 123, - "stCalcDate": "2020-07-31 15:10:17", - "stIndexLevel": { "id": 1, "indexLevelName": "Dobry" }, - "stSourceDataDate": "2020-07-31 14:00:00", - "so2CalcDate": "2020-07-31 15:10:17", - "so2IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, - "so2SourceDataDate": "2020-07-31 14:00:00", - "no2CalcDate": 1596201017000, - "no2IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "no2SourceDataDate": "2020-07-31 14:00:00", - "coCalcDate": "2020-07-31 15:10:17", - "coIndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "coSourceDataDate": "2020-07-31 14:00:00", - "pm10CalcDate": "2020-07-31 15:10:17", - "pm10IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "pm10SourceDataDate": "2020-07-31 14:00:00", - "pm25CalcDate": "2020-07-31 15:10:17", - "pm25IndexLevel": { "id": 0, "indexLevelName": "Dobry" }, - "pm25SourceDataDate": "2020-07-31 14:00:00", - "o3CalcDate": "2020-07-31 15:10:17", - "o3IndexLevel": { "id": 1, "indexLevelName": "Dobry" }, - "o3SourceDataDate": "2020-07-31 14:00:00", - "c6h6CalcDate": "2020-07-31 15:10:17", - "c6h6IndexLevel": { "id": 0, "indexLevelName": "Bardzo dobry" }, - "c6h6SourceDataDate": "2020-07-31 14:00:00", - "stIndexStatus": true, - "stIndexCrParam": "OZON" + "AqIndex": { + "Identyfikator stacji pomiarowej": 123, + "Data wykonania obliczeń indeksu": "2020-07-31 15:10:17", + "Nazwa kategorii indeksu": "Dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika st": "2020-07-31 14:00:00", + "Data wykonania obliczeń indeksu dla wskaźnika SO2": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika SO2": 0, + "Nazwa kategorii indeksu dla wskażnika SO2": "Bardzo dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika SO2": "2020-07-31 14:00:00", + "Data wykonania obliczeń indeksu dla wskaźnika NO2": "2020-07-31 14:00:00", + "Wartość indeksu dla wskaźnika NO2": 0, + "Nazwa kategorii indeksu dla wskażnika NO2": "Dobry", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika NO2": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika CO": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika CO": 0, + "Nazwa kategorii indeksu dla wskażnika CO": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika CO": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM10": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika PM10": 0, + "Nazwa kategorii indeksu dla wskażnika PM10": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika PM10": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika PM2.5": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika PM2.5": 0, + "Nazwa kategorii indeksu dla wskażnika PM2.5": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika PM2.5": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika O3": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika O3": 1, + "Nazwa kategorii indeksu dla wskażnika O3": "Dobry", + "Data wykonania obliczeń indeksu dla wskaźnika O3": "2020-07-31 14:00:00", + "Data danych źródłowych, z których policzono wartość indeksu dla wskaźnika C6H6": "2020-07-31 15:10:17", + "Wartość indeksu dla wskaźnika C6H6": 0, + "Nazwa kategorii indeksu dla wskażnika C6H6": "Bardzo dobry", + "Data wykonania obliczeń indeksu dla wskaźnika C6H6": "2020-07-31 14:00:00", + "Status indeksu ogólnego dla stacji pomiarowej": true, + "Kod zanieczyszczenia krytycznego": "OZON" + } } diff --git a/tests/components/gios/fixtures/sensors.json b/tests/components/gios/fixtures/sensors.json index db0cf2ff849..0fe387d3126 100644 --- a/tests/components/gios/fixtures/sensors.json +++ b/tests/components/gios/fixtures/sensors.json @@ -1,51 +1,51 @@ { "so2": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 4.35478 }, - { "date": "2020-07-31 14:00:00", "value": 4.25478 }, - { "date": "2020-07-31 13:00:00", "value": 4.34309 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 4.35478 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4.25478 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 4.34309 } ] }, "c6h6": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 0.23789 }, - { "date": "2020-07-31 14:00:00", "value": 0.22789 }, - { "date": "2020-07-31 13:00:00", "value": 0.21315 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 0.23789 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 0.22789 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 0.21315 } ] }, "co": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 251.874 }, - { "date": "2020-07-31 14:00:00", "value": 250.874 }, - { "date": "2020-07-31 13:00:00", "value": 251.097 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 251.874 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 250.874 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 251.097 } ] }, "no2": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 7.13411 }, - { "date": "2020-07-31 14:00:00", "value": 7.33411 }, - { "date": "2020-07-31 13:00:00", "value": 9.32578 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 7.33411 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 } ] }, "o3": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 95.7768 }, - { "date": "2020-07-31 14:00:00", "value": 93.7768 }, - { "date": "2020-07-31 13:00:00", "value": 89.4232 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 93.7768 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 89.4232 } ] }, "pm2.5": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 4 }, - { "date": "2020-07-31 14:00:00", "value": 4 }, - { "date": "2020-07-31 13:00:00", "value": 5 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 4 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 5 } ] }, "pm10": { - "values": [ - { "date": "2020-07-31 15:00:00", "value": 16.8344 }, - { "date": "2020-07-31 14:00:00", "value": 17.8344 }, - { "date": "2020-07-31 13:00:00", "value": 20.8094 } + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 16.8344 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 17.8344 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 20.8094 } ] } } diff --git a/tests/components/gios/fixtures/station.json b/tests/components/gios/fixtures/station.json index 16cd824a489..167e4db3aee 100644 --- a/tests/components/gios/fixtures/station.json +++ b/tests/components/gios/fixtures/station.json @@ -1,72 +1,58 @@ [ { - "id": 672, - "stationId": 117, - "param": { - "paramName": "dwutlenek siarki", - "paramFormula": "SO2", - "paramCode": "SO2", - "idParam": 1 - } + "Identyfikator stanowiska": 672, + "Identyfikator stacji": 117, + "Wskaźnik": "dwutlenek siarki", + "Wskaźnik - wzór": "SO2", + "Wskaźnik - kod": "SO2", + "Id wskaźnika": 1 }, { - "id": 658, - "stationId": 117, - "param": { - "paramName": "benzen", - "paramFormula": "C6H6", - "paramCode": "C6H6", - "idParam": 10 - } + "Identyfikator stanowiska": 658, + "Identyfikator stacji": 117, + "Wskaźnik": "benzen", + "Wskaźnik - wzór": "C6H6", + "Wskaźnik - kod": "C6H6", + "Id wskaźnika": 10 }, { - "id": 660, - "stationId": 117, - "param": { - "paramName": "tlenek węgla", - "paramFormula": "CO", - "paramCode": "CO", - "idParam": 8 - } + "Identyfikator stanowiska": 660, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenek węgla", + "Wskaźnik - wzór": "CO", + "Wskaźnik - kod": "CO", + "Id wskaźnika": 8 }, { - "id": 665, - "stationId": 117, - "param": { - "paramName": "dwutlenek azotu", - "paramFormula": "NO2", - "paramCode": "NO2", - "idParam": 6 - } + "Identyfikator stanowiska": 665, + "Identyfikator stacji": 117, + "Wskaźnik": "dwutlenek azotu", + "Wskaźnik - wzór": "NO2", + "Wskaźnik - kod": "NO2", + "Id wskaźnika": 6 }, { - "id": 667, - "stationId": 117, - "param": { - "paramName": "ozon", - "paramFormula": "O3", - "paramCode": "O3", - "idParam": 5 - } + "Identyfikator stanowiska": 667, + "Identyfikator stacji": 117, + "Wskaźnik": "ozon", + "Wskaźnik - wzór": "O3", + "Wskaźnik - kod": "O3", + "Id wskaźnika": 5 }, { - "id": 670, - "stationId": 117, - "param": { - "paramName": "pył zawieszony PM2.5", - "paramFormula": "PM2.5", - "paramCode": "PM2.5", - "idParam": 69 - } + "Identyfikator stanowiska": 670, + "Identyfikator stacji": 117, + "Wskaźnik": "pył zawieszony PM2.5", + "Wskaźnik - wzór": "PM2.5", + "Wskaźnik - kod": "PM2.5", + "Id wskaźnika": 69 }, { - "id": 14395, - "stationId": 117, - "param": { - "paramName": "pył zawieszony PM10", - "paramFormula": "PM10", - "paramCode": "PM10", - "idParam": 3 - } + "Identyfikator stanowiska": 14395, + "Identyfikator stacji": 117, + "Wskaźnik": "pył zawieszony PM10", + "Wskaźnik - wzór": "PM10", + "Wskaźnik - kod": "PM10", + "Id wskaźnika": 3 } ] diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 890edc00482..4095bf8bf53 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -42,12 +42,14 @@ 'name': 'carbon monoxide', 'value': 251.874, }), + 'no': None, 'no2': dict({ 'id': 665, 'index': 'good', 'name': 'nitrogen dioxide', 'value': 7.13411, }), + 'nox': None, 'o3': dict({ 'id': 667, 'index': 'good', From 42b50c71ec064297fb6623ae284305183be35f4e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 7 Jul 2025 11:54:36 +0200 Subject: [PATCH 1189/1664] Revert "Add tests for Sonos Alarms" (#148319) --- tests/components/sonos/conftest.py | 28 +------------- tests/components/sonos/test_init.py | 5 --- tests/components/sonos/test_switch.py | 54 +-------------------------- 3 files changed, 3 insertions(+), 84 deletions(-) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index a2a4e53cae4..d3de2a889d5 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -214,25 +214,12 @@ class MockSoCo(MagicMock): surround_level = 3 music_surround_level = 4 soundbar_audio_input_format = "Dolby 5.1" - factory: SoCoMockFactory | None = None @property def visible_zones(self): """Return visible zones and allow property to be overridden by device classes.""" return {self} - @property - def all_zones(self) -> set[MockSoCo]: - """Return a set of all mock zones, or just self if no factory or zones.""" - if self.factory is not None: - if zones := self.factory.mock_all_zones: - return zones - return {self} - - def set_factory(self, factory: SoCoMockFactory) -> None: - """Set the factory for this mock.""" - self.factory = factory - class SoCoMockFactory: """Factory for creating SoCo Mocks.""" @@ -257,19 +244,11 @@ class SoCoMockFactory: self.sonos_playlists = sonos_playlists self.sonos_queue = sonos_queue - @property - def mock_all_zones(self) -> set[MockSoCo]: - """Return a set of all mock zones.""" - return { - mock for mock in self.mock_list.values() if mock.mock_include_in_all_zones - } - def cache_mock( self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A" ) -> MockSoCo: """Put a user created mock into the cache.""" mock_soco.mock_add_spec(SoCo) - mock_soco.set_factory(self) mock_soco.ip_address = ip_address if ip_address != "192.168.42.2": mock_soco.uid += f"_{ip_address}" @@ -281,11 +260,6 @@ class SoCoMockFactory: my_speaker_info = self.speaker_info.copy() my_speaker_info["zone_name"] = name my_speaker_info["uid"] = mock_soco.uid - # Generate a different MAC for the non-default speakers. - # otherwise new devices will not be created. - if ip_address != "192.168.42.2": - last_octet = ip_address.split(".")[-1] - my_speaker_info["mac_address"] = f"00-00-00-00-00-{last_octet.zfill(2)}" mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) mock_soco.add_to_queue = Mock(return_value=10) mock_soco.add_uri_to_queue = Mock(return_value=10) @@ -304,7 +278,7 @@ class SoCoMockFactory: mock_soco.alarmClock = self.alarm_clock mock_soco.get_battery_info.return_value = self.battery_info - mock_soco.mock_include_in_all_zones = True + mock_soco.all_zones = {mock_soco} mock_soco.group.coordinator = mock_soco mock_soco.household_id = "test_household_id" self.mock_list[ip_address] = mock_soco diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 901ae359917..c1b98b2ec60 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -324,15 +324,10 @@ async def test_async_poll_manual_hosts_5( soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") soco_1.renderingControl = Mock() soco_1.renderingControl.GetVolume = Mock() - # Unavailable speakers should not be included in all zones - soco_1.mock_include_in_all_zones = False - speaker_1_activity = SpeakerActivity(hass, soco_1) soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") soco_2.renderingControl = Mock() soco_2.renderingControl.GetVolume = Mock() - soco_2.mock_include_in_all_zones = False - speaker_2_activity = SpeakerActivity(hass, soco_2) with caplog.at_level(logging.DEBUG): diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 56dd96b0caf..04457ee95c7 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -26,10 +26,10 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.util import dt as dt_util -from .conftest import MockSoCo, SonosMockEvent, SonosMockService +from .conftest import MockSoCo, SonosMockEvent from tests.common import async_fire_time_changed @@ -211,53 +211,3 @@ async def test_alarm_create_delete( assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities - - -async def test_alarm_change_device( - hass: HomeAssistant, - async_setup_sonos, - soco: MockSoCo, - alarm_clock: SonosMockService, - alarm_clock_extended: SonosMockService, - alarm_event: SonosMockEvent, - entity_registry: er.EntityRegistry, - device_registry: dr.DeviceRegistry, - sonos_setup_two_speakers: list[MockSoCo], -) -> None: - """Test Sonos Alarm being moved to a different speaker. - - This test simulates a scenario where an alarm is created on one speaker - and then moved to another speaker. It checks that the entity is correctly - created on the new speaker and removed from the old one. - """ - entity_id = "switch.sonos_alarm_14" - soco_lr = sonos_setup_two_speakers[0] - - await async_setup_sonos() - - # Initially, the alarm is created on the soco mock - assert entity_id in entity_registry.entities - entity = entity_registry.async_get(entity_id) - device = device_registry.async_get(entity.device_id) - assert device.name == soco.get_speaker_info()["zone_name"] - - # Simulate the alarm being moved to the soco_lr speaker - alarm_update = copy(alarm_clock_extended.ListAlarms.return_value) - alarm_update["CurrentAlarmList"] = alarm_update["CurrentAlarmList"].replace( - "RINCON_test", f"{soco_lr.uid}" - ) - alarm_clock.ListAlarms.return_value = alarm_update - - # Update the alarm_list_version so it gets processed. - alarm_event.variables["alarm_list_version"] = f"{soco_lr.uid}:1000" - alarm_update["CurrentAlarmListVersion"] = alarm_event.increment_variable( - "alarm_list_version" - ) - - alarm_clock.subscribe.return_value.callback(event=alarm_event) - await hass.async_block_till_done(wait_background_tasks=True) - - assert entity_id in entity_registry.entities - alarm_14 = entity_registry.async_get(entity_id) - device = device_registry.async_get(alarm_14.device_id) - assert device.name == soco_lr.get_speaker_info()["zone_name"] From 0c783e87d1f5a4d5e669d6cfe888d45fb98138b4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Jul 2025 11:59:35 +0200 Subject: [PATCH 1190/1664] Fix homee test (#148322) --- tests/components/homee/test_init.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/tests/components/homee/test_init.py b/tests/components/homee/test_init.py index 0b2ae21a8d0..c24cb39295d 100644 --- a/tests/components/homee/test_init.py +++ b/tests/components/homee/test_init.py @@ -18,10 +18,18 @@ from tests.common import MockConfigEntry @pytest.mark.parametrize( - "side_eff", + ("side_eff", "config_entry_state", "active_flows"), [ - HomeeConnectionFailedException("connection timed out"), - HomeeAuthFailedException("wrong username or password"), + ( + HomeeConnectionFailedException("connection timed out"), + ConfigEntryState.SETUP_RETRY, + [], + ), + ( + HomeeAuthFailedException("wrong username or password"), + ConfigEntryState.SETUP_ERROR, + ["reauth"], + ), ], ) async def test_connection_errors( @@ -29,6 +37,8 @@ async def test_connection_errors( mock_homee: MagicMock, mock_config_entry: MockConfigEntry, side_eff: Exception, + config_entry_state: ConfigEntryState, + active_flows: list[str], ) -> None: """Test if connection errors on startup are handled correctly.""" mock_homee.get_access_token.side_effect = side_eff @@ -36,7 +46,11 @@ async def test_connection_errors( await hass.config_entries.async_setup(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + assert mock_config_entry.state is config_entry_state + + assert [ + flow["context"]["source"] for flow in hass.config_entries.flow.async_progress() + ] == active_flows async def test_connection_listener( From 15c9ddea78365cb9c058b40bc523221db73ae1a6 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 7 Jul 2025 04:10:50 -0700 Subject: [PATCH 1191/1664] Bump gassist-text to 0.0.14 (#148312) --- homeassistant/components/google_assistant_sdk/helpers.py | 4 ++-- homeassistant/components/google_assistant_sdk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_assistant_sdk/helpers.py b/homeassistant/components/google_assistant_sdk/helpers.py index b319e1e432c..c40c848ff3f 100644 --- a/homeassistant/components/google_assistant_sdk/helpers.py +++ b/homeassistant/components/google_assistant_sdk/helpers.py @@ -80,10 +80,10 @@ async def async_send_text_commands( credentials = Credentials(session.token[CONF_ACCESS_TOKEN]) # type: ignore[no-untyped-call] language_code = entry.options.get(CONF_LANGUAGE_CODE, default_language_code(hass)) + command_response_list = [] with TextAssistant( credentials, language_code, audio_out=bool(media_players) ) as assistant: - command_response_list = [] for command in commands: try: resp = await hass.async_add_executor_job(assistant.assist, command) @@ -117,7 +117,7 @@ async def async_send_text_commands( blocking=True, ) command_response_list.append(CommandResponse(text_response)) - return command_response_list + return command_response_list def default_language_code(hass: HomeAssistant) -> str: diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json index 70e93f39f42..5a6a42c394c 100644 --- a/homeassistant/components/google_assistant_sdk/manifest.json +++ b/homeassistant/components/google_assistant_sdk/manifest.json @@ -7,6 +7,6 @@ "documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["gassist-text==0.0.12"], + "requirements": ["gassist-text==0.0.14"], "single_config_entry": true } diff --git a/requirements_all.txt b/requirements_all.txt index 95ffd1fcf9e..73f7819e6f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -989,7 +989,7 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.12 +gassist-text==0.0.14 # homeassistant.components.google gcal-sync==7.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2298062fb96..a17bf245623 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -859,7 +859,7 @@ gTTS==2.5.3 gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk -gassist-text==0.0.12 +gassist-text==0.0.14 # homeassistant.components.google gcal-sync==7.1.0 From 448d6041e5e85221701456d1181b156d4f158954 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Jul 2025 14:06:13 +0200 Subject: [PATCH 1192/1664] Fix missing sentence-casing in `wallbox` (#148332) --- homeassistant/components/wallbox/strings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index a69251eb832..13f038d14b6 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -3,14 +3,14 @@ "step": { "user": { "data": { - "station": "Station Serial Number", + "station": "Station serial number", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "station": "Serial number of the charger, this value can be found in the Wallbox App or in the Wallbox Portal.", - "username": "Username for your Wallbox Account.", - "password": "Password for your Wallbox Account." + "station": "Serial number of the charger. Can be found in the Wallbox app or in the Wallbox portal.", + "username": "Username for your Wallbox account.", + "password": "Password for your Wallbox account." } }, "reauth_confirm": { @@ -24,7 +24,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "reauth_invalid": "Re-authentication failed; Serial Number does not match original" + "reauth_invalid": "Re-authentication failed; serial number does not match original" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", From c60e06d32f30d59abe06b90cfdad925e3a8d7364 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 7 Jul 2025 14:06:27 +0200 Subject: [PATCH 1193/1664] Fix missing sentence-casing and spelling of "REST" in `iskra` (#148330) --- homeassistant/components/iskra/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iskra/strings.json b/homeassistant/components/iskra/strings.json index 5818cdfa1db..da7817cc78b 100644 --- a/homeassistant/components/iskra/strings.json +++ b/homeassistant/components/iskra/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Configure Iskra Device", - "description": "Enter the IP address of your Iskra Device and select protocol.", + "title": "Configure Iskra device", + "description": "Enter the IP address of your Iskra device and select protocol.", "data": { "host": "[%key:common::config_flow::data::host%]" }, @@ -12,7 +12,7 @@ } }, "authentication": { - "title": "Configure Rest API Credentials", + "title": "Configure REST API credentials", "description": "Enter username and password", "data": { "username": "[%key:common::config_flow::data::username%]", @@ -44,7 +44,7 @@ "selector": { "protocol": { "options": { - "rest_api": "Rest API", + "rest_api": "REST API", "modbus_tcp": "Modbus TCP" } } From b71bcb002b75f211c0fbfd1d03da2fd5f06011ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 7 Jul 2025 13:48:48 +0100 Subject: [PATCH 1194/1664] Move target selector extractor method to common module (#148087) --- .../components/homeassistant/__init__.py | 9 +- homeassistant/components/homekit/__init__.py | 13 +- homeassistant/components/lifx/manager.py | 10 +- .../components/unifiprotect/services.py | 15 +- homeassistant/helpers/service.py | 259 ++-------- homeassistant/helpers/target.py | 240 +++++++++ tests/helpers/test_service.py | 78 +++ tests/helpers/test_target.py | 459 ++++++++++++++++++ 8 files changed, 855 insertions(+), 228 deletions(-) create mode 100644 homeassistant/helpers/target.py create mode 100644 tests/helpers/test_target.py diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index d5dabfa2e08..32fe690f0f1 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -44,11 +44,14 @@ from homeassistant.helpers.entity_component import async_update_entity from homeassistant.helpers.issue_registry import IssueSeverity from homeassistant.helpers.service import ( async_extract_config_entry_ids, - async_extract_referenced_entity_ids, async_register_admin_service, ) from homeassistant.helpers.signal import KEY_HA_STOP from homeassistant.helpers.system_info import async_get_system_info +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType @@ -111,7 +114,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async def async_handle_turn_service(service: ServiceCall) -> None: """Handle calls to homeassistant.turn_on/off.""" - referenced = async_extract_referenced_entity_ids(hass, service) + referenced = async_extract_referenced_entity_ids( + hass, TargetSelectorData(service.data) + ) all_referenced = referenced.referenced | referenced.indirectly_referenced # Generic turn on/off method requires entity id diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 8b526b62302..50b11265cf4 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -75,11 +75,12 @@ from homeassistant.helpers.entityfilter import ( EntityFilter, ) from homeassistant.helpers.reload import async_integration_yaml_config -from homeassistant.helpers.service import ( - async_extract_referenced_entity_ids, - async_register_admin_service, -) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.start import async_at_started +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.util.async_ import create_eager_task @@ -482,7 +483,9 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None: async def async_handle_homekit_unpair(service: ServiceCall) -> None: """Handle unpair HomeKit service call.""" - referenced = async_extract_referenced_entity_ids(hass, service) + referenced = async_extract_referenced_entity_ids( + hass, TargetSelectorData(service.data) + ) dev_reg = dr.async_get(hass) for device_id in referenced.referenced_devices: if not (dev_reg_ent := dev_reg.async_get(device_id)): diff --git a/homeassistant/components/lifx/manager.py b/homeassistant/components/lifx/manager.py index 33712441157..f2e37426736 100644 --- a/homeassistant/components/lifx/manager.py +++ b/homeassistant/components/lifx/manager.py @@ -28,7 +28,10 @@ from homeassistant.components.light import ( from homeassistant.const import ATTR_MODE from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from .const import _ATTR_COLOR_TEMP, ATTR_THEME, DOMAIN from .coordinator import LIFXConfigEntry, LIFXUpdateCoordinator @@ -268,7 +271,9 @@ class LIFXManager: async def service_handler(service: ServiceCall) -> None: """Apply a service, i.e. start an effect.""" - referenced = async_extract_referenced_entity_ids(self.hass, service) + referenced = async_extract_referenced_entity_ids( + self.hass, TargetSelectorData(service.data) + ) all_referenced = referenced.referenced | referenced.indirectly_referenced if all_referenced: await self.start_effect(all_referenced, service.service, **service.data) @@ -499,6 +504,5 @@ class LIFXManager: if self.entry_id_to_entity_id[entry.entry_id] in entity_ids: coordinators.append(entry.runtime_data) bulbs.append(entry.runtime_data.device) - if start_effect_func := self._effect_dispatch.get(service): await start_effect_func(self, bulbs, coordinators, **kwargs) diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 40fe0a991f2..708a4883ddd 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -26,7 +26,10 @@ from homeassistant.helpers import ( device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.service import async_extract_referenced_entity_ids +from homeassistant.helpers.target import ( + TargetSelectorData, + async_extract_referenced_entity_ids, +) from homeassistant.util.json import JsonValueType from homeassistant.util.read_only_dict import ReadOnlyDict @@ -115,7 +118,7 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl @callback def _async_get_ufp_camera(call: ServiceCall) -> Camera: - ref = async_extract_referenced_entity_ids(call.hass, call) + ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data)) entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() @@ -133,7 +136,7 @@ def _async_get_protect_from_call(call: ServiceCall) -> set[ProtectApiClient]: return { _async_get_ufp_instance(call.hass, device_id) for device_id in async_extract_referenced_entity_ids( - call.hass, call + call.hass, TargetSelectorData(call.data) ).referenced_devices } @@ -196,7 +199,7 @@ def _async_unique_id_to_mac(unique_id: str) -> str: async def set_chime_paired_doorbells(call: ServiceCall) -> None: """Set paired doorbells on chime.""" - ref = async_extract_referenced_entity_ids(call.hass, call) + ref = async_extract_referenced_entity_ids(call.hass, TargetSelectorData(call.data)) entity_registry = er.async_get(call.hass) entity_id = ref.indirectly_referenced.pop() @@ -211,7 +214,9 @@ async def set_chime_paired_doorbells(call: ServiceCall) -> None: assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) - doorbell_refs = async_extract_referenced_entity_ids(call.hass, call) + doorbell_refs = async_extract_referenced_entity_ids( + call.hass, TargetSelectorData(call.data) + ) doorbell_ids: set[str] = set() for camera_id in doorbell_refs.referenced | doorbell_refs.indirectly_referenced: doorbell_sensor = entity_registry.async_get(camera_id) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index c7d4a26c86e..1d4dac10c27 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -9,17 +9,13 @@ from enum import Enum from functools import cache, partial import logging from types import ModuleType -from typing import TYPE_CHECKING, Any, TypedDict, TypeGuard, cast +from typing import TYPE_CHECKING, Any, TypedDict, cast, override import voluptuous as vol from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.const import ( - ATTR_AREA_ID, - ATTR_DEVICE_ID, ATTR_ENTITY_ID, - ATTR_FLOOR_ID, - ATTR_LABEL_ID, CONF_ACTION, CONF_ENTITY_ID, CONF_SERVICE_DATA, @@ -54,16 +50,14 @@ from homeassistant.util.yaml import load_yaml_dict from homeassistant.util.yaml.loader import JSON_TYPE from . import ( - area_registry, config_validation as cv, device_registry, entity_registry, - floor_registry, - label_registry, + target as target_helpers, template, translation, ) -from .group import expand_entity_ids +from .deprecation import deprecated_class, deprecated_function from .selector import TargetSelector from .typing import ConfigType, TemplateVarsType, VolDictType, VolSchemaType @@ -225,87 +219,31 @@ class ServiceParams(TypedDict): target: dict | None -class ServiceTargetSelector: +@deprecated_class( + "homeassistant.helpers.target.TargetSelectorData", + breaks_in_ha_version="2026.8", +) +class ServiceTargetSelector(target_helpers.TargetSelectorData): """Class to hold a target selector for a service.""" - __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") - def __init__(self, service_call: ServiceCall) -> None: """Extract ids from service call data.""" - service_call_data = service_call.data - entity_ids: str | list | None = service_call_data.get(ATTR_ENTITY_ID) - device_ids: str | list | None = service_call_data.get(ATTR_DEVICE_ID) - area_ids: str | list | None = service_call_data.get(ATTR_AREA_ID) - floor_ids: str | list | None = service_call_data.get(ATTR_FLOOR_ID) - label_ids: str | list | None = service_call_data.get(ATTR_LABEL_ID) - - self.entity_ids = ( - set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set() - ) - self.device_ids = ( - set(cv.ensure_list(device_ids)) if _has_match(device_ids) else set() - ) - self.area_ids = set(cv.ensure_list(area_ids)) if _has_match(area_ids) else set() - self.floor_ids = ( - set(cv.ensure_list(floor_ids)) if _has_match(floor_ids) else set() - ) - self.label_ids = ( - set(cv.ensure_list(label_ids)) if _has_match(label_ids) else set() - ) - - @property - def has_any_selector(self) -> bool: - """Determine if any selectors are present.""" - return bool( - self.entity_ids - or self.device_ids - or self.area_ids - or self.floor_ids - or self.label_ids - ) + super().__init__(service_call.data) -@dataclasses.dataclass(slots=True) -class SelectedEntities: +@deprecated_class( + "homeassistant.helpers.target.SelectedEntities", + breaks_in_ha_version="2026.8", +) +class SelectedEntities(target_helpers.SelectedEntities): """Class to hold the selected entities.""" - # Entities that were explicitly mentioned. - referenced: set[str] = dataclasses.field(default_factory=set) - - # Entities that were referenced via device/area/floor/label ID. - # Should not trigger a warning when they don't exist. - indirectly_referenced: set[str] = dataclasses.field(default_factory=set) - - # Referenced items that could not be found. - missing_devices: set[str] = dataclasses.field(default_factory=set) - missing_areas: set[str] = dataclasses.field(default_factory=set) - missing_floors: set[str] = dataclasses.field(default_factory=set) - missing_labels: set[str] = dataclasses.field(default_factory=set) - - # Referenced devices - referenced_devices: set[str] = dataclasses.field(default_factory=set) - referenced_areas: set[str] = dataclasses.field(default_factory=set) - - def log_missing(self, missing_entities: set[str]) -> None: + @override + def log_missing( + self, missing_entities: set[str], logger: logging.Logger | None = None + ) -> None: """Log about missing items.""" - parts = [] - for label, items in ( - ("floors", self.missing_floors), - ("areas", self.missing_areas), - ("devices", self.missing_devices), - ("entities", missing_entities), - ("labels", self.missing_labels), - ): - if items: - parts.append(f"{label} {', '.join(sorted(items))}") - - if not parts: - return - - _LOGGER.warning( - "Referenced %s are missing or not currently available", - ", ".join(parts), - ) + super().log_missing(missing_entities, logger or _LOGGER) @bind_hass @@ -466,7 +404,10 @@ async def async_extract_entities[_EntityT: Entity]( if data_ent_id == ENTITY_MATCH_ALL: return [entity for entity in entities if entity.available] - referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) + selector_data = target_helpers.TargetSelectorData(service_call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group + ) combined = referenced.referenced | referenced.indirectly_referenced found = [] @@ -482,7 +423,7 @@ async def async_extract_entities[_EntityT: Entity]( found.append(entity) - referenced.log_missing(referenced.referenced & combined) + referenced.log_missing(referenced.referenced & combined, _LOGGER) return found @@ -495,141 +436,27 @@ async def async_extract_entity_ids( Will convert group entity ids to the entity ids it represents. """ - referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) + selector_data = target_helpers.TargetSelectorData(service_call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group + ) return referenced.referenced | referenced.indirectly_referenced -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) - - +@deprecated_function( + "homeassistant.helpers.target.async_extract_referenced_entity_ids", + breaks_in_ha_version="2026.8", +) @bind_hass def async_extract_referenced_entity_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> SelectedEntities: """Extract referenced entity IDs from a service call.""" - selector = ServiceTargetSelector(service_call) - selected = SelectedEntities() - - if not selector.has_any_selector: - return selected - - entity_ids: set[str] | list[str] = selector.entity_ids - if expand_group: - entity_ids = expand_entity_ids(hass, entity_ids) - - selected.referenced.update(entity_ids) - - if ( - not selector.device_ids - and not selector.area_ids - and not selector.floor_ids - and not selector.label_ids - ): - return selected - - entities = entity_registry.async_get(hass).entities - dev_reg = device_registry.async_get(hass) - area_reg = area_registry.async_get(hass) - - if selector.floor_ids: - floor_reg = floor_registry.async_get(hass) - for floor_id in selector.floor_ids: - if floor_id not in floor_reg.floors: - selected.missing_floors.add(floor_id) - - for area_id in selector.area_ids: - if area_id not in area_reg.areas: - selected.missing_areas.add(area_id) - - for device_id in selector.device_ids: - if device_id not in dev_reg.devices: - selected.missing_devices.add(device_id) - - if selector.label_ids: - label_reg = label_registry.async_get(hass) - for label_id in selector.label_ids: - if label_id not in label_reg.labels: - selected.missing_labels.add(label_id) - - for entity_entry in entities.get_entries_for_label(label_id): - if ( - entity_entry.entity_category is None - and entity_entry.hidden_by is None - ): - selected.indirectly_referenced.add(entity_entry.entity_id) - - for device_entry in dev_reg.devices.get_devices_for_label(label_id): - selected.referenced_devices.add(device_entry.id) - - for area_entry in area_reg.areas.get_areas_for_label(label_id): - selected.referenced_areas.add(area_entry.id) - - # Find areas for targeted floors - if selector.floor_ids: - selected.referenced_areas.update( - area_entry.id - for floor_id in selector.floor_ids - for area_entry in area_reg.areas.get_areas_for_floor(floor_id) - ) - - selected.referenced_areas.update(selector.area_ids) - selected.referenced_devices.update(selector.device_ids) - - if not selected.referenced_areas and not selected.referenced_devices: - return selected - - # Add indirectly referenced by device - selected.indirectly_referenced.update( - entry.entity_id - for device_id in selected.referenced_devices - for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if (entry.entity_category is None and entry.hidden_by is None) + selector_data = target_helpers.TargetSelectorData(service_call.data) + selected = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group ) - - # Find devices for targeted areas - referenced_devices_by_area: set[str] = set() - if selected.referenced_areas: - for area_id in selected.referenced_areas: - referenced_devices_by_area.update( - device_entry.id - for device_entry in dev_reg.devices.get_devices_for_area_id(area_id) - ) - selected.referenced_devices.update(referenced_devices_by_area) - - # Add indirectly referenced by area - selected.indirectly_referenced.update( - entry.entity_id - for area_id in selected.referenced_areas - # The entity's area matches a targeted area - for entry in entities.get_entries_for_area_id(area_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if entry.entity_category is None and entry.hidden_by is None - ) - # Add indirectly referenced by area through device - selected.indirectly_referenced.update( - entry.entity_id - for device_id in referenced_devices_by_area - for entry in entities.get_entries_for_device_id(device_id) - # Do not add entities which are hidden or which are config - # or diagnostic entities. - if ( - entry.entity_category is None - and entry.hidden_by is None - and ( - # The entity's device matches a device referenced - # by an area and the entity - # has no explicitly set area - not entry.area_id - ) - ) - ) - - return selected + return SelectedEntities(**dataclasses.asdict(selected)) @bind_hass @@ -637,7 +464,10 @@ async def async_extract_config_entry_ids( hass: HomeAssistant, service_call: ServiceCall, expand_group: bool = True ) -> set[str]: """Extract referenced config entry ids from a service call.""" - referenced = async_extract_referenced_entity_ids(hass, service_call, expand_group) + selector_data = target_helpers.TargetSelectorData(service_call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, expand_group + ) ent_reg = entity_registry.async_get(hass) dev_reg = device_registry.async_get(hass) config_entry_ids: set[str] = set() @@ -948,11 +778,14 @@ async def entity_service_call( target_all_entities = call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL if target_all_entities: - referenced: SelectedEntities | None = None + referenced: target_helpers.SelectedEntities | None = None all_referenced: set[str] | None = None else: # A set of entities we're trying to target. - referenced = async_extract_referenced_entity_ids(hass, call, True) + selector_data = target_helpers.TargetSelectorData(call.data) + referenced = target_helpers.async_extract_referenced_entity_ids( + hass, selector_data, True + ) all_referenced = referenced.referenced | referenced.indirectly_referenced # If the service function is a string, we'll pass it the service call data @@ -977,7 +810,7 @@ async def entity_service_call( missing = referenced.referenced.copy() for entity in entity_candidates: missing.discard(entity.entity_id) - referenced.log_missing(missing) + referenced.log_missing(missing, _LOGGER) entities: list[Entity] = [] for entity in entity_candidates: diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py new file mode 100644 index 00000000000..c16819235b9 --- /dev/null +++ b/homeassistant/helpers/target.py @@ -0,0 +1,240 @@ +"""Helpers for dealing with entity targets.""" + +from __future__ import annotations + +import dataclasses +from logging import Logger +from typing import TypeGuard + +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, + ENTITY_MATCH_NONE, +) +from homeassistant.core import HomeAssistant + +from . import ( + area_registry as ar, + config_validation as cv, + device_registry as dr, + entity_registry as er, + floor_registry as fr, + group, + label_registry as lr, +) +from .typing import ConfigType + + +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) + + +class TargetSelectorData: + """Class to hold data of target selector.""" + + __slots__ = ("area_ids", "device_ids", "entity_ids", "floor_ids", "label_ids") + + def __init__(self, config: ConfigType) -> None: + """Extract ids from the config.""" + entity_ids: str | list | None = config.get(ATTR_ENTITY_ID) + device_ids: str | list | None = config.get(ATTR_DEVICE_ID) + area_ids: str | list | None = config.get(ATTR_AREA_ID) + floor_ids: str | list | None = config.get(ATTR_FLOOR_ID) + label_ids: str | list | None = config.get(ATTR_LABEL_ID) + + self.entity_ids = ( + set(cv.ensure_list(entity_ids)) if _has_match(entity_ids) else set() + ) + self.device_ids = ( + set(cv.ensure_list(device_ids)) if _has_match(device_ids) else set() + ) + self.area_ids = set(cv.ensure_list(area_ids)) if _has_match(area_ids) else set() + self.floor_ids = ( + set(cv.ensure_list(floor_ids)) if _has_match(floor_ids) else set() + ) + self.label_ids = ( + set(cv.ensure_list(label_ids)) if _has_match(label_ids) else set() + ) + + @property + def has_any_selector(self) -> bool: + """Determine if any selectors are present.""" + return bool( + self.entity_ids + or self.device_ids + or self.area_ids + or self.floor_ids + or self.label_ids + ) + + +@dataclasses.dataclass(slots=True) +class SelectedEntities: + """Class to hold the selected entities.""" + + # Entities that were explicitly mentioned. + referenced: set[str] = dataclasses.field(default_factory=set) + + # Entities that were referenced via device/area/floor/label ID. + # Should not trigger a warning when they don't exist. + indirectly_referenced: set[str] = dataclasses.field(default_factory=set) + + # Referenced items that could not be found. + missing_devices: set[str] = dataclasses.field(default_factory=set) + missing_areas: set[str] = dataclasses.field(default_factory=set) + missing_floors: set[str] = dataclasses.field(default_factory=set) + missing_labels: set[str] = dataclasses.field(default_factory=set) + + referenced_devices: set[str] = dataclasses.field(default_factory=set) + referenced_areas: set[str] = dataclasses.field(default_factory=set) + + def log_missing(self, missing_entities: set[str], logger: Logger) -> None: + """Log about missing items.""" + parts = [] + for label, items in ( + ("floors", self.missing_floors), + ("areas", self.missing_areas), + ("devices", self.missing_devices), + ("entities", missing_entities), + ("labels", self.missing_labels), + ): + if items: + parts.append(f"{label} {', '.join(sorted(items))}") + + if not parts: + return + + logger.warning( + "Referenced %s are missing or not currently available", + ", ".join(parts), + ) + + +def async_extract_referenced_entity_ids( + hass: HomeAssistant, selector_data: TargetSelectorData, expand_group: bool = True +) -> SelectedEntities: + """Extract referenced entity IDs from a target selector.""" + selected = SelectedEntities() + + if not selector_data.has_any_selector: + return selected + + entity_ids: set[str] | list[str] = selector_data.entity_ids + if expand_group: + entity_ids = group.expand_entity_ids(hass, entity_ids) + + selected.referenced.update(entity_ids) + + if ( + not selector_data.device_ids + and not selector_data.area_ids + and not selector_data.floor_ids + and not selector_data.label_ids + ): + return selected + + entities = er.async_get(hass).entities + dev_reg = dr.async_get(hass) + area_reg = ar.async_get(hass) + + if selector_data.floor_ids: + floor_reg = fr.async_get(hass) + for floor_id in selector_data.floor_ids: + if floor_id not in floor_reg.floors: + selected.missing_floors.add(floor_id) + + for area_id in selector_data.area_ids: + if area_id not in area_reg.areas: + selected.missing_areas.add(area_id) + + for device_id in selector_data.device_ids: + if device_id not in dev_reg.devices: + selected.missing_devices.add(device_id) + + if selector_data.label_ids: + label_reg = lr.async_get(hass) + for label_id in selector_data.label_ids: + if label_id not in label_reg.labels: + selected.missing_labels.add(label_id) + + for entity_entry in entities.get_entries_for_label(label_id): + if ( + entity_entry.entity_category is None + and entity_entry.hidden_by is None + ): + selected.indirectly_referenced.add(entity_entry.entity_id) + + for device_entry in dev_reg.devices.get_devices_for_label(label_id): + selected.referenced_devices.add(device_entry.id) + + for area_entry in area_reg.areas.get_areas_for_label(label_id): + selected.referenced_areas.add(area_entry.id) + + # Find areas for targeted floors + if selector_data.floor_ids: + selected.referenced_areas.update( + area_entry.id + for floor_id in selector_data.floor_ids + for area_entry in area_reg.areas.get_areas_for_floor(floor_id) + ) + + selected.referenced_areas.update(selector_data.area_ids) + selected.referenced_devices.update(selector_data.device_ids) + + if not selected.referenced_areas and not selected.referenced_devices: + return selected + + # Add indirectly referenced by device + selected.indirectly_referenced.update( + entry.entity_id + for device_id in selected.referenced_devices + for entry in entities.get_entries_for_device_id(device_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if (entry.entity_category is None and entry.hidden_by is None) + ) + + # Find devices for targeted areas + referenced_devices_by_area: set[str] = set() + if selected.referenced_areas: + for area_id in selected.referenced_areas: + referenced_devices_by_area.update( + device_entry.id + for device_entry in dev_reg.devices.get_devices_for_area_id(area_id) + ) + selected.referenced_devices.update(referenced_devices_by_area) + + # Add indirectly referenced by area + selected.indirectly_referenced.update( + entry.entity_id + for area_id in selected.referenced_areas + # The entity's area matches a targeted area + for entry in entities.get_entries_for_area_id(area_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if entry.entity_category is None and entry.hidden_by is None + ) + # Add indirectly referenced by area through device + selected.indirectly_referenced.update( + entry.entity_id + for device_id in referenced_devices_by_area + for entry in entities.get_entries_for_device_id(device_id) + # Do not add entities which are hidden or which are config + # or diagnostic entities. + if ( + entry.entity_category is None + and entry.hidden_by is None + and ( + # The entity's device matches a device referenced + # by an area and the entity + # has no explicitly set area + not entry.area_id + ) + ) + ) + + return selected diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 5d018f5f3ee..0191827cd58 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -3,6 +3,7 @@ import asyncio from collections.abc import Iterable from copy import deepcopy +import dataclasses import io from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -2322,3 +2323,80 @@ async def test_reload_service_helper(hass: HomeAssistant) -> None: ] await asyncio.gather(*tasks) assert reloaded == unordered(["all", "target1", "target2", "target3", "target4"]) + + +async def test_deprecated_service_target_selector_class(hass: HomeAssistant) -> None: + """Test that the deprecated ServiceTargetSelector class forwards correctly.""" + call = ServiceCall( + hass, + "test", + "test", + { + "entity_id": ["light.test", "switch.test"], + "area_id": "kitchen", + "device_id": ["device1", "device2"], + "floor_id": "first_floor", + "label_id": ["label1", "label2"], + }, + ) + selector = service.ServiceTargetSelector(call) + + assert selector.entity_ids == {"light.test", "switch.test"} + assert selector.area_ids == {"kitchen"} + assert selector.device_ids == {"device1", "device2"} + assert selector.floor_ids == {"first_floor"} + assert selector.label_ids == {"label1", "label2"} + assert selector.has_any_selector is True + + +async def test_deprecated_selected_entities_class( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test that the deprecated SelectedEntities class forwards correctly.""" + selected = service.SelectedEntities( + referenced={"entity.test"}, + indirectly_referenced=set(), + referenced_devices=set(), + referenced_areas=set(), + missing_devices={"missing_device"}, + missing_areas={"missing_area"}, + missing_floors={"missing_floor"}, + missing_labels={"missing_label"}, + ) + + missing_entities = {"entity.missing"} + selected.log_missing(missing_entities) + assert ( + "Referenced floors missing_floor, areas missing_area, " + "devices missing_device, entities entity.missing, " + "labels missing_label are missing or not currently available" in caplog.text + ) + + +async def test_deprecated_async_extract_referenced_entity_ids( + hass: HomeAssistant, +) -> None: + """Test that the deprecated async_extract_referenced_entity_ids function forwards correctly.""" + from homeassistant.helpers import target # noqa: PLC0415 + + mock_selected = target.SelectedEntities( + referenced={"entity.test"}, + indirectly_referenced={"entity.indirect"}, + ) + with patch( + "homeassistant.helpers.target.async_extract_referenced_entity_ids", + return_value=mock_selected, + ) as mock_target_func: + call = ServiceCall(hass, "test", "test", {"entity_id": "light.test"}) + result = service.async_extract_referenced_entity_ids( + hass, call, expand_group=False + ) + + # Verify target helper was called with correct parameters + mock_target_func.assert_called_once() + args = mock_target_func.call_args + assert args[0][0] is hass + assert args[0][1].entity_ids == {"light.test"} + assert args[0][2] is False + + assert dataclasses.asdict(result) == dataclasses.asdict(mock_selected) diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py new file mode 100644 index 00000000000..ca38f316d89 --- /dev/null +++ b/tests/helpers/test_target.py @@ -0,0 +1,459 @@ +"""Test service helpers.""" + +import pytest + +# TODO(abmantis): is this import needed? +# To prevent circular import when running just this file +import homeassistant.components # noqa: F401 +from homeassistant.components.group import Group +from homeassistant.const import ( + ATTR_AREA_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + ATTR_FLOOR_ID, + ATTR_LABEL_ID, + ENTITY_MATCH_NONE, + STATE_OFF, + STATE_ON, + EntityCategory, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + target, +) +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +from tests.common import ( + RegistryEntryWithDefaults, + mock_area_registry, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def registries_mock(hass: HomeAssistant) -> None: + """Mock including floor and area info.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set("light.Kitchen", STATE_OFF) + + area_in_floor = ar.AreaEntry( + id="test-area", + name="Test area", + aliases={}, + floor_id="test-floor", + icon=None, + picture=None, + temperature_entity_id=None, + humidity_entity_id=None, + ) + area_in_floor_a = ar.AreaEntry( + id="area-a", + name="Area A", + aliases={}, + floor_id="floor-a", + icon=None, + picture=None, + temperature_entity_id=None, + humidity_entity_id=None, + ) + area_with_labels = ar.AreaEntry( + id="area-with-labels", + name="Area with labels", + aliases={}, + floor_id=None, + icon=None, + labels={"label_area"}, + picture=None, + temperature_entity_id=None, + humidity_entity_id=None, + ) + mock_area_registry( + hass, + { + area_in_floor.id: area_in_floor, + area_in_floor_a.id: area_in_floor_a, + area_with_labels.id: area_with_labels, + }, + ) + + device_in_area = dr.DeviceEntry(id="device-test-area", area_id="test-area") + device_no_area = dr.DeviceEntry(id="device-no-area-id") + device_diff_area = dr.DeviceEntry(id="device-diff-area", area_id="diff-area") + device_area_a = dr.DeviceEntry(id="device-area-a-id", area_id="area-a") + device_has_label1 = dr.DeviceEntry(id="device-has-label1-id", labels={"label1"}) + device_has_label2 = dr.DeviceEntry(id="device-has-label2-id", labels={"label2"}) + device_has_labels = dr.DeviceEntry( + id="device-has-labels-id", + labels={"label1", "label2"}, + area_id=area_with_labels.id, + ) + + mock_device_registry( + hass, + { + device_in_area.id: device_in_area, + device_no_area.id: device_no_area, + device_diff_area.id: device_diff_area, + device_area_a.id: device_area_a, + device_has_label1.id: device_has_label1, + device_has_label2.id: device_has_label2, + device_has_labels.id: device_has_labels, + }, + ) + + entity_in_own_area = RegistryEntryWithDefaults( + entity_id="light.in_own_area", + unique_id="in-own-area-id", + platform="test", + area_id="own-area", + ) + config_entity_in_own_area = RegistryEntryWithDefaults( + entity_id="light.config_in_own_area", + unique_id="config-in-own-area-id", + platform="test", + area_id="own-area", + entity_category=EntityCategory.CONFIG, + ) + hidden_entity_in_own_area = RegistryEntryWithDefaults( + entity_id="light.hidden_in_own_area", + unique_id="hidden-in-own-area-id", + platform="test", + area_id="own-area", + hidden_by=er.RegistryEntryHider.USER, + ) + entity_in_area = RegistryEntryWithDefaults( + entity_id="light.in_area", + unique_id="in-area-id", + platform="test", + device_id=device_in_area.id, + ) + config_entity_in_area = RegistryEntryWithDefaults( + entity_id="light.config_in_area", + unique_id="config-in-area-id", + platform="test", + device_id=device_in_area.id, + entity_category=EntityCategory.CONFIG, + ) + hidden_entity_in_area = RegistryEntryWithDefaults( + entity_id="light.hidden_in_area", + unique_id="hidden-in-area-id", + platform="test", + device_id=device_in_area.id, + hidden_by=er.RegistryEntryHider.USER, + ) + entity_in_other_area = RegistryEntryWithDefaults( + entity_id="light.in_other_area", + unique_id="in-area-a-id", + platform="test", + device_id=device_in_area.id, + area_id="other-area", + ) + entity_assigned_to_area = RegistryEntryWithDefaults( + entity_id="light.assigned_to_area", + unique_id="assigned-area-id", + platform="test", + device_id=device_in_area.id, + area_id="test-area", + ) + entity_no_area = RegistryEntryWithDefaults( + entity_id="light.no_area", + unique_id="no-area-id", + platform="test", + device_id=device_no_area.id, + ) + config_entity_no_area = RegistryEntryWithDefaults( + entity_id="light.config_no_area", + unique_id="config-no-area-id", + platform="test", + device_id=device_no_area.id, + entity_category=EntityCategory.CONFIG, + ) + hidden_entity_no_area = RegistryEntryWithDefaults( + entity_id="light.hidden_no_area", + unique_id="hidden-no-area-id", + platform="test", + device_id=device_no_area.id, + hidden_by=er.RegistryEntryHider.USER, + ) + entity_diff_area = RegistryEntryWithDefaults( + entity_id="light.diff_area", + unique_id="diff-area-id", + platform="test", + device_id=device_diff_area.id, + ) + entity_in_area_a = RegistryEntryWithDefaults( + entity_id="light.in_area_a", + unique_id="in-area-a-id", + platform="test", + device_id=device_area_a.id, + area_id="area-a", + ) + entity_in_area_b = RegistryEntryWithDefaults( + entity_id="light.in_area_b", + unique_id="in-area-b-id", + platform="test", + device_id=device_area_a.id, + area_id="area-b", + ) + entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.with_my_label", + unique_id="with_my_label", + platform="test", + labels={"my-label"}, + ) + hidden_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.hidden_with_my_label", + unique_id="hidden_with_my_label", + platform="test", + labels={"my-label"}, + hidden_by=er.RegistryEntryHider.USER, + ) + config_entity_with_my_label = RegistryEntryWithDefaults( + entity_id="light.config_with_my_label", + unique_id="config_with_my_label", + platform="test", + labels={"my-label"}, + entity_category=EntityCategory.CONFIG, + ) + entity_with_label1_from_device = RegistryEntryWithDefaults( + entity_id="light.with_label1_from_device", + unique_id="with_label1_from_device", + platform="test", + device_id=device_has_label1.id, + ) + entity_with_label1_from_device_and_different_area = RegistryEntryWithDefaults( + entity_id="light.with_label1_from_device_diff_area", + unique_id="with_label1_from_device_diff_area", + platform="test", + device_id=device_has_label1.id, + area_id=area_in_floor_a.id, + ) + entity_with_label1_and_label2_from_device = RegistryEntryWithDefaults( + entity_id="light.with_label1_and_label2_from_device", + unique_id="with_label1_and_label2_from_device", + platform="test", + labels={"label1"}, + device_id=device_has_label2.id, + ) + entity_with_labels_from_device = RegistryEntryWithDefaults( + entity_id="light.with_labels_from_device", + unique_id="with_labels_from_device", + platform="test", + device_id=device_has_labels.id, + ) + mock_registry( + hass, + { + entity_in_own_area.entity_id: entity_in_own_area, + config_entity_in_own_area.entity_id: config_entity_in_own_area, + hidden_entity_in_own_area.entity_id: hidden_entity_in_own_area, + entity_in_area.entity_id: entity_in_area, + config_entity_in_area.entity_id: config_entity_in_area, + hidden_entity_in_area.entity_id: hidden_entity_in_area, + entity_in_other_area.entity_id: entity_in_other_area, + entity_assigned_to_area.entity_id: entity_assigned_to_area, + entity_no_area.entity_id: entity_no_area, + config_entity_no_area.entity_id: config_entity_no_area, + hidden_entity_no_area.entity_id: hidden_entity_no_area, + entity_diff_area.entity_id: entity_diff_area, + entity_in_area_a.entity_id: entity_in_area_a, + entity_in_area_b.entity_id: entity_in_area_b, + config_entity_with_my_label.entity_id: config_entity_with_my_label, + entity_with_label1_and_label2_from_device.entity_id: entity_with_label1_and_label2_from_device, + entity_with_label1_from_device.entity_id: entity_with_label1_from_device, + entity_with_label1_from_device_and_different_area.entity_id: entity_with_label1_from_device_and_different_area, + entity_with_labels_from_device.entity_id: entity_with_labels_from_device, + entity_with_my_label.entity_id: entity_with_my_label, + hidden_entity_with_my_label.entity_id: hidden_entity_with_my_label, + }, + ) + + +@pytest.mark.parametrize( + ("selector_config", "expand_group", "expected_selected"), + [ + ( + { + ATTR_ENTITY_ID: ENTITY_MATCH_NONE, + ATTR_AREA_ID: ENTITY_MATCH_NONE, + ATTR_FLOOR_ID: ENTITY_MATCH_NONE, + ATTR_LABEL_ID: ENTITY_MATCH_NONE, + }, + False, + target.SelectedEntities(), + ), + ( + {ATTR_ENTITY_ID: "light.bowl"}, + False, + target.SelectedEntities(referenced={"light.bowl"}), + ), + ( + {ATTR_ENTITY_ID: "group.test"}, + True, + target.SelectedEntities(referenced={"light.ceiling", "light.kitchen"}), + ), + ( + {ATTR_ENTITY_ID: "group.test"}, + False, + target.SelectedEntities(referenced={"group.test"}), + ), + ( + {ATTR_AREA_ID: "own-area"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.in_own_area"}, + referenced_areas={"own-area"}, + missing_areas={"own-area"}, + ), + ), + ( + {ATTR_AREA_ID: "test-area"}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.in_area", + "light.assigned_to_area", + }, + referenced_areas={"test-area"}, + referenced_devices={"device-test-area"}, + ), + ), + ( + {ATTR_AREA_ID: ["test-area", "diff-area"]}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.in_area", + "light.diff_area", + "light.assigned_to_area", + }, + referenced_areas={"test-area", "diff-area"}, + referenced_devices={"device-diff-area", "device-test-area"}, + missing_areas={"diff-area"}, + ), + ), + ( + {ATTR_DEVICE_ID: "device-no-area-id"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.no_area"}, + referenced_devices={"device-no-area-id"}, + ), + ), + ( + {ATTR_DEVICE_ID: "device-area-a-id"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.in_area_a", "light.in_area_b"}, + referenced_devices={"device-area-a-id"}, + ), + ), + ( + {ATTR_FLOOR_ID: "test-floor"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.in_area", "light.assigned_to_area"}, + referenced_devices={"device-test-area"}, + referenced_areas={"test-area"}, + missing_floors={"test-floor"}, + ), + ), + ( + {ATTR_FLOOR_ID: ["test-floor", "floor-a"]}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.in_area", + "light.assigned_to_area", + "light.in_area_a", + "light.with_label1_from_device_diff_area", + }, + referenced_devices={"device-area-a-id", "device-test-area"}, + referenced_areas={"area-a", "test-area"}, + missing_floors={"floor-a", "test-floor"}, + ), + ), + ( + {ATTR_LABEL_ID: "my-label"}, + False, + target.SelectedEntities( + indirectly_referenced={"light.with_my_label"}, + missing_labels={"my-label"}, + ), + ), + ( + {ATTR_LABEL_ID: "label1"}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.with_label1_from_device", + "light.with_label1_from_device_diff_area", + "light.with_labels_from_device", + "light.with_label1_and_label2_from_device", + }, + referenced_devices={"device-has-label1-id", "device-has-labels-id"}, + missing_labels={"label1"}, + ), + ), + ( + {ATTR_LABEL_ID: ["label2"]}, + False, + target.SelectedEntities( + indirectly_referenced={ + "light.with_labels_from_device", + "light.with_label1_and_label2_from_device", + }, + referenced_devices={"device-has-label2-id", "device-has-labels-id"}, + missing_labels={"label2"}, + ), + ), + ( + {ATTR_LABEL_ID: ["label_area"]}, + False, + target.SelectedEntities( + indirectly_referenced={"light.with_labels_from_device"}, + referenced_devices={"device-has-labels-id"}, + referenced_areas={"area-with-labels"}, + missing_labels={"label_area"}, + ), + ), + ], +) +@pytest.mark.usefixtures("registries_mock") +async def test_extract_referenced_entity_ids( + hass: HomeAssistant, + selector_config: ConfigType, + expand_group: bool, + expected_selected: target.SelectedEntities, +) -> None: + """Test extract_entity_ids method.""" + hass.states.async_set("light.Bowl", STATE_ON) + hass.states.async_set("light.Ceiling", STATE_OFF) + hass.states.async_set("light.Kitchen", STATE_OFF) + + assert await async_setup_component(hass, "group", {}) + await hass.async_block_till_done() + await Group.async_create_group( + hass, + "test", + created_by_service=False, + entity_ids=["light.Ceiling", "light.Kitchen"], + icon=None, + mode=None, + object_id=None, + order=None, + ) + + target_data = target.TargetSelectorData(selector_config) + assert ( + target.async_extract_referenced_entity_ids( + hass, target_data, expand_group=expand_group + ) + == expected_selected + ) From 03e295ace00de6ff0c88125aa3c086e83cc8ea50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 08:01:48 -0500 Subject: [PATCH 1195/1664] Restore httpx compatibility for non-primitive REST query parameters (#148286) --- homeassistant/components/rest/data.py | 10 ++ tests/components/rest/test_sensor.py | 127 ++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index c5dcd0a73a5..cc0c51d8250 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -115,6 +115,16 @@ class RestData: for key, value in rendered_params.items(): if isinstance(value, bool): rendered_params[key] = str(value).lower() + elif not isinstance(value, (str, int, float, type(None))): + # For backward compatibility with httpx behavior, convert non-primitive + # types to strings. This maintains compatibility after switching from + # httpx to aiohttp. See https://github.com/home-assistant/core/issues/148153 + _LOGGER.debug( + "REST query parameter '%s' has type %s, converting to string", + key, + type(value).__name__, + ) + rendered_params[key] = str(value) _LOGGER.debug("Updating from %s", self._resource) # Create request kwargs diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index c688ff1b314..758aab65b59 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the REST sensor platform.""" from http import HTTPStatus +import logging import ssl from unittest.mock import patch @@ -19,6 +20,14 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + CONF_DEVICE_CLASS, + CONF_FORCE_UPDATE, + CONF_METHOD, + CONF_NAME, + CONF_PARAMS, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, CONTENT_TYPE_JSON, SERVICE_RELOAD, STATE_UNAVAILABLE, @@ -978,6 +987,124 @@ async def test_update_with_failed_get( assert "Empty reply" in caplog.text +async def test_query_param_dict_value( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test dict values in query params are handled for backward compatibility.""" + # Mock response + aioclient_mock.post( + "https://www.envertecportal.com/ApiInverters/QueryTerminalReal", + status=HTTPStatus.OK, + json={"Data": {"QueryResults": [{"POWER": 1500}]}}, + ) + + # This test checks that when template_complex processes a string that looks like + # a dict/list, it converts it to an actual dict/list, which then needs to be + # handled by our backward compatibility code + with caplog.at_level(logging.DEBUG, logger="homeassistant.components.rest.data"): + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + CONF_RESOURCE: ( + "https://www.envertecportal.com/ApiInverters/" + "QueryTerminalReal" + ), + CONF_METHOD: "POST", + CONF_PARAMS: { + "page": "1", + "perPage": "20", + "orderBy": "SN", + # When processed by template.render_complex, certain + # strings might be converted to dicts/lists if they + # look like JSON + "whereCondition": ( + "{{ {'STATIONID': 'A6327A17797C1234'} }}" + ), # Template that evaluates to dict + }, + "sensor": [ + { + CONF_NAME: "Solar MPPT1 Power", + CONF_VALUE_TEMPLATE: ( + "{{ value_json.Data.QueryResults[0].POWER }}" + ), + CONF_DEVICE_CLASS: "power", + CONF_UNIT_OF_MEASUREMENT: "W", + CONF_FORCE_UPDATE: True, + "state_class": "measurement", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + + # The sensor should be created successfully with backward compatibility + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + state = hass.states.get("sensor.solar_mppt1_power") + assert state is not None + assert state.state == "1500" + + # Check that a debug message was logged about the parameter conversion + assert "REST query parameter 'whereCondition' has type" in caplog.text + assert "converting to string" in caplog.text + + +async def test_query_param_json_string_preserved( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test that JSON strings in query params are preserved and not converted to dicts.""" + # Mock response + aioclient_mock.get( + "https://api.example.com/data", + status=HTTPStatus.OK, + json={"value": 42}, + ) + + # Config with JSON string (quoted) - should remain a string + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: [ + { + CONF_RESOURCE: "https://api.example.com/data", + CONF_METHOD: "GET", + CONF_PARAMS: { + "filter": '{"type": "sensor", "id": 123}', # JSON string + "normal": "value", + }, + "sensor": [ + { + CONF_NAME: "Test Sensor", + CONF_VALUE_TEMPLATE: "{{ value_json.value }}", + } + ], + } + ] + }, + ) + await hass.async_block_till_done() + + # Check the sensor was created + assert len(hass.states.async_all(SENSOR_DOMAIN)) == 1 + state = hass.states.get("sensor.test_sensor") + assert state is not None + assert state.state == "42" + + # Verify the request was made with the JSON string intact + assert len(aioclient_mock.mock_calls) == 1 + method, url, data, headers = aioclient_mock.mock_calls[0] + assert url.query["filter"] == '{"type": "sensor", "id": 123}' + assert url.query["normal"] == "value" + + async def test_reload(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: """Verify we can reload reset sensors.""" From e4c9df6d98022de0569c62892986112730a96a88 Mon Sep 17 00:00:00 2001 From: Mark Adkins Date: Mon, 7 Jul 2025 09:18:15 -0400 Subject: [PATCH 1196/1664] Bump sharkiq to 1.1.1 (#148244) --- homeassistant/components/sharkiq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json index 9f9009693e5..c29fc582462 100644 --- a/homeassistant/components/sharkiq/manifest.json +++ b/homeassistant/components/sharkiq/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sharkiq", "iot_class": "cloud_polling", "loggers": ["sharkiq"], - "requirements": ["sharkiq==1.1.0"] + "requirements": ["sharkiq==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 73f7819e6f3..9c0db488593 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2756,7 +2756,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.1.0 +sharkiq==1.1.1 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a17bf245623..5001148e43b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2278,7 +2278,7 @@ sentry-sdk==1.45.1 sfrbox-api==0.0.12 # homeassistant.components.sharkiq -sharkiq==1.1.0 +sharkiq==1.1.1 # homeassistant.components.simplefin simplefin4py==0.0.18 From 799dc97d4a0913f640373146a37d9683ea4ebead Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:26:23 +0800 Subject: [PATCH 1197/1664] Bump pyswitchbot to 0.68.1 (#148335) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 8e727425a2a..5ef7eec9976 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.67.0"] + "requirements": ["PySwitchbot==0.68.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9c0db488593..40a4ef46bc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.67.0 +PySwitchbot==0.68.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5001148e43b..9bccc3393c7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.67.0 +PySwitchbot==0.68.1 # homeassistant.components.syncthru PySyncThru==0.8.0 From c296e1f81899d5c5d94234b0eab406c665306456 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:27:19 +0200 Subject: [PATCH 1198/1664] Remove deprecated `register_static_path` method (#148303) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- homeassistant/components/http/__init__.py | 26 +---------------------- tests/components/http/test_init.py | 18 ---------------- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index cdf3347e24f..f048d571b9c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -37,12 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - frame, - issue_registry as ir, - storage, -) +from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage from homeassistant.helpers.http import ( KEY_ALLOW_CONFIGURED_CORS, KEY_AUTHENTICATED, # noqa: F401 @@ -505,25 +500,6 @@ class HomeAssistantHTTP: ) ) - def register_static_path( - self, url_path: str, path: str, cache_headers: bool = True - ) -> None: - """Register a folder or file to serve as a static path.""" - frame.report_usage( - "calls hass.http.register_static_path which " - "does blocking I/O in the event loop, instead " - "call `await hass.http.async_register_static_paths(" - f'[StaticPathConfig("{url_path}", "{path}", {cache_headers})])`', - exclude_integrations={"http"}, - core_behavior=frame.ReportBehavior.ERROR, - core_integration_behavior=frame.ReportBehavior.ERROR, - custom_integration_behavior=frame.ReportBehavior.ERROR, - breaks_in_ha_version="2025.7", - ) - configs = [StaticPathConfig(url_path, path, cache_headers)] - resources = self._make_static_resources(configs) - self._async_register_static_paths(configs, resources) - def _create_ssl_context(self) -> ssl.SSLContext | None: context: ssl.SSLContext | None = None assert self.ssl_certificate is not None diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 7858bbc123d..195a291b140 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -522,24 +522,6 @@ async def test_logging( assert "GET /api/states/logging.entity" not in caplog.text -async def test_register_static_paths( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test registering a static path with old api.""" - assert await async_setup_component(hass, "frontend", {}) - path = str(Path(__file__).parent) - - match_error = ( - "Detected code that calls hass.http.register_static_path " - "which does blocking I/O in the event loop, instead call " - "`await hass.http.async_register_static_paths" - ) - with pytest.raises(RuntimeError, match=match_error): - hass.http.register_static_path("/something", path) - - async def test_ssl_issue_if_no_urls_configured( hass: HomeAssistant, tmp_path: Path, From 8007bf1c310e5695fe991b3dac84fb4b003ad9b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 08:32:58 -0500 Subject: [PATCH 1199/1664] Fix REST sensor charset handling to respect Content-Type header (#148223) --- homeassistant/components/rest/data.py | 9 ++- tests/components/rest/test_sensor.py | 88 +++++++++++++++++++++++++++ tests/test_util/aiohttp.py | 21 ++++++- 3 files changed, 114 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index cc0c51d8250..3341f296fb9 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -150,7 +150,14 @@ class RestData: self._method, self._resource, **request_kwargs ) as response: # Read the response - self.data = await response.text(encoding=self._encoding) + # Only use configured encoding if no charset in Content-Type header + # If charset is present in Content-Type, let aiohttp use it + if response.charset: + # Let aiohttp use the charset from Content-Type header + self.data = await response.text() + else: + # Use configured encoding as fallback + self.data = await response.text(encoding=self._encoding) self.headers = response.headers except TimeoutError as ex: diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 758aab65b59..b830d6b7743 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -171,6 +171,94 @@ async def test_setup_encoding( assert hass.states.get("sensor.mysensor").state == "tack själv" +async def test_setup_auto_encoding_from_content_type( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with encoding auto-detected from Content-Type header.""" + # Test with ISO-8859-1 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; charset=iso-8859-1"}, + ) + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "name": "mysensor", + # encoding defaults to UTF-8, but should be ignored when charset present + "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_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"), [ diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index fe0de66f44c..c3a8be77b77 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -194,7 +194,6 @@ class AiohttpClientMockResponse: if response is None: response = b"" - self.charset = "utf-8" self.method = method self._url = url self.status = status @@ -264,16 +263,32 @@ class AiohttpClientMockResponse: """Return content.""" return mock_stream(self.response) + @property + def charset(self): + """Return charset from Content-Type header.""" + if (content_type := self._headers.get("content-type")) is None: + return None + content_type = content_type.lower() + if "charset=" in content_type: + return content_type.split("charset=")[1].split(";")[0].strip() + return None + async def read(self): """Return mock response.""" return self.response - async def text(self, encoding="utf-8", errors="strict"): + async def text(self, encoding=None, errors="strict") -> str: """Return mock response as a string.""" + # Match real aiohttp behavior: encoding=None means auto-detect + if encoding is None: + encoding = self.charset or "utf-8" return self.response.decode(encoding, errors=errors) - async def json(self, encoding="utf-8", content_type=None, loads=json_loads): + async def json(self, encoding=None, content_type=None, loads=json_loads) -> Any: """Return mock response as a json.""" + # Match real aiohttp behavior: encoding=None means auto-detect + if encoding is None: + encoding = self.charset or "utf-8" return loads(self.response.decode(encoding)) def release(self): From a46cc82916d0850c5d596749adafb8151a72c88e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 7 Jul 2025 16:52:29 +0200 Subject: [PATCH 1200/1664] Don't log deprecation warning in vacuum until after entity added to hass (#147959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Martin Hjelmare Co-authored-by: Abílio Costa --- homeassistant/components/vacuum/__init__.py | 46 +++++++++++---------- tests/components/vacuum/test_init.py | 37 ++++++++++++++--- 2 files changed, 56 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 9108fc5d879..4b7a6907455 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -321,16 +321,18 @@ class StateVacuumEntity( Integrations should implement a sensor instead. """ - report_usage( - f"is setting the {property} which has been deprecated." - f" Integration {self.platform.platform_name} should implement a sensor" - " instead with a correct device class and link it to the same device", - core_integration_behavior=ReportBehavior.LOG, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2026.8", - integration_domain=self.platform.platform_name if self.platform else None, - exclude_integrations={DOMAIN}, - ) + if self.platform: + # Don't report usage until after entity added to hass, after init + report_usage( + f"is setting the {property} which has been deprecated." + f" Integration {self.platform.platform_name} should implement a sensor" + " instead with a correct device class and link it to the same device", + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + integration_domain=self.platform.platform_name, + exclude_integrations={DOMAIN}, + ) @callback def _report_deprecated_battery_feature(self) -> None: @@ -339,17 +341,19 @@ class StateVacuumEntity( Integrations should remove the battery supported feature when migrating battery level and icon to a sensor. """ - report_usage( - f"is setting the battery supported feature which has been deprecated." - f" Integration {self.platform.platform_name} should remove this as part of migrating" - " the battery level and icon to a sensor", - core_behavior=ReportBehavior.LOG, - core_integration_behavior=ReportBehavior.LOG, - custom_integration_behavior=ReportBehavior.LOG, - breaks_in_ha_version="2026.8", - integration_domain=self.platform.platform_name if self.platform else None, - exclude_integrations={DOMAIN}, - ) + if self.platform: + # Don't report usage until after entity added to hass, after init + report_usage( + f"is setting the battery supported feature which has been deprecated." + f" Integration {self.platform.platform_name} should remove this as part of migrating" + " the battery level and icon to a sensor", + core_behavior=ReportBehavior.LOG, + core_integration_behavior=ReportBehavior.LOG, + custom_integration_behavior=ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + integration_domain=self.platform.platform_name, + exclude_integrations={DOMAIN}, + ) @cached_property def battery_level(self) -> int | None: diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index 488852521ed..60ff0a1ebde 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -562,16 +562,10 @@ async def test_vacuum_log_deprecated_battery_properties_using_attr( # Test we only log once assert ( "Detected that custom integration 'test' is setting the battery_level which has been deprecated." - " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.8," - " please report it to the author of the 'test' custom integration" not in caplog.text ) assert ( "Detected that custom integration 'test' is setting the battery_icon which has been deprecated." - " Integration test should implement a sensor instead with a correct device class and link it to" - " the same device. This will stop working in Home Assistant 2026.8," - " please report it to the author of the 'test' custom integration" not in caplog.text ) @@ -613,3 +607,34 @@ async def test_vacuum_log_deprecated_battery_supported_feature( ", please report it to the author of the 'test' custom integration" in caplog.text ) + + +async def test_vacuum_not_log_deprecated_battery_properties_during_init( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test not logging deprecation until after added to hass.""" + + class MockLegacyVacuum(MockVacuum): + """Mocked vacuum entity.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize a mock vacuum entity.""" + super().__init__(**kwargs) + self._attr_battery_level = 50 + + @property + def activity(self) -> str: + """Return the state of the entity.""" + return VacuumActivity.CLEANING + + entity = MockLegacyVacuum( + name="Testing", + entity_id="vacuum.test", + ) + assert entity.battery_level == 50 + + assert ( + "Detected that custom integration 'test' is setting the battery_level which has been deprecated." + not in caplog.text + ) From 090b8f0659ab2cb6625b72b6673d32d049b1c03b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Mon, 7 Jul 2025 19:07:28 +0300 Subject: [PATCH 1201/1664] Bump openai to 1.93.0 (#148350) --- .../openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../openai_conversation/test_conversation.py | 36 +++++++++++++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index 84369eb15a2..d8c2c3a644c 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.76.2"] + "requirements": ["openai==1.93.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 40a4ef46bc2..bfd989f849e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1597,7 +1597,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.76.2 +openai==1.93.0 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bccc3393c7..78882ff5bd9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1365,7 +1365,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.76.2 +openai==1.93.0 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 8621465bd14..3d662cb0f00 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -35,6 +35,7 @@ from openai.types.responses import ( ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response import IncompleteDetails +from openai.types.responses.response_function_web_search import ActionSearch import pytest from syrupy.assertion import SnapshotAssertion @@ -95,10 +96,12 @@ def mock_create_stream() -> Generator[AsyncMock]: ) yield ResponseCreatedEvent( response=response, + sequence_number=0, type="response.created", ) yield ResponseInProgressEvent( response=response, + sequence_number=0, type="response.in_progress", ) response.status = "completed" @@ -123,16 +126,19 @@ def mock_create_stream() -> Generator[AsyncMock]: if response.status == "incomplete": yield ResponseIncompleteEvent( response=response, + sequence_number=0, type="response.incomplete", ) elif response.status == "failed": yield ResponseFailedEvent( response=response, + sequence_number=0, type="response.failed", ) else: yield ResponseCompletedEvent( response=response, + sequence_number=0, type="response.completed", ) @@ -301,7 +307,7 @@ async def test_incomplete_response( "OpenAI response failed: Rate limit exceeded", ), ( - ResponseErrorEvent(type="error", message="Some error"), + ResponseErrorEvent(type="error", message="Some error", sequence_number=0), "OpenAI response error: Some error", ), ], @@ -359,6 +365,7 @@ def create_message_item( status="in_progress", ), output_index=output_index, + sequence_number=0, type="response.output_item.added", ), ResponseContentPartAddedEvent( @@ -366,6 +373,7 @@ def create_message_item( item_id=id, output_index=output_index, part=content, + sequence_number=0, type="response.content_part.added", ), ] @@ -377,6 +385,7 @@ def create_message_item( delta=delta, item_id=id, output_index=output_index, + sequence_number=0, type="response.output_text.delta", ) for delta in text @@ -389,6 +398,7 @@ def create_message_item( item_id=id, output_index=output_index, text="".join(text), + sequence_number=0, type="response.output_text.done", ), ResponseContentPartDoneEvent( @@ -396,6 +406,7 @@ def create_message_item( item_id=id, output_index=output_index, part=content, + sequence_number=0, type="response.content_part.done", ), ResponseOutputItemDoneEvent( @@ -407,6 +418,7 @@ def create_message_item( type="message", ), output_index=output_index, + sequence_number=0, type="response.output_item.done", ), ] @@ -433,6 +445,7 @@ def create_function_tool_call_item( status="in_progress", ), output_index=output_index, + sequence_number=0, type="response.output_item.added", ) ] @@ -442,6 +455,7 @@ def create_function_tool_call_item( delta=delta, item_id=id, output_index=output_index, + sequence_number=0, type="response.function_call_arguments.delta", ) for delta in arguments @@ -452,6 +466,7 @@ def create_function_tool_call_item( arguments="".join(arguments), item_id=id, output_index=output_index, + sequence_number=0, type="response.function_call_arguments.done", ) ) @@ -467,6 +482,7 @@ def create_function_tool_call_item( status="completed", ), output_index=output_index, + sequence_number=0, type="response.output_item.done", ) ) @@ -485,6 +501,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven status=None, ), output_index=output_index, + sequence_number=0, type="response.output_item.added", ), ResponseOutputItemDoneEvent( @@ -495,6 +512,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven status=None, ), output_index=output_index, + sequence_number=0, type="response.output_item.done", ), ] @@ -505,31 +523,42 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve return [ ResponseOutputItemAddedEvent( item=ResponseFunctionWebSearch( - id=id, status="in_progress", type="web_search_call" + id=id, + status="in_progress", + action=ActionSearch(query="query", type="search"), + type="web_search_call", ), output_index=output_index, + sequence_number=0, type="response.output_item.added", ), ResponseWebSearchCallInProgressEvent( item_id=id, output_index=output_index, + sequence_number=0, type="response.web_search_call.in_progress", ), ResponseWebSearchCallSearchingEvent( item_id=id, output_index=output_index, + sequence_number=0, type="response.web_search_call.searching", ), ResponseWebSearchCallCompletedEvent( item_id=id, output_index=output_index, + sequence_number=0, type="response.web_search_call.completed", ), ResponseOutputItemDoneEvent( item=ResponseFunctionWebSearch( - id=id, status="completed", type="web_search_call" + id=id, + status="completed", + action=ActionSearch(query="query", type="search"), + type="web_search_call", ), output_index=output_index, + sequence_number=0, type="response.output_item.done", ), ] @@ -588,6 +617,7 @@ async def test_function_call( "id": "rs_A", "summary": [], "type": "reasoning", + "encrypted_content": None, } assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic From 6396f54e0dde48aa297b198ab30819d3ed4c9669 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 7 Jul 2025 18:27:44 +0200 Subject: [PATCH 1202/1664] Move zone conditions to the zone integration (#148157) --- .../components/geo_location/trigger.py | 9 +- homeassistant/components/zone/condition.py | 156 ++++++++++++++ homeassistant/components/zone/trigger.py | 3 +- homeassistant/helpers/condition.py | 100 --------- homeassistant/helpers/config_validation.py | 13 -- script/hassfest/conditions.py | 1 + tests/components/zone/test_condition.py | 203 ++++++++++++++++++ tests/helpers/test_condition.py | 195 ----------------- 8 files changed, 368 insertions(+), 312 deletions(-) create mode 100644 homeassistant/components/zone/condition.py create mode 100644 tests/components/zone/test_condition.py diff --git a/homeassistant/components/geo_location/trigger.py b/homeassistant/components/geo_location/trigger.py index 5f0d6e92ee1..ab5bde3682e 100644 --- a/homeassistant/components/geo_location/trigger.py +++ b/homeassistant/components/geo_location/trigger.py @@ -7,6 +7,7 @@ from typing import Final import voluptuous as vol +from homeassistant.components.zone import condition as zone_condition from homeassistant.const import CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE from homeassistant.core import ( CALLBACK_TYPE, @@ -17,7 +18,7 @@ from homeassistant.core import ( State, callback, ) -from homeassistant.helpers import condition, config_validation as cv +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import entity_domain from homeassistant.helpers.event import TrackStates, async_track_state_change_filtered from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo @@ -79,9 +80,11 @@ async def async_attach_trigger( return from_match = ( - condition.zone(hass, zone_state, from_state) if from_state else False + zone_condition.zone(hass, zone_state, from_state) if from_state else False + ) + to_match = ( + zone_condition.zone(hass, zone_state, to_state) if to_state else False ) - to_match = condition.zone(hass, zone_state, to_state) if to_state else False if (trigger_event == EVENT_ENTER and not from_match and to_match) or ( trigger_event == EVENT_LEAVE and from_match and not to_match diff --git a/homeassistant/components/zone/condition.py b/homeassistant/components/zone/condition.py new file mode 100644 index 00000000000..0fb30eeda9c --- /dev/null +++ b/homeassistant/components/zone/condition.py @@ -0,0 +1,156 @@ +"""Offer zone automation rules.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_GPS_ACCURACY, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_CONDITION, + CONF_ENTITY_ID, + CONF_ZONE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.exceptions import ConditionErrorContainer, ConditionErrorMessage +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.condition import ( + Condition, + ConditionCheckerType, + trace_condition_function, +) +from homeassistant.helpers.typing import ConfigType, TemplateVarsType + +from . import in_zone + +_CONDITION_SCHEMA = vol.Schema( + { + **cv.CONDITION_BASE_SCHEMA, + vol.Required(CONF_CONDITION): "zone", + vol.Required(CONF_ENTITY_ID): cv.entity_ids, + vol.Required("zone"): cv.entity_ids, + # To support use_trigger_value in automation + # Deprecated 2016/04/25 + vol.Optional("event"): vol.Any("enter", "leave"), + } +) + + +def zone( + hass: HomeAssistant, + zone_ent: str | State | None, + entity: str | State | None, +) -> bool: + """Test if zone-condition matches. + + Async friendly. + """ + if zone_ent is None: + raise ConditionErrorMessage("zone", "no zone specified") + + if isinstance(zone_ent, str): + zone_ent_id = zone_ent + + if (zone_ent := hass.states.get(zone_ent)) is None: + raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}") + + if entity is None: + raise ConditionErrorMessage("zone", "no entity specified") + + if isinstance(entity, str): + entity_id = entity + + if (entity := hass.states.get(entity)) is None: + raise ConditionErrorMessage("zone", f"unknown entity {entity_id}") + else: + entity_id = entity.entity_id + + if entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + return False + + latitude = entity.attributes.get(ATTR_LATITUDE) + longitude = entity.attributes.get(ATTR_LONGITUDE) + + if latitude is None: + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'latitude' attribute" + ) + + if longitude is None: + raise ConditionErrorMessage( + "zone", f"entity {entity_id} has no 'longitude' attribute" + ) + + return in_zone( + zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) + ) + + +class ZoneCondition(Condition): + """Zone condition.""" + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: + """Initialize condition.""" + self._config = config + + @classmethod + async def async_validate_condition_config( + cls, hass: HomeAssistant, config: ConfigType + ) -> ConfigType: + """Validate config.""" + return _CONDITION_SCHEMA(config) # type: ignore[no-any-return] + + async def async_condition_from_config(self) -> ConditionCheckerType: + """Wrap action method with zone based condition.""" + entity_ids = self._config.get(CONF_ENTITY_ID, []) + zone_entity_ids = self._config.get(CONF_ZONE, []) + + @trace_condition_function + def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: + """Test if condition.""" + errors = [] + + all_ok = True + for entity_id in entity_ids: + entity_ok = False + for zone_entity_id in zone_entity_ids: + try: + if zone(hass, zone_entity_id, entity_id): + entity_ok = True + except ConditionErrorMessage as ex: + errors.append( + ConditionErrorMessage( + "zone", + ( + f"error matching {entity_id} with {zone_entity_id}:" + f" {ex.message}" + ), + ) + ) + + if not entity_ok: + all_ok = False + + # Raise the errors only if no definitive result was found + if errors and not all_ok: + raise ConditionErrorContainer("zone", errors=errors) + + return all_ok + + return if_in_zone + + +CONDITIONS: dict[str, type[Condition]] = { + "zone": ZoneCondition, +} + + +async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]: + """Return the sun conditions.""" + return CONDITIONS diff --git a/homeassistant/components/zone/trigger.py b/homeassistant/components/zone/trigger.py index af4999e5438..59e0f2f8821 100644 --- a/homeassistant/components/zone/trigger.py +++ b/homeassistant/components/zone/trigger.py @@ -22,7 +22,6 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import ( - condition, config_validation as cv, entity_registry as er, location, @@ -31,6 +30,8 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType +from . import condition + EVENT_ENTER = "enter" EVENT_LEAVE = "leave" DEFAULT_EVENT = EVENT_ENTER diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 5a9ffb6d91b..37ff9b22ff7 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -18,9 +18,6 @@ import voluptuous as vol from homeassistant.const import ( ATTR_DEVICE_CLASS, - ATTR_GPS_ACCURACY, - ATTR_LATITUDE, - ATTR_LONGITUDE, CONF_ABOVE, CONF_AFTER, CONF_ATTRIBUTE, @@ -36,7 +33,6 @@ from homeassistant.const import ( CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WEEKDAY, - CONF_ZONE, ENTITY_MATCH_ALL, ENTITY_MATCH_ANY, STATE_UNAVAILABLE, @@ -95,7 +91,6 @@ _PLATFORM_ALIASES: dict[str | None, str | None] = { "template": None, "time": None, "trigger": None, - "zone": None, } INPUT_ENTITY_ID = re.compile( @@ -919,101 +914,6 @@ def time_from_config(config: ConfigType) -> ConditionCheckerType: return time_if -def zone( - hass: HomeAssistant, - zone_ent: str | State | None, - entity: str | State | None, -) -> bool: - """Test if zone-condition matches. - - Async friendly. - """ - from homeassistant.components import zone as zone_cmp # noqa: PLC0415 - - if zone_ent is None: - raise ConditionErrorMessage("zone", "no zone specified") - - if isinstance(zone_ent, str): - zone_ent_id = zone_ent - - if (zone_ent := hass.states.get(zone_ent)) is None: - raise ConditionErrorMessage("zone", f"unknown zone {zone_ent_id}") - - if entity is None: - raise ConditionErrorMessage("zone", "no entity specified") - - if isinstance(entity, str): - entity_id = entity - - if (entity := hass.states.get(entity)) is None: - raise ConditionErrorMessage("zone", f"unknown entity {entity_id}") - else: - entity_id = entity.entity_id - - if entity.state in ( - STATE_UNAVAILABLE, - STATE_UNKNOWN, - ): - return False - - latitude = entity.attributes.get(ATTR_LATITUDE) - longitude = entity.attributes.get(ATTR_LONGITUDE) - - if latitude is None: - raise ConditionErrorMessage( - "zone", f"entity {entity_id} has no 'latitude' attribute" - ) - - if longitude is None: - raise ConditionErrorMessage( - "zone", f"entity {entity_id} has no 'longitude' attribute" - ) - - return zone_cmp.in_zone( - zone_ent, latitude, longitude, entity.attributes.get(ATTR_GPS_ACCURACY, 0) - ) - - -def zone_from_config(config: ConfigType) -> ConditionCheckerType: - """Wrap action method with zone based condition.""" - entity_ids = config.get(CONF_ENTITY_ID, []) - zone_entity_ids = config.get(CONF_ZONE, []) - - @trace_condition_function - def if_in_zone(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool: - """Test if condition.""" - errors = [] - - all_ok = True - for entity_id in entity_ids: - entity_ok = False - for zone_entity_id in zone_entity_ids: - try: - if zone(hass, zone_entity_id, entity_id): - entity_ok = True - except ConditionErrorMessage as ex: - errors.append( - ConditionErrorMessage( - "zone", - ( - f"error matching {entity_id} with {zone_entity_id}:" - f" {ex.message}" - ), - ) - ) - - if not entity_ok: - all_ok = False - - # Raise the errors only if no definitive result was found - if errors and not all_ok: - raise ConditionErrorContainer("zone", errors=errors) - - return all_ok - - return if_in_zone - - async def async_trigger_from_config( hass: HomeAssistant, config: ConfigType ) -> ConditionCheckerType: diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index ab347e803d6..da1c1c80619 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -1570,18 +1570,6 @@ TRIGGER_CONDITION_SCHEMA = vol.Schema( } ) -ZONE_CONDITION_SCHEMA = vol.Schema( - { - **CONDITION_BASE_SCHEMA, - vol.Required(CONF_CONDITION): "zone", - vol.Required(CONF_ENTITY_ID): entity_ids, - vol.Required("zone"): entity_ids, - # To support use_trigger_value in automation - # Deprecated 2016/04/25 - vol.Optional("event"): vol.Any("enter", "leave"), - } -) - AND_CONDITION_SCHEMA = vol.Schema( { **CONDITION_BASE_SCHEMA, @@ -1729,7 +1717,6 @@ BUILT_IN_CONDITIONS: ValueSchemas = { "template": TEMPLATE_CONDITION_SCHEMA, "time": TIME_CONDITION_SCHEMA, "trigger": TRIGGER_CONDITION_SCHEMA, - "zone": ZONE_CONDITION_SCHEMA, } diff --git a/script/hassfest/conditions.py b/script/hassfest/conditions.py index 7eb9a2c3fc0..2a1d363a5fc 100644 --- a/script/hassfest/conditions.py +++ b/script/hassfest/conditions.py @@ -54,6 +54,7 @@ CONDITIONS_SCHEMA = vol.Schema( NON_MIGRATED_INTEGRATIONS = { "device_automation", "sun", + "zone", } diff --git a/tests/components/zone/test_condition.py b/tests/components/zone/test_condition.py new file mode 100644 index 00000000000..ab78fc90bae --- /dev/null +++ b/tests/components/zone/test_condition.py @@ -0,0 +1,203 @@ +"""The tests for the location condition.""" + +import pytest + +from homeassistant.components.zone import condition as zone_condition +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConditionError +from homeassistant.helpers import condition, config_validation as cv + + +async def test_zone_raises(hass: HomeAssistant) -> None: + """Test that zone raises ConditionError on errors.""" + config = { + "condition": "zone", + "entity_id": "device_tracker.cat", + "zone": "zone.home", + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + with pytest.raises(ConditionError, match="no zone"): + zone_condition.zone(hass, zone_ent=None, entity="sensor.any") + + with pytest.raises(ConditionError, match="unknown zone"): + test(hass) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + + with pytest.raises(ConditionError, match="no entity"): + zone_condition.zone(hass, zone_ent="zone.home", entity=None) + + with pytest.raises(ConditionError, match="unknown entity"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat"}, + ) + + with pytest.raises(ConditionError, match="latitude"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat", "latitude": 2.1}, + ) + + with pytest.raises(ConditionError, match="longitude"): + test(hass) + + hass.states.async_set( + "device_tracker.cat", + "home", + {"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1}, + ) + + # All okay, now test multiple failed conditions + assert test(hass) + + config = { + "condition": "zone", + "entity_id": ["device_tracker.cat", "device_tracker.dog"], + "zone": ["zone.home", "zone.work"], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + with pytest.raises(ConditionError, match="dog"): + test(hass) + + with pytest.raises(ConditionError, match="work"): + test(hass) + + hass.states.async_set( + "zone.work", + "zoning", + {"name": "work", "latitude": 20, "longitude": 10, "radius": 25000}, + ) + + hass.states.async_set( + "device_tracker.dog", + "work", + {"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1}, + ) + + assert test(hass) + + +async def test_zone_multiple_entities(hass: HomeAssistant) -> None: + """Test with multiple entities in condition.""" + config = { + "condition": "and", + "conditions": [ + { + "alias": "Zone Condition", + "condition": "zone", + "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], + "zone": "zone.home", + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + + hass.states.async_set( + "device_tracker.person_1", + "home", + {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, + ) + hass.states.async_set( + "device_tracker.person_2", + "home", + {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, + ) + assert test(hass) + + hass.states.async_set( + "device_tracker.person_1", + "home", + {"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1}, + ) + hass.states.async_set( + "device_tracker.person_2", + "home", + {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, + ) + assert not test(hass) + + hass.states.async_set( + "device_tracker.person_1", + "home", + {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, + ) + hass.states.async_set( + "device_tracker.person_2", + "home", + {"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1}, + ) + assert not test(hass) + + +async def test_multiple_zones(hass: HomeAssistant) -> None: + """Test with multiple entities in condition.""" + config = { + "condition": "and", + "conditions": [ + { + "condition": "zone", + "entity_id": "device_tracker.person", + "zone": ["zone.home", "zone.work"], + }, + ], + } + config = cv.CONDITION_SCHEMA(config) + config = await condition.async_validate_condition_config(hass, config) + test = await condition.async_from_config(hass, config) + + hass.states.async_set( + "zone.home", + "zoning", + {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, + ) + hass.states.async_set( + "zone.work", + "zoning", + {"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10}, + ) + + hass.states.async_set( + "device_tracker.person", + "home", + {"friendly_name": "person", "latitude": 2.1, "longitude": 1.1}, + ) + assert test(hass) + + hass.states.async_set( + "device_tracker.person", + "home", + {"friendly_name": "person", "latitude": 20.1, "longitude": 10.1}, + ) + assert test(hass) + + hass.states.async_set( + "device_tracker.person", + "home", + {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, + ) + assert not test(hass) diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index 1c10048fee9..86aab3cb681 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -1892,201 +1892,6 @@ async def test_numeric_state_using_input_number(hass: HomeAssistant) -> None: ) -async def test_zone_raises(hass: HomeAssistant) -> None: - """Test that zone raises ConditionError on errors.""" - config = { - "condition": "zone", - "entity_id": "device_tracker.cat", - "zone": "zone.home", - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - with pytest.raises(ConditionError, match="no zone"): - condition.zone(hass, zone_ent=None, entity="sensor.any") - - with pytest.raises(ConditionError, match="unknown zone"): - test(hass) - - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, - ) - - with pytest.raises(ConditionError, match="no entity"): - condition.zone(hass, zone_ent="zone.home", entity=None) - - with pytest.raises(ConditionError, match="unknown entity"): - test(hass) - - hass.states.async_set( - "device_tracker.cat", - "home", - {"friendly_name": "cat"}, - ) - - with pytest.raises(ConditionError, match="latitude"): - test(hass) - - hass.states.async_set( - "device_tracker.cat", - "home", - {"friendly_name": "cat", "latitude": 2.1}, - ) - - with pytest.raises(ConditionError, match="longitude"): - test(hass) - - hass.states.async_set( - "device_tracker.cat", - "home", - {"friendly_name": "cat", "latitude": 2.1, "longitude": 1.1}, - ) - - # All okay, now test multiple failed conditions - assert test(hass) - - config = { - "condition": "zone", - "entity_id": ["device_tracker.cat", "device_tracker.dog"], - "zone": ["zone.home", "zone.work"], - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - with pytest.raises(ConditionError, match="dog"): - test(hass) - - with pytest.raises(ConditionError, match="work"): - test(hass) - - hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 20, "longitude": 10, "radius": 25000}, - ) - - hass.states.async_set( - "device_tracker.dog", - "work", - {"friendly_name": "dog", "latitude": 20.1, "longitude": 10.1}, - ) - - assert test(hass) - - -async def test_zone_multiple_entities(hass: HomeAssistant) -> None: - """Test with multiple entities in condition.""" - config = { - "condition": "and", - "conditions": [ - { - "alias": "Zone Condition", - "condition": "zone", - "entity_id": ["device_tracker.person_1", "device_tracker.person_2"], - "zone": "zone.home", - }, - ], - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, - ) - - hass.states.async_set( - "device_tracker.person_1", - "home", - {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, - ) - hass.states.async_set( - "device_tracker.person_2", - "home", - {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, - ) - assert test(hass) - - hass.states.async_set( - "device_tracker.person_1", - "home", - {"friendly_name": "person_1", "latitude": 20.1, "longitude": 10.1}, - ) - hass.states.async_set( - "device_tracker.person_2", - "home", - {"friendly_name": "person_2", "latitude": 2.1, "longitude": 1.1}, - ) - assert not test(hass) - - hass.states.async_set( - "device_tracker.person_1", - "home", - {"friendly_name": "person_1", "latitude": 2.1, "longitude": 1.1}, - ) - hass.states.async_set( - "device_tracker.person_2", - "home", - {"friendly_name": "person_2", "latitude": 20.1, "longitude": 10.1}, - ) - assert not test(hass) - - -async def test_multiple_zones(hass: HomeAssistant) -> None: - """Test with multiple entities in condition.""" - config = { - "condition": "and", - "conditions": [ - { - "condition": "zone", - "entity_id": "device_tracker.person", - "zone": ["zone.home", "zone.work"], - }, - ], - } - config = cv.CONDITION_SCHEMA(config) - config = await condition.async_validate_condition_config(hass, config) - test = await condition.async_from_config(hass, config) - - hass.states.async_set( - "zone.home", - "zoning", - {"name": "home", "latitude": 2.1, "longitude": 1.1, "radius": 10}, - ) - hass.states.async_set( - "zone.work", - "zoning", - {"name": "work", "latitude": 20.1, "longitude": 10.1, "radius": 10}, - ) - - hass.states.async_set( - "device_tracker.person", - "home", - {"friendly_name": "person", "latitude": 2.1, "longitude": 1.1}, - ) - assert test(hass) - - hass.states.async_set( - "device_tracker.person", - "home", - {"friendly_name": "person", "latitude": 20.1, "longitude": 10.1}, - ) - assert test(hass) - - hass.states.async_set( - "device_tracker.person", - "home", - {"friendly_name": "person", "latitude": 50.1, "longitude": 20.1}, - ) - assert not test(hass) - - @pytest.mark.usefixtures("hass") async def test_extract_entities() -> None: """Test extracting entities.""" From 5c4f166f6f9568d8d465a5673231b45c9ada1f82 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 7 Jul 2025 18:48:34 +0200 Subject: [PATCH 1203/1664] Add translation for write failures in nibe_heatpump (#148352) --- .../components/nibe_heatpump/coordinator.py | 40 +++++++++++- .../components/nibe_heatpump/strings.json | 11 ++++ tests/components/nibe_heatpump/test_number.py | 63 +++++++++++++++++++ 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 2451e2fbda9..71f87698792 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -10,12 +10,19 @@ from typing import Any from nibe.coil import Coil, CoilData from nibe.connection import Connection -from nibe.exceptions import CoilNotFoundException, ReadException +from nibe.exceptions import ( + CoilNotFoundException, + ReadException, + WriteDeniedException, + WriteException, + WriteTimeoutException, +) from nibe.heatpump import HeatPump, Series from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -134,7 +141,36 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): async def async_write_coil(self, coil: Coil, value: float | str) -> None: """Write coil and update state.""" data = CoilData(coil, value) - await self.connection.write_coil(data) + try: + await self.connection.write_coil(data) + except WriteDeniedException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_denied", + translation_placeholders={ + "address": str(coil.address), + "value": str(value), + }, + ) from e + except WriteTimeoutException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_timeout", + translation_placeholders={ + "address": str(coil.address), + }, + ) from e + except WriteException as e: + LOGGER.debug("Failed to write", exc_info=True) + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="write_failed", + translation_placeholders={ + "address": str(coil.address), + "value": str(value), + "error": str(e), + }, + ) from e self.data[coil.address] = data diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index c65a76d3364..3312bc2287f 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -45,5 +45,16 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "url": "The specified URL is not well formed nor supported" } + }, + "exceptions": { + "write_denied": { + "message": "Writing of coil {address} with value `{value}` was denied" + }, + "write_timeout": { + "message": "Timeout while writing coil {address}" + }, + "write_failed": { + "message": "Writing of coil {address} with value `{value}` failed with error `{error}`" + } } } diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index dc7faf0a80e..eeb9587f425 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, patch from nibe.coil import CoilData +from nibe.exceptions import WriteDeniedException, WriteException, WriteTimeoutException from nibe.heatpump import Model import pytest from syrupy.assertion import SnapshotAssertion @@ -15,6 +16,7 @@ from homeassistant.components.number import ( ) from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from . import async_add_model @@ -108,3 +110,64 @@ async def test_set_value( assert isinstance(coil, CoilData) assert coil.coil.address == address assert coil.value == value + + +@pytest.mark.parametrize( + ("exception", "translation_key", "translation_placeholders"), + [ + ( + WriteDeniedException("denied"), + "write_denied", + {"address": "47398", "value": "25.0"}, + ), + ( + WriteTimeoutException("timeout writing"), + "write_timeout", + {"address": "47398"}, + ), + ( + WriteException("failed"), + "write_failed", + { + "address": "47398", + "value": "25.0", + "error": "failed", + }, + ), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_value_fail( + hass: HomeAssistant, + mock_connection: AsyncMock, + exception: Exception, + translation_key: str, + translation_placeholders: dict[str, Any], + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + + value = 25 + model = Model.F1155 + address = 47398 + entity_id = "number.room_sensor_setpoint_s1_47398" + coils[address] = 0 + + await async_add_model(hass, model) + + await hass.async_block_till_done() + assert hass.states.get(entity_id) + + mock_connection.write_coil.side_effect = exception + + # Write value + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + assert exc_info.value.translation_domain == "nibe_heatpump" + assert exc_info.value.translation_key == translation_key + assert exc_info.value.translation_placeholders == translation_placeholders From 9d2ffa637248126335bea4544d20b88bff36b51a Mon Sep 17 00:00:00 2001 From: jlanchares <87146197+jlanchares@users.noreply.github.com> Date: Mon, 7 Jul 2025 19:37:20 +0200 Subject: [PATCH 1204/1664] Goodwe TCP support (port 502) (#147900) --- homeassistant/components/goodwe/__init__.py | 16 +++++++-- .../components/goodwe/config_flow.py | 36 ++++++++++++------- homeassistant/components/goodwe/manifest.json | 2 +- homeassistant/components/goodwe/select.py | 29 +++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 58 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/goodwe/__init__.py b/homeassistant/components/goodwe/__init__.py index b6637bc8b50..e191e1b775f 100644 --- a/homeassistant/components/goodwe/__init__.py +++ b/homeassistant/components/goodwe/__init__.py @@ -1,6 +1,7 @@ """The Goodwe inverter component.""" from goodwe import InverterError, connect +from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -20,11 +21,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoodweConfigEntry) -> bo try: inverter = await connect( host=host, + port=GOODWE_UDP_PORT, family=model_family, retries=10, ) - except InverterError as err: - raise ConfigEntryNotReady from err + except InverterError as err_udp: + # First try with UDP failed, trying with the TCP port + try: + inverter = await connect( + host=host, + port=GOODWE_TCP_PORT, + family=model_family, + retries=10, + ) + except InverterError: + # Both ports are unavailable + raise ConfigEntryNotReady from err_udp device_info = DeviceInfo( configuration_url="https://www.semsportal.com", diff --git a/homeassistant/components/goodwe/config_flow.py b/homeassistant/components/goodwe/config_flow.py index 354877e782f..72d27e02b2e 100644 --- a/homeassistant/components/goodwe/config_flow.py +++ b/homeassistant/components/goodwe/config_flow.py @@ -6,6 +6,7 @@ import logging from typing import Any from goodwe import InverterError, connect +from goodwe.const import GOODWE_TCP_PORT, GOODWE_UDP_PORT import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -27,6 +28,18 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + async def _handle_successful_connection(self, inverter, host): + await self.async_set_unique_id(inverter.serial_number) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_NAME, + data={ + CONF_HOST: host, + CONF_MODEL_FAMILY: type(inverter).__name__, + }, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -34,22 +47,19 @@ class GoodweFlowHandler(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: host = user_input[CONF_HOST] - try: - inverter = await connect(host=host, retries=10) + inverter = await connect(host=host, port=GOODWE_UDP_PORT, retries=10) except InverterError: - errors[CONF_HOST] = "connection_error" + try: + inverter = await connect( + host=host, port=GOODWE_TCP_PORT, retries=10 + ) + except InverterError: + errors[CONF_HOST] = "connection_error" + else: + return await self._handle_successful_connection(inverter, host) else: - await self.async_set_unique_id(inverter.serial_number) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=DEFAULT_NAME, - data={ - CONF_HOST: host, - CONF_MODEL_FAMILY: type(inverter).__name__, - }, - ) + return await self._handle_successful_connection(inverter, host) return self.async_show_form( step_id="user", data_schema=CONFIG_SCHEMA, errors=errors diff --git a/homeassistant/components/goodwe/manifest.json b/homeassistant/components/goodwe/manifest.json index 41e0ed91f6a..2f04ee3982f 100644 --- a/homeassistant/components/goodwe/manifest.json +++ b/homeassistant/components/goodwe/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/goodwe", "iot_class": "local_polling", "loggers": ["goodwe"], - "requirements": ["goodwe==0.3.6"] + "requirements": ["goodwe==0.4.8"] } diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py index c26e8135b3f..7d58b099ddc 100644 --- a/homeassistant/components/goodwe/select.py +++ b/homeassistant/components/goodwe/select.py @@ -54,17 +54,24 @@ async def async_setup_entry( # Inverter model does not support this setting _LOGGER.debug("Could not read inverter operation mode") else: - async_add_entities( - [ - InverterOperationModeEntity( - device_info, - OPERATION_MODE, - inverter, - [v for k, v in _MODE_TO_OPTION.items() if k in supported_modes], - _MODE_TO_OPTION[active_mode], - ) - ] - ) + active_mode_option = _MODE_TO_OPTION.get(active_mode) + if active_mode_option is not None: + async_add_entities( + [ + InverterOperationModeEntity( + device_info, + OPERATION_MODE, + inverter, + [v for k, v in _MODE_TO_OPTION.items() if k in supported_modes], + active_mode_option, + ) + ] + ) + else: + _LOGGER.warning( + "Active mode %s not found in Goodwe Inverter Operation Mode Entity. Skipping entity creation", + active_mode, + ) class InverterOperationModeEntity(SelectEntity): diff --git a/requirements_all.txt b/requirements_all.txt index bfd989f849e..974bdbd8d81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1035,7 +1035,7 @@ go2rtc-client==0.2.1 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.6 +goodwe==0.4.8 # homeassistant.components.google_mail # homeassistant.components.google_tasks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 78882ff5bd9..fcb92537c90 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -902,7 +902,7 @@ go2rtc-client==0.2.1 goalzero==0.2.2 # homeassistant.components.goodwe -goodwe==0.3.6 +goodwe==0.4.8 # homeassistant.components.google_mail # homeassistant.components.google_tasks From 0409c05265eb67f7116e37e6f0fdface3f1d9616 Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 8 Jul 2025 04:08:49 +0800 Subject: [PATCH 1205/1664] Add `basic` authentication option for Telegram bot (#148247) --- .../components/telegram_bot/services.yaml | 90 ++++++++++--------- .../components/telegram_bot/strings.json | 13 ++- 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index b1d94d381ac..ce7ebea2b66 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -82,6 +82,14 @@ send_photo: example: "My image" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -90,13 +98,6 @@ send_photo: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" target: example: "[12345, 67890] or 12345" selector: @@ -163,6 +164,14 @@ send_sticker: example: CAACAgIAAxkBAAEDDldhZD-hqWclr6krLq-FWSfCrGNmOQAC9gAD9HsZAAFeYY-ltPYnrCEE selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -171,13 +180,6 @@ send_sticker: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" target: example: "[12345, 67890] or 12345" selector: @@ -235,6 +237,14 @@ send_animation: example: "My animation" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -243,13 +253,6 @@ send_animation: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" target: example: "[12345, 67890] or 12345" selector: @@ -316,6 +319,14 @@ send_video: example: "My video" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -324,13 +335,6 @@ send_video: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" target: example: "[12345, 67890] or 12345" selector: @@ -397,6 +401,14 @@ send_voice: example: "My microphone recording" selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -405,13 +417,6 @@ send_voice: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" target: example: "[12345, 67890] or 12345" selector: @@ -469,6 +474,14 @@ send_document: example: Document Title xy selector: text: + authentication: + selector: + select: + options: + - "basic" + - "digest" + - "bearer_token" + translation_key: "authentication" username: example: myuser selector: @@ -477,13 +490,6 @@ send_document: example: myuser_pwd selector: text: - authentication: - default: digest - selector: - select: - options: - - "digest" - - "bearer_token" target: example: "[12345, 67890] or 12345" selector: diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 4187b6311d9..8ef71022492 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -130,6 +130,13 @@ "html": "HTML", "plain_text": "Plain text" } + }, + "authentication": { + "options": { + "basic": "Basic", + "digest": "Digest", + "bearer_token": "Bearer token" + } } }, "services": { @@ -213,15 +220,15 @@ }, "username": { "name": "[%key:common::config_flow::data::username%]", - "description": "Username for a URL which require HTTP authentication." + "description": "Username for a URL which requires HTTP `basic` or `digest` authentication." }, "password": { "name": "[%key:common::config_flow::data::password%]", - "description": "Password (or bearer token) for a URL which require HTTP authentication." + "description": "Password (or bearer token) for a URL which require authentication." }, "authentication": { "name": "Authentication method", - "description": "Define which authentication method to use. Set to `digest` to use HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication. Defaults to `basic`." + "description": "Define which authentication method to use. Set to `basic` for HTTP basic authentication, `digest` for HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication." }, "target": { "name": "Target", From fc53ddb3b47e6130e1ef78be78d0600d00948d78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 7 Jul 2025 22:08:43 +0000 Subject: [PATCH 1206/1664] Remove huawei_lte notify related timeout suppression (#148373) --- homeassistant/components/huawei_lte/__init__.py | 16 ---------------- homeassistant/components/huawei_lte/const.py | 1 - homeassistant/components/huawei_lte/notify.py | 3 --- 3 files changed, 20 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 6126968eab6..62d7ade1a0c 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -8,7 +8,6 @@ from contextlib import suppress from dataclasses import dataclass, field from datetime import timedelta import logging -import time from typing import Any, NamedTuple, cast from xml.parsers.expat import ExpatError @@ -78,7 +77,6 @@ from .const import ( KEY_WLAN_HOST_LIST, KEY_WLAN_WIFI_FEATURE_SWITCH, KEY_WLAN_WIFI_GUEST_NETWORK_SWITCH, - NOTIFY_SUPPRESS_TIMEOUT, SERVICE_RESUME_INTEGRATION, SERVICE_SUSPEND_INTEGRATION, UPDATE_SIGNAL, @@ -124,7 +122,6 @@ class Router: inflight_gets: set[str] = field(default_factory=set, init=False) client: Client = field(init=False) suspended: bool = field(default=False, init=False) - notify_last_attempt: float = field(default=-1, init=False) def __post_init__(self) -> None: """Set up internal state on init.""" @@ -195,19 +192,6 @@ class Router: key, ) self.subscriptions.pop(key) - except Timeout: - grace_left = ( - self.notify_last_attempt - time.monotonic() + NOTIFY_SUPPRESS_TIMEOUT - ) - if grace_left > 0: - _LOGGER.debug( - "%s timed out, %.1fs notify timeout suppress grace remaining", - key, - grace_left, - exc_info=True, - ) - else: - raise finally: self.inflight_gets.discard(key) _LOGGER.debug("%s=%s", key, self.data.get(key)) diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index af9bfd330e9..eaeb5579237 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -17,7 +17,6 @@ DEFAULT_UNAUTHENTICATED_MODE = False UPDATE_SIGNAL = f"{DOMAIN}_update" CONNECTION_TIMEOUT = 10 -NOTIFY_SUPPRESS_TIMEOUT = 30 SERVICE_RESUME_INTEGRATION = "resume_integration" SERVICE_SUSPEND_INTEGRATION = "suspend_integration" diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index fc154de3811..682470bafd0 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import time from typing import Any from huawei_lte_api.exceptions import ResponseErrorException @@ -62,5 +61,3 @@ class HuaweiLteSmsNotificationService(BaseNotificationService): _LOGGER.debug("Sent to %s: %s", targets, resp) except ResponseErrorException as ex: _LOGGER.error("Could not send to %s: %s", targets, ex) - finally: - self.router.notify_last_attempt = time.monotonic() From e3cc4acdc6105cb4df2197537de9b9863624ccc0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 8 Jul 2025 05:57:46 +0200 Subject: [PATCH 1207/1664] Remove deprecated `max_health`, `habits` and `rewards` sensors from Habitica integration (#148377) --- homeassistant/components/habitica/icons.json | 9 - homeassistant/components/habitica/sensor.py | 168 +-------- .../components/habitica/strings.json | 13 - .../habitica/snapshots/test_sensor.ambr | 349 ------------------ tests/components/habitica/test_sensor.py | 111 +----- 5 files changed, 9 insertions(+), 641 deletions(-) diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index d241d3855d6..be25bebe779 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -82,9 +82,6 @@ "0": "mdi:skull-outline" } }, - "health_max": { - "default": "mdi:heart" - }, "mana": { "default": "mdi:flask", "state": { @@ -121,12 +118,6 @@ "rogue": "mdi:ninja" } }, - "habits": { - "default": "mdi:contrast-box" - }, - "rewards": { - "default": "mdi:treasure-chest" - }, "strength": { "default": "mdi:arm-flex-outline" }, diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 5b64d0d8119..6d077495c4f 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -2,43 +2,26 @@ from __future__ import annotations -from collections.abc import Callable, Mapping -from dataclasses import asdict, dataclass +from collections.abc import Callable +from dataclasses import dataclass from enum import StrEnum import logging from typing import Any -from habiticalib import ( - ContentData, - HabiticaClass, - TaskData, - TaskType, - UserData, - deserialize_task, - ha, -) +from habiticalib import ContentData, HabiticaClass, TaskData, UserData, ha -from homeassistant.components.automation import automations_with_entity -from homeassistant.components.script import scripts_with_entity from homeassistant.components.sensor import ( - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorEntityDescription, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from .const import ASSETS_URL, DOMAIN -from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator +from .const import ASSETS_URL +from .coordinator import HabiticaConfigEntry from .entity import HabiticaBase from .util import ( get_attribute_points, @@ -84,7 +67,6 @@ class HabiticaSensorEntity(StrEnum): DISPLAY_NAME = "display_name" HEALTH = "health" - HEALTH_MAX = "health_max" MANA = "mana" MANA_MAX = "mana_max" EXPERIENCE = "experience" @@ -136,12 +118,6 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( value_fn=lambda user, _: user.stats.hp, entity_picture=ha.HP, ), - HabiticaSensorEntityDescription( - key=HabiticaSensorEntity.HEALTH_MAX, - translation_key=HabiticaSensorEntity.HEALTH_MAX, - entity_registry_enabled_default=False, - value_fn=lambda user, _: 50, - ), HabiticaSensorEntityDescription( key=HabiticaSensorEntity.MANA, translation_key=HabiticaSensorEntity.MANA, @@ -286,57 +262,6 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = ( ) -TASKS_MAP_ID = "id" -TASKS_MAP = { - "repeat": "repeat", - "challenge": "challenge", - "group": "group", - "frequency": "frequency", - "every_x": "everyX", - "streak": "streak", - "up": "up", - "down": "down", - "counter_up": "counterUp", - "counter_down": "counterDown", - "next_due": "nextDue", - "yester_daily": "yesterDaily", - "completed": "completed", - "collapse_checklist": "collapseChecklist", - "type": "Type", - "notes": "notes", - "tags": "tags", - "value": "value", - "priority": "priority", - "start_date": "startDate", - "days_of_month": "daysOfMonth", - "weeks_of_month": "weeksOfMonth", - "created_at": "createdAt", - "text": "text", - "is_due": "isDue", -} - - -TASK_SENSOR_DESCRIPTION: tuple[HabiticaTaskSensorEntityDescription, ...] = ( - HabiticaTaskSensorEntityDescription( - key=HabiticaSensorEntity.HABITS, - translation_key=HabiticaSensorEntity.HABITS, - value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.HABIT], - ), - HabiticaTaskSensorEntityDescription( - key=HabiticaSensorEntity.REWARDS, - translation_key=HabiticaSensorEntity.REWARDS, - value_fn=lambda tasks: [r for r in tasks if r.Type is TaskType.REWARD], - ), -) - - -def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]: - """Get list of related automations and scripts.""" - used_in = automations_with_entity(hass, entity_id) - used_in += scripts_with_entity(hass, entity_id) - return used_in - - async def async_setup_entry( hass: HomeAssistant, config_entry: HabiticaConfigEntry, @@ -345,59 +270,10 @@ async def async_setup_entry( """Set up the habitica sensors.""" coordinator = config_entry.runtime_data - ent_reg = er.async_get(hass) - entities: list[SensorEntity] = [] - description: SensorEntityDescription - def add_deprecated_entity( - description: SensorEntityDescription, - entity_cls: Callable[ - [HabiticaDataUpdateCoordinator, SensorEntityDescription], SensorEntity - ], - ) -> None: - """Add deprecated entities.""" - if entity_id := ent_reg.async_get_entity_id( - SENSOR_DOMAIN, - DOMAIN, - f"{config_entry.unique_id}_{description.key}", - ): - entity_entry = ent_reg.async_get(entity_id) - if entity_entry and entity_entry.disabled: - ent_reg.async_remove(entity_id) - async_delete_issue( - hass, - DOMAIN, - f"deprecated_entity_{description.key}", - ) - elif entity_entry: - entities.append(entity_cls(coordinator, description)) - if entity_used_in(hass, entity_id): - async_create_issue( - hass, - DOMAIN, - f"deprecated_entity_{description.key}", - breaks_in_ha_version="2025.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_entity", - translation_placeholders={ - "name": str( - entity_entry.name or entity_entry.original_name - ), - "entity": entity_id, - }, - ) - - for description in SENSOR_DESCRIPTIONS: - if description.key is HabiticaSensorEntity.HEALTH_MAX: - add_deprecated_entity(description, HabiticaSensor) - else: - entities.append(HabiticaSensor(coordinator, description)) - - for description in TASK_SENSOR_DESCRIPTION: - add_deprecated_entity(description, HabiticaTaskSensor) - - async_add_entities(entities, True) + async_add_entities( + HabiticaSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS + ) class HabiticaSensor(HabiticaBase, SensorEntity): @@ -441,31 +317,3 @@ class HabiticaSensor(HabiticaBase, SensorEntity): ) return None - - -class HabiticaTaskSensor(HabiticaBase, SensorEntity): - """A Habitica task sensor.""" - - entity_description: HabiticaTaskSensorEntityDescription - - @property - def native_value(self) -> StateType: - """Return the state of the device.""" - - return len(self.entity_description.value_fn(self.coordinator.data.tasks)) - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return the state attributes of all user tasks.""" - attrs = {} - - # Map tasks to TASKS_MAP - for task_data in self.entity_description.value_fn(self.coordinator.data.tasks): - received_task = deserialize_task(asdict(task_data)) - task_id = received_task[TASKS_MAP_ID] - task = {} - for map_key, map_value in TASKS_MAP.items(): - if value := received_task.get(map_value): - task[map_key] = value - attrs[str(task_id)] = task - return attrs diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 22bc79555e8..6f0b3dc35cd 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -4,7 +4,6 @@ "dailies": "Dailies", "config_entry_name": "Select character", "task_name": "Task name", - "unit_tasks": "tasks", "unit_health_points": "HP", "unit_mana_points": "MP", "unit_experience_points": "XP", @@ -276,10 +275,6 @@ "name": "Health", "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" }, - "health_max": { - "name": "Max. health", - "unit_of_measurement": "[%key:component::habitica::common::unit_health_points%]" - }, "mana": { "name": "Mana", "unit_of_measurement": "[%key:component::habitica::common::unit_mana_points%]" @@ -319,14 +314,6 @@ "rogue": "Rogue" } }, - "habits": { - "name": "Habits", - "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]" - }, - "rewards": { - "name": "Rewards", - "unit_of_measurement": "[%key:component::habitica::common::unit_tasks%]" - }, "strength": { "name": "Strength", "state_attributes": { diff --git a/tests/components/habitica/snapshots/test_sensor.ambr b/tests/components/habitica/snapshots/test_sensor.ambr index 06f9ff9a6cd..30c0f9d66eb 100644 --- a/tests/components/habitica/snapshots/test_sensor.ambr +++ b/tests/components/habitica/snapshots/test_sensor.ambr @@ -381,214 +381,6 @@ 'state': '137.625872146098', }) # --- -# name: test_sensors[sensor.test_user_habits-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.test_user_habits', - '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': 'Habits', - 'platform': 'habitica', - 'previous_unique_id': None, - 'suggested_object_id': 'test_user_habits', - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_habits', - 'unit_of_measurement': 'tasks', - }) -# --- -# name: test_sensors[sensor.test_user_habits-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - '1d147de6-5c02-4740-8e2f-71d3015a37f4': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.266000+00:00', - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Eine kurze Pause machen', - 'type': 'habit', - 'up': True, - }), - 'bc1d1855-b2b8-4663-98ff-62e7b763dfc4': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.265000+00:00', - 'down': True, - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'notes': 'Oder lösche es über die Bearbeitungs-Ansicht', - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Klicke hier um dies als schlechte Gewohnheit zu markieren, die Du gerne loswerden möchtest', - 'type': 'habit', - }), - 'e97659e0-2c42-4599-a7bb-00282adc410d': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.264000+00:00', - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'notes': 'Eine Gewohnheit, eine Tagesaufgabe oder ein To-Do', - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Füge eine Aufgabe zu Habitica hinzu', - 'type': 'habit', - 'up': True, - }), - 'f21fa608-cfc6-4413-9fc7-0eb1b48ca43a': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.268000+00:00', - 'down': True, - 'frequency': 'daily', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'text': 'Gesundes Essen/Junkfood', - 'type': 'habit', - 'up': True, - }), - 'friendly_name': 'test-user Habits', - 'unit_of_measurement': 'tasks', - }), - 'context': , - 'entity_id': 'sensor.test_user_habits', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '4', - }) -# --- # name: test_sensors[sensor.test_user_hatching_potions-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -853,55 +645,6 @@ 'state': '50.9', }) # --- -# name: test_sensors[sensor.test_user_max_health-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.test_user_max_health', - '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': 'Max. health', - 'platform': 'habitica', - 'previous_unique_id': None, - 'suggested_object_id': 'test_user_max_health', - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_health_max', - 'unit_of_measurement': 'HP', - }) -# --- -# name: test_sensors[sensor.test_user_max_health-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'test-user Max. health', - 'unit_of_measurement': 'HP', - }), - 'context': , - 'entity_id': 'sensor.test_user_max_health', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '50', - }) -# --- # name: test_sensors[sensor.test_user_max_mana-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1321,98 +1064,6 @@ 'state': '2', }) # --- -# name: test_sensors[sensor.test_user_rewards-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.test_user_rewards', - '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': 'Rewards', - 'platform': 'habitica', - 'previous_unique_id': None, - 'suggested_object_id': 'test_user_rewards', - 'supported_features': 0, - 'translation_key': , - 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_rewards', - 'unit_of_measurement': 'tasks', - }) -# --- -# name: test_sensors[sensor.test_user_rewards-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - '5e2ea1df-f6e6-4ba3-bccb-97c5ec63e99b': dict({ - 'challenge': dict({ - 'broken': None, - 'id': None, - 'shortName': None, - 'taskId': None, - 'winner': None, - }), - 'created_at': '2024-07-07T17:51:53.266000+00:00', - 'group': dict({ - 'assignedDate': None, - 'assignedUsers': list([ - ]), - 'assignedUsersDetail': dict({ - }), - 'assigningUsername': None, - 'completedBy': dict({ - 'date': None, - 'userId': None, - }), - 'id': None, - 'managerNotes': None, - 'taskId': None, - }), - 'notes': 'Schaue fern, spiele ein Spiel, gönne Dir einen Leckerbissen, es liegt ganz bei Dir!', - 'priority': 1, - 'repeat': dict({ - 'f': False, - 'm': True, - 's': False, - 'su': False, - 't': True, - 'th': False, - 'w': True, - }), - 'tags': list([ - '3450351f-1323-4c7e-9fd2-0cdff25b3ce0', - 'b2780f82-b3b5-49a3-a677-48f2c8c7e3bb', - ]), - 'text': 'Belohne Dich selbst', - 'type': 'reward', - 'value': 10.0, - }), - 'friendly_name': 'test-user Rewards', - 'unit_of_measurement': 'tasks', - }), - 'context': , - 'entity_id': 'sensor.test_user_rewards', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '1', - }) -# --- # name: test_sensors[sensor.test_user_saddles-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/habitica/test_sensor.py b/tests/components/habitica/test_sensor.py index 1c648e38720..9dde266d214 100644 --- a/tests/components/habitica/test_sensor.py +++ b/tests/components/habitica/test_sensor.py @@ -6,13 +6,10 @@ from unittest.mock import patch import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.habitica.const import DOMAIN -from homeassistant.components.habitica.sensor import HabiticaSensorEntity -from homeassistant.components.sensor.const import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform @@ -36,19 +33,6 @@ async def test_sensors( ) -> None: """Test setup of the Habitica sensor platform.""" - for entity in ( - ("test_user_habits", "habits"), - ("test_user_rewards", "rewards"), - ("test_user_max_health", "health_max"), - ): - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{entity[1]}", - suggested_object_id=entity[0], - disabled_by=None, - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -56,96 +40,3 @@ async def test_sensors( assert config_entry.state is ConfigEntryState.LOADED await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) - - -@pytest.mark.parametrize( - ("entity_id", "key"), - [ - ("test_user_habits", HabiticaSensorEntity.HABITS), - ("test_user_rewards", HabiticaSensorEntity.REWARDS), - ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), - ], -) -@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") -async def test_sensor_deprecation_issue( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, - entity_registry: er.EntityRegistry, - entity_id: str, - key: HabiticaSensorEntity, -) -> None: - """Test sensor deprecation issue.""" - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", - suggested_object_id=entity_id, - disabled_by=None, - ) - - assert entity_registry is not None - with patch( - "homeassistant.components.habitica.sensor.entity_used_in", return_value=True - ): - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - assert entity_registry.async_get(f"sensor.{entity_id}") is not None - assert issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=f"deprecated_entity_{key}", - ) - - -@pytest.mark.parametrize( - ("entity_id", "key"), - [ - ("test_user_habits", HabiticaSensorEntity.HABITS), - ("test_user_rewards", HabiticaSensorEntity.REWARDS), - ("test_user_max_health", HabiticaSensorEntity.HEALTH_MAX), - ], -) -@pytest.mark.usefixtures("habitica", "entity_registry_enabled_by_default") -async def test_sensor_deprecation_delete_disabled( - hass: HomeAssistant, - config_entry: MockConfigEntry, - issue_registry: ir.IssueRegistry, - entity_registry: er.EntityRegistry, - entity_id: str, - key: HabiticaSensorEntity, -) -> None: - """Test sensor deletion .""" - - entity_registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - f"a380546a-94be-4b8e-8a0b-23e0d5c03303_{key}", - suggested_object_id=entity_id, - disabled_by=er.RegistryEntryDisabler.USER, - ) - - assert entity_registry is not None - with patch( - "homeassistant.components.habitica.sensor.entity_used_in", return_value=True - ): - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.LOADED - - assert ( - issue_registry.async_get_issue( - domain=DOMAIN, - issue_id=f"deprecated_entity_{key}", - ) - is None - ) - - assert entity_registry.async_get(f"sensor.{entity_id}") is None From b151a9bf75ebf89547440a5f481916b8621357d4 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 8 Jul 2025 06:02:56 +0200 Subject: [PATCH 1208/1664] Add missing connection for gardena ble device (#148376) --- homeassistant/components/gardena_bluetooth/__init__.py | 2 ++ tests/components/gardena_bluetooth/snapshots/test_init.ambr | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/gardena_bluetooth/__init__.py b/homeassistant/components/gardena_bluetooth/__init__.py index 34f72bf0a5a..4a21bb3d3e4 100644 --- a/homeassistant/components/gardena_bluetooth/__init__.py +++ b/homeassistant/components/gardena_bluetooth/__init__.py @@ -13,6 +13,7 @@ from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.util import dt as dt_util @@ -74,6 +75,7 @@ async def async_setup_entry( device = DeviceInfo( identifiers={(DOMAIN, address)}, + connections={(dr.CONNECTION_BLUETOOTH, address)}, name=name, sw_version=sw_version, manufacturer=manufacturer, diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index 8dc9d220e85..d2af92b3f8f 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -6,6 +6,10 @@ 'config_entries_subentries': , 'configuration_url': None, 'connections': set({ + tuple( + 'bluetooth', + '00000000-0000-0000-0000-000000000001', + ), }), 'disabled_by': None, 'entry_type': None, From 4b8dcc39b477e56580c1687a9af8074a3b7f805a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 8 Jul 2025 06:05:18 +0200 Subject: [PATCH 1209/1664] Bump holidays to 0.76 (#148363) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/workday/test_config_flow.py | 1 + tests/components/workday/test_init.py | 6 +----- 6 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index c76d6638730..e39525563e9 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.75", "babel==2.15.0"] + "requirements": ["holidays==0.76", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index f9fae38f1f5..86c0884ee9d 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.75"] + "requirements": ["holidays==0.76"] } diff --git a/requirements_all.txt b/requirements_all.txt index 974bdbd8d81..9f9767a266d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1165,7 +1165,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.75 +holidays==0.76 # homeassistant.components.frontend home-assistant-frontend==20250702.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcb92537c90..575a1d52f80 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,7 +1014,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.75 +holidays==0.76 # homeassistant.components.frontend home-assistant-frontend==20250702.1 diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 2c0e9aa1123..c618c5fd830 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -108,6 +108,7 @@ async def test_form_province_no_alias(hass: HomeAssistant) -> None: "name": "Workday Sensor", "country": "US", "excludes": ["sat", "sun", "holiday"], + "language": "en_US", "days_offset": 0, "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index 2735175b49b..f288c340d9f 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -61,8 +61,4 @@ async def test_workday_subdiv_aliases() -> None: years=2025, ) subdiv_aliases = country.get_subdivision_aliases() - assert subdiv_aliases["GES"] == [ # codespell:ignore - "Alsace", - "Champagne-Ardenne", - "Lorraine", - ] + assert subdiv_aliases["6AE"] == ["Alsace"] From 19951d9403da55381d0002f924aaa254e77a6c37 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 8 Jul 2025 06:07:41 +0200 Subject: [PATCH 1210/1664] Handle when heat pump rejects same value writes in nibe_heatpump (#148366) --- .../components/nibe_heatpump/button.py | 1 + .../components/nibe_heatpump/coordinator.py | 15 +++--- .../components/nibe_heatpump/strings.json | 3 -- .../nibe_heatpump/snapshots/test_number.ambr | 18 +++++++ tests/components/nibe_heatpump/test_number.py | 47 +++++++++++++++++-- 5 files changed, 67 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py index 849912af656..8b6c8abf359 100644 --- a/homeassistant/components/nibe_heatpump/button.py +++ b/homeassistant/components/nibe_heatpump/button.py @@ -52,6 +52,7 @@ class NibeAlarmResetButton(CoordinatorEntity[CoilCoordinator], ButtonEntity): async def async_press(self) -> None: """Execute the command.""" + await self.coordinator.async_write_coil(self._reset_coil, 0) await self.coordinator.async_write_coil(self._reset_coil, 1) await self.coordinator.async_read_coil(self._alarm_coil) diff --git a/homeassistant/components/nibe_heatpump/coordinator.py b/homeassistant/components/nibe_heatpump/coordinator.py index 71f87698792..05e652d7f42 100644 --- a/homeassistant/components/nibe_heatpump/coordinator.py +++ b/homeassistant/components/nibe_heatpump/coordinator.py @@ -143,15 +143,12 @@ class CoilCoordinator(ContextCoordinator[dict[int, CoilData], int]): data = CoilData(coil, value) try: await self.connection.write_coil(data) - except WriteDeniedException as e: - raise HomeAssistantError( - translation_domain=DOMAIN, - translation_key="write_denied", - translation_placeholders={ - "address": str(coil.address), - "value": str(value), - }, - ) from e + except WriteDeniedException: + LOGGER.debug( + "Denied write on address %d with value %s. This is likely already the value the pump has internally", + coil.address, + value, + ) except WriteTimeoutException as e: raise HomeAssistantError( translation_domain=DOMAIN, diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json index 3312bc2287f..1b339526586 100644 --- a/homeassistant/components/nibe_heatpump/strings.json +++ b/homeassistant/components/nibe_heatpump/strings.json @@ -47,9 +47,6 @@ } }, "exceptions": { - "write_denied": { - "message": "Writing of coil {address} with value `{value}` was denied" - }, "write_timeout": { "message": "Timeout while writing coil {address}" }, diff --git a/tests/components/nibe_heatpump/snapshots/test_number.ambr b/tests/components/nibe_heatpump/snapshots/test_number.ambr index 343d5569a2d..9c0dbaa83ca 100644 --- a/tests/components/nibe_heatpump/snapshots/test_number.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_number.ambr @@ -1,4 +1,22 @@ # serializer version: 1 +# name: test_set_value_same + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1155 Room sensor setpoint S1', + 'max': 30.0, + 'min': 5.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.room_sensor_setpoint_s1_47398', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- # name: test_update[Model.F1155-47011-number.heat_offset_s1_47011--10] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index eeb9587f425..b789515e764 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -115,11 +115,6 @@ async def test_set_value( @pytest.mark.parametrize( ("exception", "translation_key", "translation_placeholders"), [ - ( - WriteDeniedException("denied"), - "write_denied", - {"address": "47398", "value": "25.0"}, - ), ( WriteTimeoutException("timeout writing"), "write_timeout", @@ -171,3 +166,45 @@ async def test_set_value_fail( assert exc_info.value.translation_domain == "nibe_heatpump" assert exc_info.value.translation_key == translation_key assert exc_info.value.translation_placeholders == translation_placeholders + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_set_value_same( + hass: HomeAssistant, + mock_connection: AsyncMock, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting a value, which the pump will reject.""" + + value = 25 + model = Model.F1155 + address = 47398 + entity_id = "number.room_sensor_setpoint_s1_47398" + coils[address] = 0 + + await async_add_model(hass, model) + + await hass.async_block_till_done() + assert hass.states.get(entity_id) + + mock_connection.write_coil.side_effect = WriteDeniedException() + + # Write value + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + # Verify attempt was done + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.value == value + + # State should have been set + assert hass.states.get(entity_id) == snapshot From 9ce03c79f00c675e1b8330a82b6df684d951f9a7 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 8 Jul 2025 06:09:22 +0200 Subject: [PATCH 1211/1664] Switch to box default for numbers in nibe_heatpump integration (#148364) --- homeassistant/components/nibe_heatpump/number.py | 3 ++- .../snapshots/test_coordinator.ambr | 16 ++++++++-------- .../nibe_heatpump/snapshots/test_number.ambr | 16 ++++++++-------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index d85e5e9b765..59f365f52bf 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from nibe.coil import Coil, CoilData -from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity +from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity, NumberMode from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant @@ -61,6 +61,7 @@ class Number(CoilEntity, NumberEntity): self._attr_native_step = 1 / coil.factor self._attr_native_unit_of_measurement = coil.unit + self._attr_mode = NumberMode.BOX def _async_read_coil(self, data: CoilData) -> None: if data.value is None: diff --git a/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr index 50755533ee5..965d5a3b2bb 100644 --- a/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_coordinator.ambr @@ -5,7 +5,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -22,7 +22,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -39,7 +39,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -56,7 +56,7 @@ 'friendly_name': 'S320 Min supply climate system 1', 'max': 80.0, 'min': 5.0, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -77,7 +77,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -94,7 +94,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -111,7 +111,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -128,7 +128,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , diff --git a/tests/components/nibe_heatpump/snapshots/test_number.ambr b/tests/components/nibe_heatpump/snapshots/test_number.ambr index 9c0dbaa83ca..49bdec9e4ea 100644 --- a/tests/components/nibe_heatpump/snapshots/test_number.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_number.ambr @@ -23,7 +23,7 @@ 'friendly_name': 'F1155 Heat Offset S1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -40,7 +40,7 @@ 'friendly_name': 'F1155 Heat Offset S1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -60,7 +60,7 @@ 'friendly_name': 'F750 HW charge offset', 'max': 12.7, 'min': -12.8, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -78,7 +78,7 @@ 'friendly_name': 'F750 HW charge offset', 'max': 12.7, 'min': -12.8, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -96,7 +96,7 @@ 'friendly_name': 'F750 HW charge offset', 'max': 12.7, 'min': -12.8, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), @@ -114,7 +114,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -131,7 +131,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , @@ -148,7 +148,7 @@ 'friendly_name': 'S320 Heating offset climate system 1', 'max': 10.0, 'min': -10.0, - 'mode': , + 'mode': , 'step': 1.0, }), 'context': , From f478812568be424fe93907853a57853ec664adb3 Mon Sep 17 00:00:00 2001 From: Ruben van Dijk <15885455+RubenNL@users.noreply.github.com> Date: Tue, 8 Jul 2025 06:13:08 +0200 Subject: [PATCH 1212/1664] Allow multiple set-cookie headers with hassio ingress (#148148) --- homeassistant/components/hassio/ingress.py | 16 ++++++++-------- tests/components/hassio/test_ingress.py | 14 +++++++++++++- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index ca6764cfa34..e1f96b76bcb 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -239,13 +239,13 @@ def _forwarded_for_header(forward_for: str | None, peer_name: str) -> str: return f"{forward_for}, {connected_ip!s}" if forward_for else f"{connected_ip!s}" -def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, str]: +def _init_header(request: web.Request, token: str) -> CIMultiDict: """Create initial header.""" - headers = { - name: value + headers = CIMultiDict( + (name, value) for name, value in request.headers.items() if name not in INIT_HEADERS_FILTER - } + ) # Ingress information headers[X_HASS_SOURCE] = "core.ingress" headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" @@ -273,13 +273,13 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st return headers -def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]: +def _response_header(response: aiohttp.ClientResponse) -> CIMultiDict: """Create response header.""" - return { - name: value + return CIMultiDict( + (name, value) for name, value in response.headers.items() if name not in RESPONSE_HEADERS_FILTER - } + ) def _is_websocket(request: web.Request) -> bool: diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 069abaa8513..cad410e6a21 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -4,6 +4,7 @@ from http import HTTPStatus from unittest.mock import MagicMock, patch from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO +from multidict import CIMultiDict import pytest from homeassistant.components.hassio.const import X_AUTH_TOKEN @@ -28,15 +29,22 @@ async def test_ingress_request_get( aioclient_mock.get( f"http://127.0.0.1/ingress/{build_type[0]}/{build_type[1]}", text="test", + headers=CIMultiDict( + [("Set-Cookie", "cookie1=value1"), ("Set-Cookie", "cookie2=value2")] + ), ) resp = await hassio_noauth_client.get( f"/api/hassio_ingress/{build_type[0]}/{build_type[1]}", - headers={"X-Test-Header": "beer"}, + headers=CIMultiDict( + [("X-Test-Header", "beer"), ("X-Test-Header", "more beer")] + ), ) # Check we got right response assert resp.status == HTTPStatus.OK + assert resp.headers["Set-Cookie"] == "cookie1=value1" + assert resp.headers.getall("Set-Cookie") == ["cookie1=value1", "cookie2=value2"] body = await resp.text() assert body == "test" @@ -49,6 +57,10 @@ async def test_ingress_request_get( == f"/api/hassio_ingress/{build_type[0]}" ) assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer" + assert aioclient_mock.mock_calls[-1][3].getall("X-Test-Header") == [ + "beer", + "more beer", + ] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST] assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO] From 7875290256bedb11e3bbd828c1c3af8a20ddcf1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 8 Jul 2025 05:24:31 +0100 Subject: [PATCH 1213/1664] Adds claude-code feature to the devcontainer (#148338) --- .devcontainer/devcontainer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 29d5a95ea01..085aa9c2b01 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,6 +8,7 @@ "PYTHONASYNCIODEBUG": "1" }, "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, "ghcr.io/devcontainers/features/github-cli:1": {} }, // Port 5683 udp is used by Shelly integration From b0f7c985e41580ff4614e39cf9a9380621263fe9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 8 Jul 2025 06:25:53 +0200 Subject: [PATCH 1214/1664] Add snapshots tests for new platforms in tuya (#148334) --- tests/components/tuya/__init__.py | 40 ++ tests/components/tuya/conftest.py | 5 +- ...ete_two_12l_dehumidifier_air_purifier.json | 24 +- .../tuya/fixtures/cwwsq_cleverio_pf100.json | 101 +++ .../cwysj_pixi_smart_drinking_fountain.json | 132 ++++ .../fixtures/cz_dual_channel_metering.json | 88 +++ .../tuya/fixtures/mcs_door_sensor.json | 28 +- .../tuya/fixtures/sfkzq_valve_controller.json | 56 ++ tests/components/tuya/fixtures/tdq_4_443.json | 248 +++++++ .../tuya/snapshots/test_binary_sensor.ambr | 50 ++ .../tuya/snapshots/test_number.ambr | 58 ++ .../tuya/snapshots/test_select.ambr | 59 ++ .../tuya/snapshots/test_sensor.ambr | 486 +++++++++++++- .../tuya/snapshots/test_switch.ambr | 632 ++++++++++++++++++ tests/components/tuya/test_binary_sensor.py | 58 ++ tests/components/tuya/test_fan.py | 23 +- tests/components/tuya/test_humidifier.py | 24 +- tests/components/tuya/test_number.py | 55 ++ tests/components/tuya/test_select.py | 23 +- tests/components/tuya/test_sensor.py | 25 +- tests/components/tuya/test_switch.py | 55 ++ 21 files changed, 2240 insertions(+), 30 deletions(-) create mode 100644 tests/components/tuya/fixtures/cwwsq_cleverio_pf100.json create mode 100644 tests/components/tuya/fixtures/cwysj_pixi_smart_drinking_fountain.json create mode 100644 tests/components/tuya/fixtures/cz_dual_channel_metering.json create mode 100644 tests/components/tuya/fixtures/sfkzq_valve_controller.json create mode 100644 tests/components/tuya/fixtures/tdq_4_443.json create mode 100644 tests/components/tuya/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/tuya/snapshots/test_number.ambr create mode 100644 tests/components/tuya/snapshots/test_switch.ambr create mode 100644 tests/components/tuya/test_binary_sensor.py create mode 100644 tests/components/tuya/test_number.py create mode 100644 tests/components/tuya/test_switch.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1d468a46814..7ca1312154f 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -7,10 +7,50 @@ from unittest.mock import patch from tuya_sharing import CustomerDevice from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +DEVICE_MOCKS = { + "cs_arete_two_12l_dehumidifier_air_purifier": [ + Platform.FAN, + Platform.HUMIDIFIER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "cwwsq_cleverio_pf100": [ + # https://github.com/home-assistant/core/issues/144745 + Platform.NUMBER, + Platform.SENSOR, + ], + "cwysj_pixi_smart_drinking_fountain": [ + # https://github.com/home-assistant/core/pull/146599 + Platform.SENSOR, + Platform.SWITCH, + ], + "cz_dual_channel_metering": [ + # https://github.com/home-assistant/core/issues/147149 + Platform.SENSOR, + Platform.SWITCH, + ], + "mcs_door_sensor": [ + # https://github.com/home-assistant/core/issues/108301 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], + "sfkzq_valve_controller": [ + # https://github.com/home-assistant/core/issues/148116 + Platform.SWITCH, + ], + "tdq_4_443": [ + # https://github.com/home-assistant/core/issues/146845 + Platform.SELECT, + Platform.SWITCH, + ], +} + async def initialize_entry( hass: HomeAssistant, diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 017c6f00241..7884597576d 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -18,6 +18,7 @@ from homeassistant.components.tuya.const import ( DOMAIN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import json_dumps from tests.common import MockConfigEntry, async_load_json_object_fixture @@ -142,11 +143,11 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev device.product_name = details["product_name"] device.online = details["online"] device.function = { - key: MagicMock(type=value["type"], values=value["values"]) + key: MagicMock(type=value["type"], values=json_dumps(value["value"])) for key, value in details["function"].items() } device.status_range = { - key: MagicMock(type=value["type"], values=value["values"]) + key: MagicMock(type=value["type"], values=json_dumps(value["value"])) for key, value in details["status_range"].items() } device.status = details["status"] diff --git a/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json b/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json index 1e50e7e3fec..5574153a439 100644 --- a/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json +++ b/tests/components/tuya/fixtures/cs_arete_two_12l_dehumidifier_air_purifier.json @@ -6,39 +6,41 @@ "product_name": "Arete\u00ae Two 12L Dehumidifier/Air Purifier", "online": true, "function": { - "switch": { "type": "Boolean", "values": "{}" }, + "switch": { "type": "Boolean", "value": {} }, "dehumidify_set_value": { "type": "Integer", - "values": "{\"unit\": \"%\", \"min\": 35, \"max\": 70, \"scale\": 0, \"step\": 5}" + "value": { "unit": "%", "min": 35, "max": 70, "scale": 0, "step": 5 } }, - "child_lock": { "type": "Boolean", "values": "{}" }, + "child_lock": { "type": "Boolean", "value": {} }, "countdown_set": { "type": "Enum", - "values": "{\"range\": [\"cancel\", \"1h\", \"2h\", \"3h\"]}" + "value": { "range": ["cancel", "1h", "2h", "3h"] } } }, "status_range": { - "switch": { "type": "Boolean", "values": "{}" }, + "switch": { "type": "Boolean", "value": {} }, "dehumidify_set_value": { "type": "Integer", - "values": "{\"unit\": \"%\", \"min\": 35, \"max\": 70, \"scale\": 0, \"step\": 5}" + "value": { "unit": "%", "min": 35, "max": 70, "scale": 0, "step": 5 } }, - "child_lock": { "type": "Boolean", "values": "{}" }, + "child_lock": { "type": "Boolean", "value": {} }, "humidity_indoor": { "type": "Integer", - "values": "{\"unit\": \"%\", \"min\": 0, \"max\": 100, \"scale\": 0, \"step\": 1}" + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } }, "countdown_set": { "type": "Enum", - "values": "{\"range\": [\"cancel\", \"1h\", \"2h\", \"3h\"]}" + "value": { "range": ["cancel", "1h", "2h", "3h"] } }, "countdown_left": { "type": "Integer", - "values": "{\"unit\": \"h\", \"min\": 0, \"max\": 24, \"scale\": 0, \"step\": 1}" + "value": { "unit": "h", "min": 0, "max": 24, "scale": 0, "step": 1 } }, "fault": { "type": "Bitmap", - "values": "{\"label\": [\"tankfull\", \"defrost\", \"E1\", \"E2\", \"L2\", \"L3\", \"L4\", \"wet\"]}" + "value": { + "label": ["tankfull", "defrost", "E1", "E2", "L2", "L3", "L4", "wet"] + } } }, "status": { diff --git a/tests/components/tuya/fixtures/cwwsq_cleverio_pf100.json b/tests/components/tuya/fixtures/cwwsq_cleverio_pf100.json new file mode 100644 index 00000000000..ec6f3ce5122 --- /dev/null +++ b/tests/components/tuya/fixtures/cwwsq_cleverio_pf100.json @@ -0,0 +1,101 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1747045731408d0tb5M", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfd0273e59494eb34esvrx", + "name": "Cleverio PF100", + "category": "cwwsq", + "product_id": "wfkzyy0evslzsmoi", + "product_name": "Cleverio PF100", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-10-20T13:09:34+00:00", + "create_time": "2024-10-20T13:09:34+00:00", + "update_time": "2024-10-20T13:09:34+00:00", + "function": { + "meal_plan": { + "type": "Raw", + "value": {} + }, + "manual_feed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "meal_plan": { + "type": "Raw", + "value": {} + }, + "manual_feed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "factory_reset": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "charge_state": { + "type": "Boolean", + "value": {} + }, + "feed_report": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 20, + "scale": 0, + "step": 1 + } + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "meal_plan": "fwQAAgB/BgABAH8JAAIBfwwAAQB/DwACAX8VAAIBfxcAAQAIEgABAQ==", + "manual_feed": 1, + "factory_reset": false, + "battery_percentage": 90, + "charge_state": false, + "feed_report": 2, + "light": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cwysj_pixi_smart_drinking_fountain.json b/tests/components/tuya/fixtures/cwysj_pixi_smart_drinking_fountain.json new file mode 100644 index 00000000000..0f5e5e5f241 --- /dev/null +++ b/tests/components/tuya/fixtures/cwysj_pixi_smart_drinking_fountain.json @@ -0,0 +1,132 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1751729689584Vh0VoL", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "23536058083a8dc57d96", + "name": "PIXI Smart Drinking Fountain", + "category": "cwysj", + "product_id": "z3rpyvznfcch99aa", + "product_name": "", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-06-17T13:29:17+00:00", + "create_time": "2025-06-17T13:29:17+00:00", + "update_time": "2025-06-17T13:29:17+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_reset": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "uv_runtime": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 10800, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "water_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 7200, + "scale": 0, + "step": 1 + } + }, + "filter_life": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 43200, + "scale": 0, + "step": 1 + } + }, + "pump_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "water_reset": { + "type": "Boolean", + "value": {} + }, + "filter_reset": { + "type": "Boolean", + "value": {} + }, + "pump_reset": { + "type": "Boolean", + "value": {} + }, + "uv": { + "type": "Boolean", + "value": {} + }, + "uv_runtime": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 10800, + "scale": 0, + "step": 1 + } + }, + "water_level": { + "type": "Enum", + "value": { + "range": ["level_1", "level_2", "level_3"] + } + } + }, + "status": { + "switch": true, + "water_time": 0, + "filter_life": 18965, + "pump_time": 18965, + "water_reset": false, + "filter_reset": false, + "pump_reset": false, + "uv": false, + "uv_runtime": 0, + "water_level": "level_3" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cz_dual_channel_metering.json b/tests/components/tuya/fixtures/cz_dual_channel_metering.json new file mode 100644 index 00000000000..9cd3c4ffd6f --- /dev/null +++ b/tests/components/tuya/fixtures/cz_dual_channel_metering.json @@ -0,0 +1,88 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1742695000703Ozq34h", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "eb0c772dabbb19d653ssi5", + "name": "HVAC Meter", + "category": "cz", + "product_id": "2jxesipczks0kdct", + "product_name": "Dual channel metering", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2025-06-19T14:19:08+00:00", + "create_time": "2025-06-19T14:19:08+00:00", + "update_time": "2025-06-19T14:19:08+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "add_ele": { + "type": "Integer", + "value": { + "unit": "kwh", + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "A", + "min": 0, + "max": 80000, + "scale": 3, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 200000, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "switch_1": true, + "switch_2": true, + "add_ele": 190, + "cur_current": 83, + "cur_power": 64, + "cur_voltage": 1217 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/mcs_door_sensor.json b/tests/components/tuya/fixtures/mcs_door_sensor.json index cec9547c2ea..c73b6c34878 100644 --- a/tests/components/tuya/fixtures/mcs_door_sensor.json +++ b/tests/components/tuya/fixtures/mcs_door_sensor.json @@ -1,16 +1,38 @@ { + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "380", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, "id": "bf5cccf9027080e2dbb9w3", - "name": "Door Sensor", + "name": "Door Garage ", + "model": "", "category": "mcs", "product_id": "7jIGJAymiH8OsFFb", "product_name": "Door Sensor", "online": true, + "sub": true, + "time_zone": "+01:00", + "active_time": "2024-01-18T12:27:56+00:00", + "create_time": "2024-01-18T12:27:56+00:00", + "update_time": "2024-01-18T12:29:19+00:00", "function": {}, "status_range": { - "switch": { "type": "Boolean", "values": "{}" }, + "switch": { + "type": "Boolean", + "value": {} + }, "battery": { "type": "Integer", - "values": "{\"unit\": \"\", \"min\": 0, \"max\": 500, \"scale\": 0, \"step\": 1}" + "value": { + "unit": "", + "min": 0, + "max": 500, + "scale": 0, + "step": 1 + } } }, "status": { diff --git a/tests/components/tuya/fixtures/sfkzq_valve_controller.json b/tests/components/tuya/fixtures/sfkzq_valve_controller.json new file mode 100644 index 00000000000..dd95050e2bf --- /dev/null +++ b/tests/components/tuya/fixtures/sfkzq_valve_controller.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1739471569144tcmeiO", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb9bfc18eeaed2d85yt5m", + "name": "Sprinkler Cesare", + "category": "sfkzq", + "product_id": "o6dagifntoafakst", + "product_name": "Valve Controller", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-19T07:56:02+00:00", + "create_time": "2025-06-19T07:56:02+00:00", + "update_time": "2025-06-19T07:56:02+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "countdown": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/tdq_4_443.json b/tests/components/tuya/fixtures/tdq_4_443.json new file mode 100644 index 00000000000..c139e79d19b --- /dev/null +++ b/tests/components/tuya/fixtures/tdq_4_443.json @@ -0,0 +1,248 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1748383912663Y2lvlm", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf082711d275c0c883vb4p", + "name": "4-433", + "category": "tdq", + "product_id": "cq1p0nt0a4rixnex", + "product_name": "4-433", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-12T16:57:13+00:00", + "create_time": "2025-06-12T16:57:13+00:00", + "update_time": "2025-06-12T16:57:13+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "switch_interlock": { + "type": "Raw", + "value": {} + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "switch_2": { + "type": "Boolean", + "value": {} + }, + "switch_3": { + "type": "Boolean", + "value": {} + }, + "switch_4": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_2": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_3": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "countdown_4": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["0", "1", "2"] + } + }, + "random_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_inching": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "switch_type": { + "type": "Enum", + "value": { + "range": ["flip", "sync", "button"] + } + }, + "switch_interlock": { + "type": "Raw", + "value": {} + }, + "remote_add": { + "type": "Raw", + "value": {} + }, + "remote_list": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch_1": false, + "switch_2": false, + "switch_3": false, + "switch_4": true, + "countdown_1": 0, + "countdown_2": 0, + "countdown_3": 0, + "countdown_4": 0, + "test_bit": 0, + "relay_status": 2, + "random_time": "", + "cycle_time": "", + "switch_inching": "AQAjAwAeBAACBgAC", + "switch_type": "button", + "switch_interlock": "", + "remote_add": "AAA=", + "remote_list": "AA==" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..aacda463769 --- /dev/null +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-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.door_garage_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.bf5cccf9027080e2dbb9w3switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Door Garage Door', + }), + 'context': , + 'entity_id': 'binary_sensor.door_garage_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr new file mode 100644 index 00000000000..6d741e4e76c --- /dev/null +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 20.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': None, + 'entity_id': 'number.cleverio_pf100_feed', + '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': 'Feed', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'feed', + 'unique_id': 'tuya.bfd0273e59494eb34esvrxmanual_feed', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cleverio PF100 Feed', + 'max': 20.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.cleverio_pf100_feed', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index a9daca637b5..b9e11f5b50a 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -60,3 +60,62 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[tdq_4_443][select.4_433_power_on_behavior-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.4_433_power_on_behavior', + '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 on behavior', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'relay_status', + 'unique_id': 'tuya.bf082711d275c0c883vb4prelay_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][select.4_433_power_on_behavior-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '4-433 Power on behavior', + 'options': list([ + '0', + '1', + '2', + ]), + }), + 'context': , + 'entity_id': 'select.4_433_power_on_behavior', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 47709b03a5e..562f34cc8b9 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -52,7 +52,483 @@ 'state': '47.0', }) # --- -# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_sensor_battery-entry] +# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_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.cleverio_pf100_last_amount', + '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': 'Last amount', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_amount', + 'unique_id': 'tuya.bfd0273e59494eb34esvrxfeed_report', + 'unit_of_measurement': '', + }) +# --- +# name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Cleverio PF100 Last amount', + 'state_class': , + 'unit_of_measurement': '', + }), + 'context': , + 'entity_id': 'sensor.cleverio_pf100_last_amount', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-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.pixi_smart_drinking_fountain_filter_duration', + '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': 'Filter duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_duration', + 'unique_id': 'tuya.23536058083a8dc57d96filter_life', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_filter_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18965.0', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-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.pixi_smart_drinking_fountain_uv_runtime', + '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': 'UV runtime', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_runtime', + 'unique_id': 'tuya.23536058083a8dc57d96uv_runtime', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', + 'state_class': , + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_uv_runtime', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_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.pixi_smart_drinking_fountain_water_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': 'Water level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_level_state', + 'unique_id': 'tuya.23536058083a8dc57d96water_level', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water level', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'level_3', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-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.pixi_smart_drinking_fountain_water_pump_duration', + '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': 'Water pump duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pump_time', + 'unique_id': 'tuya.23536058083a8dc57d96pump_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_pump_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18965.0', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-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.pixi_smart_drinking_fountain_water_usage_duration', + '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': 'Water usage duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_time', + 'unique_id': 'tuya.23536058083a8dc57d96water_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', + 'state_class': , + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'sensor.pixi_smart_drinking_fountain_water_usage_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_current-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.hvac_meter_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'HVAC Meter Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.083', + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_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.hvac_meter_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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_power', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'HVAC Meter Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.4', + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_voltage-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.hvac_meter_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': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'HVAC Meter Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.hvac_meter_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '121.7', + }) +# --- +# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_garage_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -67,7 +543,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': , - 'entity_id': 'sensor.door_sensor_battery', + 'entity_id': 'sensor.door_garage_battery', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -89,16 +565,16 @@ 'unit_of_measurement': '%', }) # --- -# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_sensor_battery-state] +# name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_garage_battery-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'battery', - 'friendly_name': 'Door Sensor Battery', + 'friendly_name': 'Door Garage Battery', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.door_sensor_battery', + 'entity_id': 'sensor.door_garage_battery', 'last_changed': , 'last_reported': , 'last_updated': , diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr new file mode 100644 index 00000000000..d4d94d4a119 --- /dev/null +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -0,0 +1,632 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][switch.dehumidifier_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.dehumidifier_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][switch.dehumidifier_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifier_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-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.pixi_smart_drinking_fountain_filter_reset', + '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': 'Filter reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'filter_reset', + 'unique_id': 'tuya.23536058083a8dc57d96filter_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Filter reset', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_filter_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_power-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': None, + 'entity_id': 'switch.pixi_smart_drinking_fountain_power', + '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', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.23536058083a8dc57d96switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Power', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-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.pixi_smart_drinking_fountain_reset_of_water_usage_days', + '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': 'Reset of water usage days', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'reset_of_water_usage_days', + 'unique_id': 'tuya.23536058083a8dc57d96water_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_reset_of_water_usage_days-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Reset of water usage days', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_reset_of_water_usage_days', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_uv_sterilization-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.pixi_smart_drinking_fountain_uv_sterilization', + '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': 'UV sterilization', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'uv_sterilization', + 'unique_id': 'tuya.23536058083a8dc57d96uv', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_uv_sterilization-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain UV sterilization', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_uv_sterilization', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_water_pump_reset-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.pixi_smart_drinking_fountain_water_pump_reset', + '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': 'Water pump reset', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_pump_reset', + 'unique_id': 'tuya.23536058083a8dc57d96pump_reset', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_water_pump_reset-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'PIXI Smart Drinking Fountain Water pump reset', + }), + 'context': , + 'entity_id': 'switch.pixi_smart_drinking_fountain_water_pump_reset', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_1-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': None, + 'entity_id': 'switch.hvac_meter_socket_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket_1', + 'unique_id': 'tuya.eb0c772dabbb19d653ssi5switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'HVAC Meter Socket 1', + }), + 'context': , + 'entity_id': 'switch.hvac_meter_socket_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_2-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': None, + 'entity_id': 'switch.hvac_meter_socket_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Socket 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'socket_2', + 'unique_id': 'tuya.eb0c772dabbb19d653ssi5switch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cz_dual_channel_metering][switch.hvac_meter_socket_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'HVAC Meter Socket 2', + }), + 'context': , + 'entity_id': 'switch.hvac_meter_socket_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-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': None, + 'entity_id': 'switch.sprinkler_cesare_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bfb9bfc18eeaed2d85yt5mswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sprinkler Cesare Switch', + }), + 'context': , + 'entity_id': 'switch.sprinkler_cesare_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_1-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': None, + 'entity_id': 'switch.4_433_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_1', + 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 1', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_2-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': None, + 'entity_id': 'switch.4_433_switch_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_2', + 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 2', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_3-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': None, + 'entity_id': 'switch.4_433_switch_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 3', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_3', + 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 3', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_4-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': None, + 'entity_id': 'switch.4_433_switch_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 4', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_4', + 'unique_id': 'tuya.bf082711d275c0c883vb4pswitch_4', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[tdq_4_443][switch.4_433_switch_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': '4-433 Switch 4', + }), + 'context': , + 'entity_id': 'switch.4_433_switch_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py new file mode 100644 index 00000000000..ec2120db0b4 --- /dev/null +++ b/tests/components/tuya/test_binary_sensor.py @@ -0,0 +1,58 @@ +"""Test Tuya binary sensor platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BINARY_SENSOR in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.BINARY_SENSOR not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index f8a2c5bbee8..736ac0d0691 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -13,13 +13,13 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import initialize_entry +from . import DEVICE_MOCKS, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - "mock_device_code", ["cs_arete_two_12l_dehumidifier_air_purifier"] + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.FAN in v] ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) async def test_platform_setup_and_discovery( @@ -34,3 +34,22 @@ async def test_platform_setup_and_discovery( await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.FAN not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.FAN]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index aad5782ee13..7b68de17698 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -13,13 +13,13 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import initialize_entry +from . import DEVICE_MOCKS, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - "mock_device_code", ["cs_arete_two_12l_dehumidifier_air_purifier"] + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.HUMIDIFIER in v] ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) async def test_platform_setup_and_discovery( @@ -34,3 +34,23 @@ async def test_platform_setup_and_discovery( await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.HUMIDIFIER not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.HUMIDIFIER]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py new file mode 100644 index 00000000000..44ed8eaf9b3 --- /dev/null +++ b/tests/components/tuya/test_number.py @@ -0,0 +1,55 @@ +"""Test Tuya number platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.NUMBER in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.NUMBER not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.NUMBER]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index 5f1111a0fd3..cf6ce169256 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -13,13 +13,13 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import initialize_entry +from . import DEVICE_MOCKS, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - "mock_device_code", ["cs_arete_two_12l_dehumidifier_air_purifier"] + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SELECT in v] ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) async def test_platform_setup_and_discovery( @@ -34,3 +34,22 @@ async def test_platform_setup_and_discovery( await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SELECT not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SELECT]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index bf424e289ef..7f1e71dabc2 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -13,16 +13,16 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import initialize_entry +from . import DEVICE_MOCKS, initialize_entry from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.parametrize( - "mock_device_code", - ["cs_arete_two_12l_dehumidifier_air_purifier", "mcs_door_sensor"], + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SENSOR in v] ) @patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_platform_setup_and_discovery( hass: HomeAssistant, mock_manager: ManagerCompat, @@ -35,3 +35,22 @@ async def test_platform_setup_and_discovery( await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SENSOR not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SENSOR]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py new file mode 100644 index 00000000000..68e8c9e29c4 --- /dev/null +++ b/tests/components/tuya/test_switch.py @@ -0,0 +1,55 @@ +"""Test Tuya switch platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SWITCH in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SWITCH not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SWITCH]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From ccc80c78a00325b4ca40e69fe699dd27ba05f33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 8 Jul 2025 04:32:29 +0000 Subject: [PATCH 1215/1664] Add huawei_lte device registry upnp udn connection (#148370) --- homeassistant/components/huawei_lte/__init__.py | 6 +++++- homeassistant/components/huawei_lte/config_flow.py | 13 +++++++++---- homeassistant/components/huawei_lte/const.py | 1 + tests/components/huawei_lte/test_config_flow.py | 7 ++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 62d7ade1a0c..56b7c5023f5 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -57,6 +57,7 @@ from .const import ( ATTR_CONFIG_ENTRY_ID, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, + CONF_UPNP_UDN, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_MANUFACTURER, @@ -147,9 +148,12 @@ class Router: @property def device_connections(self) -> set[tuple[str, str]]: """Get router connections for device registry.""" - return { + connections = { (dr.CONNECTION_NETWORK_MAC, x) for x in self.config_entry.data[CONF_MAC] } + if udn := self.config_entry.data.get(CONF_UPNP_UDN): + connections.add((dr.CONNECTION_UPNP, udn)) + return connections def _get_data(self, key: str, func: Callable[[], Any]) -> None: if not self.subscriptions.get(key): diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index f574441afed..002f19bc9e0 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -51,6 +51,7 @@ from .const import ( CONF_MANUFACTURER, CONF_TRACK_WIRED_CLIENTS, CONF_UNAUTHENTICATED_MODE, + CONF_UPNP_UDN, CONNECTION_TIMEOUT, DEFAULT_DEVICE_NAME, DEFAULT_NOTIFY_SERVICE_NAME, @@ -69,6 +70,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 3 manufacturer: str | None = None + upnp_udn: str | None = None url: str | None = None @staticmethod @@ -250,6 +252,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): { CONF_MAC: get_device_macs(info, wlan_settings), CONF_MANUFACTURER: self.manufacturer, + CONF_UPNP_UDN: self.upnp_udn, } ) @@ -284,11 +287,12 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): # url_normalize only returns None if passed None, and we don't do that assert url is not None - unique_id = discovery_info.upnp.get( - ATTR_UPNP_SERIAL, discovery_info.upnp[ATTR_UPNP_UDN] - ) + upnp_udn = discovery_info.upnp.get(ATTR_UPNP_UDN) + unique_id = discovery_info.upnp.get(ATTR_UPNP_SERIAL, upnp_udn) await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured(updates={CONF_URL: url}) + self._abort_if_unique_id_configured( + updates={CONF_UPNP_UDN: upnp_udn, CONF_URL: url} + ) def _is_supported_device() -> bool: """See if we are looking at a possibly supported device. @@ -319,6 +323,7 @@ class HuaweiLteConfigFlow(ConfigFlow, domain=DOMAIN): } ) self.manufacturer = discovery_info.upnp.get(ATTR_UPNP_MANUFACTURER) + self.upnp_udn = upnp_udn self.url = url return await self._async_show_user_form() diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index eaeb5579237..b7662200767 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -7,6 +7,7 @@ ATTR_CONFIG_ENTRY_ID = "config_entry_id" CONF_MANUFACTURER = "manufacturer" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" +CONF_UPNP_UDN = "upnp_udn" DEFAULT_DEVICE_NAME = "LTE" DEFAULT_MANUFACTURER = "Huawei Technologies Co., Ltd." diff --git a/tests/components/huawei_lte/test_config_flow.py b/tests/components/huawei_lte/test_config_flow.py index 5e018e73f2a..e40a3ca5a01 100644 --- a/tests/components/huawei_lte/test_config_flow.py +++ b/tests/components/huawei_lte/test_config_flow.py @@ -13,7 +13,11 @@ import requests_mock from requests_mock import ANY from homeassistant import config_entries -from homeassistant.components.huawei_lte.const import CONF_UNAUTHENTICATED_MODE, DOMAIN +from homeassistant.components.huawei_lte.const import ( + CONF_UNAUTHENTICATED_MODE, + CONF_UPNP_UDN, + DOMAIN, +) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, @@ -373,6 +377,7 @@ async def test_ssdp( assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == service_info.upnp[ATTR_UPNP_MODEL_NAME] + assert result["result"].data[CONF_UPNP_UDN] == service_info.upnp[ATTR_UPNP_UDN] @pytest.mark.parametrize( From dcf8d7f74dcd52548b7532648e7826225df39ae1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 7 Jul 2025 23:41:20 -0500 Subject: [PATCH 1216/1664] Track ESPHome entities by (device_id, key) to support sub-devices with overlaping names (#148297) --- homeassistant/components/esphome/entity.py | 82 ++++++++- .../components/esphome/entry_data.py | 53 ++++-- homeassistant/components/esphome/manager.py | 2 +- .../components/esphome/test_binary_sensor.py | 156 +++++++++++++++++- tests/components/esphome/test_entity.py | 10 +- 5 files changed, 278 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index b9f0125094a..a6267ba17a5 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -33,7 +33,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN # Import config flow so that it's added to the registry -from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id +from .entry_data import ( + DeviceEntityKey, + ESPHomeConfigEntry, + RuntimeEntryData, + build_device_unique_id, +) from .enum_mapper import EsphomeEnumMapper _LOGGER = logging.getLogger(__name__) @@ -59,17 +64,32 @@ def async_static_info_updated( device_info = entry_data.device_info if TYPE_CHECKING: assert device_info is not None - new_infos: dict[int, EntityInfo] = {} + new_infos: dict[DeviceEntityKey, EntityInfo] = {} add_entities: list[_EntityT] = [] ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) + # Track info by (info.device_id, info.key) to properly handle entities + # moving between devices and support sub-devices with overlapping keys for info in infos: - new_infos[info.key] = info + info_key = (info.device_id, info.key) + new_infos[info_key] = info + + # Try to find existing entity - first with current device_id + old_info = current_infos.pop(info_key, None) + + # If not found, search for entity with same key but different device_id + # This handles the case where entity moved between devices + if not old_info: + for existing_device_id, existing_key in list(current_infos): + if existing_key == info.key: + # Found entity with same key but different device_id + old_info = current_infos.pop((existing_device_id, existing_key)) + break # Create new entity if it doesn't exist - if not (old_info := current_infos.pop(info.key, None)): + if not old_info: entity = entity_type(entry_data, platform.domain, info, state_type) add_entities.append(entity) continue @@ -78,7 +98,7 @@ def async_static_info_updated( if old_info.device_id == info.device_id: continue - # Entity has switched devices, need to migrate unique_id + # Entity has switched devices, need to migrate unique_id and handle state subscriptions old_unique_id = build_device_unique_id(device_info.mac_address, old_info) entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id) @@ -103,7 +123,7 @@ def async_static_info_updated( if old_unique_id != new_unique_id: updates["new_unique_id"] = new_unique_id - # Update device assignment + # Update device assignment in registry if info.device_id: # Entity now belongs to a sub device new_device = dev_reg.async_get_device( @@ -118,10 +138,32 @@ def async_static_info_updated( if new_device: updates["device_id"] = new_device.id - # Apply all updates at once + # Apply all registry updates at once if updates: ent_reg.async_update_entity(entity_id, **updates) + # IMPORTANT: The entity's device assignment in Home Assistant is only read when the entity + # is first added. Updating the registry alone won't move the entity to the new device + # in the UI. Additionally, the entity's state subscription is tied to the old device_id, + # so it won't receive state updates for the new device_id. + # + # We must remove the old entity and re-add it to ensure: + # 1. The entity appears under the correct device in the UI + # 2. The entity's state subscription is updated to use the new device_id + _LOGGER.debug( + "Entity %s moving from device_id %s to %s", + info.key, + old_info.device_id, + info.device_id, + ) + + # Signal the existing entity to remove itself + # The entity is registered with the old device_id, so we signal with that + entry_data.async_signal_entity_removal(info_type, old_info.device_id, info.key) + + # Create new entity with the new device_id + add_entities.append(entity_type(entry_data, platform.domain, info, state_type)) + # Anything still in current_infos is now gone if current_infos: entry_data.async_remove_entities( @@ -341,7 +383,10 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): ) self.async_on_remove( entry_data.async_subscribe_state_update( - self._state_type, self._key, self._on_state_update + self._static_info.device_id, + self._state_type, + self._key, + self._on_state_update, ) ) self.async_on_remove( @@ -349,8 +394,29 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]): self._static_info, self._on_static_info_update ) ) + # Register to be notified when this entity should remove itself + # This happens when the entity moves to a different device + self.async_on_remove( + entry_data.async_register_entity_removal_callback( + type(self._static_info), + self._static_info.device_id, + self._key, + self._on_removal_signal, + ) + ) self._update_state_from_entry_data() + @callback + def _on_removal_signal(self) -> None: + """Handle signal to remove this entity.""" + _LOGGER.debug( + "Entity %s received removal signal due to device_id change", + self.entity_id, + ) + # Schedule the entity to be removed + # This must be done as a task since we're in a callback + self.hass.async_create_task(self.async_remove()) + @callback def _on_static_info_update(self, static_info: EntityInfo) -> None: """Save the static info for this entity when it changes. diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 71680873611..dddbb598a57 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -60,7 +60,9 @@ from .const import DOMAIN from .dashboard import async_get_dashboard type ESPHomeConfigEntry = ConfigEntry[RuntimeEntryData] - +type EntityStateKey = tuple[type[EntityState], int, int] # (state_type, device_id, key) +type EntityInfoKey = tuple[type[EntityInfo], int, int] # (info_type, device_id, key) +type DeviceEntityKey = tuple[int, int] # (device_id, key) INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} @@ -137,8 +139,10 @@ class RuntimeEntryData: # When the disconnect callback is called, we mark all states # as stale so we will always dispatch a state update when the # device reconnects. This is the same format as state_subscriptions. - stale_state: set[tuple[type[EntityState], int]] = field(default_factory=set) - info: dict[type[EntityInfo], dict[int, EntityInfo]] = field(default_factory=dict) + stale_state: set[EntityStateKey] = field(default_factory=set) + info: dict[type[EntityInfo], dict[DeviceEntityKey, EntityInfo]] = field( + default_factory=dict + ) services: dict[int, UserService] = field(default_factory=dict) available: bool = False expected_disconnect: bool = False # Last disconnect was expected (e.g. deep sleep) @@ -147,7 +151,7 @@ class RuntimeEntryData: api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[CALLBACK_TYPE] = field(default_factory=list) disconnect_callbacks: set[CALLBACK_TYPE] = field(default_factory=set) - state_subscriptions: dict[tuple[type[EntityState], int], CALLBACK_TYPE] = field( + state_subscriptions: dict[EntityStateKey, CALLBACK_TYPE] = field( default_factory=dict ) device_update_subscriptions: set[CALLBACK_TYPE] = field(default_factory=set) @@ -164,7 +168,7 @@ class RuntimeEntryData: type[EntityInfo], list[Callable[[list[EntityInfo]], None]] ] = field(default_factory=dict) entity_info_key_updated_callbacks: dict[ - tuple[type[EntityInfo], int], list[Callable[[EntityInfo], None]] + EntityInfoKey, list[Callable[[EntityInfo], None]] ] = field(default_factory=dict) original_options: dict[str, Any] = field(default_factory=dict) media_player_formats: dict[str, list[MediaPlayerSupportedFormat]] = field( @@ -177,6 +181,9 @@ class RuntimeEntryData: default_factory=list ) device_id_to_name: dict[int, str] = field(default_factory=dict) + entity_removal_callbacks: dict[EntityInfoKey, list[CALLBACK_TYPE]] = field( + default_factory=dict + ) @property def name(self) -> str: @@ -210,7 +217,7 @@ class RuntimeEntryData: callback_: Callable[[EntityInfo], None], ) -> CALLBACK_TYPE: """Register to receive callbacks when static info is updated for a specific key.""" - callback_key = (type(static_info), static_info.key) + callback_key = (type(static_info), static_info.device_id, static_info.key) callbacks = self.entity_info_key_updated_callbacks.setdefault(callback_key, []) callbacks.append(callback_) return partial(callbacks.remove, callback_) @@ -250,7 +257,9 @@ class RuntimeEntryData: """Call static info updated callbacks.""" callbacks = self.entity_info_key_updated_callbacks for static_info in static_infos: - for callback_ in callbacks.get((type(static_info), static_info.key), ()): + for callback_ in callbacks.get( + (type(static_info), static_info.device_id, static_info.key), () + ): callback_(static_info) async def _ensure_platforms_loaded( @@ -342,12 +351,13 @@ class RuntimeEntryData: @callback def async_subscribe_state_update( self, + device_id: int, state_type: type[EntityState], state_key: int, entity_callback: CALLBACK_TYPE, ) -> CALLBACK_TYPE: """Subscribe to state updates.""" - subscription_key = (state_type, state_key) + subscription_key = (state_type, device_id, state_key) self.state_subscriptions[subscription_key] = entity_callback return partial(delitem, self.state_subscriptions, subscription_key) @@ -359,7 +369,7 @@ class RuntimeEntryData: stale_state = self.stale_state current_state_by_type = self.state[state_type] current_state = current_state_by_type.get(key, _SENTINEL) - subscription_key = (state_type, key) + subscription_key = (state_type, state.device_id, key) if ( current_state == state and subscription_key not in stale_state @@ -367,7 +377,7 @@ class RuntimeEntryData: and not ( state_type is SensorState and (platform_info := self.info.get(SensorInfo)) - and (entity_info := platform_info.get(state.key)) + and (entity_info := platform_info.get((state.device_id, state.key))) and (cast(SensorInfo, entity_info)).force_update ) ): @@ -520,3 +530,26 @@ class RuntimeEntryData: """Notify listeners that the Assist satellite wake word has been set.""" for callback_ in self.assist_satellite_set_wake_word_callbacks.copy(): callback_(wake_word_id) + + @callback + def async_register_entity_removal_callback( + self, + info_type: type[EntityInfo], + device_id: int, + key: int, + callback_: CALLBACK_TYPE, + ) -> CALLBACK_TYPE: + """Register to receive a callback when the entity should remove itself.""" + callback_key = (info_type, device_id, key) + callbacks = self.entity_removal_callbacks.setdefault(callback_key, []) + callbacks.append(callback_) + return partial(callbacks.remove, callback_) + + @callback + def async_signal_entity_removal( + self, info_type: type[EntityInfo], device_id: int, key: int + ) -> None: + """Signal that an entity should remove itself.""" + callback_key = (info_type, device_id, key) + for callback_ in self.entity_removal_callbacks.get(callback_key, []).copy(): + callback_() diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 6c2da31e48b..5e9e11171af 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -588,7 +588,7 @@ class ESPHomeManager: # Mark state as stale so that we will always dispatch # the next state update of that type when the device reconnects entry_data.stale_state = { - (type(entity_state), key) + (type(entity_state), entity_state.device_id, key) for state_dict in entry_data.state.values() for key, entity_state in state_dict.items() } diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index d2cab36c672..d6e94e61766 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -1,6 +1,6 @@ """Test ESPHome binary sensors.""" -from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState +from aioesphomeapi import APIClient, BinarySensorInfo, BinarySensorState, SubDeviceInfo import pytest from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN @@ -127,3 +127,157 @@ async def test_binary_sensor_has_state_false( state = hass.states.get("binary_sensor.test_my_binary_sensor") assert state is not None assert state.state == STATE_ON + + +async def test_binary_sensors_same_key_different_device_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test binary sensors with same key but different device_id.""" + # Create sub-devices + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device 1", area_id=0), + SubDeviceInfo(device_id=22222222, name="Sub Device 2", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Both sub-devices have a binary sensor with key=1 + entity_info = [ + BinarySensorInfo( + object_id="sensor", + key=1, + name="Motion", + unique_id="motion_1", + device_id=11111111, + ), + BinarySensorInfo( + object_id="sensor", + key=1, + name="Motion", + unique_id="motion_2", + device_id=22222222, + ), + ] + + # States for both sensors with same key but different device_id + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111), + BinarySensorState(key=1, state=False, missing_state=False, device_id=22222222), + ] + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist and have correct states + state1 = hass.states.get("binary_sensor.sub_device_1_motion") + assert state1 is not None + assert state1.state == STATE_ON + + state2 = hass.states.get("binary_sensor.sub_device_2_motion") + assert state2 is not None + assert state2.state == STATE_OFF + + # Update states to verify they update independently + mock_device.set_state( + BinarySensorState(key=1, state=False, missing_state=False, device_id=11111111) + ) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.sub_device_1_motion") + assert state1.state == STATE_OFF + + # Sub device 2 should remain unchanged + state2 = hass.states.get("binary_sensor.sub_device_2_motion") + assert state2.state == STATE_OFF + + # Update sub device 2 + mock_device.set_state( + BinarySensorState(key=1, state=True, missing_state=False, device_id=22222222) + ) + await hass.async_block_till_done() + + state2 = hass.states.get("binary_sensor.sub_device_2_motion") + assert state2.state == STATE_ON + + # Sub device 1 should remain unchanged + state1 = hass.states.get("binary_sensor.sub_device_1_motion") + assert state1.state == STATE_OFF + + +async def test_binary_sensor_main_and_sub_device_same_key( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test binary sensor on main device and sub-device with same key.""" + # Create sub-device + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device", area_id=0), + ] + + device_info = { + "name": "test", + "devices": sub_devices, + } + + # Main device and sub-device both have a binary sensor with key=1 + entity_info = [ + BinarySensorInfo( + object_id="main_sensor", + key=1, + name="Main Sensor", + unique_id="main_1", + device_id=0, # Main device + ), + BinarySensorInfo( + object_id="sub_sensor", + key=1, + name="Sub Sensor", + unique_id="sub_1", + device_id=11111111, + ), + ] + + # States for both sensors + states = [ + BinarySensorState(key=1, state=True, missing_state=False, device_id=0), + BinarySensorState(key=1, state=False, missing_state=False, device_id=11111111), + ] + + mock_device = await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist + main_state = hass.states.get("binary_sensor.test_main_sensor") + assert main_state is not None + assert main_state.state == STATE_ON + + sub_state = hass.states.get("binary_sensor.sub_device_sub_sensor") + assert sub_state is not None + assert sub_state.state == STATE_OFF + + # Update main device sensor + mock_device.set_state( + BinarySensorState(key=1, state=False, missing_state=False, device_id=0) + ) + await hass.async_block_till_done() + + main_state = hass.states.get("binary_sensor.test_main_sensor") + assert main_state.state == STATE_OFF + + # Sub device sensor should remain unchanged + sub_state = hass.states.get("binary_sensor.sub_device_sub_sensor") + assert sub_state.state == STATE_OFF diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index ba6a82bbd23..f364e1f528f 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -754,9 +754,9 @@ async def test_entity_assignment_to_sub_device( ] states = [ - BinarySensorState(key=1, state=True, missing_state=False), - BinarySensorState(key=2, state=False, missing_state=False), - BinarySensorState(key=3, state=True, missing_state=False), + BinarySensorState(key=1, state=True, missing_state=False, device_id=0), + BinarySensorState(key=2, state=False, missing_state=False, device_id=11111111), + BinarySensorState(key=3, state=True, missing_state=False, device_id=22222222), ] device = await mock_esphome_device( @@ -938,7 +938,7 @@ async def test_entity_switches_between_devices( ] states = [ - BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=1, state=True, missing_state=False, device_id=0), ] device = await mock_esphome_device( @@ -1507,7 +1507,7 @@ async def test_entity_device_id_rename_in_yaml( ] states = [ - BinarySensorState(key=1, state=True, missing_state=False), + BinarySensorState(key=1, state=True, missing_state=False, device_id=11111111), ] device = await mock_esphome_device( From 7a7e16bbb6030eb75df509f132d1ac796b638fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 8 Jul 2025 05:52:41 +0100 Subject: [PATCH 1217/1664] Change how subscription information is fetched (#148337) Co-authored-by: Franck Nijhof --- homeassistant/components/cloud/repairs.py | 6 +++--- homeassistant/components/cloud/subscription.py | 17 +++++------------ tests/components/cloud/conftest.py | 6 +++++- tests/components/cloud/test_http_api.py | 11 ++++++----- tests/components/cloud/test_subscription.py | 13 ++++++++----- 5 files changed, 27 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index fe418fb5340..ed66cb8244f 100644 --- a/homeassistant/components/cloud/repairs.py +++ b/homeassistant/components/cloud/repairs.py @@ -3,8 +3,8 @@ from __future__ import annotations import asyncio -from typing import Any +from hass_nabucasa.payments_api import SubscriptionInfo import voluptuous as vol from homeassistant.components.repairs import ( @@ -26,7 +26,7 @@ MAX_RETRIES = 60 # This allows for 10 minutes of retries @callback def async_manage_legacy_subscription_issue( hass: HomeAssistant, - subscription_info: dict[str, Any], + subscription_info: SubscriptionInfo, ) -> None: """Manage the legacy subscription issue. @@ -50,7 +50,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" wait_task: asyncio.Task | None = None - _data: dict[str, Any] | None = None + _data: SubscriptionInfo | None = None async def async_step_init(self, _: None = None) -> FlowResult: """Handle the first step of a fix flow.""" diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index dc6679a6e40..9ee154dbff4 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -8,6 +8,7 @@ from typing import Any from aiohttp.client_exceptions import ClientError from hass_nabucasa import Cloud, cloud_api +from hass_nabucasa.payments_api import PaymentsApiError, SubscriptionInfo from .client import CloudClient from .const import REQUEST_TIMEOUT @@ -15,21 +16,13 @@ from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: +async def async_subscription_info(cloud: Cloud[CloudClient]) -> SubscriptionInfo | None: """Fetch the subscription info.""" try: async with asyncio.timeout(REQUEST_TIMEOUT): - return await cloud_api.async_subscription_info(cloud) - except TimeoutError: - _LOGGER.error( - ( - "A timeout of %s was reached while trying to fetch subscription" - " information" - ), - REQUEST_TIMEOUT, - ) - except ClientError: - _LOGGER.error("Failed to fetch subscription information") + return await cloud.payments.subscription_info() + except PaymentsApiError as exception: + _LOGGER.error("Failed to fetch subscription information - %s", exception) return None diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 0e118f251de..e63af0ced09 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Any from unittest.mock import DEFAULT, AsyncMock, MagicMock, PropertyMock, patch -from hass_nabucasa import Cloud +from hass_nabucasa import Cloud, payments_api from hass_nabucasa.auth import CognitoAuth from hass_nabucasa.cloudhooks import Cloudhooks from hass_nabucasa.const import DEFAULT_SERVERS, DEFAULT_VALUES, STATE_CONNECTED @@ -71,6 +71,10 @@ async def cloud_fixture() -> AsyncGenerator[MagicMock]: mock_cloud.voice = MagicMock(spec=Voice) mock_cloud.files = MagicMock(spec=Files) mock_cloud.started = None + mock_cloud.payments = MagicMock( + spec=payments_api.PaymentsApi, + subscription_info=AsyncMock(), + ) mock_cloud.ice_servers = MagicMock( spec=IceServers, async_register_ice_servers_listener=AsyncMock( diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 79764e552c7..84630bc0320 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -18,6 +18,7 @@ from hass_nabucasa.auth import ( UnknownError, ) from hass_nabucasa.const import STATE_CONNECTED +from hass_nabucasa.payments_api import PaymentsApiError from hass_nabucasa.remote import CertificateStatus import pytest from syrupy.assertion import SnapshotAssertion @@ -1008,16 +1009,14 @@ async def test_websocket_subscription_info( cloud: MagicMock, setup_cloud: None, ) -> None: - """Test subscription info and connecting because valid account.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={"provider": "stripe"}) + """Test subscription info.""" + cloud.payments.subscription_info.return_value = {"provider": "stripe"} client = await hass_ws_client(hass) - mock_renew = cloud.auth.async_renew_access_token await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() assert response["result"] == {"provider": "stripe"} - assert mock_renew.call_count == 1 async def test_websocket_subscription_fail( @@ -1028,7 +1027,9 @@ async def test_websocket_subscription_fail( setup_cloud: None, ) -> None: """Test subscription info fail.""" - aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=HTTPStatus.INTERNAL_SERVER_ERROR) + cloud.payments.subscription_info.side_effect = PaymentsApiError( + "Failed to fetch subscription information" + ) client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "cloud/subscription"}) diff --git a/tests/components/cloud/test_subscription.py b/tests/components/cloud/test_subscription.py index 22839b585fd..c34ca1bc871 100644 --- a/tests/components/cloud/test_subscription.py +++ b/tests/components/cloud/test_subscription.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, Mock -from hass_nabucasa import Cloud +from hass_nabucasa import Cloud, payments_api import pytest from homeassistant.components.cloud.subscription import ( @@ -22,6 +22,10 @@ async def mocked_cloud_object(hass: HomeAssistant) -> Cloud: accounts_server="accounts.nabucasa.com", auth=Mock(async_check_token=AsyncMock()), websession=async_get_clientsession(hass), + payments=Mock( + spec=payments_api.PaymentsApi, + subscription_info=AsyncMock(), + ), ) @@ -31,14 +35,13 @@ async def test_fetching_subscription_with_timeout_error( mocked_cloud: Cloud, ) -> None: """Test that we handle timeout error.""" - aioclient_mock.get( - "https://accounts.nabucasa.com/payments/subscription_info", - exc=TimeoutError(), + mocked_cloud.payments.subscription_info.side_effect = payments_api.PaymentsApiError( + "Timeout reached while calling API" ) assert await async_subscription_info(mocked_cloud) is None assert ( - "A timeout of 10 was reached while trying to fetch subscription information" + "Failed to fetch subscription information - Timeout reached while calling API" in caplog.text ) From f780b9763d400d899c3fc39233fe7794355e98ef Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 8 Jul 2025 07:24:55 +0200 Subject: [PATCH 1218/1664] Add support for ELV-SH-CTV Sensor to homematicip_cloud (#143737) --- .../components/homematicip_cloud/icons.json | 11 + .../components/homematicip_cloud/sensor.py | 311 ++++++++++++------ .../components/homematicip_cloud/strings.json | 11 + .../fixtures/homematicip_cloud.json | 146 ++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_sensor.py | 51 +++ 6 files changed, 434 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/icons.json b/homeassistant/components/homematicip_cloud/icons.json index 53a39d8213c..561ae79abc2 100644 --- a/homeassistant/components/homematicip_cloud/icons.json +++ b/homeassistant/components/homematicip_cloud/icons.json @@ -1,4 +1,15 @@ { + "entity": { + "sensor": { + "tilt_state": { + "state": { + "neutral": "mdi:garage", + "non_neutral": "mdi:garage-open", + "tilted": "mdi:garage-alert" + } + } + } + }, "services": { "activate_eco_mode_with_duration": { "service": "mdi:leaf" diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 13f3694de7a..95de7f15af0 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -11,6 +11,7 @@ from homematicip.base.functionalChannels import ( FunctionalChannel, ) from homematicip.device import ( + Device, EnergySensorsInterface, FloorTerminalBlock6, FloorTerminalBlock10, @@ -31,6 +32,7 @@ from homematicip.device import ( TemperatureHumiditySensorDisplay, TemperatureHumiditySensorOutdoor, TemperatureHumiditySensorWithoutDisplay, + TiltVibrationSensor, WeatherSensor, WeatherSensorPlus, WeatherSensorPro, @@ -44,6 +46,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + DEGREE, LIGHT_LUX, PERCENTAGE, UnitOfEnergy, @@ -62,6 +65,11 @@ from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP from .helpers import get_channels_from_device +ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION = "acceleration_sensor_neutral_position" +ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE = "acceleration_sensor_trigger_angle" +ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE = ( + "acceleration_sensor_second_trigger_angle" +) ATTR_CURRENT_ILLUMINATION = "current_illumination" ATTR_LOWEST_ILLUMINATION = "lowest_illumination" ATTR_HIGHEST_ILLUMINATION = "highest_illumination" @@ -89,6 +97,136 @@ ILLUMINATION_DEVICE_ATTRIBUTES = { "highestIllumination": ATTR_HIGHEST_ILLUMINATION, } +TILT_STATE_VALUES = ["neutral", "tilted", "non_neutral"] + + +def get_device_handlers(hap: HomematicipHAP) -> dict[type, Callable]: + """Generate a mapping of device types to handler functions.""" + return { + HomeControlAccessPoint: lambda device: [ + HomematicipAccesspointDutyCycle(hap, device) + ], + HeatingThermostat: lambda device: [ + HomematicipHeatingThermostat(hap, device), + HomematicipTemperatureSensor(hap, device), + ], + HeatingThermostatCompact: lambda device: [ + HomematicipHeatingThermostat(hap, device), + HomematicipTemperatureSensor(hap, device), + ], + HeatingThermostatEvo: lambda device: [ + HomematicipHeatingThermostat(hap, device), + HomematicipTemperatureSensor(hap, device), + ], + TemperatureHumiditySensorDisplay: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + TemperatureHumiditySensorWithoutDisplay: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + TemperatureHumiditySensorOutdoor: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + RoomControlDeviceAnalog: lambda device: [ + HomematicipTemperatureSensor(hap, device), + ], + LightSensor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + MotionDetectorIndoor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + MotionDetectorOutdoor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + MotionDetectorPushButton: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + PresenceDetectorIndoor: lambda device: [ + HomematicipIlluminanceSensor(hap, device), + ], + SwitchMeasuring: lambda device: [ + HomematicipPowerSensor(hap, device), + HomematicipEnergySensor(hap, device), + ], + PassageDetector: lambda device: [ + HomematicipPassageDetectorDeltaCounter(hap, device), + ], + TemperatureDifferenceSensor2: lambda device: [ + HomematicpTemperatureExternalSensorCh1(hap, device), + HomematicpTemperatureExternalSensorCh2(hap, device), + HomematicpTemperatureExternalSensorDelta(hap, device), + ], + TiltVibrationSensor: lambda device: [ + HomematicipTiltStateSensor(hap, device), + HomematicipTiltAngleSensor(hap, device), + ], + WeatherSensor: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipIlluminanceSensor(hap, device), + HomematicipWindspeedSensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + WeatherSensorPlus: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipIlluminanceSensor(hap, device), + HomematicipWindspeedSensor(hap, device), + HomematicipTodayRainSensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + WeatherSensorPro: lambda device: [ + HomematicipTemperatureSensor(hap, device), + HomematicipHumiditySensor(hap, device), + HomematicipIlluminanceSensor(hap, device), + HomematicipWindspeedSensor(hap, device), + HomematicipTodayRainSensor(hap, device), + HomematicipAbsoluteHumiditySensor(hap, device), + ], + EnergySensorsInterface: lambda device: _handle_energy_sensor_interface( + hap, device + ), + } + + +def _handle_energy_sensor_interface( + hap: HomematicipHAP, device: Device +) -> list[HomematicipGenericEntity]: + """Handle energy sensor interface devices.""" + result: list[HomematicipGenericEntity] = [] + for ch in get_channels_from_device( + device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL + ): + if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_IEC: + if ch.currentPowerConsumption is not None: + result.append(HmipEsiIecPowerConsumption(hap, device)) + if ch.energyCounterOneType != ESI_TYPE_UNKNOWN: + result.append(HmipEsiIecEnergyCounterHighTariff(hap, device)) + if ch.energyCounterTwoType != ESI_TYPE_UNKNOWN: + result.append(HmipEsiIecEnergyCounterLowTariff(hap, device)) + if ch.energyCounterThreeType != ESI_TYPE_UNKNOWN: + result.append(HmipEsiIecEnergyCounterInputSingleTariff(hap, device)) + + if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_GAS: + if ch.currentGasFlow is not None: + result.append(HmipEsiGasCurrentGasFlow(hap, device)) + if ch.gasVolume is not None: + result.append(HmipEsiGasGasVolume(hap, device)) + + if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_LED: + if ch.currentPowerConsumption is not None: + result.append(HmipEsiLedCurrentPowerConsumption(hap, device)) + result.append(HmipEsiLedEnergyCounterHighTariff(hap, device)) + + return result + async def async_setup_entry( hass: HomeAssistant, @@ -98,109 +236,88 @@ async def async_setup_entry( """Set up the HomematicIP Cloud sensors from a config entry.""" hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] + + # Get device handlers dynamically + device_handlers = get_device_handlers(hap) + + # Process all devices for device in hap.home.devices: - if isinstance(device, HomeControlAccessPoint): - entities.append(HomematicipAccesspointDutyCycle(hap, device)) - if isinstance( - device, - ( - HeatingThermostat, - HeatingThermostatCompact, - HeatingThermostatEvo, - ), - ): - entities.append(HomematicipHeatingThermostat(hap, device)) - entities.append(HomematicipTemperatureSensor(hap, device)) - if isinstance( - device, - ( - TemperatureHumiditySensorDisplay, - TemperatureHumiditySensorWithoutDisplay, - TemperatureHumiditySensorOutdoor, - WeatherSensor, - WeatherSensorPlus, - WeatherSensorPro, - ), - ): - entities.append(HomematicipTemperatureSensor(hap, device)) - entities.append(HomematicipHumiditySensor(hap, device)) - entities.append(HomematicipAbsoluteHumiditySensor(hap, device)) - elif isinstance(device, (RoomControlDeviceAnalog,)): - entities.append(HomematicipTemperatureSensor(hap, device)) - if isinstance( - device, - ( - LightSensor, - MotionDetectorIndoor, - MotionDetectorOutdoor, - MotionDetectorPushButton, - PresenceDetectorIndoor, - WeatherSensor, - WeatherSensorPlus, - WeatherSensorPro, - ), - ): - entities.append(HomematicipIlluminanceSensor(hap, device)) - if isinstance(device, SwitchMeasuring): - entities.append(HomematicipPowerSensor(hap, device)) - entities.append(HomematicipEnergySensor(hap, device)) - if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)): - entities.append(HomematicipWindspeedSensor(hap, device)) - if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)): - entities.append(HomematicipTodayRainSensor(hap, device)) - if isinstance(device, PassageDetector): - entities.append(HomematicipPassageDetectorDeltaCounter(hap, device)) - if isinstance(device, TemperatureDifferenceSensor2): - entities.append(HomematicpTemperatureExternalSensorCh1(hap, device)) - entities.append(HomematicpTemperatureExternalSensorCh2(hap, device)) - entities.append(HomematicpTemperatureExternalSensorDelta(hap, device)) - if isinstance(device, EnergySensorsInterface): - for ch in get_channels_from_device( - device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL - ): - if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_IEC: - if ch.currentPowerConsumption is not None: - entities.append(HmipEsiIecPowerConsumption(hap, device)) - if ch.energyCounterOneType != ESI_TYPE_UNKNOWN: - entities.append(HmipEsiIecEnergyCounterHighTariff(hap, device)) - if ch.energyCounterTwoType != ESI_TYPE_UNKNOWN: - entities.append(HmipEsiIecEnergyCounterLowTariff(hap, device)) - if ch.energyCounterThreeType != ESI_TYPE_UNKNOWN: - entities.append( - HmipEsiIecEnergyCounterInputSingleTariff(hap, device) - ) + for device_class, handler in device_handlers.items(): + if isinstance(device, device_class): + entities.extend(handler(device)) - if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_GAS: - if ch.currentGasFlow is not None: - entities.append(HmipEsiGasCurrentGasFlow(hap, device)) - if ch.gasVolume is not None: - entities.append(HmipEsiGasGasVolume(hap, device)) - - if ch.connectedEnergySensorType == ESI_CONNECTED_SENSOR_TYPE_LED: - if ch.currentPowerConsumption is not None: - entities.append(HmipEsiLedCurrentPowerConsumption(hap, device)) - entities.append(HmipEsiLedEnergyCounterHighTariff(hap, device)) - if isinstance( - device, - ( - FloorTerminalBlock6, - FloorTerminalBlock10, - FloorTerminalBlock12, - WiredFloorTerminalBlock12, - ), - ): - entities.extend( - HomematicipFloorTerminalBlockMechanicChannelValve( - hap, device, channel=channel.index - ) - for channel in device.functionalChannels - if isinstance(channel, FloorTerminalBlockMechanicChannel) - and getattr(channel, "valvePosition", None) is not None - ) + # Handle floor terminal blocks separately + floor_terminal_blocks = ( + FloorTerminalBlock6, + FloorTerminalBlock10, + FloorTerminalBlock12, + WiredFloorTerminalBlock12, + ) + entities.extend( + HomematicipFloorTerminalBlockMechanicChannelValve( + hap, device, channel=channel.index + ) + for device in hap.home.devices + if isinstance(device, floor_terminal_blocks) + for channel in device.functionalChannels + if isinstance(channel, FloorTerminalBlockMechanicChannel) + and getattr(channel, "valvePosition", None) is not None + ) async_add_entities(entities) +class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP tilt angle sensor.""" + + _attr_native_unit_of_measurement = DEGREE + _attr_state_class = SensorStateClass.MEASUREMENT_ANGLE + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt angle sensor device.""" + super().__init__(hap, device, post="Tilt Angle") + + @property + def native_value(self) -> int | None: + """Return the state.""" + return getattr(self.functional_channel, "absoluteAngle", None) + + +class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity): + """Representation of the HomematicIP tilt sensor.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = TILT_STATE_VALUES + _attr_translation_key = "tilt_state" + + def __init__(self, hap: HomematicipHAP, device) -> None: + """Initialize the tilt sensor device.""" + super().__init__(hap, device, post="Tilt State") + + @property + def native_value(self) -> str | None: + """Return the state.""" + tilt_state = getattr(self.functional_channel, "tiltState", None) + return tilt_state.lower() if tilt_state is not None else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the tilt sensor.""" + state_attr = super().extra_state_attributes + + state_attr[ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION] = getattr( + self.functional_channel, "accelerationSensorNeutralPosition", None + ) + state_attr[ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE] = getattr( + self.functional_channel, "accelerationSensorTriggerAngle", None + ) + state_attr[ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE] = getattr( + self.functional_channel, "accelerationSensorSecondTriggerAngle", None + ) + + return state_attr + + class HomematicipFloorTerminalBlockMechanicChannelValve( HomematicipGenericEntity, SensorEntity ): diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json index 7b1b08ac4e2..bc170d5f0c3 100644 --- a/homeassistant/components/homematicip_cloud/strings.json +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -27,6 +27,17 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "entity": { + "sensor": { + "tilt_state": { + "state": { + "neutral": "Neutral", + "non_neutral": "Non-neutral", + "tilted": "Tilted" + } + } + } + }, "exceptions": { "access_point_not_found": { "message": "No matching access point found for access point ID {id}" diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index 65f8afe55fa..c378190d00c 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8297,6 +8297,152 @@ "type": "DOOR_BELL_CONTACT_INTERFACE", "updateState": "UP_TO_DATE" }, + "3014F7110000000000000CTV": { + "availableFirmwareVersion": "0.0.0", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "firmwareVersion": "1.0.6", + "firmwareVersionInteger": 65542, + "functionalChannels": { + "0": { + "busConfigMismatch": null, + "coProFaulty": false, + "coProRestartNeeded": false, + "coProUpdateFailure": false, + "configPending": false, + "controlsMountingOrientation": null, + "daliBusState": null, + "defaultLinkedGroup": [], + "deviceAliveSignalEnabled": null, + "deviceCommunicationError": null, + "deviceDriveError": null, + "deviceDriveModeError": null, + "deviceId": "3014F7110000000000000CTV", + "deviceOperationMode": null, + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": [ + "00000000-0000-0000-0000-000000000041", + "00000000-0000-0000-0000-000000000042" + ], + "index": 0, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": false, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -102, + "rssiPeerValue": null, + "sensorCommunicationError": null, + "sensorError": null, + "shortCircuitDataLine": null, + "supportedOptionalFeatures": { + "IFeatureBusConfigMismatch": false, + "IFeatureDeviceCoProError": false, + "IFeatureDeviceCoProRestart": false, + "IFeatureDeviceCoProUpdate": false, + "IFeatureDeviceCommunicationError": false, + "IFeatureDeviceDaliBusError": false, + "IFeatureDeviceDriveError": false, + "IFeatureDeviceDriveModeError": false, + "IFeatureDeviceIdentify": false, + "IFeatureDeviceOverheated": false, + "IFeatureDeviceOverloaded": false, + "IFeatureDeviceParticulateMatterSensorCommunicationError": false, + "IFeatureDeviceParticulateMatterSensorError": false, + "IFeatureDevicePowerFailure": false, + "IFeatureDeviceSensorCommunicationError": false, + "IFeatureDeviceSensorError": false, + "IFeatureDeviceTemperatureHumiditySensorCommunicationError": false, + "IFeatureDeviceTemperatureHumiditySensorError": false, + "IFeatureDeviceTemperatureOutOfRange": false, + "IFeatureDeviceUndervoltage": false, + "IFeatureMulticastRouter": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceOperationMode": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLowBat": true, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false + }, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "unreach": false + }, + "1": { + "absoluteAngle": 89, + "accelerationSensorEventFilterPeriod": 3.0, + "accelerationSensorMode": "TILT", + "accelerationSensorNeutralPosition": "VERTICAL", + "accelerationSensorSecondTriggerAngle": 75, + "accelerationSensorSensitivity": "SENSOR_RANGE_2G_2PLUS_SENSE", + "accelerationSensorTriggerAngle": 20, + "accelerationSensorTriggered": false, + "channelRole": "ACCELERATION_SENSOR", + "deviceId": "3014F7110000000000000CTV", + "functionalChannelType": "TILT_VIBRATION_SENSOR_CHANNEL", + "groupIndex": 1, + "groups": [ + "00000000-0000-0000-0000-000000000023", + "00000000-0000-0000-0000-000000000041", + "00000000-0000-0000-0000-000000000043" + ], + "index": 1, + "label": "", + "supportedOptionalFeatures": { + "IFeatureLightGroupSensorChannel": false, + "IOptionalFeatureAbsoluteAngle": true, + "IOptionalFeatureAccelerationSensorTiltTriggerAngle": true, + "IOptionalFeatureTiltDetection": true, + "IOptionalFeatureTiltState": true, + "IOptionalFeatureTiltVisualization": true + }, + "tiltState": "NEUTRAL", + "tiltVisualization": "GARAGE_DOOR" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F7110000000000000CTV", + "label": "Neigungssensor Tor", + "lastStatusUpdate": 1741379260066, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 9, + "measuredAttributes": {}, + "modelId": 580, + "modelType": "ELV-SH-CTV", + "oem": "eQ-3", + "permanentlyReachable": false, + "serializedGlobalTradeItemNumber": "3014F7110000000000000CTV", + "type": "TILT_VIBRATION_SENSOR_COMPACT", + "updateState": "UP_TO_DATE" + }, "3014F71100000000000SVCTH": { "availableFirmwareVersion": "1.0.10", "connectionType": "HMIP_RF", diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index abd0e18b368..aff698cd3d9 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) == 325 + assert len(mock_hap.hmip_device_by_entity_id) == 331 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 3b5773cfa4d..a107214b373 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -13,6 +13,9 @@ from homeassistant.components.homematicip_cloud.entity import ( ) from homeassistant.components.homematicip_cloud.hap import HomematicipHAP from homeassistant.components.homematicip_cloud.sensor import ( + ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION, + ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE, + ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE, ATTR_CURRENT_ILLUMINATION, ATTR_HIGHEST_ILLUMINATION, ATTR_LEFT_COUNTER, @@ -708,6 +711,54 @@ async def test_hmip_esi_led_energy_counter_usage_high_tariff( assert ha_state.state == "23825.748" +async def test_hmip_tilt_vibration_sensor_tilt_state( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipTiltVibrationSensor.""" + entity_id = "sensor.neigungssensor_tor_tilt_state" + entity_name = "Neigungssensor Tor Tilt State" + device_model = "ELV-SH-CTV" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Neigungssensor Tor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "neutral" + + await async_manipulate_test_data(hass, hmip_device, "tiltState", "NON_NEUTRAL", 1) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "non_neutral" + + await async_manipulate_test_data(hass, hmip_device, "tiltState", "TILTED", 1) + ha_state = hass.states.get(entity_id) + assert ha_state.state == "tilted" + + assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_NEUTRAL_POSITION] == "VERTICAL" + assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE] == 20 + assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_SECOND_TRIGGER_ANGLE] == 75 + + +async def test_hmip_tilt_vibration_sensor_tilt_angle( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipTiltVibrationSensor.""" + entity_id = "sensor.neigungssensor_tor_tilt_angle" + entity_name = "Neigungssensor Tor Tilt Angle" + device_model = "ELV-SH-CTV" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["Neigungssensor Tor"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == "89" + + async def test_hmip_absolute_humidity_sensor( hass: HomeAssistant, default_mock_hap_factory: HomeFactory ) -> None: From 87b00fdc7ba4fae70b1ab91f95c4226caadcae61 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 8 Jul 2025 07:28:16 +0200 Subject: [PATCH 1219/1664] Emoncms add reconfigure flow (#145108) Co-authored-by: Joost Lekkerkerker --- .../components/emoncms/config_flow.py | 41 +++++++++++ homeassistant/components/emoncms/strings.json | 4 +- tests/components/emoncms/conftest.py | 6 +- tests/components/emoncms/test_config_flow.py | 72 ++++++++++++++++++- 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index c34aa1b629b..b14903a78f9 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -179,6 +179,47 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Reconfigure the entry.""" + errors: dict[str, str] = {} + description_placeholders = {} + reconfig_entry = self._get_reconfigure_entry() + if user_input is not None: + url = user_input[CONF_URL] + api_key = user_input[CONF_API_KEY] + emoncms_client = EmoncmsClient( + url, api_key, session=async_get_clientsession(self.hass) + ) + result = await get_feed_list(emoncms_client) + if not result[CONF_SUCCESS]: + errors["base"] = "api_error" + description_placeholders = {"details": result[CONF_MESSAGE]} + else: + await self.async_set_unique_id(await emoncms_client.async_get_uuid()) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfig_entry, + title=sensor_name(url), + data=user_input, + reload_even_if_entry_is_unchanged=False, + ) + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_API_KEY): str, + } + ), + user_input or reconfig_entry.data, + ), + errors=errors, + description_placeholders=description_placeholders, + ) + class EmoncmsOptionsFlow(OptionsFlow): """Emoncms Options flow handler.""" diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json index 3efb0720eab..900e8dd0474 100644 --- a/homeassistant/components/emoncms/strings.json +++ b/homeassistant/components/emoncms/strings.json @@ -22,7 +22,9 @@ } }, "abort": { - "already_configured": "This server is already configured" + "already_configured": "This server is already configured", + "unique_id_mismatch": "This emoncms serial number does not match the previous serial number", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "selector": { diff --git a/tests/components/emoncms/conftest.py b/tests/components/emoncms/conftest.py index 100fb2bd879..c9c1eafc838 100644 --- a/tests/components/emoncms/conftest.py +++ b/tests/components/emoncms/conftest.py @@ -43,6 +43,8 @@ FLOW_RESULT = { SENSOR_NAME = "emoncms@1.1.1.1" +UNIQUE_ID = "123-53535292" + @pytest.fixture def config_entry() -> MockConfigEntry: @@ -65,7 +67,7 @@ def config_entry_unique_id() -> MockConfigEntry: domain=DOMAIN, title=SENSOR_NAME, data=FLOW_RESULT_SECOND_URL, - unique_id="123-53535292", + unique_id=UNIQUE_ID, ) @@ -121,5 +123,5 @@ async def emoncms_client() -> AsyncGenerator[AsyncMock]: ): client = mock_client.return_value client.async_request.return_value = {"success": True, "message": FEEDS} - client.async_get_uuid.return_value = "123-53535292" + client.async_get_uuid.return_value = UNIQUE_ID yield client diff --git a/tests/components/emoncms/test_config_flow.py b/tests/components/emoncms/test_config_flow.py index 3157ccdd574..bbb994002ac 100644 --- a/tests/components/emoncms/test_config_flow.py +++ b/tests/components/emoncms/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import AsyncMock +import pytest + from homeassistant.components.emoncms.const import ( CONF_ONLY_INCLUDE_FEEDID, DOMAIN, @@ -15,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import setup_integration -from .conftest import EMONCMS_FAILURE, FLOW_RESULT, SENSOR_NAME +from .conftest import EMONCMS_FAILURE, FLOW_RESULT, SENSOR_NAME, UNIQUE_ID from tests.common import MockConfigEntry @@ -25,6 +27,74 @@ USER_INPUT = { } +@pytest.mark.parametrize( + ("url", "api_key"), + [ + (USER_INPUT[CONF_URL], "regenerated_api_key"), + ("http://1.1.1.2", USER_INPUT[CONF_API_KEY]), + ], +) +async def test_reconfigure( + hass: HomeAssistant, + emoncms_client: AsyncMock, + url: str, + api_key: str, +) -> None: + """Test reconfigure flow.""" + new_input = { + CONF_URL: url, + CONF_API_KEY: api_key, + } + config_entry = MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=new_input, + unique_id=UNIQUE_ID, + ) + await setup_integration(hass, config_entry) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + new_input, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == new_input + + +async def test_reconfigure_api_error( + hass: HomeAssistant, + emoncms_client: AsyncMock, +) -> None: + """Test reconfigure flow with API error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title=SENSOR_NAME, + data=USER_INPUT, + unique_id=UNIQUE_ID, + ) + await setup_integration(hass, config_entry) + emoncms_client.async_request.return_value = EMONCMS_FAILURE + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "api_error"} + assert result["description_placeholders"]["details"] == "failure" + assert result["step_id"] == "reconfigure" + + async def test_user_flow_failure( hass: HomeAssistant, emoncms_client: AsyncMock ) -> None: From 73730e3eb3fb68bcaf57a6bd323a17ee169a143f Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Tue, 8 Jul 2025 15:57:41 +1000 Subject: [PATCH 1220/1664] Bump aiolifx to 1.2.0 (#148382) --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index b93714a2cdf..3c03cdccba2 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -52,7 +52,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.1.5", + "aiolifx==1.2.0", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/requirements_all.txt b/requirements_all.txt index 9f9767a266d..aa396027379 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -301,7 +301,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.5 +aiolifx==1.2.0 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 575a1d52f80..3a33c41fac3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -283,7 +283,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.1.5 +aiolifx==1.2.0 # homeassistant.components.lookin aiolookin==1.0.0 From 6d0891e9701ce31fdab69ef50faad39f25721c27 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 8 Jul 2025 08:01:49 +0200 Subject: [PATCH 1221/1664] OpenAI: Extract file attachment logic (#148288) --- .../openai_conversation/__init__.py | 49 +++------------ .../components/openai_conversation/entity.py | 63 ++++++++++++++++++- .../openai_conversation/test_init.py | 19 ++---- 3 files changed, 74 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 38c08a1720b..721ab44639f 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -2,8 +2,6 @@ from __future__ import annotations -import base64 -from mimetypes import guess_file_type from pathlib import Path import openai @@ -11,8 +9,6 @@ from openai.types.images_response import ImagesResponse from openai.types.responses import ( EasyInputMessageParam, Response, - ResponseInputFileParam, - ResponseInputImageParam, ResponseInputMessageContentListParam, ResponseInputParam, ResponseInputTextParam, @@ -58,6 +54,7 @@ from .const import ( RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_P, ) +from .entity import async_prepare_files_for_prompt SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_CONTENT = "generate_content" @@ -68,15 +65,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] -def encode_file(file_path: str) -> tuple[str, str]: - """Return base64 version of file contents.""" - mime_type, _ = guess_file_type(file_path) - if mime_type is None: - mime_type = "application/octet-stream" - with open(file_path, "rb") as image_file: - return (mime_type, base64.b64encode(image_file.read()).decode("utf-8")) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up OpenAI Conversation.""" await async_migrate_integration(hass) @@ -146,41 +134,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT]) ] - def append_files_to_content() -> None: - for filename in call.data[CONF_FILENAMES]: + if filenames := call.data.get(CONF_FILENAMES): + for filename in filenames: if not hass.config.is_allowed_path(filename): raise HomeAssistantError( f"Cannot read `{filename}`, no access to path; " "`allowlist_external_dirs` may need to be adjusted in " "`configuration.yaml`" ) - if not Path(filename).exists(): - raise HomeAssistantError(f"`{filename}` does not exist") - mime_type, base64_file = encode_file(filename) - if "image/" in mime_type: - content.append( - ResponseInputImageParam( - type="input_image", - image_url=f"data:{mime_type};base64,{base64_file}", - detail="auto", - ) - ) - elif "application/pdf" in mime_type: - content.append( - ResponseInputFileParam( - type="input_file", - filename=filename, - file_data=f"data:{mime_type};base64,{base64_file}", - ) - ) - else: - raise HomeAssistantError( - "Only images and PDF are supported by the OpenAI API," - f"`{filename}` is not an image file or PDF" - ) - if CONF_FILENAMES in call.data: - await hass.async_add_executor_job(append_files_to_content) + content.extend( + await async_prepare_files_for_prompt( + hass, [Path(filename) for filename in filenames] + ) + ) messages: ResponseInputParam = [ EasyInputMessageParam(type="message", role="user", content=content) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index ba7153deb24..69ca4c9a1eb 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -1,8 +1,13 @@ """Base entity for OpenAI.""" +from __future__ import annotations + +import base64 from collections.abc import AsyncGenerator, Callable import json -from typing import Any, Literal, cast +from mimetypes import guess_file_type +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal, cast import openai from openai._streaming import AsyncStream @@ -17,6 +22,9 @@ from openai.types.responses import ( ResponseFunctionToolCall, ResponseFunctionToolCallParam, ResponseIncompleteEvent, + ResponseInputFileParam, + ResponseInputImageParam, + ResponseInputMessageContentListParam, ResponseInputParam, ResponseOutputItemAddedEvent, ResponseOutputItemDoneEvent, @@ -35,11 +43,11 @@ from voluptuous_openapi import convert from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity -from . import OpenAIConfigEntry from .const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, @@ -63,6 +71,10 @@ from .const import ( RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, ) +if TYPE_CHECKING: + from . import OpenAIConfigEntry + + # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -312,3 +324,50 @@ class OpenAIBaseLLMEntity(Entity): if not chat_log.unresponded_tool_results: break + + +async def async_prepare_files_for_prompt( + hass: HomeAssistant, files: list[Path] +) -> ResponseInputMessageContentListParam: + """Append files to a prompt. + + Caller needs to ensure that the files are allowed. + """ + + def append_files_to_content() -> ResponseInputMessageContentListParam: + content: ResponseInputMessageContentListParam = [] + + for file_path in files: + if not file_path.exists(): + raise HomeAssistantError(f"`{file_path}` does not exist") + + mime_type, _ = guess_file_type(file_path) + + if not mime_type or not mime_type.startswith(("image/", "application/pdf")): + raise HomeAssistantError( + "Only images and PDF are supported by the OpenAI API," + f"`{file_path}` is not an image file or PDF" + ) + + base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8") + + if mime_type.startswith("image/"): + content.append( + ResponseInputImageParam( + type="input_image", + image_url=f"data:{mime_type};base64,{base64_file}", + detail="auto", + ) + ) + elif mime_type.startswith("application/pdf"): + content.append( + ResponseInputFileParam( + type="input_file", + filename=str(file_path), + file_data=f"data:{mime_type};base64,{base64_file}", + ) + ) + + return content + + return await hass.async_add_executor_job(append_files_to_content) diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index d7e8b29cab2..3e13cb3dd1c 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,6 +1,6 @@ """Tests for the OpenAI integration.""" -from unittest.mock import AsyncMock, mock_open, patch +from unittest.mock import AsyncMock, Mock, mock_open, patch import httpx from openai import ( @@ -16,7 +16,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from syrupy.filters import props -from homeassistant.components.openai_conversation import CONF_CHAT_MODEL, CONF_FILENAMES +from homeassistant.components.openai_conversation import CONF_CHAT_MODEL from homeassistant.components.openai_conversation.const import DOMAIN from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant @@ -394,7 +394,7 @@ async def test_generate_content_service( patch( "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] ) as mock_b64encode, - patch("builtins.open", mock_open(read_data="ABC")) as mock_file, + patch("pathlib.Path.read_bytes", Mock(return_value=b"ABC")) as mock_file, patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), ): @@ -434,15 +434,13 @@ async def test_generate_content_service( assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][2] == expected_args assert mock_b64encode.call_count == number_of_files - for idx, file in enumerate(service_data[CONF_FILENAMES]): - assert mock_file.call_args_list[idx][0][0] == file + assert mock_file.call_count == number_of_files @pytest.mark.parametrize( ( "service_data", "error", - "number_of_files", "exists_side_effect", "is_allowed_side_effect", ), @@ -450,7 +448,6 @@ async def test_generate_content_service( ( {"prompt": "Picture of a dog", "filenames": ["/a/b/c.jpg"]}, "`/a/b/c.jpg` does not exist", - 0, [False], [True], ), @@ -460,14 +457,12 @@ async def test_generate_content_service( "filenames": ["/a/b/c.jpg", "d/e/f.png"], }, "Cannot read `d/e/f.png`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`", - 1, [True, True], [True, False], ), ( {"prompt": "Not a picture of a dog", "filenames": ["/a/b/c.mov"]}, "Only images and PDF are supported by the OpenAI API,`/a/b/c.mov` is not an image file or PDF", - 1, [True], [True], ), @@ -479,7 +474,6 @@ async def test_generate_content_service_invalid( mock_init_component, service_data, error, - number_of_files, exists_side_effect, is_allowed_side_effect, ) -> None: @@ -491,9 +485,7 @@ async def test_generate_content_service_invalid( "openai.resources.responses.AsyncResponses.create", new_callable=AsyncMock, ) as mock_create, - patch( - "base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"] - ) as mock_b64encode, + patch("base64.b64encode", side_effect=[b"BASE64IMAGE1", b"BASE64IMAGE2"]), patch("builtins.open", mock_open(read_data="ABC")), patch("pathlib.Path.exists", side_effect=exists_side_effect), patch.object( @@ -509,7 +501,6 @@ async def test_generate_content_service_invalid( return_response=True, ) assert len(mock_create.mock_calls) == 0 - assert mock_b64encode.call_count == number_of_files @pytest.mark.usefixtures("mock_init_component") From d44b8222958f2a8295b7f25ba12f090e686988d2 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Tue, 8 Jul 2025 02:51:18 -0400 Subject: [PATCH 1222/1664] Add play media support to Russound RIO (#148240) --- .../components/russound_rio/const.py | 4 + .../components/russound_rio/media_player.py | 51 +++++++++- .../components/russound_rio/strings.json | 9 ++ tests/components/russound_rio/conftest.py | 1 + .../russound_rio/fixtures/get_sources.json | 9 +- .../russound_rio/test_media_player.py | 96 ++++++++++++++++++- 6 files changed, 167 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py index 9647c419da0..7a8c0bb4fbc 100644 --- a/homeassistant/components/russound_rio/const.py +++ b/homeassistant/components/russound_rio/const.py @@ -6,6 +6,10 @@ from aiorussound import CommandError DOMAIN = "russound_rio" +RUSSOUND_MEDIA_TYPE_PRESET = "preset" + +SELECT_SOURCE_DELAY = 0.5 + RUSSOUND_RIO_EXCEPTIONS = ( CommandError, ConnectionRefusedError, diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index aaaad05a2bc..29944de09b0 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -2,9 +2,10 @@ from __future__ import annotations +import asyncio import datetime as dt import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiorussound import Controller from aiorussound.const import FeatureFlag @@ -19,9 +20,11 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import RussoundConfigEntry +from .const import DOMAIN, RUSSOUND_MEDIA_TYPE_PRESET, SELECT_SOURCE_DELAY from .entity import RussoundBaseEntity, command _LOGGER = logging.getLogger(__name__) @@ -45,6 +48,17 @@ async def async_setup_entry( ) +def _parse_preset_source_id(media_id: str) -> tuple[int | None, int]: + source_id = None + if "," in media_id: + source_id_str, preset_id_str = media_id.split(",", maxsplit=1) + source_id = int(source_id_str.strip()) + preset_id = int(preset_id_str.strip()) + else: + preset_id = int(media_id) + return source_id, preset_id + + class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): """Representation of a Russound Zone.""" @@ -58,6 +72,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.PLAY_MEDIA ) _attr_name = None @@ -215,3 +230,37 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): async def async_media_seek(self, position: float) -> None: """Seek to a position in the current media.""" await self._zone.set_seek_time(int(position)) + + @command + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play media on the Russound zone.""" + + if media_type != RUSSOUND_MEDIA_TYPE_PRESET: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_media_type", + translation_placeholders={ + "media_type": media_type, + }, + ) + + try: + source_id, preset_id = _parse_preset_source_id(media_id) + except ValueError as ve: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="preset_non_integer", + translation_placeholders={"preset_id": media_id}, + ) from ve + if source_id: + await self._zone.select_source(source_id) + await asyncio.sleep(SELECT_SOURCE_DELAY) + if not self._source.presets or preset_id not in self._source.presets: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="missing_preset", + translation_placeholders={"preset_id": media_id}, + ) + await self._zone.restore_preset(preset_id) diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index aa9a1cbc65d..9149a22aac0 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -67,6 +67,15 @@ }, "command_error": { "message": "Error executing {function_name} on entity {entity_id}" + }, + "unsupported_media_type": { + "message": "Unsupported media type for Russound zone: {media_type}" + }, + "missing_preset": { + "message": "The specified preset is not available for this source: {preset_id}" + }, + "preset_non_integer": { + "message": "Preset must be an integer, got: {preset_id}" } } } diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py index 81091e1d5a8..15922f76b9f 100644 --- a/tests/components/russound_rio/conftest.py +++ b/tests/components/russound_rio/conftest.py @@ -84,6 +84,7 @@ def mock_russound_client() -> Generator[AsyncMock]: zone.set_treble = AsyncMock() zone.set_turn_on_volume = AsyncMock() zone.set_loudness = AsyncMock() + zone.restore_preset = AsyncMock() client.controllers = { 1: Controller( diff --git a/tests/components/russound_rio/fixtures/get_sources.json b/tests/components/russound_rio/fixtures/get_sources.json index e39d702b8a1..a9f4b4e14af 100644 --- a/tests/components/russound_rio/fixtures/get_sources.json +++ b/tests/components/russound_rio/fixtures/get_sources.json @@ -1,7 +1,14 @@ { "1": { "name": "Aux", - "type": "Miscellaneous Audio" + "type": "RNET AM/FM Tuner (Internal)", + "presets": { + "1": "WOOD", + "2": "89.7 MHz FM", + "7": "WWKR", + "8": "WKLA", + "11": "WGN" + } }, "2": { "name": "Spotify", diff --git a/tests/components/russound_rio/test_media_player.py b/tests/components/russound_rio/test_media_player.py index 04e1057565d..d8eacd5f30b 100644 --- a/tests/components/russound_rio/test_media_player.py +++ b/tests/components/russound_rio/test_media_player.py @@ -9,10 +9,13 @@ import pytest from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, DOMAIN as MP_DOMAIN, + SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, ) from homeassistant.const import ( @@ -32,7 +35,7 @@ from homeassistant.const import ( STATE_PLAYING, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from . import mock_state_update, setup_integration from .const import ENTITY_ID_ZONE_1 @@ -253,3 +256,94 @@ async def test_media_seek( mock_russound_client.controllers[1].zones[1].set_seek_time.assert_called_once_with( 100 ) + + +async def test_play_media_preset_item_id( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test playing media with a preset item id.""" + await setup_integration(hass, mock_config_entry) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].restore_preset.assert_called_once_with( + 1 + ) + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "1,2", + }, + blocking=True, + ) + mock_russound_client.controllers[1].zones[1].select_source.assert_called_once_with( + 1 + ) + mock_russound_client.controllers[1].zones[1].restore_preset.assert_called_with(2) + + with pytest.raises( + ServiceValidationError, + match="The specified preset is not available for this source: 10", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "10", + }, + blocking=True, + ) + + with pytest.raises( + ServiceValidationError, match="Preset must be an integer, got: UNKNOWN_PRESET" + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "preset", + ATTR_MEDIA_CONTENT_ID: "UNKNOWN_PRESET", + }, + blocking=True, + ) + + +async def test_play_media_unknown_type( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_russound_client: AsyncMock, +) -> None: + """Test playing media with an unsupported content type.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises( + HomeAssistantError, + match="Unsupported media type for Russound zone: unsupported_content_type", + ): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_1, + ATTR_MEDIA_CONTENT_TYPE: "unsupported_content_type", + ATTR_MEDIA_CONTENT_ID: "1", + }, + blocking=True, + ) From ac5d4f4a81f56a2d924a8ad8fe24666cd164128b Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 8 Jul 2025 09:17:27 +0200 Subject: [PATCH 1223/1664] Fix CI issues due to nibe heatpump (#148388) --- tests/components/nibe_heatpump/snapshots/test_number.ambr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/nibe_heatpump/snapshots/test_number.ambr b/tests/components/nibe_heatpump/snapshots/test_number.ambr index 49bdec9e4ea..ac6354c902a 100644 --- a/tests/components/nibe_heatpump/snapshots/test_number.ambr +++ b/tests/components/nibe_heatpump/snapshots/test_number.ambr @@ -5,7 +5,7 @@ 'friendly_name': 'F1155 Room sensor setpoint S1', 'max': 30.0, 'min': 5.0, - 'mode': , + 'mode': , 'step': 0.1, 'unit_of_measurement': '°C', }), From 0dc145aee3940b05db41882871bdfbda9c054b76 Mon Sep 17 00:00:00 2001 From: Jiacheng Ma Date: Tue, 8 Jul 2025 01:03:35 -0700 Subject: [PATCH 1224/1664] Fix tuya vacuum return_to_base function (#144362) Co-authored-by: Franck Nijhof --- homeassistant/components/tuya/vacuum.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index e36a682fa4e..f722fd918ca 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -91,14 +91,15 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): if self.find_dpcode(DPCode.PAUSE, prefer_function=True): self._attr_supported_features |= VacuumEntityFeature.PAUSE - if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True) or ( - ( - enum_type := self.find_dpcode( - DPCode.MODE, dptype=DPType.ENUM, prefer_function=True - ) + self._return_home_use_switch_charge = False + if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True): + self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME + self._return_home_use_switch_charge = True + elif ( + enum_type := self.find_dpcode( + DPCode.MODE, dptype=DPType.ENUM, prefer_function=True ) - and TUYA_MODE_RETURN_HOME in enum_type.range - ): + ) and TUYA_MODE_RETURN_HOME in enum_type.range: self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME if self.find_dpcode(DPCode.SEEK, prefer_function=True): @@ -159,12 +160,10 @@ class TuyaVacuumEntity(TuyaEntity, StateVacuumEntity): def return_to_base(self, **kwargs: Any) -> None: """Return device to dock.""" - self._send_command( - [ - {"code": DPCode.SWITCH_CHARGE, "value": True}, - {"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}, - ] - ) + if self._return_home_use_switch_charge: + self._send_command([{"code": DPCode.SWITCH_CHARGE, "value": True}]) + else: + self._send_command([{"code": DPCode.MODE, "value": TUYA_MODE_RETURN_HOME}]) def locate(self, **kwargs: Any) -> None: """Locate the device.""" From a77a071954733b09c7594b28b8683d4d9c2ec655 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 11:14:41 +0300 Subject: [PATCH 1225/1664] Bump aioamazondevices to 3.2.8 (#148365) Co-authored-by: Joakim Plate --- homeassistant/components/alexa_devices/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/alexa_devices/conftest.py | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 70281390436..34fdd1448a5 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": "bronze", - "requirements": ["aioamazondevices==3.2.3"] + "requirements": ["aioamazondevices==3.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa396027379..0d0529af638 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.3 +aioamazondevices==3.2.8 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a33c41fac3..2ca2f905f8c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.3 +aioamazondevices==3.2.8 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 79851550528..a5a49a343a9 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -50,6 +50,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: device_type="echo", device_owner_customer_id="amazon_ower_id", device_cluster_members=[TEST_SERIAL_NUMBER], + device_locale="en-US", online=True, serial_number=TEST_SERIAL_NUMBER, software_version="echo_test_software_version", From f58c76c8837fd6a7cdfa16ee682bffa293ad2a7c Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 8 Jul 2025 10:16:10 +0200 Subject: [PATCH 1226/1664] Fix error when `personalDetail` is missing in PlayStation Network integration (#148389) --- homeassistant/components/playstation_network/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index f4a634d5fb5..cfd81fe4033 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -156,9 +156,9 @@ class PlaystationNetworkSensorEntity( def entity_picture(self) -> str | None: """Return the entity picture to use in the frontend, if any.""" if self.entity_description.key is PlaystationNetworkSensor.ONLINE_ID and ( - profile_pictures := self.coordinator.data.profile["personalDetail"].get( - "profilePictures" - ) + profile_pictures := self.coordinator.data.profile.get( + "personalDetail", {} + ).get("profilePictures") ): return next( (pic.get("url") for pic in profile_pictures if pic.get("size") == "xl"), From 7541e266daa705e1625532e64b0bed2d8131c339 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 8 Jul 2025 11:46:13 +0200 Subject: [PATCH 1227/1664] Make api_version runtime_data in pi_hole (#148238) --- homeassistant/components/pi_hole/__init__.py | 18 ++++++------------ .../components/pi_hole/config_flow.py | 3 --- homeassistant/components/pi_hole/sensor.py | 4 ++-- tests/components/pi_hole/__init__.py | 2 -- tests/components/pi_hole/test_config_flow.py | 6 +++--- tests/components/pi_hole/test_init.py | 8 ++++---- 6 files changed, 15 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index f211d646c0b..f73b7156d3e 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -12,7 +12,6 @@ from hole.exceptions import HoleConnectionError, HoleError from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -52,13 +51,13 @@ class PiHoleData: api: Hole coordinator: DataUpdateCoordinator[None] + api_version: int async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bool: """Set up Pi-hole entry.""" name = entry.data[CONF_NAME] host = entry.data[CONF_HOST] - version = entry.data.get(CONF_API_VERSION) # remove obsolet CONF_STATISTICS_ONLY from entry.data if CONF_STATISTICS_ONLY in entry.data: @@ -100,15 +99,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo await er.async_migrate_entries(hass, entry.entry_id, update_unique_id) - if version is None: - _LOGGER.debug( - "No API version specified, determining Pi-hole API version for %s", host - ) - version = await determine_api_version(hass, dict(entry.data)) - _LOGGER.debug("Pi-hole API version determined: %s", version) - hass.config_entries.async_update_entry( - entry, data={**entry.data, CONF_API_VERSION: version} - ) + _LOGGER.debug("Determining Pi-hole API version for %s", host) + version = await determine_api_version(hass, dict(entry.data)) + _LOGGER.debug("Pi-hole API version determined: %s", version) + # Once API version 5 is deprecated we should instantiate Hole directly api = api_by_version(hass, dict(entry.data), version) @@ -151,7 +145,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: PiHoleConfigEntry) -> bo await coordinator.async_config_entry_first_refresh() - entry.runtime_data = PiHoleData(api, coordinator) + entry.runtime_data = PiHoleData(api, coordinator, version) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/pi_hole/config_flow.py b/homeassistant/components/pi_hole/config_flow.py index da994b74e6d..327ce32847e 100644 --- a/homeassistant/components/pi_hole/config_flow.py +++ b/homeassistant/components/pi_hole/config_flow.py @@ -12,7 +12,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import ( CONF_API_KEY, - CONF_API_VERSION, CONF_HOST, CONF_LOCATION, CONF_NAME, @@ -145,7 +144,6 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): try: await pi_hole.authenticate() _LOGGER.debug("Success authenticating with pihole API version: %s", 6) - self._config[CONF_API_VERSION] = 6 except HoleError: _LOGGER.debug("Failed authenticating with pihole API version: %s", 6) return {CONF_API_KEY: "invalid_auth"} @@ -171,7 +169,6 @@ class PiHoleFlowHandler(ConfigFlow, domain=DOMAIN): "Success connecting to, but necessarily authenticating with, pihole, API version is: %s", 5, ) - self._config[CONF_API_VERSION] = 5 # the v5 API returns an empty list to unauthenticated requests. if not isinstance(pi_hole.data, dict): _LOGGER.debug( diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py index aa79805cc2d..844b03acf7c 100644 --- a/homeassistant/components/pi_hole/sensor.py +++ b/homeassistant/components/pi_hole/sensor.py @@ -8,7 +8,7 @@ from typing import Any from hole import Hole from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.const import CONF_API_VERSION, CONF_NAME, PERCENTAGE +from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType @@ -133,7 +133,7 @@ async def async_setup_entry( description, ) for description in ( - SENSOR_TYPES if entry.data[CONF_API_VERSION] == 5 else SENSOR_TYPES_V6 + SENSOR_TYPES if hole_data.api_version == 5 else SENSOR_TYPES_V6 ) ] async_add_entities(sensors, True) diff --git a/tests/components/pi_hole/__init__.py b/tests/components/pi_hole/__init__.py index 36ee963a16f..c20f22ac58d 100644 --- a/tests/components/pi_hole/__init__.py +++ b/tests/components/pi_hole/__init__.py @@ -185,7 +185,6 @@ CONFIG_ENTRY_WITH_API_KEY = { CONF_API_KEY: API_KEY, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, - CONF_API_VERSION: API_VERSION, } CONFIG_ENTRY_WITHOUT_API_KEY = { @@ -194,7 +193,6 @@ CONFIG_ENTRY_WITHOUT_API_KEY = { CONF_NAME: NAME, CONF_SSL: SSL, CONF_VERIFY_SSL: VERIFY_SSL, - CONF_API_VERSION: API_VERSION, } SWITCH_ENTITY_ID = "switch.pi_hole" diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py index e92a845ce1e..e79f65b406e 100644 --- a/tests/components/pi_hole/test_config_flow.py +++ b/tests/components/pi_hole/test_config_flow.py @@ -3,7 +3,7 @@ from homeassistant.components import pi_hole from homeassistant.components.pi_hole.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_API_VERSION +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -104,7 +104,7 @@ async def test_flow_user_with_api_key_v5(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == NAME - assert result["data"] == {**CONFIG_ENTRY_WITH_API_KEY, CONF_API_VERSION: 5} + assert result["data"] == {**CONFIG_ENTRY_WITH_API_KEY} mock_setup.assert_called_once() # duplicated server @@ -148,7 +148,7 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: mocked_hole = _create_mocked_hole(has_data=False, api_version=5) entry = MockConfigEntry( domain=pi_hole.DOMAIN, - data={**CONFIG_DATA_DEFAULTS, CONF_API_VERSION: 5, CONF_API_KEY: "oldkey"}, + data={**CONFIG_DATA_DEFAULTS, CONF_API_KEY: "oldkey"}, ) entry.add_to_hass(hass) with _patch_init_hole(mocked_hole), _patch_config_flow_hole(mocked_hole): diff --git a/tests/components/pi_hole/test_init.py b/tests/components/pi_hole/test_init.py index b4cc11529d9..94170e967d4 100644 --- a/tests/components/pi_hole/test_init.py +++ b/tests/components/pi_hole/test_init.py @@ -51,7 +51,7 @@ async def test_setup_api_v6( entry.add_to_hass(hass) with _patch_init_hole(mocked_hole) as patched_init_hole: assert await hass.config_entries.async_setup(entry.entry_id) - patched_init_hole.assert_called_once_with( + patched_init_hole.assert_called_with( host=config_entry_data[CONF_HOST], session=ANY, password=expected_api_token, @@ -78,7 +78,7 @@ async def test_setup_api_v5( entry.add_to_hass(hass) with _patch_init_hole(mocked_hole) as patched_init_hole: assert await hass.config_entries.async_setup(entry.entry_id) - patched_init_hole.assert_called_once_with( + patched_init_hole.assert_called_with( host=config_entry_data[CONF_HOST], session=ANY, api_token=expected_api_token, @@ -206,7 +206,7 @@ async def test_setup_without_api_version(hass: HomeAssistant) -> None: with _patch_init_hole(mocked_hole): assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.data[CONF_API_VERSION] == 6 + assert entry.runtime_data.api_version == 6 mocked_hole = _create_mocked_hole(api_version=5) config = {**CONFIG_DATA_DEFAULTS} @@ -216,7 +216,7 @@ async def test_setup_without_api_version(hass: HomeAssistant) -> None: with _patch_init_hole(mocked_hole): assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.data[CONF_API_VERSION] == 5 + assert entry.runtime_data.api_version == 5 async def test_setup_name_config(hass: HomeAssistant) -> None: From bd1917c9b6f8ecc03df677164ee2181bf108aefb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 8 Jul 2025 12:34:51 +0200 Subject: [PATCH 1228/1664] Bump pySmartThings to 3.2.7 (#148394) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 7c3fc47e512..2c4974a6567 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.5"] + "requirements": ["pysmartthings==3.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0d0529af638..7c4228f4407 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.5 +pysmartthings==3.2.7 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ca2f905f8c..80756efa959 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1951,7 +1951,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.5 +pysmartthings==3.2.7 # homeassistant.components.smarty pysmarty2==0.10.2 From a7cba2b9bb3d62282a6a6fe7556e36ad7b940791 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 8 Jul 2025 13:05:16 +0200 Subject: [PATCH 1229/1664] Handle binary coils with non default mappings in nibe heatpump (#148354) --- .../components/nibe_heatpump/binary_sensor.py | 3 +- .../components/nibe_heatpump/switch.py | 8 +- tests/components/nibe_heatpump/__init__.py | 6 +- .../snapshots/test_binary_sensor.ambr | 97 +++++++++ .../nibe_heatpump/snapshots/test_switch.ambr | 193 ++++++++++++++++++ .../nibe_heatpump/test_binary_sensor.py | 49 +++++ tests/components/nibe_heatpump/test_button.py | 2 +- .../components/nibe_heatpump/test_climate.py | 2 +- tests/components/nibe_heatpump/test_number.py | 2 +- tests/components/nibe_heatpump/test_switch.py | 133 ++++++++++++ 10 files changed, 487 insertions(+), 8 deletions(-) create mode 100644 tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/nibe_heatpump/snapshots/test_switch.ambr create mode 100644 tests/components/nibe_heatpump/test_binary_sensor.py create mode 100644 tests/components/nibe_heatpump/test_switch.py diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py index 284e4d83569..d49862180bd 100644 --- a/homeassistant/components/nibe_heatpump/binary_sensor.py +++ b/homeassistant/components/nibe_heatpump/binary_sensor.py @@ -39,6 +39,7 @@ class BinarySensor(CoilEntity, BinarySensorEntity): def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) + self._on_value = coil.get_mapping_for(1) def _async_read_coil(self, data: CoilData) -> None: - self._attr_is_on = data.value == "ON" + self._attr_is_on = data.value == self._on_value diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 2daf3fc48ff..452244f05b5 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -41,14 +41,16 @@ class Switch(CoilEntity, SwitchEntity): def __init__(self, coordinator: CoilCoordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) + self._on_value = coil.get_mapping_for(1) + self._off_value = coil.get_mapping_for(0) def _async_read_coil(self, data: CoilData) -> None: - self._attr_is_on = data.value == "ON" + self._attr_is_on = data.value == self._on_value async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self._async_write_coil("ON") + await self._async_write_coil(self._on_value) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._async_write_coil("OFF") + await self._async_write_coil(self._off_value) diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index 15cd9859d6e..e5ce32b2293 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -24,6 +24,8 @@ MOCK_ENTRY_DATA = { "connection_type": "nibegw", } +MOCK_UNIQUE_ID = "mock_entry_unique_id" + class MockConnection(Connection): """A mock connection class.""" @@ -59,7 +61,9 @@ class MockConnection(Connection): async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> MockConfigEntry: """Add entry and get the coordinator.""" - entry = MockConfigEntry(domain=DOMAIN, title="Dummy", data=data) + entry = MockConfigEntry( + domain=DOMAIN, title="Dummy", data=data, unique_id=MOCK_UNIQUE_ID + ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) diff --git a/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr b/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..37dd7a8679c --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_binary_sensor.ambr @@ -0,0 +1,97 @@ +# serializer version: 1 +# name: test_update[Model.F1255-49239-OFF][binary_sensor.eb101_installed_49239-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': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + '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': 'EB101 Installed', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'eb101_installed_49239', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-49239', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-49239-OFF][binary_sensor.eb101_installed_49239-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 EB101 Installed', + }), + 'context': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-49239-ON][binary_sensor.eb101_installed_49239-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': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + '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': 'EB101 Installed', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'eb101_installed_49239', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-49239', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-49239-ON][binary_sensor.eb101_installed_49239-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 EB101 Installed', + }), + 'context': , + 'entity_id': 'binary_sensor.eb101_installed_49239', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nibe_heatpump/snapshots/test_switch.ambr b/tests/components/nibe_heatpump/snapshots/test_switch.ambr new file mode 100644 index 00000000000..01f35bd8a54 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_switch.ambr @@ -0,0 +1,193 @@ +# serializer version: 1 +# name: test_update[Model.F1255-48043-ACTIVE][switch.holiday_activated_48043-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.holiday_activated_48043', + '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': 'Holiday - Activated', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'holiday_activated_48043', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48043', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48043-ACTIVE][switch.holiday_activated_48043-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 Holiday - Activated', + }), + 'context': , + 'entity_id': 'switch.holiday_activated_48043', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update[Model.F1255-48043-INACTIVE][switch.holiday_activated_48043-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.holiday_activated_48043', + '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': 'Holiday - Activated', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'holiday_activated_48043', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48043', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48043-INACTIVE][switch.holiday_activated_48043-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 Holiday - Activated', + }), + 'context': , + 'entity_id': 'switch.holiday_activated_48043', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-48071-OFF][switch.flm_1_accessory_48071-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.flm_1_accessory_48071', + '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': 'FLM 1 accessory', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'flm_1_accessory_48071', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48071', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48071-OFF][switch.flm_1_accessory_48071-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 FLM 1 accessory', + }), + 'context': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_update[Model.F1255-48071-ON][switch.flm_1_accessory_48071-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.flm_1_accessory_48071', + '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': 'FLM 1 accessory', + 'platform': 'nibe_heatpump', + 'previous_unique_id': None, + 'suggested_object_id': 'flm_1_accessory_48071', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'mock_entry_unique_id-48071', + 'unit_of_measurement': None, + }) +# --- +# name: test_update[Model.F1255-48071-ON][switch.flm_1_accessory_48071-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1255 FLM 1 accessory', + }), + 'context': , + 'entity_id': 'switch.flm_1_accessory_48071', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_binary_sensor.py b/tests/components/nibe_heatpump/test_binary_sensor.py new file mode 100644 index 00000000000..30010ac61c4 --- /dev/null +++ b/tests/components/nibe_heatpump/test_binary_sensor.py @@ -0,0 +1,49 @@ +"""Test the Nibe Heat Pump binary sensor entities.""" + +from typing import Any +from unittest.mock import patch + +from nibe.heatpump import Model +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 . import async_add_model + +from tests.common import snapshot_platform + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch( + "homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.BINARY_SENSOR] + ): + yield + + +@pytest.mark.parametrize( + ("model", "address", "value"), + [ + (Model.F1255, 49239, "OFF"), + (Model.F1255, 49239, "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: Model, + address: int, + value: Any, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + entry = await async_add_model(hass, model) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index 5015bba4092..4f2bab7ad0a 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump buttons.""" from typing import Any from unittest.mock import AsyncMock, patch diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index a9620b5ddb3..85e932f8018 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump climate entities.""" from typing import Any from unittest.mock import call, patch diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py index b789515e764..6e004a0554e 100644 --- a/tests/components/nibe_heatpump/test_number.py +++ b/tests/components/nibe_heatpump/test_number.py @@ -1,4 +1,4 @@ -"""Test the Nibe Heat Pump config flow.""" +"""Test the Nibe Heat Pump number entities.""" from typing import Any from unittest.mock import AsyncMock, patch diff --git a/tests/components/nibe_heatpump/test_switch.py b/tests/components/nibe_heatpump/test_switch.py new file mode 100644 index 00000000000..4221de52ba1 --- /dev/null +++ b/tests/components/nibe_heatpump/test_switch.py @@ -0,0 +1,133 @@ +"""Test the Nibe Heat Pump switch entities.""" + +from typing import Any +from unittest.mock import AsyncMock, patch + +from nibe.coil import CoilData +from nibe.heatpump import Model +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.switch import ( + DOMAIN as SWITCH_PLATFORM, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import async_add_model + +from tests.common import snapshot_platform + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.SWITCH]): + yield + + +@pytest.mark.parametrize( + ("model", "address", "value"), + [ + (Model.F1255, 48043, "INACTIVE"), + (Model.F1255, 48043, "ACTIVE"), + (Model.F1255, 48071, "OFF"), + (Model.F1255, 48071, "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_update( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + model: Model, + address: int, + value: Any, + coils: dict[int, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + entry = await async_add_model(hass, model) + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "state"), + [ + (Model.F1255, 48043, "switch.holiday_activated_48043", "INACTIVE"), + (Model.F1255, 48071, "switch.flm_1_accessory_48071", "OFF"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_turn_on( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + state: Any, + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + coils[address] = state + + await async_add_model(hass, model) + + # Write value + await hass.services.async_call( + SWITCH_PLATFORM, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.raw_value == 1 + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "state"), + [ + (Model.F1255, 48043, "switch.holiday_activated_48043", "INACTIVE"), + (Model.F1255, 48071, "switch.flm_1_accessory_48071", "ON"), + ], +) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_turn_off( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + state: Any, + coils: dict[int, Any], +) -> None: + """Test setting of value.""" + coils[address] = state + + await async_add_model(hass, model) + + # Write value + await hass.services.async_call( + SWITCH_PLATFORM, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.raw_value == 0 From 824006729b650863d861e3edc6c18144e0b04b5e Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 8 Jul 2025 13:06:05 +0200 Subject: [PATCH 1230/1664] Create own clientsession for lamarzocco (#148385) --- homeassistant/components/lamarzocco/__init__.py | 5 ++--- homeassistant/components/lamarzocco/config_flow.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index ff977438f38..2d68b3be345 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import CONF_USE_BLUETOOTH, DOMAIN from .coordinator import ( @@ -57,11 +57,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - assert entry.unique_id serial = entry.unique_id - client = async_get_clientsession(hass) cloud_client = LaMarzoccoCloudClient( username=entry.data[CONF_USERNAME], password=entry.data[CONF_PASSWORD], - client=client, + client=async_create_clientsession(hass), ) try: diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index 8cb2e4dfc61..e352e337d0b 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -33,7 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, SelectSelector, @@ -83,7 +83,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): **user_input, } - self._client = async_get_clientsession(self.hass) + self._client = async_create_clientsession(self.hass) cloud_client = LaMarzoccoCloudClient( username=data[CONF_USERNAME], password=data[CONF_PASSWORD], From d2bf27195a66e6f25d941bc4ef6214831013a8e7 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 8 Jul 2025 13:06:43 +0200 Subject: [PATCH 1231/1664] Bump pylamarzocco to 2.0.11 (#148386) --- homeassistant/components/lamarzocco/binary_sensor.py | 2 +- homeassistant/components/lamarzocco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py index 4fc2c0b05df..afbb779b696 100644 --- a/homeassistant/components/lamarzocco/binary_sensor.py +++ b/homeassistant/components/lamarzocco/binary_sensor.py @@ -66,7 +66,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = ( WidgetType.CM_BACK_FLUSH, BackFlush(status=BackFlushStatus.OFF) ), ).status - is BackFlushStatus.REQUESTED + in (BackFlushStatus.REQUESTED, BackFlushStatus.CLEANING) ), entity_category=EntityCategory.DIAGNOSTIC, supported_fn=lambda coordinator: ( diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json index 10cb23146ae..3c070769b5b 100644 --- a/homeassistant/components/lamarzocco/manifest.json +++ b/homeassistant/components/lamarzocco/manifest.json @@ -37,5 +37,5 @@ "iot_class": "cloud_push", "loggers": ["pylamarzocco"], "quality_scale": "platinum", - "requirements": ["pylamarzocco==2.0.10"] + "requirements": ["pylamarzocco==2.0.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7c4228f4407..a45a1f31b83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2100,7 +2100,7 @@ pykwb==0.0.8 pylacrosse==0.4 # homeassistant.components.lamarzocco -pylamarzocco==2.0.10 +pylamarzocco==2.0.11 # homeassistant.components.lastfm pylast==5.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80756efa959..476a2f9e6fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ pykrakenapi==0.1.8 pykulersky==0.5.8 # homeassistant.components.lamarzocco -pylamarzocco==2.0.10 +pylamarzocco==2.0.11 # homeassistant.components.lastfm pylast==5.1.0 From b775ba29553ac59a1fa4006daf2f144c55ca90c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 8 Jul 2025 13:23:28 +0200 Subject: [PATCH 1232/1664] Do not add switch_as_x config entry to source device (#148346) --- .../components/switch_as_x/__init__.py | 42 +++---- .../components/switch_as_x/config_flow.py | 2 +- .../components/switch_as_x/entity.py | 7 +- homeassistant/helpers/entity_platform.py | 34 +++--- homeassistant/helpers/helper_integration.py | 58 +++++++++- tests/components/switch_as_x/__init__.py | 23 ++++ tests/components/switch_as_x/test_init.py | 92 ++++++++++++++-- tests/helpers/test_helper_integration.py | 104 +++++++++++++++++- 8 files changed, 306 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/switch_as_x/__init__.py b/homeassistant/components/switch_as_x/__init__.py index c77eda9b294..b511e2af2b2 100644 --- a/homeassistant/components/switch_as_x/__init__.py +++ b/homeassistant/components/switch_as_x/__init__.py @@ -10,8 +10,11 @@ from homeassistant.components.homeassistant import exposed_entities from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_INVERT, CONF_TARGET_DOMAIN @@ -19,24 +22,14 @@ _LOGGER = logging.getLogger(__name__) @callback -def async_add_to_device( - hass: HomeAssistant, entry: ConfigEntry, entity_id: str -) -> str | None: - """Add our config entry to the tracked entity's device.""" +def async_get_parent_device_id(hass: HomeAssistant, entity_id: str) -> str | None: + """Get the parent device id.""" registry = er.async_get(hass) - device_registry = dr.async_get(hass) - device_id = None - if ( - not (wrapped_switch := registry.async_get(entity_id)) - or not (device_id := wrapped_switch.device_id) - or not (device_registry.async_get(device_id)) - ): - return device_id + if not (wrapped_switch := registry.async_get(entity_id)): + return None - device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id) - - return device_id + return wrapped_switch.device_id async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -68,9 +61,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, - source_device_id=async_add_to_device(hass, entry, entity_id), + source_device_id=async_get_parent_device_id(hass, entity_id), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], source_entity_removed=source_entity_removed, ) @@ -96,8 +90,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> options = {**config_entry.options} if config_entry.minor_version < 2: options.setdefault(CONF_INVERT, False) + if config_entry.version < 3: + # Remove the switch_as_x config entry from the source device + if source_device_id := async_get_parent_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) hass.config_entries.async_update_entry( - config_entry, options=options, minor_version=2 + config_entry, options=options, minor_version=3 ) _LOGGER.debug( diff --git a/homeassistant/components/switch_as_x/config_flow.py b/homeassistant/components/switch_as_x/config_flow.py index aa9f1d411ce..cf442256cbe 100644 --- a/homeassistant/components/switch_as_x/config_flow.py +++ b/homeassistant/components/switch_as_x/config_flow.py @@ -58,7 +58,7 @@ class SwitchAsXConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title and hide the wrapped entity if registered.""" diff --git a/homeassistant/components/switch_as_x/entity.py b/homeassistant/components/switch_as_x/entity.py index 64bfe712086..7611725d457 100644 --- a/homeassistant/components/switch_as_x/entity.py +++ b/homeassistant/components/switch_as_x/entity.py @@ -15,7 +15,6 @@ from homeassistant.const import ( ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.event import async_track_state_change_event @@ -48,12 +47,8 @@ class BaseEntity(Entity): if wrapped_switch: name = wrapped_switch.original_name - self._device_id = device_id if device_id and (device := device_registry.async_get(device_id)): - self._attr_device_info = DeviceInfo( - connections=device.connections, - identifiers=device.identifiers, - ) + self.device_entry = device self._attr_entity_category = entity_category self._attr_has_entity_name = has_entity_name self._attr_name = name diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0423a1979bc..e798e85ed02 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -825,21 +825,25 @@ class EntityPlatform: entity.add_to_platform_abort() return - if self.config_entry and (device_info := entity.device_info): - try: - device = dev_reg.async_get(self.hass).async_get_or_create( - config_entry_id=self.config_entry.entry_id, - config_subentry_id=config_subentry_id, - **device_info, - ) - except dev_reg.DeviceInfoError as exc: - self.logger.error( - "%s: Not adding entity with invalid device info: %s", - self.platform_name, - str(exc), - ) - entity.add_to_platform_abort() - return + device: dev_reg.DeviceEntry | None + if self.config_entry: + if device_info := entity.device_info: + try: + device = dev_reg.async_get(self.hass).async_get_or_create( + config_entry_id=self.config_entry.entry_id, + config_subentry_id=config_subentry_id, + **device_info, + ) + except dev_reg.DeviceInfoError as exc: + self.logger.error( + "%s: Not adding entity with invalid device info: %s", + self.platform_name, + str(exc), + ) + entity.add_to_platform_abort() + return + else: + device = entity.device_entry else: device = None diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index 61bb0bcd45d..d43c1b22a25 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -14,6 +14,7 @@ from .event import async_track_entity_registry_updated_event def async_handle_source_entity_changes( hass: HomeAssistant, *, + add_helper_config_entry_to_device: bool = True, helper_config_entry_id: str, set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, @@ -88,15 +89,17 @@ def async_handle_source_entity_changes( helper_entity.entity_id, device_id=source_entity_entry.device_id ) - if source_entity_entry.device_id is not None: + if add_helper_config_entry_to_device: + if source_entity_entry.device_id is not None: + device_registry.async_update_device( + source_entity_entry.device_id, + add_config_entry_id=helper_config_entry_id, + ) + device_registry.async_update_device( - source_entity_entry.device_id, - add_config_entry_id=helper_config_entry_id, + source_device_id, remove_config_entry_id=helper_config_entry_id ) - device_registry.async_update_device( - source_device_id, remove_config_entry_id=helper_config_entry_id - ) source_device_id = source_entity_entry.device_id # Reload the config entry so the helper entity is recreated with @@ -111,3 +114,46 @@ def async_handle_source_entity_changes( return async_track_entity_registry_updated_event( hass, source_entity_id, async_registry_updated ) + + +def async_remove_helper_config_entry_from_source_device( + hass: HomeAssistant, + *, + helper_config_entry_id: str, + source_device_id: str, +) -> None: + """Remove helper config entry from source device. + + This is a convenience function to migrate from helpers which added their config + entry to the source device. + """ + device_registry = dr.async_get(hass) + + if ( + not (source_device := device_registry.async_get(source_device_id)) + or helper_config_entry_id not in source_device.config_entries + ): + return + + entity_registry = er.async_get(hass) + helper_entity_entries = er.async_entries_for_config_entry( + entity_registry, helper_config_entry_id + ) + + # Disconnect helper entities from the device to prevent them from + # being removed when the config entry link to the device is removed. + modified_helpers: list[er.RegistryEntry] = [] + for helper in helper_entity_entries: + if helper.device_id != source_device_id: + continue + modified_helpers.append(helper) + entity_registry.async_update_entity(helper.entity_id, device_id=None) + # Remove the helper config entry from the device + device_registry.async_update_device( + source_device_id, remove_config_entry_id=helper_config_entry_id + ) + # Connect the helper entity to the device + for helper in modified_helpers: + entity_registry.async_update_entity( + helper.entity_id, device_id=source_device_id + ) diff --git a/tests/components/switch_as_x/__init__.py b/tests/components/switch_as_x/__init__.py index 2addb832462..dbf1afa54ac 100644 --- a/tests/components/switch_as_x/__init__.py +++ b/tests/components/switch_as_x/__init__.py @@ -1,6 +1,11 @@ """The tests for Switch as X platforms.""" +from homeassistant.components.cover import CoverEntityFeature +from homeassistant.components.fan import FanEntityFeature +from homeassistant.components.light import ATTR_SUPPORTED_COLOR_MODES, ColorMode from homeassistant.components.lock import LockState +from homeassistant.components.siren import SirenEntityFeature +from homeassistant.components.valve import ValveEntityFeature from homeassistant.const import STATE_CLOSED, STATE_OFF, STATE_ON, STATE_OPEN, Platform PLATFORMS_TO_TEST = ( @@ -12,6 +17,15 @@ PLATFORMS_TO_TEST = ( Platform.VALVE, ) +CAPABILITY_MAP = { + Platform.COVER: None, + Platform.FAN: {}, + Platform.LIGHT: {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.ONOFF]}, + Platform.LOCK: None, + Platform.SIREN: None, + Platform.VALVE: None, +} + STATE_MAP = { False: { Platform.COVER: {STATE_ON: STATE_OPEN, STATE_OFF: STATE_CLOSED}, @@ -30,3 +44,12 @@ STATE_MAP = { Platform.VALVE: {STATE_ON: STATE_CLOSED, STATE_OFF: STATE_OPEN}, }, } + +SUPPORTED_FEATURE_MAP = { + Platform.COVER: CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE, + Platform.FAN: FanEntityFeature.TURN_ON | FanEntityFeature.TURN_OFF, + Platform.LIGHT: 0, + Platform.LOCK: 0, + Platform.SIREN: SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF, + Platform.VALVE: ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE, +} diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index 2c87b0e3a92..a201cb258d6 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -25,12 +25,12 @@ from homeassistant.const import ( EntityCategory, Platform, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component -from . import PLATFORMS_TO_TEST +from . import CAPABILITY_MAP, PLATFORMS_TO_TEST, SUPPORTED_FEATURE_MAP from tests.common import MockConfigEntry @@ -79,6 +79,22 @@ def switch_as_x_config_entry( return config_entry +def track_entity_registry_actions( + hass: HomeAssistant, entity_id: str +) -> list[er.EventEntityRegistryUpdatedData]: + """Track entity registry actions for an entity.""" + events = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_config_entry_unregistered_uuid( hass: HomeAssistant, target_domain: str @@ -222,7 +238,7 @@ async def test_device_registry_config_entry_1( assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) - assert switch_as_x_config_entry.entry_id in device_entry.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries events = [] @@ -304,7 +320,7 @@ async def test_device_registry_config_entry_2( assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) - assert switch_as_x_config_entry.entry_id in device_entry.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries events = [] @@ -386,7 +402,7 @@ async def test_device_registry_config_entry_3( assert entity_entry.device_id == switch_entity_entry.device_id device_entry = device_registry.async_get(device_entry.id) - assert switch_as_x_config_entry.entry_id in device_entry.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry.config_entries device_entry_2 = device_registry.async_get(device_entry_2.id) assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries @@ -413,7 +429,7 @@ async def test_device_registry_config_entry_3( device_entry = device_registry.async_get(device_entry.id) assert switch_as_x_config_entry.entry_id not in device_entry.config_entries device_entry_2 = device_registry.async_get(device_entry_2.id) - assert switch_as_x_config_entry.entry_id in device_entry_2.config_entries + assert switch_as_x_config_entry.entry_id not in device_entry_2.config_entries # Check that the switch_as_x config entry is not removed assert switch_as_x_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -1083,11 +1099,31 @@ async def test_restore_expose_settings( @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_migrate( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, target_domain: Platform, ) -> None: """Test migration.""" - # Setup the config entry + # Switch config entry, device and entity + switch_config_entry = MockConfigEntry() + switch_config_entry.add_to_hass(hass) + + device_entry = device_registry.async_get_or_create( + config_entry_id=switch_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + switch_entity_entry = entity_registry.async_get_or_create( + "switch", + "test", + "unique", + config_entry=switch_config_entry, + device_id=device_entry.id, + original_name="ABC", + suggested_object_id="test", + ) + assert switch_entity_entry.entity_id == "switch.test" + + # Switch_as_x config entry, device and entity config_entry = MockConfigEntry( data={}, domain=DOMAIN, @@ -1100,9 +1136,37 @@ async def test_migrate( minor_version=1, ) config_entry.add_to_hass(hass) + device_registry.async_update_device( + device_entry.id, add_config_entry_id=config_entry.entry_id + ) + switch_as_x_entity_entry = entity_registry.async_get_or_create( + target_domain, + "switch_as_x", + config_entry.entry_id, + capabilities=CAPABILITY_MAP[target_domain], + config_entry=config_entry, + device_id=device_entry.id, + original_name="ABC", + suggested_object_id="abc", + supported_features=SUPPORTED_FEATURE_MAP[target_domain], + ) + entity_registry.async_update_entity_options( + switch_as_x_entity_entry.entity_id, + DOMAIN, + {"entity_id": "switch.test", "invert": False}, + ) + + events = track_entity_registry_actions(hass, switch_as_x_entity_entry.entity_id) + + # Setup the switch_as_x config entry assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert set(entity_registry.entities) == { + switch_entity_entry.entity_id, + switch_as_x_entity_entry.entity_id, + } + # Check migration was successful and added invert option assert config_entry.state is ConfigEntryState.LOADED assert config_entry.options == { @@ -1117,6 +1181,20 @@ async def test_migrate( assert hass.states.get(f"{target_domain}.abc") is not None assert entity_registry.async_get(f"{target_domain}.abc") is not None + # Entity removed from device to prevent deletion, then added back to device + assert events == [ + { + "action": "update", + "changes": {"device_id": device_entry.id}, + "entity_id": switch_as_x_entity_entry.entity_id, + }, + { + "action": "update", + "changes": {"device_id": None}, + "entity_id": switch_as_x_entity_entry.entity_id, + }, + ] + @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) async def test_migrate_from_future( diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 47f1b62feb7..91932a51ac2 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, Mock import pytest from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from tests.common import ( MockConfigEntry, @@ -184,6 +187,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -193,6 +197,20 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s return events +def listen_entity_registry_events(hass: HomeAssistant) -> list[str]: + """Track entity registry actions for an entity.""" + events: list[er.EventEntityRegistryUpdatedData] = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data) + + hass.bus.async_listen(er.EVENT_ENTITY_REGISTRY_UPDATED, add_event) + + return events + + @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") async def test_async_handle_source_entity_changes_source_entity_removed( @@ -425,3 +443,85 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("source_entity_entry") +async def test_async_remove_helper_config_entry_from_source_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, +) -> None: + """Test removing the helper config entry from the source device.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + + # Create a helper entity entry, not connected to the source device + extra_helper_entity_entry = entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + f"{helper_config_entry.entry_id}_2", + config_entry=helper_config_entry, + original_name="ABC", + ) + assert extra_helper_entity_entry.entity_id != helper_entity_entry.entity_id + + events = listen_entity_registry_events(hass) + + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + source_device_id=source_device.id, + ) + + # Check we got the expected events + assert events == [ + { + "action": "update", + "changes": {"device_id": source_device.id}, + "entity_id": helper_entity_entry.entity_id, + }, + { + "action": "update", + "changes": {"device_id": None}, + "entity_id": helper_entity_entry.entity_id, + }, + ] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("source_entity_entry") +async def test_async_remove_helper_config_entry_from_source_device_helper_not_in_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_device: dr.DeviceEntry, +) -> None: + """Test removing the helper config entry from the source device.""" + # Create a helper entity entry, not connected to the source device + extra_helper_entity_entry = entity_registry.async_get_or_create( + "sensor", + HELPER_DOMAIN, + f"{helper_config_entry.entry_id}_2", + config_entry=helper_config_entry, + original_name="ABC", + ) + assert extra_helper_entity_entry.entity_id != helper_entity_entry.entity_id + + events = listen_entity_registry_events(hass) + + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=helper_config_entry.entry_id, + source_device_id=source_device.id, + ) + + # Check we got the expected events + assert events == [] From 1a8d4c50414ddf3151131913eeb7bd5ffa3c26d0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:40:16 +0200 Subject: [PATCH 1233/1664] Add tuya snapshot tests for Avatto WT598 thermostat (#148398) --- tests/components/tuya/__init__.py | 5 + .../wk_wifi_smart_gas_boiler_thermostat.json | 188 ++++++++++++++++++ .../tuya/snapshots/test_climate.ambr | 67 +++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++ tests/components/tuya/test_climate.py | 57 ++++++ 5 files changed, 365 insertions(+) create mode 100644 tests/components/tuya/fixtures/wk_wifi_smart_gas_boiler_thermostat.json create mode 100644 tests/components/tuya/snapshots/test_climate.ambr create mode 100644 tests/components/tuya/test_climate.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 7ca1312154f..61c559ecffe 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -49,6 +49,11 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "wk_wifi_smart_gas_boiler_thermostat": [ + # https://github.com/orgs/home-assistant/discussions/243 + Platform.CLIMATE, + Platform.SWITCH, + ], } diff --git a/tests/components/tuya/fixtures/wk_wifi_smart_gas_boiler_thermostat.json b/tests/components/tuya/fixtures/wk_wifi_smart_gas_boiler_thermostat.json new file mode 100644 index 00000000000..e96389ca215 --- /dev/null +++ b/tests/components/tuya/fixtures/wk_wifi_smart_gas_boiler_thermostat.json @@ -0,0 +1,188 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "xxxxxxxxxxxxxxxxxxx", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfb45cb8a9452fba66lexg", + "name": "WiFi Smart Gas Boiler Thermostat ", + "category": "wk", + "product_id": "fi6dne5tu4t1nm6j", + "product_name": "WiFi Smart Gas Boiler Thermostat ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-05T17:50:52+00:00", + "create_time": "2025-07-05T17:50:52+00:00", + "update_time": "2025-07-05T17:50:52+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 150, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 140, + "scale": 1, + "step": 5 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "factory_reset": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["auto"] + } + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "temp_correction": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 99, + "scale": 1, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["battery_temp_fault"] + } + }, + "upper_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 150, + "max": 350, + "scale": 1, + "step": 5 + } + }, + "lower_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 50, + "max": 140, + "scale": 1, + "step": 5 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "frost": { + "type": "Boolean", + "value": {} + }, + "factory_reset": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "switch": true, + "mode": "auto", + "temp_set": 220, + "temp_current": 249, + "temp_correction": -15, + "fault": 0, + "upper_temp": 350, + "lower_temp": 50, + "battery_percentage": 100, + "child_lock": false, + "frost": false, + "factory_reset": 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 new file mode 100644 index 00000000000..4360ef7f436 --- /dev/null +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -0,0 +1,67 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + '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.bfb45cb8a9452fba66lexg', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][climate.wifi_smart_gas_boiler_thermostat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 24.9, + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat ', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 35.0, + 'min_temp': 5.0, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 22.0, + }), + 'context': , + 'entity_id': 'climate.wifi_smart_gas_boiler_thermostat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heat_cool', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index d4d94d4a119..8f03c6d7313 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -630,3 +630,51 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_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.wifi_smart_gas_boiler_thermostat_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.bfb45cb8a9452fba66lexgchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][switch.wifi_smart_gas_boiler_thermostat_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Child lock', + }), + 'context': , + 'entity_id': 'switch.wifi_smart_gas_boiler_thermostat_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py new file mode 100644 index 00000000000..2ffac1a06d2 --- /dev/null +++ b/tests/components/tuya/test_climate.py @@ -0,0 +1,57 @@ +"""Test Tuya climate platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.CLIMATE not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.CLIMATE]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 94862e6a50cf9be7d641ec203ce85c8545af5361 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 14:49:00 +0300 Subject: [PATCH 1234/1664] Update Alexa Devices quality scale (#147259) --- .../alexa_devices/quality_scale.yaml | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index 4662134efe8..6b1d084b842 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -28,33 +28,31 @@ rules: # Silver action-exceptions: done config-entry-unloading: done - docs-configuration-parameters: todo - docs-installation-parameters: todo + 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: - status: todo - comment: all tests missing + test-coverage: done # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Network information not relevant discovery: status: exempt comment: There are a ton of mac address ranges in use, but also by kindles which are not supported by this integration - docs-data-update: todo - docs-examples: todo + docs-data-update: done + docs-examples: done docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo + docs-supported-devices: done + docs-supported-functions: done docs-troubleshooting: todo - docs-use-cases: todo + docs-use-cases: done dynamic-devices: todo entity-category: done entity-device-class: done From 11938762eb856cbf467224452d8d470b0eb2e345 Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 8 Jul 2025 19:57:30 +0800 Subject: [PATCH 1235/1664] Fix Switchbot cloud plug mini current unit Issue (#148314) --- homeassistant/components/switchbot_cloud/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 5a424ea7892..f93df234289 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -113,11 +113,11 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { ), "Plug Mini (US)": ( VOLTAGE_DESCRIPTION, - CURRENT_DESCRIPTION_IN_A, + CURRENT_DESCRIPTION_IN_MA, ), "Plug Mini (JP)": ( VOLTAGE_DESCRIPTION, - CURRENT_DESCRIPTION_IN_A, + CURRENT_DESCRIPTION_IN_MA, ), "Hub 2": ( TEMPERATURE_DESCRIPTION, From e3939290149bcc5f1425d7daf41d9bd1e028d1b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Tue, 8 Jul 2025 14:28:13 +0200 Subject: [PATCH 1236/1664] Matter EVSE StateOfCharge (#148213) --- homeassistant/components/matter/sensor.py | 12 +++++ homeassistant/components/matter/strings.json | 3 ++ .../fixtures/nodes/silabs_evse_charging.json | 1 + .../matter/snapshots/test_sensor.ambr | 53 +++++++++++++++++++ 4 files changed, 69 insertions(+) diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index f563c246186..9e2ef33167b 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -1140,6 +1140,18 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="EnergyEvseStateOfCharge", + translation_key="evse_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, + ), + entity_class=MatterSensor, + required_attributes=(clusters.EnergyEvse.Attributes.StateOfCharge,), + ), MatterDiscoverySchema( platform=Platform.SENSOR, entity_description=MatterSensorEntityDescription( diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index f7cec270f54..6d167e4136e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -402,6 +402,9 @@ "other": "Other fault" } }, + "evse_soc": { + "name": "State of charge" + }, "pump_control_mode": { "name": "Control mode", "state": { diff --git a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json index 3188ba81ad6..3540f376f42 100644 --- a/tests/components/matter/fixtures/nodes/silabs_evse_charging.json +++ b/tests/components/matter/fixtures/nodes/silabs_evse_charging.json @@ -447,6 +447,7 @@ "1/153/37": null, "1/153/38": null, "1/153/39": null, + "1/153/48": 75, "1/153/64": 2, "1/153/65": 0, "1/153/66": 0, diff --git a/tests/components/matter/snapshots/test_sensor.ambr b/tests/components/matter/snapshots/test_sensor.ambr index 472799b80ae..140384283cc 100644 --- a/tests/components/matter/snapshots/test_sensor.ambr +++ b/tests/components/matter/snapshots/test_sensor.ambr @@ -5022,6 +5022,59 @@ 'state': '2.0', }) # --- +# name: test_sensors[silabs_evse_charging][sensor.evse_state_of_charge-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.evse_state_of_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'State of charge', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'evse_soc', + 'unique_id': '00000000000004D2-0000000000000017-MatterNodeDevice-1-EnergyEvseStateOfCharge-153-48', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensors[silabs_evse_charging][sensor.evse_state_of_charge-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'evse State of charge', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.evse_state_of_charge', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '75', + }) +# --- # name: test_sensors[silabs_evse_charging][sensor.evse_user_max_charge_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 91b82621287aba9fa7bb7561ecd390dda3c9945b Mon Sep 17 00:00:00 2001 From: hanwg Date: Tue, 8 Jul 2025 20:48:44 +0800 Subject: [PATCH 1237/1664] Update strings for Telegram bot (#148409) --- homeassistant/components/telegram_bot/strings.json | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json index 8ef71022492..df3de556efb 100644 --- a/homeassistant/components/telegram_bot/strings.json +++ b/homeassistant/components/telegram_bot/strings.json @@ -220,15 +220,15 @@ }, "username": { "name": "[%key:common::config_flow::data::username%]", - "description": "Username for a URL which requires HTTP `basic` or `digest` authentication." + "description": "Username for a URL that requires 'Basic' or 'Digest' authentication." }, "password": { "name": "[%key:common::config_flow::data::password%]", - "description": "Password (or bearer token) for a URL which require authentication." + "description": "Password (or bearer token) for a URL that requires authentication." }, "authentication": { "name": "Authentication method", - "description": "Define which authentication method to use. Set to `basic` for HTTP basic authentication, `digest` for HTTP digest authentication, or `bearer_token` for OAuth 2.0 bearer token authentication." + "description": "Define which authentication method to use. Set to 'Basic' for HTTP basic authentication, 'Digest' for HTTP digest authentication, or 'Bearer token' for OAuth 2.0 bearer token authentication." }, "target": { "name": "Target", @@ -950,10 +950,6 @@ "deprecated_yaml_import_issue_error": { "title": "YAML import failed due to invalid {error_field}", "description": "Configuring {integration_title} using YAML is being removed but there was an error while importing your existing configuration ({telegram_bot}): {error_message}.\nSetup will not proceed.\n\nVerify that your {telegram_bot} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." - }, - "proxy_params_auth_deprecation": { - "title": "{telegram_bot}: Proxy authentication should be moved to the URL", - "description": "Authentication details for the the proxy configured in the {telegram_bot} integration should be moved into the {proxy_url} instead. Please update your configuration and restart Home Assistant to fix this issue.\n\nThe {proxy_params} config key will be removed in a future release." } } } From 420d1e169dd669805f800f9e0d76389e746cd8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Tue, 8 Jul 2025 13:49:09 +0100 Subject: [PATCH 1238/1664] Fix hassfest command in copilot-instructions (#148405) --- .github/copilot-instructions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c2b863b55be..603cf407081 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1149,7 +1149,7 @@ _LOGGER.debug("Processing data: %s", data) # Use lazy logging ### Validation Commands ```bash # Check specific integration -python -m script.hassfest --integration my_integration +python -m script.hassfest --integration-path homeassistant/components/my_integration # Validate quality scale # Check quality_scale.yaml against current rules From 77ae6048ef885aeced7945347b82078070578822 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:49:52 +0200 Subject: [PATCH 1239/1664] Add tuya snapshot tests for gas leak sensor (#148400) --- tests/components/tuya/__init__.py | 5 ++ .../tuya/fixtures/rqbj_gas_sensor.json | 90 +++++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++++++++ .../tuya/snapshots/test_sensor.ambr | 52 +++++++++++ 4 files changed, 196 insertions(+) create mode 100644 tests/components/tuya/fixtures/rqbj_gas_sensor.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 61c559ecffe..1dacd799744 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,11 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "rqbj_gas_sensor": [ + # https://github.com/orgs/home-assistant/discussions/100 + Platform.BINARY_SENSOR, + Platform.SENSOR, + ], "sfkzq_valve_controller": [ # https://github.com/home-assistant/core/issues/148116 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/rqbj_gas_sensor.json b/tests/components/tuya/fixtures/rqbj_gas_sensor.json new file mode 100644 index 00000000000..58cbaedb0f1 --- /dev/null +++ b/tests/components/tuya/fixtures/rqbj_gas_sensor.json @@ -0,0 +1,90 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "17421891051898r7yM6", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "ebb9d0eb5014f98cfboxbz", + "name": "Gas sensor", + "category": "rqbj", + "product_id": "4iqe2hsfyd86kwwc", + "product_name": "Gas sensor", + "online": true, + "sub": false, + "time_zone": "-04:00", + "active_time": "2025-06-24T20:33:10+00:00", + "create_time": "2025-06-24T20:33:10+00:00", + "update_time": "2025-06-24T20:33:10+00:00", + "function": { + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "self_checking": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "checking_result": { + "type": "Enum", + "value": { + "range": ["checking", "check_success", "check_failure", "others"] + } + }, + "gas_sensor_status": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "gas_sensor_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "self_checking": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + } + }, + "status": { + "checking_result": "check_success", + "gas_sensor_status": "normal", + "alarm_time": 300, + "gas_sensor_value": 0, + "self_checking": false, + "muffling": true + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index aacda463769..b269664a2d4 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -48,3 +48,52 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][binary_sensor.gas_sensor_gas-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.gas_sensor_gas', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Gas', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.ebb9d0eb5014f98cfboxbzgas_sensor_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][binary_sensor.gas_sensor_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'context': , + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 562f34cc8b9..ac34dc615b7 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -581,3 +581,55 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-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.gas_sensor_gas', + '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': 'Gas', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'gas', + 'unique_id': 'tuya.ebb9d0eb5014f98cfboxbzgas_sensor_value', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': , + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.gas_sensor_gas', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- From 8ccd097e987103e8936cae9df82e75fbfe32df8b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 8 Jul 2025 14:50:49 +0200 Subject: [PATCH 1240/1664] Add tuya snapshot tests for bladeless tower fan (#148401) --- tests/components/tuya/__init__.py | 6 ++ .../tuya/fixtures/kj_bladeless_tower_fan.json | 79 +++++++++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 57 +++++++++++++ .../tuya/snapshots/test_select.ambr | 65 +++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++++ 5 files changed, 255 insertions(+) create mode 100644 tests/components/tuya/fixtures/kj_bladeless_tower_fan.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1dacd799744..5f9c8ef86c6 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -35,6 +35,12 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "kj_bladeless_tower_fan": [ + # https://github.com/orgs/home-assistant/discussions/61 + Platform.FAN, + Platform.SELECT, + Platform.SWITCH, + ], "mcs_door_sensor": [ # https://github.com/home-assistant/core/issues/108301 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json new file mode 100644 index 00000000000..8cbe875718e --- /dev/null +++ b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json @@ -0,0 +1,79 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "CENSORED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "CENSORED", + "name": "Bree", + "category": "kj", + "product_id": "CENSORED", + "product_name": "40\" Bladeless Tower Fan", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-22T07:35:33+00:00", + "create_time": "2025-06-22T07:35:33+00:00", + "update_time": "2025-06-22T07:35:33+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["sleep"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "mode": { + "type": "Enum", + "value": { + "range": ["sleep"] + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h", "4h", "5h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 1440, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "switch": false, + "mode": "normal", + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 399056e7665..cbd3c997625 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -49,3 +49,60 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.bree', + '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.CENSORED', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree', + 'preset_mode': 'normal', + 'preset_modes': list([ + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.bree', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index b9e11f5b50a..519ac33fb9f 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -60,6 +60,71 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.bree_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.CENSOREDcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + '4h', + '5h', + ]), + }), + 'context': , + 'entity_id': 'select.bree_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cancel', + }) +# --- # name: test_platform_setup_and_discovery[tdq_4_443][select.4_433_power_on_behavior-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 8f03c6d7313..c4e813ddfdc 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -386,6 +386,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-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': None, + 'entity_id': 'switch.bree_power', + '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', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.CENSOREDswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Bree Power', + }), + 'context': , + 'entity_id': 'switch.bree_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 546f6afac25a77bfe90f7e7c2de5faf918f4b35d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 8 Jul 2025 16:11:15 +0200 Subject: [PATCH 1241/1664] Bump gios to version 6.1.1 (#148414) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index ba87890de03..1782320a357 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.0"] + "requirements": ["gios==6.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a45a1f31b83..11c2f3d3787 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.0 +gios==6.1.1 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 476a2f9e6fe..eb03b87f5cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.0 +gios==6.1.1 # homeassistant.components.glances glances-api==0.8.0 From ae7bc140596e9a4cc1239def1bcf55b6432000ae Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 8 Jul 2025 16:14:02 +0200 Subject: [PATCH 1242/1664] Make the update interval a property of the NextDNS coordinator class (#148410) --- homeassistant/components/nextdns/__init__.py | 26 +++++++------------ .../components/nextdns/coordinator.py | 25 +++++++++++++++--- 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py index eb8bd26cb9b..acc9504988d 100644 --- a/homeassistant/components/nextdns/__init__.py +++ b/homeassistant/components/nextdns/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from dataclasses import dataclass -from datetime import timedelta from aiohttp.client_exceptions import ClientConnectorError from nextdns import ( @@ -37,9 +36,6 @@ from .const import ( ATTR_STATUS, CONF_PROFILE_ID, DOMAIN, - UPDATE_INTERVAL_ANALYTICS, - UPDATE_INTERVAL_CONNECTION, - UPDATE_INTERVAL_SETTINGS, ) from .coordinator import ( NextDnsConnectionUpdateCoordinator, @@ -69,14 +65,14 @@ class NextDnsData: PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR, Platform.SWITCH] -COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator], timedelta]] = [ - (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator, UPDATE_INTERVAL_CONNECTION), - (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_PROTOCOLS, NextDnsProtocolsUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), - (ATTR_SETTINGS, NextDnsSettingsUpdateCoordinator, UPDATE_INTERVAL_SETTINGS), - (ATTR_STATUS, NextDnsStatusUpdateCoordinator, UPDATE_INTERVAL_ANALYTICS), +COORDINATORS: list[tuple[str, type[NextDnsUpdateCoordinator]]] = [ + (ATTR_CONNECTION, NextDnsConnectionUpdateCoordinator), + (ATTR_DNSSEC, NextDnsDnssecUpdateCoordinator), + (ATTR_ENCRYPTION, NextDnsEncryptionUpdateCoordinator), + (ATTR_IP_VERSIONS, NextDnsIpVersionsUpdateCoordinator), + (ATTR_PROTOCOLS, NextDnsProtocolsUpdateCoordinator), + (ATTR_SETTINGS, NextDnsSettingsUpdateCoordinator), + (ATTR_STATUS, NextDnsStatusUpdateCoordinator), ] @@ -109,10 +105,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b # Independent DataUpdateCoordinator is used for each API endpoint to avoid # unnecessary requests when entities using this endpoint are disabled. - for coordinator_name, coordinator_class, update_interval in COORDINATORS: - coordinator = coordinator_class( - hass, entry, nextdns, profile_id, update_interval - ) + for coordinator_name, coordinator_class in COORDINATORS: + coordinator = coordinator_class(hass, entry, nextdns, profile_id) tasks.append(coordinator.async_config_entry_first_refresh()) coordinators[coordinator_name] = coordinator diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py index 9b82e82ffe0..44470fe0070 100644 --- a/homeassistant/components/nextdns/coordinator.py +++ b/homeassistant/components/nextdns/coordinator.py @@ -29,7 +29,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda if TYPE_CHECKING: from . import NextDnsConfigEntry -from .const import DOMAIN +from .const import ( + DOMAIN, + UPDATE_INTERVAL_ANALYTICS, + UPDATE_INTERVAL_CONNECTION, + UPDATE_INTERVAL_SETTINGS, +) _LOGGER = logging.getLogger(__name__) @@ -40,6 +45,7 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( """Class to manage fetching NextDNS data API.""" config_entry: NextDnsConfigEntry + _update_interval: timedelta def __init__( self, @@ -47,7 +53,6 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( config_entry: NextDnsConfigEntry, nextdns: NextDns, profile_id: str, - update_interval: timedelta, ) -> None: """Initialize.""" self.nextdns = nextdns @@ -58,7 +63,7 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( _LOGGER, config_entry=config_entry, name=DOMAIN, - update_interval=update_interval, + update_interval=self._update_interval, ) async def _async_update_data(self) -> CoordinatorDataT: @@ -93,6 +98,8 @@ class NextDnsUpdateCoordinator[CoordinatorDataT: NextDnsData]( class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): """Class to manage fetching NextDNS analytics status data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsStatus: """Update data via library.""" return await self.nextdns.get_analytics_status(self.profile_id) @@ -101,6 +108,8 @@ class NextDnsStatusUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsStatus]): class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): """Class to manage fetching NextDNS analytics Dnssec data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsDnssec: """Update data via library.""" return await self.nextdns.get_analytics_dnssec(self.profile_id) @@ -109,6 +118,8 @@ class NextDnsDnssecUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsDnssec]): class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncryption]): """Class to manage fetching NextDNS analytics encryption data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsEncryption: """Update data via library.""" return await self.nextdns.get_analytics_encryption(self.profile_id) @@ -117,6 +128,8 @@ class NextDnsEncryptionUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsEncry class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVersions]): """Class to manage fetching NextDNS analytics IP versions data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsIpVersions: """Update data via library.""" return await self.nextdns.get_analytics_ip_versions(self.profile_id) @@ -125,6 +138,8 @@ class NextDnsIpVersionsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsIpVer class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtocols]): """Class to manage fetching NextDNS analytics protocols data from API.""" + _update_interval = UPDATE_INTERVAL_ANALYTICS + async def _async_update_data_internal(self) -> AnalyticsProtocols: """Update data via library.""" return await self.nextdns.get_analytics_protocols(self.profile_id) @@ -133,6 +148,8 @@ class NextDnsProtocolsUpdateCoordinator(NextDnsUpdateCoordinator[AnalyticsProtoc class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): """Class to manage fetching NextDNS connection data from API.""" + _update_interval = UPDATE_INTERVAL_SETTINGS + async def _async_update_data_internal(self) -> Settings: """Update data via library.""" return await self.nextdns.get_settings(self.profile_id) @@ -141,6 +158,8 @@ class NextDnsSettingsUpdateCoordinator(NextDnsUpdateCoordinator[Settings]): class NextDnsConnectionUpdateCoordinator(NextDnsUpdateCoordinator[ConnectionStatus]): """Class to manage fetching NextDNS connection data from API.""" + _update_interval = UPDATE_INTERVAL_CONNECTION + async def _async_update_data_internal(self) -> ConnectionStatus: """Update data via library.""" return await self.nextdns.connection_status(self.profile_id) From aab8908af8b84b6d60cf3926f6345d3a41c544fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 8 Jul 2025 16:24:06 +0200 Subject: [PATCH 1243/1664] Improve entity registry tests related to config entries in devices (#148399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- tests/helpers/test_entity_registry.py | 88 +++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 12 deletions(-) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 714dfed32e9..5afffebb5f6 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1640,6 +1640,8 @@ async def test_remove_config_entry_from_device_removes_entities_2( config_entry_1.add_to_hass(hass) config_entry_2 = MockConfigEntry(domain="device_tracker") config_entry_2.add_to_hass(hass) + config_entry_3 = MockConfigEntry(domain="some_helper") + config_entry_3.add_to_hass(hass) # Create device with two config entries device_registry.async_get_or_create( @@ -1662,8 +1664,18 @@ async def test_remove_config_entry_from_device_removes_entities_2( "5678", device_id=device_entry.id, ) + # Create an entity with a config entry not in the device + entry_2 = entity_registry.async_get_or_create( + "light", + "some_helper", + "5678", + config_entry=config_entry_3, + device_id=device_entry.id, + ) + assert entry_1.entity_id != entry_2.entity_id assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) # Remove the first config entry from the device device_registry.async_update_device( @@ -1673,6 +1685,19 @@ async def test_remove_config_entry_from_device_removes_entities_2( assert device_registry.async_get(device_entry.id) assert entity_registry.async_is_registered(entry_1.entity_id) + # Entities with a config entry not in the device are removed + assert not entity_registry.async_is_registered(entry_2.entity_id) + + # Remove the second config entry from the device + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry_2.entry_id + ) + await hass.async_block_till_done() + + assert not device_registry.async_get(device_entry.id) + # The device is removed, both entities are now removed + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not entity_registry.async_is_registered(entry_2.entity_id) async def test_remove_config_subentry_from_device_removes_entities( @@ -1797,10 +1822,19 @@ async def test_remove_config_subentry_from_device_removes_entities( assert not entity_registry.async_is_registered(entry_3.entity_id) +@pytest.mark.parametrize( + ("subentries_in_device", "subentry_in_entity"), + [ + (["mock-subentry-id-1", "mock-subentry-id-2"], None), + ([None, "mock-subentry-id-2"], "mock-subentry-id-1"), + ], +) async def test_remove_config_subentry_from_device_removes_entities_2( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + subentries_in_device: list[str | None], + subentry_in_entity: str | None, ) -> None: """Test that we don't remove entities with no config entry when device is modified.""" config_entry_1 = MockConfigEntry( @@ -1820,28 +1854,31 @@ async def test_remove_config_subentry_from_device_removes_entities_2( title="Mock title", unique_id="test", ), + config_entries.ConfigSubentryData( + data={}, + subentry_id="mock-subentry-id-3", + subentry_type="test", + title="Mock title", + unique_id="test", + ), ], ) config_entry_1.add_to_hass(hass) - # Create device with three config subentries + # Create device with two config subentries device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-1", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - device_registry.async_get_or_create( - config_entry_id=config_entry_1.entry_id, - config_subentry_id="mock-subentry-id-2", + config_subentry_id=subentries_in_device[0], connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) device_entry = device_registry.async_get_or_create( config_entry_id=config_entry_1.entry_id, + config_subentry_id=subentries_in_device[1], connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) assert device_entry.config_entries == {config_entry_1.entry_id} assert device_entry.config_entries_subentries == { - config_entry_1.entry_id: {None, "mock-subentry-id-1", "mock-subentry-id-2"}, + config_entry_1.entry_id: set(subentries_in_device), } # Create an entity without config entry or subentry @@ -1851,30 +1888,57 @@ async def test_remove_config_subentry_from_device_removes_entities_2( "5678", device_id=device_entry.id, ) + # Create an entity for same config entry but subentry not in device + entry_2 = entity_registry.async_get_or_create( + "light", + "some_helper", + "5678", + config_entry=config_entry_1, + config_subentry_id=subentry_in_entity, + device_id=device_entry.id, + ) + # Create an entity for same config entry but subentry not in device + entry_3 = entity_registry.async_get_or_create( + "light", + "some_helper", + "abcd", + config_entry=config_entry_1, + config_subentry_id="mock-subentry-id-3", + device_id=device_entry.id, + ) + assert len({entry_1.entity_id, entry_2.entity_id, entry_3.entity_id}) == 3 assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) # Remove the first config subentry from the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id=None, + remove_config_subentry_id=subentries_in_device[0], ) await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) assert entity_registry.async_is_registered(entry_1.entity_id) + # Entities with a config subentry not in the device are removed + assert not entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) # Remove the second config subentry from the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, - remove_config_subentry_id="mock-subentry-id-1", + remove_config_subentry_id=subentries_in_device[1], ) await hass.async_block_till_done() - assert device_registry.async_get(device_entry.id) - assert entity_registry.async_is_registered(entry_1.entity_id) + assert not device_registry.async_get(device_entry.id) + # All entities are now removed + assert not entity_registry.async_is_registered(entry_1.entity_id) + assert not entity_registry.async_is_registered(entry_2.entity_id) + assert not entity_registry.async_is_registered(entry_3.entity_id) async def test_update_device_race( From c97ad9657f586e21afaab0a0c5c18ef1b6cf9fbc Mon Sep 17 00:00:00 2001 From: Tucker Kern Date: Tue, 8 Jul 2025 08:58:32 -0600 Subject: [PATCH 1244/1664] Add metadata support to Snapcast media players (#132283) Co-authored-by: Joostlek --- .../components/snapcast/media_player.py | 74 +++++ tests/components/snapcast/__init__.py | 12 + tests/components/snapcast/conftest.py | 158 +++++++++- tests/components/snapcast/const.py | 4 + .../snapcast/snapshots/test_media_player.ambr | 271 ++++++++++++++++++ tests/components/snapcast/test_config_flow.py | 20 +- .../components/snapcast/test_media_player.py | 30 ++ 7 files changed, 550 insertions(+), 19 deletions(-) create mode 100644 tests/components/snapcast/const.py create mode 100644 tests/components/snapcast/snapshots/test_media_player.ambr create mode 100644 tests/components/snapcast/test_media_player.py diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 7d9cf74b2cc..8e3f787e71d 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -12,9 +12,11 @@ import voluptuous as vol from homeassistant.components.media_player import ( DOMAIN as MEDIA_PLAYER_DOMAIN, + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, + MediaType, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT @@ -180,6 +182,8 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.SELECT_SOURCE ) + _attr_media_content_type = MediaType.MUSIC + _attr_device_class = MediaPlayerDeviceClass.SPEAKER def __init__( self, @@ -275,6 +279,76 @@ class SnapcastBaseDevice(SnapcastCoordinatorEntity, MediaPlayerEntity): """Handle the unjoin service.""" raise NotImplementedError + @property + def metadata(self) -> Mapping[str, Any]: + """Get metadata from the current stream.""" + if metadata := self.coordinator.server.stream( + self._current_group.stream + ).metadata: + return metadata + + # Fallback to an empty dict + return {} + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + return self.metadata.get("title") + + @property + def media_image_url(self) -> str | None: + """Image url of current playing media.""" + return self.metadata.get("artUrl") + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + if (value := self.metadata.get("artist")) is not None: + return ", ".join(value) + + return None + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + return self.metadata.get("album") + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + if (value := self.metadata.get("albumArtist")) is not None: + return ", ".join(value) + + return None + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + if (value := self.metadata.get("trackNumber")) is not None: + return int(value) + + return None + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if (value := self.metadata.get("duration")) is not None: + return int(value) + + return None + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + # Position is part of properties object, not metadata object + if properties := self.coordinator.server.stream( + self._current_group.stream + ).properties: + if (value := properties.get("position")) is not None: + return int(value) + + return None + class SnapcastGroupDevice(SnapcastBaseDevice): """Representation of a Snapcast group device.""" diff --git a/tests/components/snapcast/__init__.py b/tests/components/snapcast/__init__.py index a325bd41bd7..69bf252f53a 100644 --- a/tests/components/snapcast/__init__.py +++ b/tests/components/snapcast/__init__.py @@ -1 +1,13 @@ """Tests for the Snapcast integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the Snapcast integration in Home Assistant.""" + 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/snapcast/conftest.py b/tests/components/snapcast/conftest.py index bcc0ac5bc30..9c8a0bc5668 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -1,9 +1,19 @@ """Test the snapcast config flow.""" from collections.abc import Generator -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch import pytest +from snapcast.control.client import Snapclient +from snapcast.control.group import Snapgroup +from snapcast.control.server import CONTROL_PORT +from snapcast.control.stream import Snapstream + +from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.components.snapcast.coordinator import Snapserver +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry @pytest.fixture @@ -16,10 +26,144 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_create_server() -> Generator[AsyncMock]: +def mock_create_server( + mock_group: AsyncMock, + mock_client: AsyncMock, + mock_stream_1: AsyncMock, + mock_stream_2: AsyncMock, +) -> Generator[AsyncMock]: """Create mock snapcast connection.""" - mock_connection = AsyncMock() - mock_connection.start = AsyncMock(return_value=None) - mock_connection.stop = MagicMock() - with patch("snapcast.control.create_server", return_value=mock_connection): - yield mock_connection + with patch( + "homeassistant.components.snapcast.coordinator.Snapserver", autospec=True + ) as mock_snapserver: + mock_server = mock_snapserver.return_value + mock_server.groups = [mock_group] + mock_server.clients = [mock_client] + mock_server.streams = [mock_stream_1, mock_stream_2] + mock_server.group.return_value = mock_group + mock_server.client.return_value = mock_client + + def get_stream(identifier: str) -> AsyncMock: + return {s.identifier: s for s in mock_server.streams}[identifier] + + mock_server.stream = get_stream + yield mock_server + + +@pytest.fixture +async def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + + # Create a mock config entry + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: CONTROL_PORT, + }, + ) + + +@pytest.fixture +def mock_server_connection() -> Generator[Snapserver]: + """Create a mock server connection.""" + + # Patch the start method of the Snapserver class to avoid network connections + with patch.object(Snapserver, "start", new_callable=AsyncMock) as mock_start: + yield mock_start + + +@pytest.fixture +def mock_group(stream: str, streams: dict[str, AsyncMock]) -> AsyncMock: + """Create a mock Snapgroup.""" + group = AsyncMock(spec=Snapgroup) + group.identifier = "4dcc4e3b-c699-a04b-7f0c-8260d23c43e1" + group.name = "test_group" + group.friendly_name = "test_group" + group.stream = stream + group.muted = False + group.stream_status = streams[stream].status + group.volume = 48 + group.streams_by_name.return_value = {s.friendly_name: s for s in streams.values()} + return group + + +@pytest.fixture +def mock_client(mock_group: AsyncMock) -> AsyncMock: + """Create a mock Snapclient.""" + client = AsyncMock(spec=Snapclient) + client.identifier = "00:21:6a:7d:74:fc#2" + client.friendly_name = "test_client" + client.version = "0.10.0" + client.connected = True + client.name = "Snapclient" + client.latency = 6 + client.muted = False + client.volume = 48 + client.group = mock_group + mock_group.clients = [client.identifier] + return client + + +@pytest.fixture +def mock_stream_1() -> AsyncMock: + """Create a mock stream.""" + stream = AsyncMock(spec=Snapstream) + stream.identifier = "test_stream_1" + stream.status = "playing" + stream.name = "Test Stream 1" + stream.friendly_name = "Test Stream 1" + stream.metadata = { + "album": "Test Album", + "artist": ["Test Artist 1", "Test Artist 2"], + "title": "Test Title", + "artUrl": "http://localhost/test_art.jpg", + "albumArtist": [ + "Test Album Artist 1", + "Test Album Artist 2", + ], + "trackNumber": 10, + "duration": 60.0, + } + stream.meta = stream.metadata + stream.properties = { + "position": 30.0, + **stream.metadata, + } + stream.path = None + return stream + + +@pytest.fixture +def mock_stream_2() -> AsyncMock: + """Create a mock stream.""" + stream = AsyncMock(spec=Snapstream) + stream.identifier = "test_stream_2" + stream.status = "idle" + stream.name = "Test Stream 2" + stream.friendly_name = "Test Stream 2" + stream.metadata = None + stream.meta = None + stream.properties = None + stream.path = None + return stream + + +@pytest.fixture( + params=[ + "test_stream_1", + "test_stream_2", + ] +) +def stream(request: pytest.FixtureRequest) -> Generator[str]: + """Return every device.""" + return request.param + + +@pytest.fixture +def streams(mock_stream_1: AsyncMock, mock_stream_2: AsyncMock) -> dict[str, AsyncMock]: + """Return a dictionary of mock streams.""" + return { + mock_stream_1.identifier: mock_stream_1, + mock_stream_2.identifier: mock_stream_2, + } diff --git a/tests/components/snapcast/const.py b/tests/components/snapcast/const.py new file mode 100644 index 00000000000..0fbd5a05460 --- /dev/null +++ b/tests/components/snapcast/const.py @@ -0,0 +1,4 @@ +"""Constants for Snapcast tests.""" + +TEST_CLIENT_ENTITY_ID = "media_player.test_client_snapcast_client" +TEST_GROUP_ENTITY_ID = "media_player.test_group_snapcast_group" diff --git a/tests/components/snapcast/snapshots/test_media_player.ambr b/tests/components/snapcast/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..c497cdd861b --- /dev/null +++ b/tests/components/snapcast/snapshots/test_media_player.ambr @@ -0,0 +1,271 @@ +# serializer version: 1 +# name: test_state[test_stream_1][media_player.test_client_snapcast_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_client_snapcast_client', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_client Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_1][media_player.test_client_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': '/api/media_player_proxy/media_player.test_client_snapcast_client?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_client Snapcast Client', + 'is_volume_muted': False, + 'latency': 6, + 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist 1, Test Artist 2', + 'media_content_type': , + 'media_duration': 60, + 'media_position': 30, + 'media_title': 'Test Title', + 'media_track': 10, + 'source': 'test_stream_1', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_client_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_state[test_stream_1][media_player.test_group_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_group Snapcast Group', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_1][media_player.test_group_snapcast_group-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'entity_picture': '/api/media_player_proxy/media_player.test_group_snapcast_group?token=mock_token&cache=6e2dee674d9d1dc7', + 'friendly_name': 'test_group Snapcast Group', + 'is_volume_muted': False, + 'media_album_artist': 'Test Album Artist 1, Test Album Artist 2', + 'media_album_name': 'Test Album', + 'media_artist': 'Test Artist 1, Test Artist 2', + 'media_content_type': , + 'media_duration': 60, + 'media_position': 30, + 'media_title': 'Test Title', + 'media_track': 10, + 'source': 'test_stream_1', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_group_snapcast_group', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_state[test_stream_2][media_player.test_client_snapcast_client-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_client_snapcast_client', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_client Snapcast Client', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_client_127.0.0.1:1705_00:21:6a:7d:74:fc#2', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_2][media_player.test_client_snapcast_client-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_client Snapcast Client', + 'is_volume_muted': False, + 'latency': 6, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_client_snapcast_client', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- +# name: test_state[test_stream_2][media_player.test_group_snapcast_group-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.test_group_snapcast_group', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'test_group Snapcast Group', + 'platform': 'snapcast', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'snapcast_group_127.0.0.1:1705_4dcc4e3b-c699-a04b-7f0c-8260d23c43e1', + 'unit_of_measurement': None, + }) +# --- +# name: test_state[test_stream_2][media_player.test_group_snapcast_group-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speaker', + 'friendly_name': 'test_group Snapcast Group', + 'is_volume_muted': False, + 'media_content_type': , + 'source': 'test_stream_2', + 'source_list': list([ + 'Test Stream 1', + 'Test Stream 2', + ]), + 'supported_features': , + 'volume_level': 0.48, + }), + 'context': , + 'entity_id': 'media_player.test_group_snapcast_group', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'idle', + }) +# --- diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index 3bdba8b4c58..50ab4f0c170 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -15,12 +15,10 @@ from tests.common import MockConfigEntry TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "mock_create_server") +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_form( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock -) -> None: +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form and handle errors and successful connection.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -55,21 +53,19 @@ async def test_form( assert result["errors"] == {"base": "cannot_connect"} # test success - result = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_CONNECTION - ) - await hass.async_block_till_done() + with patch("snapcast.control.create_server"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Snapcast" assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} - assert len(mock_create_server.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort( - hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_create_server: AsyncMock -) -> None: +async def test_abort(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test config flow abort if device is already configured.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/snapcast/test_media_player.py b/tests/components/snapcast/test_media_player.py new file mode 100644 index 00000000000..57a8a865ddf --- /dev/null +++ b/tests/components/snapcast/test_media_player.py @@ -0,0 +1,30 @@ +"""Test the snapcast media player implementation.""" + +from unittest.mock import AsyncMock, patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +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_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_server: AsyncMock, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test basic state information.""" + + # Setup and verify the integration is loaded + with patch("secrets.token_hex", return_value="mock_token"): + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From a35299d94ce3ad790fe4c435e041de78549f20de Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 8 Jul 2025 11:04:06 -0400 Subject: [PATCH 1245/1664] Add preview tests for number and sensor (#148426) --- tests/components/template/conftest.py | 44 ++++++++++++++++++++++++ tests/components/template/test_number.py | 24 ++++++++++++- tests/components/template/test_sensor.py | 19 ++++++++++ tests/components/template/test_switch.py | 38 ++++---------------- 4 files changed, 93 insertions(+), 32 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index c69c9e9e9a4..6d1776f24cd 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -4,11 +4,15 @@ from enum import Enum import pytest +from homeassistant.components import template +from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service +from tests.conftest import WebSocketGenerator class ConfigurationStyle(Enum): @@ -51,3 +55,43 @@ async def caplog_setup_text(caplog: pytest.LogCaptureFixture) -> str: @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" + + +async def async_get_flow_preview_state( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + domain: str, + user_input: ConfigType, +) -> ConfigType: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + result = await hass.config_entries.flow.async_init( + template.DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": domain}, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == domain + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": user_input, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + return msg["event"] diff --git a/tests/components/template/test_number.py b/tests/components/template/test_number.py index a15ae1e46c0..21dea28b73f 100644 --- a/tests/components/template/test_number.py +++ b/tests/components/template/test_number.py @@ -35,9 +35,10 @@ from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.typing import WebSocketGenerator _TEST_OBJECT_ID = "template_number" _TEST_NUMBER = f"number.{_TEST_OBJECT_ID}" @@ -608,3 +609,24 @@ async def test_empty_action_config(hass: HomeAssistant, setup_number) -> None: state = hass.states.get(_TEST_NUMBER) assert float(state.state) == 4 + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + number.DOMAIN, + { + "name": "My template", + "min": 0.0, + "max": 100.0, + **TEST_REQUIRED, + }, + ) + + assert state["state"] == "0.0" diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index eb4f6c3596b..e89e98601d6 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -30,6 +30,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import ATTR_COMPONENT, async_setup_component from homeassistant.util import dt as dt_util +from .conftest import async_get_flow_preview_state + from tests.common import ( MockConfigEntry, assert_setup_component, @@ -37,6 +39,7 @@ from tests.common import ( async_fire_time_changed, mock_restore_cache_with_extra_data, ) +from tests.conftest import WebSocketGenerator TEST_NAME = "sensor.test_template_sensor" @@ -2434,3 +2437,19 @@ async def test_device_id( template_entity = entity_registry.async_get("sensor.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + sensor.DOMAIN, + {"name": "My template", "state": "{{ 0.0 }}"}, + ) + + assert state["state"] == "0.0" diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index de6894c73a8..c6ed303af7b 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -8,7 +8,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -18,12 +17,11 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State -from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import ( MockConfigEntry, @@ -396,37 +394,15 @@ async def test_flow_preview( hass_ws_client: WebSocketGenerator, ) -> None: """Test the config flow preview.""" - client = await hass_ws_client(hass) - result = await hass.config_entries.flow.async_init( - template.DOMAIN, context={"source": SOURCE_USER} + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + switch.DOMAIN, + {"name": "My template", state_key: "{{ 'on' }}"}, ) - assert result["type"] is FlowResultType.MENU - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {"next_step_id": SWITCH_DOMAIN}, - ) - await hass.async_block_till_done() - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == SWITCH_DOMAIN - assert result["errors"] is None - assert result["preview"] == "template" - - await client.send_json_auto_id( - { - "type": "template/start_preview", - "flow_id": result["flow_id"], - "flow_type": "config_flow", - "user_input": {"name": "My template", state_key: "{{ 'on' }}"}, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] is None - - msg = await client.receive_json() - assert msg["event"]["state"] == "on" + assert state["state"] == STATE_ON @pytest.mark.parametrize( From 6e63c17b396a6d7ed024fa2a6da581445ef8853a Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 18:58:48 +0300 Subject: [PATCH 1246/1664] Improve exceptions in Alexa Devices (#148260) --- .../components/alexa_devices/config_flow.py | 11 ++++++++++- .../components/alexa_devices/coordinator.py | 14 ++++++++++++-- .../components/alexa_devices/strings.json | 5 +++-- homeassistant/components/alexa_devices/utils.py | 4 ++-- tests/components/alexa_devices/test_config_flow.py | 9 ++++++++- tests/components/alexa_devices/test_utils.py | 4 ++-- 6 files changed, 37 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/alexa_devices/config_flow.py b/homeassistant/components/alexa_devices/config_flow.py index aa9bbb4ae5e..5ee3bc2e5f0 100644 --- a/homeassistant/components/alexa_devices/config_flow.py +++ b/homeassistant/components/alexa_devices/config_flow.py @@ -6,7 +6,12 @@ from collections.abc import Mapping from typing import Any from aioamazondevices.api import AmazonEchoApi -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + WrongCountry, +) import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult @@ -57,6 +62,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" except WrongCountry: errors["base"] = "wrong_country" else: @@ -106,6 +113,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" except CannotAuthenticate: errors["base"] = "invalid_auth" + except CannotRetrieveData: + errors["base"] = "cannot_retrieve_data" else: return self.async_update_reload_and_abort( reauth_entry, diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 031f52abebf..7af66f4bb8b 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -52,8 +52,18 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]): try: await self.api.login_mode_stored_data() return await self.api.get_devices_data() - except (CannotConnect, CannotRetrieveData) as err: - raise UpdateFailed(f"Error occurred while updating {self.name}") from err + except CannotConnect as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect_with_error", + translation_placeholders={"error": repr(err)}, + ) from err + except CannotRetrieveData as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_retrieve_data_with_error", + translation_placeholders={"error": repr(err)}, + ) from err except CannotAuthenticate as err: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, diff --git a/homeassistant/components/alexa_devices/strings.json b/homeassistant/components/alexa_devices/strings.json index 03a6cc3de64..19cc39cab42 100644 --- a/homeassistant/components/alexa_devices/strings.json +++ b/homeassistant/components/alexa_devices/strings.json @@ -43,6 +43,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "cannot_retrieve_data": "Unable to retrieve data from Amazon. Please try again later.", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.", "unknown": "[%key:common::config_flow::error::unknown%]" @@ -84,10 +85,10 @@ } }, "exceptions": { - "cannot_connect": { + "cannot_connect_with_error": { "message": "Error connecting: {error}" }, - "cannot_retrieve_data": { + "cannot_retrieve_data_with_error": { "message": "Error retrieving data: {error}" } } diff --git a/homeassistant/components/alexa_devices/utils.py b/homeassistant/components/alexa_devices/utils.py index 4d1365d1d41..437b681413b 100644 --- a/homeassistant/components/alexa_devices/utils.py +++ b/homeassistant/components/alexa_devices/utils.py @@ -26,14 +26,14 @@ def alexa_api_call[_T: AmazonEntity, **_P]( self.coordinator.last_update_success = False raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_connect", + translation_key="cannot_connect_with_error", translation_placeholders={"error": repr(err)}, ) from err except CannotRetrieveData as err: self.coordinator.last_update_success = False raise HomeAssistantError( translation_domain=DOMAIN, - translation_key="cannot_retrieve_data", + translation_key="cannot_retrieve_data_with_error", translation_placeholders={"error": repr(err)}, ) from err diff --git a/tests/components/alexa_devices/test_config_flow.py b/tests/components/alexa_devices/test_config_flow.py index def3a6ec547..e1b2974184b 100644 --- a/tests/components/alexa_devices/test_config_flow.py +++ b/tests/components/alexa_devices/test_config_flow.py @@ -2,7 +2,12 @@ from unittest.mock import AsyncMock -from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry +from aioamazondevices.exceptions import ( + CannotAuthenticate, + CannotConnect, + CannotRetrieveData, + WrongCountry, +) import pytest from homeassistant.components.alexa_devices.const import CONF_LOGIN_DATA, DOMAIN @@ -57,6 +62,7 @@ async def test_full_flow( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), (WrongCountry, "wrong_country"), ], ) @@ -165,6 +171,7 @@ async def test_reauth_successful( [ (CannotConnect, "cannot_connect"), (CannotAuthenticate, "invalid_auth"), + (CannotRetrieveData, "cannot_retrieve_data"), ], ) async def test_reauth_not_successful( diff --git a/tests/components/alexa_devices/test_utils.py b/tests/components/alexa_devices/test_utils.py index 12009719a2f..1cf190bd297 100644 --- a/tests/components/alexa_devices/test_utils.py +++ b/tests/components/alexa_devices/test_utils.py @@ -21,8 +21,8 @@ ENTITY_ID = "switch.echo_test_do_not_disturb" @pytest.mark.parametrize( ("side_effect", "key", "error"), [ - (CannotConnect, "cannot_connect", "CannotConnect()"), - (CannotRetrieveData, "cannot_retrieve_data", "CannotRetrieveData()"), + (CannotConnect, "cannot_connect_with_error", "CannotConnect()"), + (CannotRetrieveData, "cannot_retrieve_data_with_error", "CannotRetrieveData()"), ], ) async def test_alexa_api_call_exceptions( From ab1e323d49f1ce744a0f4a63aa1d1a915e98b706 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Jul 2025 18:44:11 +0200 Subject: [PATCH 1247/1664] Fix spelling of "non-volatile memory" in `z-wave_js` (#148422) --- 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 5029e8c6108..63dad248246 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -15,7 +15,7 @@ "config_entry_not_loaded": "The Z-Wave configuration entry is not loaded. Please try again when the configuration entry is loaded.", "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.", "discovery_requires_supervisor": "Discovery requires the supervisor.", - "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the Non Volatile Memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", + "migration_low_sdk_version": "The SDK version of the old adapter is lower than {ok_sdk_version}. This means it's not possible to migrate the non-volatile memory (NVM) of the old adapter to another adapter.\n\nCheck the documentation on the manufacturer support pages of the old adapter, if it's possible to upgrade the firmware of the old adapter to a version that is built with SDK version {ok_sdk_version} or higher.", "migration_successful": "Migration successful.", "not_zwave_device": "Discovered device is not a Z-Wave device.", "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.", From ebffaed0bd1346d5cea4f89260b8b608f520c71e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Jul 2025 18:45:39 +0200 Subject: [PATCH 1248/1664] Fix spelling of "non-resettable" in `iskra` (#148417) --- homeassistant/components/iskra/strings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/iskra/strings.json b/homeassistant/components/iskra/strings.json index da7817cc78b..ee62974c90d 100644 --- a/homeassistant/components/iskra/strings.json +++ b/homeassistant/components/iskra/strings.json @@ -88,16 +88,16 @@ "name": "Phase 3 current" }, "non_resettable_counter_1": { - "name": "Non Resettable counter 1" + "name": "Non-resettable counter 1" }, "non_resettable_counter_2": { - "name": "Non Resettable counter 2" + "name": "Non-resettable counter 2" }, "non_resettable_counter_3": { - "name": "Non Resettable counter 3" + "name": "Non-resettable counter 3" }, "non_resettable_counter_4": { - "name": "Non Resettable counter 4" + "name": "Non-resettable counter 4" }, "resettable_counter_1": { "name": "Resettable counter 1" From 70c01efe570c3dbdec4952e5da5b529c465a5e1b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 8 Jul 2025 19:58:35 +0300 Subject: [PATCH 1249/1664] Update Alexa Devices quality scale to silver (#148435) --- homeassistant/components/alexa_devices/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alexa_devices/manifest.json b/homeassistant/components/alexa_devices/manifest.json index 34fdd1448a5..41154d91779 100644 --- a/homeassistant/components/alexa_devices/manifest.json +++ b/homeassistant/components/alexa_devices/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["aioamazondevices==3.2.8"] } From ed8effa1623efeb77c48fe2cd7d0d3459dba4612 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 8 Jul 2025 23:58:39 +0200 Subject: [PATCH 1250/1664] Fix spelling of "non-existent", "non-blocking" and "currently used" (#148440) --- homeassistant/components/homeassistant/strings.json | 6 +++--- tests/test_core.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 7c95680076c..77c29e7c495 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -40,7 +40,7 @@ }, "python_version": { "title": "Support for Python {current_python_version} is being removed", - "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." + "description": "Support for running Home Assistant in the currently used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." }, "config_entry_only": { "title": "The {domain} integration does not support YAML configuration", @@ -81,7 +81,7 @@ "title": "Integration {domain} not found", "fix_flow": { "abort": { - "issue_ignored": "Not existing integration {domain} ignored." + "issue_ignored": "Non-existent integration {domain} ignored." }, "step": { "init": { @@ -274,7 +274,7 @@ "message": "Failed to process the returned action response data, expected a dictionary, but got {response_data_type}." }, "service_should_be_blocking": { - "message": "A non blocking action call with argument {non_blocking_argument} can't be used together with argument {return_response}." + "message": "A non-blocking action call with argument {non_blocking_argument} can't be used together with argument {return_response}." } } } diff --git a/tests/test_core.py b/tests/test_core.py index d4b5933aebe..0daaafe74cf 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1847,7 +1847,7 @@ async def test_services_call_return_response_requires_blocking( return_response=True, ) assert str(exc.value) == ( - "A non blocking action call with argument blocking=False " + "A non-blocking action call with argument blocking=False " "can't be used together with argument return_response=True" ) From 6b5b35feceee75c57ea29630819c7d00445b0819 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 8 Jul 2025 22:34:35 -0600 Subject: [PATCH 1251/1664] Bump aioesphomeapi to 34.2.0 (#148456) --- 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 01e04df6db8..9099af63ad9 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==34.1.0", + "aioesphomeapi==34.2.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==2.16.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 11c2f3d3787..1b14f4b4b2d 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==34.1.0 +aioesphomeapi==34.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb03b87f5cc..06b78118f78 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==34.1.0 +aioesphomeapi==34.2.0 # homeassistant.components.flo aioflo==2021.11.0 From afcd9912622d40187de3622169c3102d66de95cd Mon Sep 17 00:00:00 2001 From: Oliver Heesakkers <10373284+OliverHe@users.noreply.github.com> Date: Wed, 9 Jul 2025 08:01:54 +0200 Subject: [PATCH 1252/1664] Handle processing errors when writing to Zabbix (#148449) --- homeassistant/components/zabbix/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py index 31a09242a71..432b5d50c4e 100644 --- a/homeassistant/components/zabbix/__init__.py +++ b/homeassistant/components/zabbix/__init__.py @@ -13,7 +13,7 @@ from urllib.parse import urljoin import voluptuous as vol from zabbix_utils import ItemValue, Sender, ZabbixAPI -from zabbix_utils.exceptions import APIRequestError +from zabbix_utils.exceptions import APIRequestError, ProcessingError from homeassistant.const import ( CONF_HOST, @@ -282,6 +282,8 @@ class ZabbixThread(threading.Thread): if not self.write_errors: _LOGGER.error("Write error: %s", err) self.write_errors += len(metrics) + except ProcessingError as prerr: + _LOGGER.error("Error writing to Zabbix: %s", prerr) def run(self) -> None: """Process incoming events.""" From a02359b25ddf96ed8544f56f8bb0af26bb2440ab Mon Sep 17 00:00:00 2001 From: Rico Hageman Date: Wed, 9 Jul 2025 09:28:55 +0200 Subject: [PATCH 1253/1664] Add dew point to Awair integration (#148403) --- homeassistant/components/awair/const.py | 1 + homeassistant/components/awair/sensor.py | 10 ++++++++++ homeassistant/components/awair/strings.json | 3 +++ 3 files changed, 14 insertions(+) diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py index a1c5781e9a4..10f7cb115da 100644 --- a/homeassistant/components/awair/const.py +++ b/homeassistant/components/awair/const.py @@ -6,6 +6,7 @@ from datetime import timedelta import logging API_CO2 = "carbon_dioxide" +API_DEW_POINT = "dew_point" API_DUST = "dust" API_HUMID = "humidity" API_LUX = "illuminance" diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index a0c4b5ba8fe..d1f3ec34bf4 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -34,6 +34,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( API_CO2, + API_DEW_POINT, API_DUST, API_HUMID, API_LUX, @@ -110,6 +111,15 @@ SENSOR_TYPES: tuple[AwairSensorEntityDescription, ...] = ( unique_id_tag="CO2", # matches legacy format state_class=SensorStateClass.MEASUREMENT, ), + AwairSensorEntityDescription( + key=API_DEW_POINT, + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + translation_key="dew_point", + unique_id_tag="dew_point", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ) SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = ( diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index a7c5c647af8..30425d2e1bc 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -57,6 +57,9 @@ }, "sound_level": { "name": "Sound level" + }, + "dew_point": { + "name": "Dew point" } } } From 6de630ef3e18edc1ca7d466817eae4f5f7ae2034 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Jul 2025 09:43:22 +0200 Subject: [PATCH 1254/1664] Fix sentence-casing of trigger subtypes in `xiaomi_ble` (#148463) --- .../components/xiaomi_ble/strings.json | 18 +++++++++--------- tests/components/xiaomi_ble/test_sensor.py | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 06b49b8e86f..ffdd8f29a79 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -59,13 +59,13 @@ "device_automation": { "trigger_subtype": { "press": "Press", - "double_press": "Double Press", - "long_press": "Long Press", - "motion_detected": "Motion Detected", - "rotate_left": "Rotate Left", - "rotate_right": "Rotate Right", - "rotate_left_pressed": "Rotate Left (Pressed)", - "rotate_right_pressed": "Rotate Right (Pressed)", + "double_press": "Double press", + "long_press": "Long press", + "motion_detected": "Motion detected", + "rotate_left": "Rotate left", + "rotate_right": "Rotate right", + "rotate_left_pressed": "Rotate left (pressed)", + "rotate_right_pressed": "Rotate right (pressed)", "match_successful": "Match successful", "match_failed": "Match failed", "low_quality_too_light_fuzzy": "Low quality (too light, fuzzy)", @@ -224,7 +224,7 @@ "state_attributes": { "event_type": { "state": { - "motion_detected": "Motion Detected" + "motion_detected": "Motion detected" } } } @@ -235,7 +235,7 @@ "name": "Impedance" }, "weight_non_stabilized": { - "name": "Weight non stabilized" + "name": "Weight non-stabilized" } } } diff --git a/tests/components/xiaomi_ble/test_sensor.py b/tests/components/xiaomi_ble/test_sensor.py index f5625d4e74d..3540c92682b 100644 --- a/tests/components/xiaomi_ble/test_sensor.py +++ b/tests/components/xiaomi_ble/test_sensor.py @@ -700,7 +700,7 @@ async def test_miscale_v1_uuid(hass: HomeAssistant) -> None: assert mass_non_stabilized_sensor.state == "86.55" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Smart Scale (B5DC) Weight non stabilized" + == "Mi Smart Scale (B5DC) Weight non-stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" @@ -742,7 +742,7 @@ async def test_miscale_v2_uuid(hass: HomeAssistant) -> None: assert mass_non_stabilized_sensor.state == "85.15" assert ( mass_non_stabilized_sensor_attr[ATTR_FRIENDLY_NAME] - == "Mi Body Composition Scale (B5DC) Weight non stabilized" + == "Mi Body Composition Scale (B5DC) Weight non-stabilized" ) assert mass_non_stabilized_sensor_attr[ATTR_UNIT_OF_MEASUREMENT] == "kg" assert mass_non_stabilized_sensor_attr[ATTR_STATE_CLASS] == "measurement" From cb2095bcbe26bac13627e4d73401a36d19e76013 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Wed, 9 Jul 2025 17:43:29 +1000 Subject: [PATCH 1255/1664] Bump aiolifx to 1.2.1 (#148464) Signed-off-by: Avi Miller --- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index 3c03cdccba2..3c755779846 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -52,7 +52,7 @@ "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], "requirements": [ - "aiolifx==1.2.0", + "aiolifx==1.2.1", "aiolifx-effects==0.3.2", "aiolifx-themes==0.6.4" ] diff --git a/requirements_all.txt b/requirements_all.txt index 1b14f4b4b2d..6e949c7a7f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -301,7 +301,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.2.0 +aiolifx==1.2.1 # homeassistant.components.lookin aiolookin==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06b78118f78..820f681a841 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -283,7 +283,7 @@ aiolifx-effects==0.3.2 aiolifx-themes==0.6.4 # homeassistant.components.lifx -aiolifx==1.2.0 +aiolifx==1.2.1 # homeassistant.components.lookin aiolookin==1.0.0 From 13d05a338bf717f8e3cd1804b7a99ff421b86dc9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:42:55 +0200 Subject: [PATCH 1256/1664] Sort tuya definitions by category (#148472) --- .../components/tuya/binary_sensor.py | 94 +- homeassistant/components/tuya/button.py | 16 +- homeassistant/components/tuya/camera.py | 6 +- homeassistant/components/tuya/climate.py | 9 +- homeassistant/components/tuya/cover.py | 50 +- homeassistant/components/tuya/fan.py | 2 +- homeassistant/components/tuya/light.py | 54 +- homeassistant/components/tuya/number.py | 126 +- homeassistant/components/tuya/select.py | 314 ++--- homeassistant/components/tuya/sensor.py | 1115 ++++++++--------- homeassistant/components/tuya/siren.py | 16 +- homeassistant/components/tuya/switch.py | 332 ++--- 12 files changed, 1068 insertions(+), 1066 deletions(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 486dd6e1387..a613661149f 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -46,6 +46,40 @@ TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( # end up being a binary sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO2_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATE, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="1", + ), + TuyaBinarySensorEntityDescription( + key=DPCode.CO_STATUS, + device_class=BinarySensorDeviceClass.SAFETY, + on_value="alarm", + ), + TAMPER_BINARY_SENSOR, + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaBinarySensorEntityDescription( + key=DPCode.FEED_STATE, + translation_key="feeding", + on_value="feeding", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -111,40 +145,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - TuyaBinarySensorEntityDescription( - key=DPCode.CO2_STATE, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="alarm", - ), - TAMPER_BINARY_SENSOR, - ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( - TuyaBinarySensorEntityDescription( - key=DPCode.CO_STATE, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="1", - ), - TuyaBinarySensorEntityDescription( - key=DPCode.CO_STATUS, - device_class=BinarySensorDeviceClass.SAFETY, - on_value="alarm", - ), - TAMPER_BINARY_SENSOR, - ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( - TuyaBinarySensorEntityDescription( - key=DPCode.FEED_STATE, - translation_key="feeding", - on_value="feeding", - ), - ), # Human Presence Sensor # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs "hps": ( @@ -174,6 +174,16 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Luminance Sensor + # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + "ldcg": ( + TuyaBinarySensorEntityDescription( + key=DPCode.TEMPER_ALARM, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TAMPER_BINARY_SENSOR, + ), # Door and Window Controller # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 "mc": ( @@ -205,16 +215,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { on_value={"AQAB"}, ), ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( - TuyaBinarySensorEntityDescription( - key=DPCode.TEMPER_ALARM, - device_class=BinarySensorDeviceClass.TAMPER, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TAMPER_BINARY_SENSOR, - ), # PIR Detector # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 "pir": ( @@ -235,6 +235,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": (TAMPER_BINARY_SENSOR,), # Gas Detector # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw "rqbj": ( @@ -291,9 +294,6 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": (TAMPER_BINARY_SENSOR,), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": (TAMPER_BINARY_SENSOR,), # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 8e538b07309..928e584e77d 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -17,6 +17,14 @@ from .entity import TuyaEntity # All descriptions can be found here. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { + # Wake Up Light II + # Not documented + "hxd": ( + ButtonEntityDescription( + key=DPCode.SWITCH_USB6, + translation_key="snooze", + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -46,14 +54,6 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Wake Up Light II - # Not documented - "hxd": ( - ButtonEntityDescription( - key=DPCode.SWITCH_USB6, - translation_key="snooze", - ), - ), } diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index c04a8a043dc..788a9bcc5c3 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -17,12 +17,12 @@ from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq CAMERAS: tuple[str, ...] = ( - # Smart Camera (including doorbells) - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sp", # Smart Camera - Low power consumption camera # Undocumented, see https://github.com/home-assistant/core/issues/132844 "dghsxj", + # Smart Camera (including doorbells) + # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + "sp", ) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 547f3a14c93..991c3589e12 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -47,6 +47,12 @@ class TuyaClimateEntityDescription(ClimateEntityDescription): CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { + # Electric Fireplace + # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + "dbl": TuyaClimateEntityDescription( + key="dbl", + switch_only_hvac_mode=HVACMode.HEAT, + ), # Air conditioner # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n "kt": TuyaClimateEntityDescription( @@ -77,9 +83,6 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { key="wkf", switch_only_hvac_mode=HVACMode.HEAT, ), - # Electric Fireplace - # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop - "dbl": TuyaClimateEntityDescription(key="dbl", switch_only_hvac_mode=HVACMode.HEAT), } diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 315075e7f37..015daae4212 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -38,6 +38,31 @@ class TuyaCoverEntityDescription(CoverEntityDescription): COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { + # Garage Door Opener + # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + "ckmkzq": ( + TuyaCoverEntityDescription( + key=DPCode.SWITCH_1, + translation_key="door", + current_state=DPCode.DOORCONTACT_STATE, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_2, + translation_key="door_2", + current_state=DPCode.DOORCONTACT_STATE_2, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + TuyaCoverEntityDescription( + key=DPCode.SWITCH_3, + translation_key="door_3", + current_state=DPCode.DOORCONTACT_STATE_3, + current_state_inverse=True, + device_class=CoverDeviceClass.GARAGE, + ), + ), # Curtain # Note: Multiple curtains isn't documented # https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df @@ -84,31 +109,6 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = { device_class=CoverDeviceClass.BLIND, ), ), - # Garage Door Opener - # https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee - "ckmkzq": ( - TuyaCoverEntityDescription( - key=DPCode.SWITCH_1, - translation_key="door", - current_state=DPCode.DOORCONTACT_STATE, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - TuyaCoverEntityDescription( - key=DPCode.SWITCH_2, - translation_key="door_2", - current_state=DPCode.DOORCONTACT_STATE_2, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - TuyaCoverEntityDescription( - key=DPCode.SWITCH_3, - translation_key="door_3", - current_state=DPCode.DOORCONTACT_STATE_3, - current_state_inverse=True, - device_class=CoverDeviceClass.GARAGE, - ), - ), # Curtain Switch # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 "clkg": ( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 3b951e75da1..f2d856b6d86 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -25,11 +25,11 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import EnumTypeData, IntegerTypeData, TuyaEntity TUYA_SUPPORT_TYPE = { + "cs", # Dehumidifier "fs", # Fan "fsd", # Fan with Light "fskg", # Fan wall switch "kj", # Air Purifier - "cs", # Dehumidifier } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 67a94c4e267..37c79b952d4 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -135,6 +135,22 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE, ), ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name=None, + color_mode=DPCode.WORK_MODE, + brightness=DPCode.BRIGHT_VALUE, + color_temp=DPCode.TEMP_VALUE, + ), + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light_2", + brightness=DPCode.BRIGHT_VALUE_1, + ), + ), # Ceiling Fan Light # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v "fsd": ( @@ -176,6 +192,17 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_data=DPCode.COLOUR_DATA, ), ), + # Wake Up Light II + # Not documented + "hxd": ( + TuyaLightEntityDescription( + key=DPCode.SWITCH_LED, + translation_key="light", + brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), + brightness_max=DPCode.BRIGHTNESS_MAX_1, + brightness_min=DPCode.BRIGHTNESS_MIN_1, + ), + ), # Humidifier Light # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b "jsq": ( @@ -316,17 +343,6 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { brightness=DPCode.BRIGHT_VALUE_2, ), ), - # Wake Up Light II - # Not documented - "hxd": ( - TuyaLightEntityDescription( - key=DPCode.SWITCH_LED, - translation_key="light", - brightness=(DPCode.BRIGHT_VALUE_V2, DPCode.BRIGHT_VALUE), - brightness_max=DPCode.BRIGHTNESS_MAX_1, - brightness_min=DPCode.BRIGHTNESS_MIN_1, - ), - ), # Outdoor Flood Light # Not documented "tyd": ( @@ -378,22 +394,6 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { color_temp=DPCode.TEMP_CONTROLLER, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( - TuyaLightEntityDescription( - key=DPCode.LIGHT, - name=None, - color_mode=DPCode.WORK_MODE, - brightness=DPCode.BRIGHT_VALUE, - color_temp=DPCode.TEMP_VALUE, - ), - TuyaLightEntityDescription( - key=DPCode.SWITCH_LED, - translation_key="light_2", - brightness=DPCode.BRIGHT_VALUE_1, - ), - ), } # Socket (duplicate of `kg`) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index d4fe7836daa..ddee46b8799 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -22,15 +22,6 @@ from .entity import IntegerTypeData, TuyaEntity # default instructions set of each category end up being a number. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( - NumberEntityDescription( - key=DPCode.ALARM_TIME, - translation_key="time", - entity_category=EntityCategory.CONFIG, - ), - ), # Smart Kettle # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 "bh": ( @@ -64,6 +55,17 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="alarm_duration", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -76,6 +78,24 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { translation_key="voice_times", ), ), + # Multi-functional Sensor + # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + "dgnbj": ( + NumberEntityDescription( + key=DPCode.ALARM_TIME, + translation_key="time", + entity_category=EntityCategory.CONFIG, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + NumberEntityDescription( + key=DPCode.TEMP, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), # Human Presence Sensor # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs "hps": ( @@ -102,6 +122,20 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.DISTANCE, ), ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + NumberEntityDescription( + key=DPCode.TEMP_SET, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + NumberEntityDescription( + key=DPCode.TEMP_SET_F, + translation_key="temperature", + device_class=NumberDeviceClass.TEMPERATURE, + ), + ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f "kfj": ( @@ -174,6 +208,26 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Fingerbot + "szjqr": ( + NumberEntityDescription( + key=DPCode.ARM_DOWN_PERCENT, + translation_key="move_down", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ARM_UP_PERCENT, + translation_key="move_up", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLICK_SUSTAIN_TIME, + translation_key="down_delay", + entity_category=EntityCategory.CONFIG, + ), + ), # Dimmer Switch # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o "tgkg": ( @@ -241,49 +295,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), - # Fingerbot - "szjqr": ( - NumberEntityDescription( - key=DPCode.ARM_DOWN_PERCENT, - translation_key="move_down", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.ARM_UP_PERCENT, - translation_key="move_up", - native_unit_of_measurement=PERCENTAGE, - entity_category=EntityCategory.CONFIG, - ), - NumberEntityDescription( - key=DPCode.CLICK_SUSTAIN_TIME, - translation_key="down_delay", - entity_category=EntityCategory.CONFIG, - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( - NumberEntityDescription( - key=DPCode.TEMP, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - NumberEntityDescription( - key=DPCode.TEMP_SET, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, - ), - NumberEntityDescription( - key=DPCode.TEMP_SET_F, - translation_key="temperature", - device_class=NumberDeviceClass.TEMPERATURE, - ), - ), # Pool HeatPump "znrb": ( NumberEntityDescription( @@ -292,17 +303,6 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { device_class=NumberDeviceClass.TEMPERATURE, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - NumberEntityDescription( - key=DPCode.ALARM_TIME, - translation_key="alarm_duration", - native_unit_of_measurement=UnitOfTime.SECONDS, - device_class=NumberDeviceClass.DURATION, - entity_category=EntityCategory.CONFIG, - ), - ), } # Smart Camera - Low power consumption camera (duplicate of `sp`) diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 21f88156236..4ad4355f876 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -18,6 +18,43 @@ from .entity import TuyaEntity # default instructions set of each category end up being a select. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SelectEntityDescription( + key=DPCode.CONTROL_BACK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_motor_mode", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="curtain_mode", + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SelectEntityDescription( + key=DPCode.ALARM_VOLUME, + translation_key="volume", + entity_category=EntityCategory.CONFIG, + ), + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.DEHUMIDITY_SET_ENUM, + translation_key="target_humidity", + entity_category=EntityCategory.CONFIG, + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -27,6 +64,81 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SelectEntityDescription( + key=DPCode.LEVEL, + name="Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_1, + name="Side A Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + SelectEntityDescription( + key=DPCode.LEVEL_2, + name="Side B Level", + icon="mdi:thermometer-lines", + translation_key="blanket_level", + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge + "fs": ( + SelectEntityDescription( + key=DPCode.FAN_VERTICAL, + entity_category=EntityCategory.CONFIG, + translation_key="vertical_fan_angle", + ), + SelectEntityDescription( + key=DPCode.FAN_HORIZONTAL, + entity_category=EntityCategory.CONFIG, + translation_key="horizontal_fan_angle", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( + SelectEntityDescription( + key=DPCode.SPRAY_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_spray_mode", + ), + SelectEntityDescription( + key=DPCode.LEVEL, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_level", + ), + SelectEntityDescription( + key=DPCode.MOODLIGHTING, + entity_category=EntityCategory.CONFIG, + translation_key="humidifier_moodlighting", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), # Coffee maker # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f "kfj": ( @@ -63,6 +175,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="light_mode", ), ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + SelectEntityDescription( + key=DPCode.COUNTDOWN, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + SelectEntityDescription( + key=DPCode.COUNTDOWN_SET, + entity_category=EntityCategory.CONFIG, + translation_key="countdown", + ), + ), # Heater # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm "qn": ( @@ -71,6 +197,25 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="temperature_level", ), ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + SelectEntityDescription( + key=DPCode.CISTERN, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_cistern", + ), + SelectEntityDescription( + key=DPCode.COLLECTION_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_collection", + ), + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="vacuum_mode", + ), + ), # Smart Water Timer "sfkzq": ( # Irrigation will not be run within this set delay period @@ -128,6 +273,14 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="motion_sensitivity", ), ), + # Fingerbot + "szjqr": ( + SelectEntityDescription( + key=DPCode.MODE, + entity_category=EntityCategory.CONFIG, + translation_key="fingerbot_mode", + ), + ), # IoT Switch? # Note: Undocumented "tdq": ( @@ -185,173 +338,20 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { translation_key="led_type_2", ), ), - # Fingerbot - "szjqr": ( - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="fingerbot_mode", - ), - ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( - SelectEntityDescription( - key=DPCode.CISTERN, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_cistern", - ), - SelectEntityDescription( - key=DPCode.COLLECTION_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_collection", - ), - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="vacuum_mode", - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge - "fs": ( - SelectEntityDescription( - key=DPCode.FAN_VERTICAL, - entity_category=EntityCategory.CONFIG, - translation_key="vertical_fan_angle", - ), - SelectEntityDescription( - key=DPCode.FAN_HORIZONTAL, - entity_category=EntityCategory.CONFIG, - translation_key="horizontal_fan_angle", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( - SelectEntityDescription( - key=DPCode.CONTROL_BACK_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="curtain_motor_mode", - ), - SelectEntityDescription( - key=DPCode.MODE, - entity_category=EntityCategory.CONFIG, - translation_key="curtain_mode", - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - SelectEntityDescription( - key=DPCode.SPRAY_MODE, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_spray_mode", - ), - SelectEntityDescription( - key=DPCode.LEVEL, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_level", - ), - SelectEntityDescription( - key=DPCode.MOODLIGHTING, - entity_category=EntityCategory.CONFIG, - translation_key="humidifier_moodlighting", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( - SelectEntityDescription( - key=DPCode.COUNTDOWN, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( - SelectEntityDescription( - key=DPCode.COUNTDOWN_SET, - entity_category=EntityCategory.CONFIG, - translation_key="countdown", - ), - SelectEntityDescription( - key=DPCode.DEHUMIDITY_SET_ENUM, - translation_key="target_humidity", - entity_category=EntityCategory.CONFIG, - ), - ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - SelectEntityDescription( - key=DPCode.ALARM_VOLUME, - translation_key="volume", - entity_category=EntityCategory.CONFIG, - ), - ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( - SelectEntityDescription( - key=DPCode.LEVEL, - name="Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - SelectEntityDescription( - key=DPCode.LEVEL_1, - name="Side A Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - SelectEntityDescription( - key=DPCode.LEVEL_2, - name="Side B Level", - icon="mdi:thermometer-lines", - translation_key="blanket_level", - ), - ), } # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SELECTS["cz"] = SELECTS["kg"] -# Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["pc"] = SELECTS["kg"] - # Smart Camera - Low power consumption camera (duplicate of `sp`) # Undocumented, see https://github.com/home-assistant/core/issues/132844 SELECTS["dghsxj"] = SELECTS["sp"] +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SELECTS["pc"] = SELECTS["kg"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index bdfc8fe15e7..5151f39eb26 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -89,6 +89,169 @@ BATTERY_SENSORS: tuple[TuyaSensorEntityDescription, ...] = ( # end up being a sensor. # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { + # Single Phase power meter + # Note: Undocumented + "aqcz": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), + # Smart Kettle + # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + "bh": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="current_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.STATUS, + translation_key="status", + ), + ), + # Curtain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre + "cl": ( + TuyaSensorEntityDescription( + key=DPCode.TIME_TOTAL, + translation_key="last_operation_duration", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CO2_VALUE, + translation_key="carbon_dioxide", + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CH2O_VALUE, + translation_key="formaldehyde", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VOC_VALUE, + translation_key="voc", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.PM25_VALUE, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # CO Detector + # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + "cobj": ( + TuyaSensorEntityDescription( + key=DPCode.CO_VALUE, + translation_key="carbon_monoxide", + device_class=SensorDeviceClass.CO, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e + "cs": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_INDOOR, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_INDOOR, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Smart Pet Feeder + # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + "cwwsq": ( + TuyaSensorEntityDescription( + key=DPCode.FEED_REPORT, + translation_key="last_amount", + state_class=SensorStateClass.MEASUREMENT, + ), + ), + # Pet Fountain + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln + "cwysj": ( + TuyaSensorEntityDescription( + key=DPCode.UV_RUNTIME, + translation_key="uv_runtime", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.PUMP_TIME, + translation_key="pump_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_DURATION, + translation_key="filter_duration", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.WATER_TIME, + translation_key="water_time", + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.WATER_LEVEL, translation_key="water_level_state" + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -162,81 +325,92 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( + # Circuit Breaker + # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 + "dlq": ( TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="current_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, + key=DPCode.TOTAL_FORWARD_ENERGY, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_F, - translation_key="current_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, + key=DPCode.CUR_NEUTRAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), TuyaSensorEntityDescription( - key=DPCode.STATUS, - translation_key="status", - ), - ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.PHASE_A, + translation_key="phase_a_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key=DPCode.PHASE_A, + translation_key="phase_a_power", + device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", ), TuyaSensorEntityDescription( - key=DPCode.CO2_VALUE, - translation_key="carbon_dioxide", - device_class=SensorDeviceClass.CO2, + key=DPCode.PHASE_A, + translation_key="phase_a_voltage", + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", ), TuyaSensorEntityDescription( - key=DPCode.CH2O_VALUE, - translation_key="formaldehyde", + key=DPCode.PHASE_B, + translation_key="phase_b_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", ), TuyaSensorEntityDescription( - key=DPCode.VOC_VALUE, - translation_key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + key=DPCode.PHASE_B, + translation_key="phase_b_power", + device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", ), TuyaSensorEntityDescription( - key=DPCode.PM25_VALUE, - translation_key="pm25", - device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.PHASE_B, + translation_key="phase_b_voltage", + device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, + key=DPCode.PHASE_C, + translation_key="phase_c_current", + device_class=SensorDeviceClass.CURRENT, + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, + subkey="electriccurrent", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.KILO_WATT, + subkey="power", + ), + TuyaSensorEntityDescription( + key=DPCode.PHASE_C, + translation_key="phase_c_voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + subkey="voltage", ), TuyaSensorEntityDescription( key=DPCode.CUR_CURRENT, @@ -260,84 +434,19 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # Single Phase power meter - # Note: Undocumented - "aqcz": ( + # Fan + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 + "fs": ( TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( - TuyaSensorEntityDescription( - key=DPCode.CO_VALUE, - translation_key="carbon_monoxide", - device_class=SensorDeviceClass.CO, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( - TuyaSensorEntityDescription( - key=DPCode.FEED_REPORT, - translation_key="last_amount", + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), ), - # Pet Fountain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r0as4ln - "cwysj": ( - TuyaSensorEntityDescription( - key=DPCode.UV_RUNTIME, - translation_key="uv_runtime", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.PUMP_TIME, - translation_key="pump_time", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.FILTER_DURATION, - translation_key="filter_duration", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.WATER_TIME, - translation_key="water_time", - device_class=SensorDeviceClass.DURATION, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.WATER_LEVEL, translation_key="water_level_state" - ), - ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": BATTERY_SENSORS, # Air Quality Monitor # https://developer.tuya.com/en/docs/iot/hjjcy?id=Kbeoad8y1nnlv "hjjcy": ( @@ -428,6 +537,33 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Humidifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 + "jsq": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_CURRENT, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_F, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LEVEL_CURRENT, + translation_key="water_level", + entity_category=EntityCategory.DIAGNOSTIC, + ), + ), # Methane Detector # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm "jwbj": ( @@ -463,61 +599,60 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { entity_registry_enabled_default=False, ), ), - # IoT Switch - # Note: Undocumented - "tdq": ( + # Air Purifier + # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 + "kj": ( TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, + key=DPCode.FILTER, + translation_key="filter_utilization", + entity_category=EntityCategory.DIAGNOSTIC, ), TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, + key=DPCode.PM25, + translation_key="pm25", + device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, + key=DPCode.TEMP, translation_key="temperature", device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, + key=DPCode.HUMIDITY, translation_key="humidity", device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, + key=DPCode.TVOC, + translation_key="total_volatile_organic_compound", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, state_class=SensorStateClass.MEASUREMENT, ), TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, + key=DPCode.ECO2, + translation_key="concentration_carbon_dioxide", + device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, ), - *BATTERY_SENSORS, + TuyaSensorEntityDescription( + key=DPCode.TOTAL_TIME, + translation_key="total_operating_time", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_PM, + translation_key="total_absorption_particles", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), + TuyaSensorEntityDescription( + key=DPCode.AIR_QUALITY, + translation_key="air_quality", + ), ), # Luminance Sensor # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 @@ -642,6 +777,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT_EXTERNAL, + translation_key="temperature_external", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Gas Detector # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw "rqbj": ( @@ -653,6 +829,55 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Robot Vacuum + # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + "sd": ( + TuyaSensorEntityDescription( + key=DPCode.CLEAN_AREA, + translation_key="cleaning_area", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CLEAN_TIME, + translation_key="cleaning_time", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_AREA, + translation_key="total_cleaning_area", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_TIME, + translation_key="total_cleaning_time", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.TOTAL_CLEAN_COUNT, + translation_key="total_cleaning_times", + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.DUSTER_CLOTH, + translation_key="duster_cloth_life", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.EDGE_BRUSH, + translation_key="side_brush_life", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.FILTER_LIFE, + translation_key="filter_life", + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.ROLL_BRUSH, + translation_key="rolling_brush_life", + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Smart Water Timer "sfkzq": ( # Total seconds of irrigation. Read-write value; the device appears to ignore the write action (maybe firmware bug) @@ -664,9 +889,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": BATTERY_SENSORS, # Water Detector # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli "sj": BATTERY_SENSORS, @@ -696,8 +918,80 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Gardening system + # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 + "sz": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_CURRENT, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Fingerbot "szjqr": BATTERY_SENSORS, + # IoT Switch + # Note: Undocumented + "tdq": ( + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.BRIGHT_VALUE, + translation_key="illuminance", + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), # Solar Light # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 "tyndj": BATTERY_SENSORS, @@ -741,9 +1035,87 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY_VALUE, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": BATTERY_SENSORS, + # eMylo Smart WiFi IR Remote + # Air Conditioner Mate (Smart IR Socket) + "wnykq": ( + TuyaSensorEntityDescription( + key=DPCode.VA_TEMPERATURE, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.VA_HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_CURRENT, + translation_key="current", + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_POWER, + translation_key="power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + TuyaSensorEntityDescription( + key=DPCode.CUR_VOLTAGE, + translation_key="voltage", + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + ), # Temperature and Humidity Sensor # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 "wsdcg": ( @@ -779,48 +1151,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_EXTERNAL, - translation_key="temperature_external", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_VALUE, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.BRIGHT_VALUE, - translation_key="illuminance", - device_class=SensorDeviceClass.ILLUMINANCE, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), - # Pressure Sensor + # Wireless Switch + # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + "wxkg": BATTERY_SENSORS, # Pressure Sensor # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm "ylcg": ( TuyaSensorEntityDescription( @@ -940,353 +1273,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { subkey="voltage", ), ), - # Circuit Breaker - # https://developer.tuya.com/en/docs/iot/dlq?id=Kb0kidk9enyh8 - "dlq": ( - TuyaSensorEntityDescription( - key=DPCode.TOTAL_FORWARD_ENERGY, - translation_key="total_energy", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_NEUTRAL, - translation_key="total_production", - device_class=SensorDeviceClass.ENERGY, - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_A, - translation_key="phase_a_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_B, - translation_key="phase_b_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_current", - device_class=SensorDeviceClass.CURRENT, - native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, - state_class=SensorStateClass.MEASUREMENT, - subkey="electriccurrent", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfPower.KILO_WATT, - subkey="power", - ), - TuyaSensorEntityDescription( - key=DPCode.PHASE_C, - translation_key="phase_c_voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=UnitOfElectricPotential.VOLT, - subkey="voltage", - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, - ), - ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( - TuyaSensorEntityDescription( - key=DPCode.CLEAN_AREA, - translation_key="cleaning_area", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CLEAN_TIME, - translation_key="cleaning_time", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_CLEAN_AREA, - translation_key="total_cleaning_area", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_CLEAN_TIME, - translation_key="total_cleaning_time", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_CLEAN_COUNT, - translation_key="total_cleaning_times", - state_class=SensorStateClass.TOTAL_INCREASING, - ), - TuyaSensorEntityDescription( - key=DPCode.DUSTER_CLOTH, - translation_key="duster_cloth_life", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.EDGE_BRUSH, - translation_key="side_brush_life", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.FILTER_LIFE, - translation_key="filter_life", - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.ROLL_BRUSH, - translation_key="rolling_brush_life", - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_CURRENT, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qy7wkre - "cl": ( - TuyaSensorEntityDescription( - key=DPCode.TIME_TOTAL, - translation_key="last_operation_duration", - entity_category=EntityCategory.DIAGNOSTIC, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48qwjz0i3 - "jsq": ( - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_CURRENT, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT_F, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.LEVEL_CURRENT, - translation_key="water_level", - entity_category=EntityCategory.DIAGNOSTIC, - ), - ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r41mn81 - "kj": ( - TuyaSensorEntityDescription( - key=DPCode.FILTER, - translation_key="filter_utilization", - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.PM25, - translation_key="pm25", - device_class=SensorDeviceClass.PM25, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TEMP, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TVOC, - translation_key="total_volatile_organic_compound", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.ECO2, - translation_key="concentration_carbon_dioxide", - device_class=SensorDeviceClass.CO2, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_TIME, - translation_key="total_operating_time", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.TOTAL_PM, - translation_key="total_absorption_particles", - state_class=SensorStateClass.TOTAL_INCREASING, - entity_category=EntityCategory.DIAGNOSTIC, - ), - TuyaSensorEntityDescription( - key=DPCode.AIR_QUALITY, - translation_key="air_quality", - ), - ), - # Fan - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48quojr54 - "fs": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # eMylo Smart WiFi IR Remote - # Air Conditioner Mate (Smart IR Socket) - "wnykq": ( - TuyaSensorEntityDescription( - key=DPCode.VA_TEMPERATURE, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.VA_HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_CURRENT, - translation_key="current", - device_class=SensorDeviceClass.CURRENT, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_POWER, - translation_key="power", - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - TuyaSensorEntityDescription( - key=DPCode.CUR_VOLTAGE, - translation_key="voltage", - device_class=SensorDeviceClass.VOLTAGE, - state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, - ), - ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r6jke8e - "cs": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_INDOOR, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY_INDOOR, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - ), - # Soil sensor (Plant monitor) - "zwjcy": ( - TuyaSensorEntityDescription( - key=DPCode.TEMP_CURRENT, - translation_key="temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - ), - TuyaSensorEntityDescription( - key=DPCode.HUMIDITY, - translation_key="humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - ), - *BATTERY_SENSORS, - ), # VESKA-micro inverter "znnbq": ( TuyaSensorEntityDescription( @@ -1314,23 +1300,36 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": BATTERY_SENSORS, + # Soil sensor (Plant monitor) + "zwjcy": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + translation_key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.HUMIDITY, + translation_key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + *BATTERY_SENSORS, + ), } # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s SENSORS["cz"] = SENSORS["kg"] -# Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SENSORS["pc"] = SENSORS["kg"] - # Smart Camera - Low power consumption camera (duplicate of `sp`) # Undocumented, see https://github.com/home-assistant/core/issues/132844 SENSORS["dghsxj"] = SENSORS["sp"] +# Power Socket (duplicate of `kg`) +# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s +SENSORS["pc"] = SENSORS["kg"] + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 039442dafe5..8003dc2cf21 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -23,6 +23,14 @@ from .entity import TuyaEntity # All descriptions can be found here: # https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { + # CO2 Detector + # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + "co2bj": ( + SirenEntityDescription( + key=DPCode.ALARM_SWITCH, + entity_category=EntityCategory.CONFIG, + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( @@ -44,14 +52,6 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { key=DPCode.SIREN_SWITCH, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( - SirenEntityDescription( - key=DPCode.ALARM_SWITCH, - entity_category=EntityCategory.CONFIG, - ), - ), } # Smart Camera - Low power consumption camera (duplicate of `sp`) diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index b786644fd05..9b4cc332d94 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -37,6 +37,20 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Curtain + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc + "cl": ( + SwitchEntityDescription( + key=DPCode.CONTROL_BACK, + translation_key="reverse", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OPPOSITE, + translation_key="reverse", + entity_category=EntityCategory.CONFIG, + ), + ), # EasyBaby # Undocumented, might have a wider use "cn": ( @@ -131,6 +145,116 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), + # Electric Blanket + # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + "dr": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Power", + icon="mdi:power", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_1, + name="Side A Power", + icon="mdi:alpha-a", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + name="Side B Power", + icon="mdi:alpha-b", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT, + name="Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_1, + name="Side A Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + SwitchEntityDescription( + key=DPCode.PREHEAT_2, + name="Side B Preheat", + icon="mdi:radiator", + device_class=SwitchDeviceClass.SWITCH, + ), + ), + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="anion", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.HUMIDIFIER, + translation_key="humidification", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.OXYGEN, + translation_key="oxygen_bar", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_COOL, + translation_key="natural_wind", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.FAN_BEEP, + translation_key="sound", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.CHILD_LOCK, + translation_key="child_lock", + entity_category=EntityCategory.CONFIG, + ), + ), + # Irrigator + # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k + "ggq": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_3, + translation_key="switch_3", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_4, + translation_key="switch_4", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_5, + translation_key="switch_5", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_6, + translation_key="switch_6", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_7, + translation_key="switch_7", + ), + SwitchEntityDescription( + key=DPCode.SWITCH_8, + translation_key="switch_8", + ), + ), # Wake Up Light II # Not documented "hxd": ( @@ -163,19 +287,23 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="sleep_aid", ), ), - # Two-way temperature and humidity switch - # "MOES Temperature and Humidity Smart Switch Module MS-103" - # Documentation not found - "wkcz": ( + # Humidifier + # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + "jsq": ( SwitchEntityDescription( - key=DPCode.SWITCH_1, - translation_key="switch_1", - device_class=SwitchDeviceClass.OUTLET, + key=DPCode.SWITCH_SOUND, + translation_key="voice", + entity_category=EntityCategory.CONFIG, ), SwitchEntityDescription( - key=DPCode.SWITCH_2, - translation_key="switch_2", - device_class=SwitchDeviceClass.OUTLET, + key=DPCode.SLEEP, + translation_key="sleep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.STERILIZATION, + translation_key="sterilization", + entity_category=EntityCategory.CONFIG, ), ), # Switch @@ -408,6 +536,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe + # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 + "qxj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( @@ -429,42 +566,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Irrigator - # https://developer.tuya.com/en/docs/iot/categoryggq?id=Kaiuz1qib7z0k - "ggq": ( - SwitchEntityDescription( - key=DPCode.SWITCH_1, - translation_key="switch_1", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_2, - translation_key="switch_2", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_3, - translation_key="switch_3", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_4, - translation_key="switch_4", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_5, - translation_key="switch_5", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_6, - translation_key="switch_6", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_7, - translation_key="switch_7", - ), - SwitchEntityDescription( - key=DPCode.SWITCH_8, - translation_key="switch_8", - ), - ), # Siren Alarm # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu "sgbj": ( @@ -552,13 +653,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Hejhome whitelabel Fingerbot - "znjxs": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - ), - ), # IoT Switch? # Note: Undocumented "tdq": ( @@ -606,6 +700,21 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Two-way temperature and humidity switch + # "MOES Temperature and Humidity Smart Switch Module MS-103" + # Documentation not found + "wkcz": ( + SwitchEntityDescription( + key=DPCode.SWITCH_1, + translation_key="switch_1", + device_class=SwitchDeviceClass.OUTLET, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_2, + translation_key="switch_2", + device_class=SwitchDeviceClass.OUTLET, + ), + ), # Thermostatic Radiator Valve # Not documented "wkf": ( @@ -636,15 +745,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), - # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - translation_key="switch", - device_class=SwitchDeviceClass.OUTLET, - ), - ), # Ceiling Light # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r "xdd": ( @@ -679,71 +779,11 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + # Hejhome whitelabel Fingerbot + "znjxs": ( SwitchEntityDescription( - key=DPCode.ANION, - translation_key="anion", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.HUMIDIFIER, - translation_key="humidification", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.OXYGEN, - translation_key="oxygen_bar", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.FAN_COOL, - translation_key="natural_wind", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.FAN_BEEP, - translation_key="sound", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.CHILD_LOCK, - translation_key="child_lock", - entity_category=EntityCategory.CONFIG, - ), - ), - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( - SwitchEntityDescription( - key=DPCode.CONTROL_BACK, - translation_key="reverse", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.OPPOSITE, - translation_key="reverse", - entity_category=EntityCategory.CONFIG, - ), - ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( - SwitchEntityDescription( - key=DPCode.SWITCH_SOUND, - translation_key="voice", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.SLEEP, - translation_key="sleep", - entity_category=EntityCategory.CONFIG, - ), - SwitchEntityDescription( - key=DPCode.STERILIZATION, - translation_key="sterilization", - entity_category=EntityCategory.CONFIG, + key=DPCode.SWITCH, + translation_key="switch", ), ), # Pool HeatPump @@ -753,46 +793,6 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { translation_key="switch", ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( - SwitchEntityDescription( - key=DPCode.SWITCH, - name="Power", - icon="mdi:power", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.SWITCH_1, - name="Side A Power", - icon="mdi:alpha-a", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.SWITCH_2, - name="Side B Power", - icon="mdi:alpha-b", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT, - name="Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT_1, - name="Side A Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - SwitchEntityDescription( - key=DPCode.PREHEAT_2, - name="Side B Preheat", - icon="mdi:radiator", - device_class=SwitchDeviceClass.SWITCH, - ), - ), } # Socket (duplicate of `pc`) From 39ed877a1784a384a9124969790cdbd2ed44b704 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:43:55 +0200 Subject: [PATCH 1257/1664] Fix unloading update listener in Axis (#148470) --- homeassistant/components/axis/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py index e6c6fab47a1..92bd240c736 100644 --- a/homeassistant/components/axis/__init__.py +++ b/homeassistant/components/axis/__init__.py @@ -30,7 +30,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: AxisConfigEntry) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) hub.setup() - config_entry.add_update_listener(hub.async_new_address_callback) + config_entry.async_on_unload( + config_entry.add_update_listener(hub.async_new_address_callback) + ) config_entry.async_on_unload(hub.teardown) config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) From e387d4834f7d3141bb7c5ebe24fbc0993c52adb7 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:44:21 +0200 Subject: [PATCH 1258/1664] Fix unloading update listener in Unifi (#148471) --- homeassistant/components/unifi/hub/hub.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/hub/hub.py b/homeassistant/components/unifi/hub/hub.py index f2ed95a0c79..6cf8825a26c 100644 --- a/homeassistant/components/unifi/hub/hub.py +++ b/homeassistant/components/unifi/hub/hub.py @@ -91,7 +91,9 @@ class UnifiHub: assert self.config.entry.unique_id is not None self.is_admin = self.api.sites[self.config.entry.unique_id].role == "admin" - self.config.entry.add_update_listener(self.async_config_entry_updated) + self.config.entry.async_on_unload( + self.config.entry.add_update_listener(self.async_config_entry_updated) + ) @property def device_info(self) -> DeviceInfo: From de849b920ae8925e73593b5ef991a01e71e8eb43 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 9 Jul 2025 15:54:49 +0700 Subject: [PATCH 1259/1664] Enable web search for OpenAI reasoning models (#148393) --- .../openai_conversation/config_flow.py | 4 ++-- .../components/openai_conversation/const.py | 13 +++++------ .../openai_conversation/test_config_flow.py | 23 ------------------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index 63ebc351ee3..ae1e2f31a85 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -66,7 +66,7 @@ from .const import ( RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_USER_LOCATION, UNSUPPORTED_MODELS, - WEB_SEARCH_MODELS, + UNSUPPORTED_WEB_SEARCH_MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -320,7 +320,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) - if model.startswith(tuple(WEB_SEARCH_MODELS)): + if not model.startswith(tuple(UNSUPPORTED_WEB_SEARCH_MODELS)): step_schema.update( { vol.Optional( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 3f1c0dc7429..6a6a5b2ce6e 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -44,11 +44,10 @@ UNSUPPORTED_MODELS: list[str] = [ "gpt-4o-mini-realtime-preview-2024-12-17", ] -WEB_SEARCH_MODELS: list[str] = [ - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4o", - "gpt-4o-search-preview", - "gpt-4o-mini", - "gpt-4o-mini-search-preview", +UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ + "gpt-3.5", + "gpt-4-turbo", + "gpt-4.1-nano", + "o1", + "o3-mini", ] diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index b77542fbab3..e845828570c 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -286,29 +286,6 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_PROMPT: "", }, ), - ( # options with no model-specific settings - {}, - ( - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - }, - { - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: "gpt-4.5-preview", - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - }, - ), - { - CONF_RECOMMENDED: False, - CONF_PROMPT: "Speak like a pirate", - CONF_TEMPERATURE: 1.0, - CONF_CHAT_MODEL: "gpt-4.5-preview", - CONF_TOP_P: RECOMMENDED_TOP_P, - CONF_MAX_TOKENS: RECOMMENDED_MAX_TOKENS, - }, - ), ( # options for reasoning models {}, ( From 434ac421d1535277a4cedde6e8f2b27568772d25 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 9 Jul 2025 12:04:00 +0200 Subject: [PATCH 1260/1664] Tiny tweaks to task form (#148475) --- .github/ISSUE_TEMPLATE/task.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/task.yml b/.github/ISSUE_TEMPLATE/task.yml index b5d2b1deb06..5c286613068 100644 --- a/.github/ISSUE_TEMPLATE/task.yml +++ b/.github/ISSUE_TEMPLATE/task.yml @@ -21,7 +21,7 @@ body: - type: textarea id: description attributes: - label: Task description + label: Description description: | Provide a clear and detailed description of the task that needs to be accomplished. @@ -43,9 +43,11 @@ body: Include links to related issues, research, prototypes, roadmap opportunities etc. placeholder: | - - Roadmap opportunity: [links] + - Roadmap opportunity: [link] + - Epic: [link] - Feature request: [link] - Technical design documents: [link] - Prototype/mockup: [link] + - Dependencies: [links] validations: required: false From 659504c91fa2757c1935e165237b0b2bc4ead83b Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 9 Jul 2025 12:24:44 +0200 Subject: [PATCH 1261/1664] Fix friendly name of `increased_non_neutral_output` in `zha` (#148468) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 87c3903b342..23d17ea128f 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -1219,7 +1219,7 @@ "name": "Smart fan LED display levels" }, "increased_non_neutral_output": { - "name": "Non neutral output" + "name": "Increased non-neutral output" }, "leading_or_trailing_edge": { "name": "Dimming mode" From 828037de1f6153842457ffce604c8165d9db5a95 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Wed, 9 Jul 2025 11:25:56 +0100 Subject: [PATCH 1262/1664] Set quality scale on Mealie to silver (#148467) --- homeassistant/components/mealie/manifest.json | 1 + script/hassfest/quality_scale.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mealie/manifest.json b/homeassistant/components/mealie/manifest.json index d90e979582e..0aa9aa86847 100644 --- a/homeassistant/components/mealie/manifest.json +++ b/homeassistant/components/mealie/manifest.json @@ -6,5 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/mealie", "integration_type": "service", "iot_class": "local_polling", + "quality_scale": "silver", "requirements": ["aiomealie==0.9.6"] } diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 46751bda4f8..6d4e536744f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1667,7 +1667,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "matter", "maxcube", "mazda", - "mealie", "meater", "medcom_ble", "media_extractor", From b97b04661eaae1e944a02e2d25c15c4d072a2791 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:29:56 +0200 Subject: [PATCH 1263/1664] Improve logging in bootstrap (#148469) --- homeassistant/bootstrap.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 397f765174d..493b9b1eab6 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -332,6 +332,9 @@ async def async_setup_hass( if not is_virtual_env(): await async_mount_local_lib_path(runtime_config.config_dir) + if hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") + basic_setup_success = ( await async_from_config_dict(config_dict, hass) is not None ) @@ -384,8 +387,6 @@ async def async_setup_hass( {"recovery_mode": {}, "http": http_conf}, hass, ) - elif hass.config.safe_mode: - _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) @@ -870,9 +871,9 @@ async def _async_set_up_integrations( domains = set(integrations) & all_domains _LOGGER.info( - "Domains to be set up: %s | %s", - domains, - all_domains - domains, + "Domains to be set up: %s\nDependencies: %s", + domains or "{}", + (all_domains - domains) or "{}", ) async_set_domains_to_be_loaded(hass, all_domains) @@ -913,12 +914,13 @@ async def _async_set_up_integrations( stage_all_domains = stage_domains | stage_dep_domains _LOGGER.info( - "Setting up stage %s: %s | %s\nDependencies: %s | %s", + "Setting up stage %s: %s; already set up: %s\n" + "Dependencies: %s; already set up: %s", name, stage_domains, - stage_domains_unfiltered - stage_domains, - stage_dep_domains, - stage_dep_domains_unfiltered - stage_dep_domains, + (stage_domains_unfiltered - stage_domains) or "{}", + stage_dep_domains or "{}", + (stage_dep_domains_unfiltered - stage_dep_domains) or "{}", ) if timeout is None: From 98604f09fc50cdc139299049245fc803c4258a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 9 Jul 2025 11:30:43 +0100 Subject: [PATCH 1264/1664] Bump hass-nabucasa from 0.105.0 to 0.106.0 (#148473) --- 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 0d44d57ac5e..7c64100873c 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.105.0"], + "requirements": ["hass-nabucasa==0.106.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9d985fae6c5..b73a458b7ec 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==3.49.0 -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.1 diff --git a/pyproject.toml b/pyproject.toml index 25f4d6d4a1a..3841d234ddf 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.105.0", + "hass-nabucasa==0.106.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 d6912b8898b..c246af65758 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.105.0 +hass-nabucasa==0.106.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 6e949c7a7f4..9f012d492a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 820f681a841..9c02ef478f8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==3.49.0 # homeassistant.components.cloud -hass-nabucasa==0.105.0 +hass-nabucasa==0.106.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From 71df8ffe6ed3afc7c0cf02c735e4c2e836651017 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:37:45 +0200 Subject: [PATCH 1265/1664] Bump uiprotect to version 7.14.2 (#148453) --- 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 47e2a01e798..8243a55d779 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.14.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.14.2", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 9f012d492a8..d57393004b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2994,7 +2994,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c02ef478f8..a375ebee7f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2468,7 +2468,7 @@ typedmonarchmoney==0.4.4 uasiren==0.0.1 # homeassistant.components.unifiprotect -uiprotect==7.14.1 +uiprotect==7.14.2 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From ef2e699d2c2a467d9d9283acf589f27f2f4ae941 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 13:05:53 +0200 Subject: [PATCH 1266/1664] Add tuya snapshot tests for curtain switch (#148465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Franck Nijhof Co-authored-by: Abílio Costa --- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/clkg_curtain_switch.json | 95 +++++++++++++++++++ .../components/tuya/snapshots/test_cover.ambr | 52 ++++++++++ .../components/tuya/snapshots/test_light.ambr | 58 +++++++++++ tests/components/tuya/test_cover.py | 57 +++++++++++ tests/components/tuya/test_light.py | 57 +++++++++++ 6 files changed, 324 insertions(+) create mode 100644 tests/components/tuya/fixtures/clkg_curtain_switch.json create mode 100644 tests/components/tuya/snapshots/test_cover.ambr create mode 100644 tests/components/tuya/snapshots/test_light.ambr create mode 100644 tests/components/tuya/test_cover.py create mode 100644 tests/components/tuya/test_light.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5f9c8ef86c6..cc14003bcf5 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,6 +13,11 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { + "clkg_curtain_switch": [ + # https://github.com/home-assistant/core/issues/136055 + Platform.COVER, + Platform.LIGHT, + ], "cs_arete_two_12l_dehumidifier_air_purifier": [ Platform.FAN, Platform.HUMIDIFIER, diff --git a/tests/components/tuya/fixtures/clkg_curtain_switch.json b/tests/components/tuya/fixtures/clkg_curtain_switch.json new file mode 100644 index 00000000000..28e3248f8b5 --- /dev/null +++ b/tests/components/tuya/fixtures/clkg_curtain_switch.json @@ -0,0 +1,95 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1729466466688hgsTp2", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf1fa053e0ba4e002c6we8", + "name": "Tapparelle studio", + "category": "clkg", + "product_id": "nhyj64w2", + "product_name": "Curtain switch", + "online": true, + "sub": true, + "time_zone": "+02:00", + "active_time": "2025-01-13T23:37:14+00:00", + "create_time": "2025-01-13T23:37:14+00:00", + "update_time": "2025-01-13T23:37:14+00:00", + "function": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { + "range": ["open", "stop", "close"] + } + }, + "percent_control": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 10 + } + }, + "cur_calibration": { + "type": "Enum", + "value": { + "range": ["start", "end"] + } + }, + "switch_backlight": { + "type": "Boolean", + "value": {} + }, + "control_back_mode": { + "type": "Enum", + "value": { + "range": ["forward", "back"] + } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "cur_calibration": "end", + "switch_backlight": true, + "control_back_mode": "forward" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr new file mode 100644 index 00000000000..843ee2db6b0 --- /dev/null +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.tapparelle_studio_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.bf1fa053e0ba4e002c6we8control', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 0, + 'device_class': 'curtain', + 'friendly_name': 'Tapparelle studio Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.tapparelle_studio_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'closed', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr new file mode 100644 index 00000000000..b9395b3d682 --- /dev/null +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -0,0 +1,58 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[clkg_curtain_switch][light.tapparelle_studio_backlight-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': , + 'entity_id': 'light.tapparelle_studio_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.bf1fa053e0ba4e002c6we8switch_backlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[clkg_curtain_switch][light.tapparelle_studio_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tapparelle studio Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tapparelle_studio_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py new file mode 100644 index 00000000000..6f94896c8c7 --- /dev/null +++ b/tests/components/tuya/test_cover.py @@ -0,0 +1,57 @@ +"""Test Tuya cover platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.COVER in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.COVER not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py new file mode 100644 index 00000000000..cb7639fb662 --- /dev/null +++ b/tests/components/tuya/test_light.py @@ -0,0 +1,57 @@ +"""Test Tuya light platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.LIGHT in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.LIGHT not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.LIGHT]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From b08391903176b94dbd45f43fecb3b3ad3a888b4a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 9 Jul 2025 13:53:15 +0200 Subject: [PATCH 1267/1664] Revert "Deprecate hddtemp" (#148482) --- homeassistant/components/hddtemp/__init__.py | 2 -- homeassistant/components/hddtemp/sensor.py | 20 +------------------ tests/components/hddtemp/test_sensor.py | 21 ++------------------ 3 files changed, 3 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py index 66a819f1e8d..121238df9fe 100644 --- a/homeassistant/components/hddtemp/__init__.py +++ b/homeassistant/components/hddtemp/__init__.py @@ -1,3 +1 @@ """The hddtemp component.""" - -DOMAIN = "hddtemp" diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py index 192ddffd330..4d9bbeb9516 100644 --- a/homeassistant/components/hddtemp/sensor.py +++ b/homeassistant/components/hddtemp/sensor.py @@ -22,14 +22,11 @@ from homeassistant.const import ( CONF_PORT, UnitOfTemperature, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN - _LOGGER = logging.getLogger(__name__) ATTR_DEVICE = "device" @@ -59,21 +56,6 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the HDDTemp sensor.""" - create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - breaks_in_ha_version="2025.12.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_system_packages_yaml_integration", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "hddtemp", - }, - ) - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py index 62882c7df8b..56ad9fdcb0e 100644 --- a/tests/components/hddtemp/test_sensor.py +++ b/tests/components/hddtemp/test_sensor.py @@ -1,15 +1,12 @@ """The tests for the hddtemp platform.""" import socket -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest -from homeassistant.components.hddtemp import DOMAIN -from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN from homeassistant.const import UnitOfTemperature -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component VALID_CONFIG_MINIMAL = {"sensor": {"platform": "hddtemp"}} @@ -195,17 +192,3 @@ async def test_hddtemp_host_unreachable(hass: HomeAssistant, telnetmock) -> None assert await async_setup_component(hass, "sensor", VALID_CONFIG_HOST_UNREACHABLE) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 - - -@patch.dict("sys.modules", gsp=Mock()) -async def test_repair_issue_is_created( - hass: HomeAssistant, - issue_registry: ir.IssueRegistry, -) -> None: - """Test repair issue is created.""" - assert await async_setup_component(hass, PLATFORM_DOMAIN, VALID_CONFIG_MINIMAL) - await hass.async_block_till_done() - assert ( - HOMEASSISTANT_DOMAIN, - f"deprecated_system_packages_yaml_integration_{DOMAIN}", - ) in issue_registry.issues From fe0ce9bc6d2ed50878115542b5a1c23f7333b98f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 14:44:18 +0200 Subject: [PATCH 1268/1664] Use real product_id in tuya fixture (#148415) --- tests/components/tuya/fixtures/kj_bladeless_tower_fan.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json index 8cbe875718e..909022793ba 100644 --- a/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json +++ b/tests/components/tuya/fixtures/kj_bladeless_tower_fan.json @@ -7,7 +7,7 @@ "id": "CENSORED", "name": "Bree", "category": "kj", - "product_id": "CENSORED", + "product_id": "yrzylxax1qspdgpp", "product_name": "40\" Bladeless Tower Fan", "online": true, "sub": false, From f6e2b962fdd83f5dd5d29aef03bd824132b1b8a8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:30:17 +0200 Subject: [PATCH 1269/1664] Use SnapshotAssertion in lifx diagnostics tests (#148491) --- .../lifx/snapshots/test_diagnostics.ambr | 292 ++++++++++++++++++ tests/components/lifx/test_diagnostics.py | 271 ++-------------- 2 files changed, 314 insertions(+), 249 deletions(-) create mode 100644 tests/components/lifx/snapshots/test_diagnostics.ambr diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..82499c3632e --- /dev/null +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -0,0 +1,292 @@ +# serializer version: 1 +# name: test_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 2500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 1, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_clean_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': True, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hev': dict({ + 'hev_config': dict({ + 'duration': 7200, + 'indication': False, + }), + 'hev_cycle': dict({ + 'duration': 7200, + 'last_power': False, + 'remaining': 30, + }), + 'last_result': 0, + }), + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 90, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_infrared_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': True, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'infrared': dict({ + 'brightness': 65535, + }), + 'kelvin': 4, + 'power': 0, + 'product_id': 29, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_legacy_multizone_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_kelvin': 2500, + 'multizone': True, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 31, + 'saturation': 2, + 'vendor': None, + 'zones': dict({ + 'count': 8, + 'state': dict({ + '0': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '1': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '2': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '3': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '4': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '5': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '6': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '7': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- +# name: test_multizone_bulb_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': True, + 'hev': False, + 'infrared': False, + 'matrix': False, + 'max_kelvin': 9000, + 'min_ext_mz_firmware': 1532997580, + 'min_ext_mz_firmware_components': list([ + 2, + 77, + ]), + 'min_kelvin': 1500, + 'multizone': True, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'power': 0, + 'product_id': 38, + 'saturation': 2, + 'vendor': None, + 'zones': dict({ + 'count': 8, + 'state': dict({ + '0': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '1': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '2': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '3': dict({ + 'brightness': 65535, + 'hue': 54612, + 'kelvin': 3500, + 'saturation': 65535, + }), + '4': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '5': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '6': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + '7': dict({ + 'brightness': 65535, + 'hue': 46420, + 'kelvin': 3500, + 'saturation': 65535, + }), + }), + }), + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 22e335612f8..5883ac046e7 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -1,5 +1,7 @@ """Test LIFX diagnostics.""" +from syrupy.assertion import SnapshotAssertion + from homeassistant.components import lifx from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -25,7 +27,9 @@ from tests.typing import ClientSessionGenerator async def test_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -45,36 +49,13 @@ async def test_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 2500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 1, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_clean_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -94,41 +75,13 @@ async def test_clean_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": True, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 1500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hev": { - "hev_config": {"duration": 7200, "indication": False}, - "hev_cycle": {"duration": 7200, "last_power": False, "remaining": 30}, - "last_result": 0, - }, - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 90, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_infrared_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -148,37 +101,13 @@ async def test_infrared_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": True, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 1500, - "multizone": False, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "infrared": {"brightness": 65535}, - "kelvin": 4, - "power": 0, - "product_id": 29, - "saturation": 2, - "vendor": None, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_legacy_multizone_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -225,89 +154,13 @@ async def test_legacy_multizone_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": False, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_kelvin": 2500, - "multizone": True, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 31, - "saturation": 2, - "vendor": None, - "zones": { - "count": 8, - "state": { - "0": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "1": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "2": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "3": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "4": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "5": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "6": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "7": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - }, - }, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot async def test_multizone_bulb_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics for a standard bulb.""" config_entry = MockConfigEntry( @@ -355,84 +208,4 @@ async def test_multizone_bulb_diagnostics( await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - assert diag == { - "data": { - "brightness": 3, - "features": { - "buttons": False, - "chain": False, - "color": True, - "extended_multizone": True, - "hev": False, - "infrared": False, - "matrix": False, - "max_kelvin": 9000, - "min_ext_mz_firmware": 1532997580, - "min_ext_mz_firmware_components": [2, 77], - "min_kelvin": 1500, - "multizone": True, - "relays": False, - }, - "firmware": "3.00", - "hue": 1, - "kelvin": 4, - "power": 0, - "product_id": 38, - "saturation": 2, - "vendor": None, - "zones": { - "count": 8, - "state": { - "0": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "1": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "2": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "3": { - "brightness": 65535, - "hue": 54612, - "kelvin": 3500, - "saturation": 65535, - }, - "4": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "5": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "6": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - "7": { - "brightness": 65535, - "hue": 46420, - "kelvin": 3500, - "saturation": 65535, - }, - }, - }, - }, - "entry": {"data": {"host": "**REDACTED**"}, "title": "My Bulb"}, - } + assert diag == snapshot From e1cdc1af1cff2b09f9b226afa538ce79a6686eac Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:47:48 +0200 Subject: [PATCH 1270/1664] Add diagnostics tests to tuya (#148489) --- tests/components/tuya/conftest.py | 35 +++- .../tuya/snapshots/test_diagnostics.ambr | 183 ++++++++++++++++++ tests/components/tuya/test_diagnostics.py | 67 +++++++ 3 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/snapshots/test_diagnostics.ambr create mode 100644 tests/components/tuya/test_diagnostics.py diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 7884597576d..9aa8e8ea147 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -6,7 +6,7 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest -from tuya_sharing import CustomerDevice +from tuya_sharing import CustomerApi, CustomerDevice, DeviceFunction, DeviceStatusRange from homeassistant.components.tuya import ManagerCompat from homeassistant.components.tuya.const import ( @@ -19,6 +19,7 @@ from homeassistant.components.tuya.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.json import json_dumps +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_load_json_object_fixture @@ -116,6 +117,12 @@ def mock_manager() -> ManagerCompat: manager = MagicMock(spec=ManagerCompat) manager.device_map = {} manager.mq = MagicMock() + manager.mq.client = MagicMock() + manager.mq.client.is_connected = MagicMock(return_value=True) + manager.customer_api = MagicMock(spec=CustomerApi) + # Meaningless URL / UUIDs + manager.customer_api.endpoint = "https://apigw.tuyaeu.com" + manager.terminal_id = "7cd96aff-6ec8-4006-b093-3dbff7947591" return manager @@ -142,12 +149,34 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev device.product_id = details["product_id"] device.product_name = details["product_name"] device.online = details["online"] + device.sub = details.get("sub") + device.time_zone = details.get("time_zone") + device.active_time = details.get("active_time") + if device.active_time: + device.active_time = int(dt_util.as_timestamp(device.active_time)) + device.create_time = details.get("create_time") + if device.create_time: + device.create_time = int(dt_util.as_timestamp(device.create_time)) + device.update_time = details.get("update_time") + if device.update_time: + device.update_time = int(dt_util.as_timestamp(device.update_time)) + device.support_local = details.get("support_local") + device.mqtt_connected = details.get("mqtt_connected") + device.function = { - key: MagicMock(type=value["type"], values=json_dumps(value["value"])) + key: DeviceFunction( + code=value.get("code"), + type=value["type"], + values=json_dumps(value["value"]), + ) for key, value in details["function"].items() } device.status_range = { - key: MagicMock(type=value["type"], values=json_dumps(value["value"])) + key: DeviceStatusRange( + code=value.get("code"), + type=value["type"], + values=json_dumps(value["value"]), + ) for key, value in details["status_range"].items() } device.status = details["status"] diff --git a/tests/components/tuya/snapshots/test_diagnostics.ambr b/tests/components/tuya/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5fc3796d109 --- /dev/null +++ b/tests/components/tuya/snapshots/test_diagnostics.ambr @@ -0,0 +1,183 @@ +# serializer version: 1 +# name: test_device_diagnostics[rqbj_gas_sensor] + dict({ + 'active_time': '2025-06-24T20:33:10+00:00', + 'category': 'rqbj', + 'create_time': '2025-06-24T20:33:10+00:00', + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'function': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'gas', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': 'measurement', + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.gas_sensor_gas', + 'state': '0.0', + }), + 'unit_of_measurement': 'ppm', + }), + ]), + 'name': 'Gas sensor', + 'name_by_user': None, + }), + 'id': 'ebb9d0eb5014f98cfboxbz', + 'mqtt_connected': True, + 'name': 'Gas sensor', + 'online': True, + 'product_id': '4iqe2hsfyd86kwwc', + 'product_name': 'Gas sensor', + 'set_up': True, + 'status': dict({ + 'alarm_time': 300, + 'checking_result': 'check_success', + 'gas_sensor_status': 'normal', + 'gas_sensor_value': 0, + 'muffling': True, + 'self_checking': False, + }), + 'status_range': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'sub': False, + 'support_local': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + 'time_zone': '-04:00', + 'update_time': '2025-06-24T20:33:10+00:00', + }) +# --- +# name: test_entry_diagnostics[rqbj_gas_sensor] + dict({ + 'devices': list([ + dict({ + 'active_time': '2025-06-24T20:33:10+00:00', + 'category': 'rqbj', + 'create_time': '2025-06-24T20:33:10+00:00', + 'function': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'home_assistant': dict({ + 'disabled': False, + 'disabled_by': None, + 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': 'gas', + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'device_class': 'gas', + 'friendly_name': 'Gas sensor Gas', + }), + 'entity_id': 'binary_sensor.gas_sensor_gas', + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'entity_category': None, + 'icon': None, + 'original_device_class': None, + 'original_icon': None, + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Gas sensor Gas', + 'state_class': 'measurement', + 'unit_of_measurement': 'ppm', + }), + 'entity_id': 'sensor.gas_sensor_gas', + 'state': '0.0', + }), + 'unit_of_measurement': 'ppm', + }), + ]), + 'name': 'Gas sensor', + 'name_by_user': None, + }), + 'id': 'ebb9d0eb5014f98cfboxbz', + 'name': 'Gas sensor', + 'online': True, + 'product_id': '4iqe2hsfyd86kwwc', + 'product_name': 'Gas sensor', + 'set_up': True, + 'status': dict({ + 'alarm_time': 300, + 'checking_result': 'check_success', + 'gas_sensor_status': 'normal', + 'gas_sensor_value': 0, + 'muffling': True, + 'self_checking': False, + }), + 'status_range': dict({ + 'null': dict({ + 'type': 'Boolean', + 'value': dict({ + }), + }), + }), + 'sub': False, + 'support_local': True, + 'time_zone': '-04:00', + 'update_time': '2025-06-24T20:33:10+00:00', + }), + ]), + 'disabled_by': None, + 'disabled_polling': False, + 'endpoint': 'https://apigw.tuyaeu.com', + 'mqtt_connected': True, + 'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591', + }) +# --- diff --git a/tests/components/tuya/test_diagnostics.py b/tests/components/tuya/test_diagnostics.py new file mode 100644 index 00000000000..2009f117efb --- /dev/null +++ b/tests/components/tuya/test_diagnostics.py @@ -0,0 +1,67 @@ +"""Test Tuya diagnostics platform.""" + +from __future__ import annotations + +import pytest +from syrupy.assertion import SnapshotAssertion +from syrupy.filters import props +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.components.tuya.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import initialize_entry + +from tests.common import MockConfigEntry +from tests.components.diagnostics import ( + get_diagnostics_for_config_entry, + get_diagnostics_for_device, +) +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize("mock_device_code", ["rqbj_gas_sensor"]) +async def test_entry_diagnostics( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) + + +@pytest.mark.parametrize("mock_device_code", ["rqbj_gas_sensor"]) +async def test_device_diagnostics( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + hass_client: ClientSessionGenerator, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test device diagnostics.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + device = device_registry.async_get_device(identifiers={(DOMAIN, mock_device.id)}) + assert device, repr(device_registry.devices) + + result = await get_diagnostics_for_device( + hass, hass_client, mock_config_entry, device + ) + assert result == snapshot( + exclude=props("last_changed", "last_reported", "last_updated") + ) From 59fe6da47ce4d2132ef22d79a20772e495ea8b45 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 15:59:43 +0200 Subject: [PATCH 1271/1664] Adjust tuya test docstrings (#148493) --- tests/components/tuya/test_binary_sensor.py | 2 +- tests/components/tuya/test_climate.py | 2 +- tests/components/tuya/test_fan.py | 2 +- tests/components/tuya/test_humidifier.py | 2 +- tests/components/tuya/test_light.py | 2 +- tests/components/tuya/test_number.py | 2 +- tests/components/tuya/test_select.py | 2 +- tests/components/tuya/test_sensor.py | 2 +- tests/components/tuya/test_switch.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index ec2120db0b4..c77be47fb2d 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -50,7 +50,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index 2ffac1a06d2..a5117983000 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -49,7 +49,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_fan.py b/tests/components/tuya/test_fan.py index 736ac0d0691..f6b9a6956bf 100644 --- a/tests/components/tuya/test_fan.py +++ b/tests/components/tuya/test_fan.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_humidifier.py b/tests/components/tuya/test_humidifier.py index 7b68de17698..f4cd264a03c 100644 --- a/tests/components/tuya/test_humidifier.py +++ b/tests/components/tuya/test_humidifier.py @@ -48,7 +48,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_light.py b/tests/components/tuya/test_light.py index cb7639fb662..33d0e36715e 100644 --- a/tests/components/tuya/test_light.py +++ b/tests/components/tuya/test_light.py @@ -49,7 +49,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_number.py b/tests/components/tuya/test_number.py index 44ed8eaf9b3..7da514964aa 100644 --- a/tests/components/tuya/test_number.py +++ b/tests/components/tuya/test_number.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_select.py b/tests/components/tuya/test_select.py index cf6ce169256..c295a07d83f 100644 --- a/tests/components/tuya/test_select.py +++ b/tests/components/tuya/test_select.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_sensor.py b/tests/components/tuya/test_sensor.py index 7f1e71dabc2..d0c6054c135 100644 --- a/tests/components/tuya/test_sensor.py +++ b/tests/components/tuya/test_sensor.py @@ -48,7 +48,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( diff --git a/tests/components/tuya/test_switch.py b/tests/components/tuya/test_switch.py index 68e8c9e29c4..6164a5c7af8 100644 --- a/tests/components/tuya/test_switch.py +++ b/tests/components/tuya/test_switch.py @@ -47,7 +47,7 @@ async def test_platform_setup_no_discovery( mock_device: CustomerDevice, entity_registry: er.EntityRegistry, ) -> None: - """Test platform setup and discovery.""" + """Test platform setup without discovery.""" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) assert not er.async_entries_for_config_entry( From 511ffdc03c8c3d37a7c7ff74864e8ce0b71a55c5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:20:29 +0200 Subject: [PATCH 1272/1664] Add tuya snapshot tests for kg category (#148492) --- tests/components/tuya/__init__.py | 4 ++ .../tuya/fixtures/kg_smart_valve.json | 56 +++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 49 ++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 tests/components/tuya/fixtures/kg_smart_valve.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index cc14003bcf5..b308df7e2f9 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,10 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "kg_smart_valve": [ + # https://github.com/home-assistant/core/issues/148347 + Platform.SWITCH, + ], "kj_bladeless_tower_fan": [ # https://github.com/orgs/home-assistant/discussions/61 Platform.FAN, diff --git a/tests/components/tuya/fixtures/kg_smart_valve.json b/tests/components/tuya/fixtures/kg_smart_valve.json new file mode 100644 index 00000000000..63d9148afbf --- /dev/null +++ b/tests/components/tuya/fixtures/kg_smart_valve.json @@ -0,0 +1,56 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1750526976566fMhqJs", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": true, + "id": "0665305284f3ebe9fdc1", + "name": "QT-Switch", + "category": "kg", + "product_id": "gbm9ata1zrzaez4a", + "product_name": "Smart Valve", + "online": false, + "sub": false, + "time_zone": "-05:00", + "active_time": "2020-01-27T23:37:47+00:00", + "create_time": "2020-01-27T23:37:47+00:00", + "update_time": "2020-01-27T23:37:47+00:00", + "function": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_1": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_1": false, + "countdown_1": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index c4e813ddfdc..0f042cbce52 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -386,6 +386,55 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-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': None, + 'entity_id': 'switch.qt_switch_switch_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Switch 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch_1', + 'unique_id': 'tuya.0665305284f3ebe9fdc1switch_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'outlet', + 'friendly_name': 'QT-Switch Switch 1', + }), + 'context': , + 'entity_id': 'switch.qt_switch_switch_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][switch.bree_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 6f31057d308a2de2c1d547e38ee2a0cecf62f1be Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 9 Jul 2025 17:01:17 +0200 Subject: [PATCH 1273/1664] Rework Snapcast config flow tests (#148434) --- tests/components/snapcast/conftest.py | 20 +-- tests/components/snapcast/test_config_flow.py | 120 ++++++++++-------- 2 files changed, 76 insertions(+), 64 deletions(-) diff --git a/tests/components/snapcast/conftest.py b/tests/components/snapcast/conftest.py index 9c8a0bc5668..c2c4ffa7997 100644 --- a/tests/components/snapcast/conftest.py +++ b/tests/components/snapcast/conftest.py @@ -10,7 +10,6 @@ from snapcast.control.server import CONTROL_PORT from snapcast.control.stream import Snapstream from homeassistant.components.snapcast.const import DOMAIN -from homeassistant.components.snapcast.coordinator import Snapserver from homeassistant.const import CONF_HOST, CONF_PORT from tests.common import MockConfigEntry @@ -25,6 +24,16 @@ def mock_setup_entry() -> Generator[AsyncMock]: yield mock_setup_entry +@pytest.fixture +def mock_server(mock_create_server: AsyncMock) -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.snapcast.config_flow.snapcast.control.create_server", + return_value=mock_create_server, + ) as mock_server: + yield mock_server + + @pytest.fixture def mock_create_server( mock_group: AsyncMock, @@ -64,15 +73,6 @@ async def mock_config_entry() -> MockConfigEntry: ) -@pytest.fixture -def mock_server_connection() -> Generator[Snapserver]: - """Create a mock server connection.""" - - # Patch the start method of the Snapserver class to avoid network connections - with patch.object(Snapserver, "start", new_callable=AsyncMock) as mock_start: - yield mock_start - - @pytest.fixture def mock_group(stream: str, streams: dict[str, AsyncMock]) -> AsyncMock: """Create a mock Snapgroup.""" diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index 50ab4f0c170..5b7d30211e1 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -1,91 +1,103 @@ """Test the Snapcast module.""" import socket -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest -from homeassistant import config_entries, setup from homeassistant.components.snapcast.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_CONNECTION = {CONF_HOST: "snapserver.test", CONF_PORT: 1705} - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") +TEST_CONNECTION = {CONF_HOST: "127.0.0.1", CONF_PORT: 1705} -async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test we get the form and handle errors and successful connection.""" - await setup.async_setup_component(hass, "persistent_notification", {}) +async def test_full_flow( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_server: AsyncMock +) -> None: + """Test the full flow.""" 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["step_id"] == "user" assert not result["errors"] - # test invalid host error - with patch("snapcast.control.create_server", side_effect=socket.gaierror): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "invalid_host"} - - # test connection error - with patch("snapcast.control.create_server", side_effect=ConnectionRefusedError): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} - - # test success - with patch("snapcast.control.create_server"): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "Snapcast" - assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} + assert result["data"] == {CONF_HOST: "127.0.0.1", CONF_PORT: 1705} assert len(mock_setup_entry.mock_calls) == 1 -async def test_abort(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: - """Test config flow abort if device is already configured.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=TEST_CONNECTION, - ) - entry.add_to_hass(hass) +@pytest.mark.parametrize( + ("exception", "error"), + [ + (socket.gaierror, "invalid_host"), + (ConnectionRefusedError, "cannot_connect"), + ], +) +async def test_exceptions( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_server: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we get the form and handle errors and successful connection.""" + mock_server.side_effect = exception 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["step_id"] == "user" assert not result["errors"] - with patch("snapcast.control.create_server", side_effect=socket.gaierror): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - TEST_CONNECTION, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_server.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_already_setup( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test config flow abort if device is already configured.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + TEST_CONNECTION, + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" From 3045f67ae5f5ba4f6cd8f8e2a7e20154b8c9540e Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Wed, 9 Jul 2025 11:49:28 -0400 Subject: [PATCH 1274/1664] Modernize binary sensor template tests (#148367) --- tests/components/template/conftest.py | 82 + .../components/template/test_binary_sensor.py | 1802 ++++++++--------- 2 files changed, 963 insertions(+), 921 deletions(-) diff --git a/tests/components/template/conftest.py b/tests/components/template/conftest.py index 6d1776f24cd..c57d1dcbfab 100644 --- a/tests/components/template/conftest.py +++ b/tests/components/template/conftest.py @@ -23,6 +23,88 @@ class ConfigurationStyle(Enum): TRIGGER = "Trigger" +def make_test_trigger(*entities: str) -> dict: + """Make a test state trigger.""" + return { + "trigger": [ + { + "trigger": "state", + "entity_id": list(entities), + }, + {"platform": "event", "event_type": "test_event"}, + ], + "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, + "action": [ + {"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}} + ], + } + + +async def async_setup_legacy_platforms( + hass: HomeAssistant, + domain: str, + slug: str, + count: int, + config: ConfigType, +) -> None: + """Do setup of any legacy platform that supports a keyed dictionary of template entities.""" + with assert_setup_component(count, domain): + assert await async_setup_component( + hass, + domain, + {domain: {"platform": "template", slug: config}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_state_format( + hass: HomeAssistant, + domain: str, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of template integration via modern format.""" + extra = extra_config or {} + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + {"template": {domain: config, **extra}}, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +async def async_setup_modern_trigger_format( + hass: HomeAssistant, + domain: str, + trigger: dict, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of template integration via trigger format.""" + extra = extra_config or {} + config = {"template": {domain: config, **trigger, **extra}} + + with assert_setup_component(count, template.DOMAIN): + assert await async_setup_component( + hass, + template.DOMAIN, + config, + ) + + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index a3b7edea919..75a9e2c9689 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1,9 +1,9 @@ """The tests for the Template Binary sensor platform.""" from collections.abc import Generator -from copy import deepcopy from datetime import UTC, datetime, timedelta import logging +from typing import Any from unittest.mock import Mock, patch from freezegun.api import FrozenDateTimeFactory @@ -23,16 +23,26 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component + +from .conftest import ( + ConfigurationStyle, + async_get_flow_preview_state, + async_setup_legacy_platforms, + async_setup_modern_state_format, + async_setup_modern_trigger_format, + make_test_trigger, +) from tests.common import ( MockConfigEntry, - assert_setup_component, async_fire_time_changed, + async_mock_restore_state_shutdown_restart, mock_restore_cache, mock_restore_cache_with_extra_data, ) +from tests.typing import WebSocketGenerator _BEER_TRIGGER_VALUE_TEMPLATE = ( "{% if trigger.event.data.beer < 0 %}" @@ -45,94 +55,202 @@ _BEER_TRIGGER_VALUE_TEMPLATE = ( ) -@pytest.mark.parametrize("count", [1]) -@pytest.mark.parametrize( - ("config", "domain", "entity_id", "name", "attributes"), - [ - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "value_template": "{{ True }}", - } - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - "test", - {"friendly_name": "test"}, - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ True }}", - } - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - "unnamed device", - {}, - ), - ], +TEST_OBJECT_ID = "test_binary_sensor" +TEST_ENTITY_ID = f"binary_sensor.{TEST_OBJECT_ID}" +TEST_STATE_ENTITY_ID = "binary_sensor.test_state" +TEST_ATTRIBUTE_ENTITY_ID = "sensor.test_attribute" +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" +TEST_STATE_TRIGGER = make_test_trigger( + TEST_STATE_ENTITY_ID, TEST_AVAILABILITY_ENTITY_ID, TEST_ATTRIBUTE_ENTITY_ID ) -@pytest.mark.usefixtures("start_ha") -async def test_setup_minimal( - hass: HomeAssistant, entity_id: str, name: str, attributes: dict[str, str] +UNIQUE_ID_CONFIG = { + "unique_id": "not-so-unique-anymore", +} + + +async def async_setup_legacy_format( + hass: HomeAssistant, count: int, config: ConfigType ) -> None: - """Test the setup.""" - state = hass.states.get(entity_id) - assert state is not None - assert state.name == name - assert state.state == STATE_ON - assert state.attributes == attributes + """Do setup of binary sensor integration via legacy format.""" + await async_setup_legacy_platforms( + hass, binary_sensor.DOMAIN, "sensors", count, config + ) + + +async def async_setup_modern_format( + hass: HomeAssistant, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of binary sensor integration via modern format.""" + await async_setup_modern_state_format( + hass, binary_sensor.DOMAIN, count, config, extra_config + ) + + +async def async_setup_trigger_format( + hass: HomeAssistant, + count: int, + config: ConfigType, + extra_config: ConfigType | None = None, +) -> None: + """Do setup of binary sensor integration via trigger format.""" + await async_setup_modern_trigger_format( + hass, binary_sensor.DOMAIN, TEST_STATE_TRIGGER, count, config, extra_config + ) + + +@pytest.fixture +async def setup_base_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + config: ConfigType | list[dict], + extra_template_options: ConfigType, +) -> None: + """Do setup of binary sensor integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format(hass, count, config) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format(hass, count, config, extra_template_options) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format(hass, count, config, extra_template_options) + + +async def async_setup_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: ConfigType, +) -> None: + """Do setup of binary sensor integration.""" + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + {TEST_OBJECT_ID: {"value_template": state_template, **extra_config}}, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + {"name": TEST_OBJECT_ID, "state": state_template, **extra_config}, + ) + + +@pytest.fixture +async def setup_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + state_template: str, + extra_config: dict[str, Any], +) -> None: + """Do setup of binary sensor integration.""" + await async_setup_binary_sensor(hass, count, style, state_template, extra_config) + + +@pytest.fixture +async def setup_single_attribute_binary_sensor( + hass: HomeAssistant, + count: int, + style: ConfigurationStyle, + attribute: str, + attribute_value: str | dict, + state_template: str, + extra_config: dict, +) -> None: + """Do setup of binary sensor integration testing a single attribute.""" + extra = {attribute: attribute_value} if attribute and attribute_value else {} + if style == ConfigurationStyle.LEGACY: + await async_setup_legacy_format( + hass, + count, + { + TEST_OBJECT_ID: { + "value_template": state_template, + **extra, + **extra_config, + } + }, + ) + elif style == ConfigurationStyle.MODERN: + await async_setup_modern_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **extra, + **extra_config, + }, + ) + elif style == ConfigurationStyle.TRIGGER: + await async_setup_trigger_format( + hass, + count, + { + "name": TEST_OBJECT_ID, + "state": state_template, + **extra, + **extra_config, + }, + ) -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "extra_config"), [(1, "{{ True }}", {})] +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_setup_minimal(hass: HomeAssistant) -> None: + """Test the setup.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.name == TEST_OBJECT_ID + assert state.state == STATE_ON + assert state.attributes == {"friendly_name": TEST_OBJECT_ID} + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), [ ( + 1, + "{{ True }}", { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ True }}", - "device_class": "motion", - } - }, - }, + "device_class": "motion", }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "{{ True }}", - "device_class": "motion", - } - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_setup(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_setup(hass: HomeAssistant) -> None: """Test the setup.""" - state = hass.states.get(entity_id) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) assert state is not None - assert state.name == "virtual thingy" + assert state.name == TEST_OBJECT_ID assert state.state == STATE_ON assert state.attributes["device_class"] == "motion" @@ -298,162 +416,144 @@ async def test_state( assert state.state == expected_result -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "icon_template": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "mdi:check" - "{% endif %}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "icon": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "mdi:check" - "{% endif %}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ 1 == 1 }}", + "{% if is_state('binary_sensor.test_state', 'on') %}mdi:check{% endif %}", + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_icon_template(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), + [ + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), + (ConfigurationStyle.TRIGGER, "icon", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_icon_template(hass: HomeAssistant, initial_state: str | None) -> None: """Test icon template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("icon") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("binary_sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["icon"] == "mdi:check" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "entity_picture_template": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "/local/sensor.png" - "{% endif %}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "picture": "{% if " - "states.binary_sensor.test_state.state == " - "'on' %}" - "/local/sensor.png" - "{% endif %}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ 1 == 1 }}", + "{% if is_state('binary_sensor.test_state', 'on') %}/local/sensor.png{% endif %}", + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_entity_picture_template(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), + [ + (ConfigurationStyle.LEGACY, "entity_picture_template", ""), + (ConfigurationStyle.MODERN, "picture", ""), + (ConfigurationStyle.TRIGGER, "picture", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_entity_picture_template( + hass: HomeAssistant, initial_state: str | None +) -> None: """Test entity_picture template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("entity_picture") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("entity_picture") == initial_state - hass.states.async_set("binary_sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["entity_picture"] == "/local/sensor.png" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ states.sensor.xyz.state }}", - "attribute_templates": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test_template_sensor", - ), - ( - { - "template": { - "binary_sensor": { - "state": "{{ states.sensor.xyz.state }}", - "attributes": { - "test_attribute": "It {{ states.sensor.test_state.state }}." - }, - }, - }, - }, - template.DOMAIN, - "binary_sensor.unnamed_device", - ), + 1, + "{{ True }}", + {"test_attribute": "It {{ states.sensor.test_attribute.state }}."}, + {}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_attribute_templates(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute", "initial_value"), + [ + (ConfigurationStyle.LEGACY, "attribute_templates", "It ."), + (ConfigurationStyle.MODERN, "attributes", "It ."), + (ConfigurationStyle.TRIGGER, "attributes", None), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_attribute_templates( + hass: HomeAssistant, initial_value: str | None +) -> None: """Test attribute_templates template.""" - state = hass.states.get(entity_id) - assert state.attributes.get("test_attribute") == "It ." - hass.states.async_set("sensor.test_state", "Works2") + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("test_attribute") == initial_value + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works2") await hass.async_block_till_done() - hass.states.async_set("sensor.test_state", "Works") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "Works") await hass.async_block_till_done() - state = hass.states.get(entity_id) + + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes["test_attribute"] == "It Works." +@pytest.mark.parametrize( + ("count", "state_template", "attribute_value", "extra_config"), + [ + ( + 1, + "{{ states.binary_sensor.test_sensor }}", + {"test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}"}, + {}, + ) + ], +) +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "attribute_templates"), + (ConfigurationStyle.MODERN, "attributes"), + (ConfigurationStyle.TRIGGER, "attributes"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_invalid_attribute_template( + hass: HomeAssistant, + style: ConfigurationStyle, + caplog_setup_text: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that errors are logged if rendering template fails.""" + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + text = ( + "Template variable error: 'None' has no attribute 'attributes' when rendering" + ) + assert text in caplog_setup_text or text in caplog.text + + @pytest.fixture def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" @@ -496,338 +596,261 @@ async def test_match_all(hass: HomeAssistant, setup_mock: Mock) -> None: assert len(setup_mock.mock_calls) == init_calls -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "extra_config"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - }, - }, - }, - }, + ( + 1, + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_event(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("style", "initial_state"), + [ + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_binary_sensor_state(hass: HomeAssistant, initial_state: str) -> None: """Test the event.""" - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == initial_state - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON @pytest.mark.parametrize( - ("config", "count", "domain"), + ("count", "state_template", "extra_config", "attribute"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - }, 1, - binary_sensor.DOMAIN, - ), - ( - { - "template": [ - { - "binary_sensor": { - "name": "test on", - "state": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": 5, - }, - }, - { - "binary_sensor": { - "name": "test off", - "state": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": 5, - }, - }, - ] - }, - 2, - template.DOMAIN, - ), - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 / 2 }) }}', - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 / 2 }) }}', - }, - }, - }, - }, - 1, - binary_sensor.DOMAIN, - ), - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test_on": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - }, - "test_off": { - "friendly_name": "virtual thingy", - "value_template": "{{ states.sensor.test_state.state == 'on' }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": states("input_number.delay")|int }) }}', - }, - }, - }, - }, - 1, - binary_sensor.DOMAIN, - ), + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + "delay_on", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_delay_on_off( - hass: HomeAssistant, freezer: FrozenDateTimeFactory +@pytest.mark.parametrize( + ("style", "initial_state"), + [ + (ConfigurationStyle.LEGACY, STATE_OFF), + (ConfigurationStyle.MODERN, STATE_OFF), + (ConfigurationStyle.TRIGGER, STATE_UNKNOWN), + ], +) +@pytest.mark.parametrize( + "attribute_value", + [ + 5, + "{{ dict(seconds=10 / 2) }}", + '{{ dict(seconds=states("sensor.test_attribute") | int(0)) }}', + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_delay_on( + hass: HomeAssistant, initial_state: str, freezer: FrozenDateTimeFactory ) -> None: """Test binary sensor template delay on.""" # Ensure the initial state is not on - assert hass.states.get("binary_sensor.test_on").state != STATE_ON - assert hass.states.get("binary_sensor.test_off").state != STATE_ON + assert hass.states.get(TEST_ENTITY_ID).state == initial_state - hass.states.async_set("input_number.delay", 5) - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON + + assert hass.states.get(TEST_ENTITY_ID).state == initial_state freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_ON - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - # check with time changes - hass.states.async_set("sensor.test_state", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON - hass.states.async_set("sensor.test_state", STATE_OFF) + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_ON + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF freezer.tick(timedelta(seconds=5)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.test_on").state == STATE_OFF - assert hass.states.get("binary_sensor.test_off").state == STATE_OFF + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "extra_config", "attribute"), [ ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "true", - "device_class": "motion", - "delay_off": 5, - }, - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + 1, + "{{ is_state('binary_sensor.test_state', 'on') }}", + {"device_class": "motion"}, + "delay_off", + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_available_without_availability_template( - hass: HomeAssistant, entity_id: str -) -> None: +@pytest.mark.parametrize( + "style", + [ + ConfigurationStyle.LEGACY, + ConfigurationStyle.MODERN, + ConfigurationStyle.TRIGGER, + ], +) +@pytest.mark.parametrize( + "attribute_value", + [ + 5, + "{{ dict(seconds=10 / 2) }}", + '{{ dict(seconds=states("sensor.test_attribute") | int(0)) }}', + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_delay_off(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Test binary sensor template delay off.""" + assert hass.states.get(TEST_ENTITY_ID).state != STATE_ON + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, 5) + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_ON + + freezer.tick(timedelta(seconds=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "state_template", "extra_config"), + [ + ( + 1, + "{{ True }}", + { + "device_class": "motion", + "delay_off": 5, + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_available_without_availability_template(hass: HomeAssistant) -> None: """Ensure availability is true without an availability_template.""" - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), + ("count", "state_template", "attribute_value", "extra_config"), [ ( + 1, + "{{ True }}", + "{{ is_state('binary_sensor.test_availability','on') }}", { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "true", - "device_class": "motion", - "delay_off": 5, - "availability_template": "{{ is_state('sensor.test_state','on') }}", - }, - }, - }, + "device_class": "motion", + "delay_off": 5, }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "name": "virtual thingy", - "state": "true", - "device_class": "motion", - "delay_off": 5, - "availability": "{{ is_state('sensor.test_state','on') }}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.virtual_thingy", - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_template(hass: HomeAssistant, entity_id: str) -> None: +@pytest.mark.parametrize( + ("style", "attribute"), + [ + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_availability_template(hass: HomeAssistant) -> None: """Test availability template.""" - hass.states.async_set("sensor.test_state", STATE_OFF) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(TEST_ENTITY_ID).state == STATE_UNAVAILABLE - hass.states.async_set("sensor.test_state", STATE_ON) + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_ON) await hass.async_block_till_done() - state = hass.states.get(entity_id) + state = hass.states.get(TEST_ENTITY_ID) assert state.state != STATE_UNAVAILABLE assert state.attributes[ATTR_DEVICE_CLASS] == "motion" -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_value", "extra_config"), + [(1, "{{ True }}", "{{ x - 12 }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "invalid_template": { - "value_template": "{{ states.binary_sensor.test_sensor }}", - "attribute_templates": { - "test_attribute": "{{ states.binary_sensor.unknown.attributes.picture }}" - }, - } - }, - }, - }, + (ConfigurationStyle.LEGACY, "availability_template"), + (ConfigurationStyle.MODERN, "availability"), + (ConfigurationStyle.TRIGGER, "availability"), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_invalid_attribute_template( - hass: HomeAssistant, caplog_setup_text: str -) -> None: - """Test that errors are logged if rendering template fails.""" - hass.states.async_set("binary_sensor.test_sensor", STATE_ON) - assert len(hass.states.async_all()) == 2 - assert ("test_attribute") in caplog_setup_text - assert ("TemplateError") in caplog_setup_text - - -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) -@pytest.mark.parametrize( - "config", - [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "my_sensor": { - "value_template": "{{ states.binary_sensor.test_sensor }}", - "availability_template": "{{ x - 12 }}", - }, - }, - }, - }, - ], -) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_invalid_availability_template_keeps_component_available( - hass: HomeAssistant, caplog_setup_text: str + hass: HomeAssistant, caplog_setup_text: str, caplog: pytest.LogCaptureFixture ) -> None: """Test that an invalid availability keeps the device available.""" - assert hass.states.get("binary_sensor.my_sensor").state != STATE_UNAVAILABLE - assert "UndefinedError: 'x' is undefined" in caplog_setup_text + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, STATE_OFF) + await hass.async_block_till_done() + + assert hass.states.get(TEST_ENTITY_ID).state != STATE_UNAVAILABLE + text = "UndefinedError: 'x' is undefined" + assert text in caplog_setup_text or text in caplog.text async def test_no_update_template_match_all(hass: HomeAssistant) -> None: @@ -896,172 +919,145 @@ async def test_no_update_template_match_all(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.all_attribute").state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize(("count", "extra_template_options"), [(1, {})]) @pytest.mark.parametrize( - "config", + ("config", "style"), [ - { - "template": { - "unique_id": "group-id", - "binary_sensor": { - "name": "top-level", - "unique_id": "sensor-id", - "state": STATE_ON, + ( + { + "test_template_01": { + "value_template": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + "test_template_02": { + "value_template": "{{ True }}", + **UNIQUE_ID_CONFIG, }, }, - "binary_sensor": { - "platform": "template", - "sensors": { - "test_template_cover_01": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ true }}", - }, - "test_template_cover_02": { - "unique_id": "not-so-unique-anymore", - "value_template": "{{ false }}", - }, + ConfigurationStyle.LEGACY, + ), + ( + [ + { + "name": "test_template_01", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, }, - }, - }, + { + "name": "test_template_02", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.MODERN, + ), + ( + [ + { + "name": "test_template_01", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + { + "name": "test_template_02", + "state": "{{ True }}", + **UNIQUE_ID_CONFIG, + }, + ], + ConfigurationStyle.TRIGGER, + ), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_unique_id( +@pytest.mark.usefixtures("setup_base_binary_sensor") +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique_id option only creates one fan per id.""" + assert len(hass.states.async_all()) == 1 + + +@pytest.mark.parametrize( + ("count", "config", "extra_template_options"), + [ + ( + 1, + [ + { + "name": "test_a", + "state": "{{ True }}", + "unique_id": "a", + }, + { + "name": "test_b", + "state": "{{ True }}", + "unique_id": "b", + }, + ], + {"unique_id": "x"}, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_base_binary_sensor") +async def test_nested_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: - """Test unique_id option only creates one binary sensor per id.""" - assert len(hass.states.async_all()) == 2 + """Test a template unique_id propagates to switch unique_ids.""" + assert len(hass.states.async_all("binary_sensor")) == 2 - assert len(entity_registry.entities) == 2 - assert entity_registry.async_get_entity_id( - "binary_sensor", "template", "group-id-sensor-id" - ) - assert entity_registry.async_get_entity_id( - "binary_sensor", "template", "not-so-unique-anymore" - ) + entry = entity_registry.async_get("binary_sensor.test_a") + assert entry + assert entry.unique_id == "x-a" + + entry = entity_registry.async_get("binary_sensor.test_b") + assert entry + assert entry.unique_id == "x-b" -@pytest.mark.parametrize(("count", "domain"), [(1, binary_sensor.DOMAIN)]) @pytest.mark.parametrize( - "config", + ("count", "state_template", "attribute_value", "extra_config"), + [(1, "{{ 1 == 1 }}", "{{ states.sensor.test_attribute.state }}", {})], +) +@pytest.mark.parametrize( + ("style", "attribute", "initial_state"), [ - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "friendly_name": "virtual thingy", - "value_template": "True", - "icon_template": "{{ states.sensor.test_state.state }}", - "device_class": "motion", - "delay_on": 5, - }, - }, - }, - }, + (ConfigurationStyle.LEGACY, "icon_template", ""), + (ConfigurationStyle.MODERN, "icon", ""), ], ) -@pytest.mark.usefixtures("start_ha") -async def test_template_validation_error( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_template_icon_validation_error( + hass: HomeAssistant, initial_state: str, caplog: pytest.LogCaptureFixture ) -> None: """Test binary sensor template delay on.""" caplog.set_level(logging.ERROR) - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("icon") == "" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes.get("icon") == initial_state - hass.states.async_set("sensor.test_state", "mdi:check") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "mdi:check") await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("icon") == "mdi:check" + state = hass.states.get(TEST_ENTITY_ID) + assert state.attributes["icon"] == "mdi:check" - hass.states.async_set("sensor.test_state", "invalid_icon") + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "invalid_icon") await hass.async_block_till_done() + assert len(caplog.records) == 1 assert caplog.records[0].message.startswith( "Error validating template result 'invalid_icon' from template" ) - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.attributes.get("icon") is None -@pytest.mark.parametrize("count", [1]) @pytest.mark.parametrize( - ("config", "domain", "entity_id"), - [ - ( - { - "binary_sensor": { - "platform": "template", - "sensors": { - "test": { - "availability_template": "{{ is_state('sensor.bla', 'available') }}", - "entity_picture_template": "{{ 'blib' + 'blub' }}", - "icon_template": "mdi:{{ 1+2 }}", - "friendly_name": "{{ 'My custom ' + 'sensor' }}", - "value_template": "{{ true }}", - }, - }, - }, - }, - binary_sensor.DOMAIN, - "binary_sensor.test", - ), - ( - { - "template": { - "binary_sensor": { - "availability": "{{ is_state('sensor.bla', 'available') }}", - "picture": "{{ 'blib' + 'blub' }}", - "icon": "mdi:{{ 1+2 }}", - "name": "{{ 'My custom ' + 'sensor' }}", - "state": "{{ true }}", - }, - }, - }, - template.DOMAIN, - "binary_sensor.my_custom_sensor", - ), - ], + ("count", "state_template"), [(1, "{{ states.binary_sensor.test_state.state }}")] ) -@pytest.mark.usefixtures("start_ha") -async def test_availability_icon_picture(hass: HomeAssistant, entity_id: str) -> None: - """Test name, icon and picture templates are rendered at setup.""" - state = hass.states.get(entity_id) - assert state.state == "unavailable" - assert state.attributes == { - "entity_picture": "blibblub", - "friendly_name": "My custom sensor", - "icon": "mdi:3", - } - - hass.states.async_set("sensor.bla", "available") - await hass.async_block_till_done() - - state = hass.states.get(entity_id) - assert state.state == "on" - assert state.attributes == { - "entity_picture": "blibblub", - "friendly_name": "My custom sensor", - "icon": "mdi:3", - } - - -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( - "config", - [ - { - "template": { - "binary_sensor": { - "name": "test", - "state": "{{ states.sensor.test_state.state }}", - }, - }, - }, - ], + "style", + [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN], ) @pytest.mark.parametrize( ("extra_config", "source_state", "restored_state", "initial_state"), @@ -1107,8 +1103,8 @@ async def test_availability_icon_picture(hass: HomeAssistant, entity_id: str) -> async def test_restore_state( hass: HomeAssistant, count: int, - domain: str, - config: ConfigType, + style: ConfigurationStyle, + state_template: str, extra_config: ConfigType, source_state: str | None, restored_state: str, @@ -1116,199 +1112,33 @@ async def test_restore_state( ) -> None: """Test restoring template binary sensor.""" - hass.states.async_set("sensor.test_state", source_state) - fake_state = State( - "binary_sensor.test", - restored_state, - {}, - ) + hass.states.async_set(TEST_STATE_ENTITY_ID, source_state) + await hass.async_block_till_done() + + fake_state = State(TEST_ENTITY_ID, restored_state, {}) mock_restore_cache(hass, (fake_state,)) - config = deepcopy(config) - config["template"]["binary_sensor"].update(**extra_config) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) - await hass.async_block_till_done() + await async_setup_binary_sensor(hass, count, style, state_template, extra_config) - context = Context() - hass.bus.async_fire("test_event", {"beer": 2}, context=context) - await hass.async_block_till_done() - - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == initial_state -@pytest.mark.parametrize(("count", "domain"), [(2, "template")]) @pytest.mark.parametrize( - "config", + ("count", "style", "state_template", "extra_config"), [ - { - "template": [ - {"invalid": "config"}, - # Config after invalid should still be set up - { - "unique_id": "listening-test-event", - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensors": { - "hello": { - "friendly_name": "Hello Name", - "unique_id": "hello_name-id", - "device_class": "battery", - "value_template": _BEER_TRIGGER_VALUE_TEMPLATE, - "entity_picture_template": "{{ '/local/dogs.png' }}", - "icon_template": "{{ 'mdi:pirate' }}", - "attribute_templates": { - "plus_one": "{{ trigger.event.data.beer + 1 }}" - }, - }, - }, - "binary_sensor": [ - { - "name": "via list", - "unique_id": "via_list-id", - "device_class": "battery", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "picture": "{{ '/local/dogs.png' }}", - "icon": "{{ 'mdi:pirate' }}", - "attributes": { - "plus_one": "{{ trigger.event.data.beer + 1 }}", - "another": "{{ trigger.event.data.uno_mas or 1 }}", - }, - } - ], - }, - { - "trigger": [], - "binary_sensors": { - "bare_minimum": { - "value_template": "{{ trigger.event.data.beer == 1 }}" - }, - }, - }, - ], - }, - ], -) -@pytest.mark.parametrize( - ( - "beer_count", - "final_state", - "icon_attr", - "entity_picture_attr", - "plus_one_attr", - "another_attr", - "another_attr_update", - ), - [ - (2, STATE_ON, "mdi:pirate", "/local/dogs.png", 3, 1, "si"), - (1, STATE_OFF, "mdi:pirate", "/local/dogs.png", 2, 1, "si"), - (0, STATE_UNKNOWN, "mdi:pirate", "/local/dogs.png", 1, 1, "si"), - (-1, STATE_UNAVAILABLE, None, None, None, None, None), - ], -) -@pytest.mark.usefixtures("start_ha") -async def test_trigger_entity( - hass: HomeAssistant, - beer_count: int, - final_state: str, - icon_attr: str | None, - entity_picture_attr: str | None, - plus_one_attr: int | None, - another_attr: int | None, - another_attr_update: str | None, - entity_registry: er.EntityRegistry, -) -> None: - """Test trigger entity works.""" - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state is not None - assert state.state == STATE_UNKNOWN - - state = hass.states.get("binary_sensor.bare_minimum") - assert state is not None - assert state.state == STATE_UNKNOWN - - context = Context() - hass.bus.async_fire("test_event", {"beer": beer_count}, context=context) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.hello_name") - assert state.state == final_state - assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == icon_attr - assert state.attributes.get("entity_picture") == entity_picture_attr - assert state.attributes.get("plus_one") == plus_one_attr - assert state.context is context - - assert len(entity_registry.entities) == 2 - assert ( - entity_registry.entities["binary_sensor.hello_name"].unique_id - == "listening-test-event-hello_name-id" - ) - assert ( - entity_registry.entities["binary_sensor.via_list"].unique_id - == "listening-test-event-via_list-id" - ) - - state = hass.states.get("binary_sensor.via_list") - assert state.state == final_state - assert state.attributes.get("device_class") == "battery" - assert state.attributes.get("icon") == icon_attr - assert state.attributes.get("entity_picture") == entity_picture_attr - assert state.attributes.get("plus_one") == plus_one_attr - assert state.attributes.get("another") == another_attr - assert state.context is context - - # Even if state itself didn't change, attributes might have changed - hass.bus.async_fire("test_event", {"beer": beer_count, "uno_mas": "si"}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.via_list") - assert state.state == final_state - assert state.attributes.get("another") == another_attr_update - - # Check None values - hass.bus.async_fire("test_event", {"beer": 0}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state.state == STATE_UNKNOWN - state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_UNKNOWN - - # Check impossible values - hass.bus.async_fire("test_event", {"beer": -1}) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.hello_name") - assert state.state == STATE_UNAVAILABLE - state = hass.states.get("binary_sensor.via_list") - assert state.state == STATE_UNAVAILABLE - - -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, + ( + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "delay_on": '{{ ({ "seconds": 6 / 2 }) }}', + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', }, - }, + ) ], ) -@pytest.mark.usefixtures("start_ha") @pytest.mark.parametrize( ("beer_count", "first_state", "second_state", "final_state"), [ @@ -1318,7 +1148,8 @@ async def test_trigger_entity( (-1, STATE_UNAVAILABLE, STATE_UNAVAILABLE, STATE_UNAVAILABLE), ], ) -async def test_template_with_trigger_templated_delay_on( +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_with_trigger_templated_auto_off( hass: HomeAssistant, beer_count: int, first_state: str, @@ -1326,8 +1157,8 @@ async def test_template_with_trigger_templated_delay_on( final_state: str, freezer: FrozenDateTimeFactory, ) -> None: - """Test binary sensor template with template delay on.""" - state = hass.states.get("binary_sensor.test") + """Test binary sensor template with template auto off.""" + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN context = Context() @@ -1335,7 +1166,7 @@ async def test_template_with_trigger_templated_delay_on( await hass.async_block_till_done() # State should still be unknown - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == first_state # Now wait for the on delay @@ -1343,7 +1174,7 @@ async def test_template_with_trigger_templated_delay_on( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == second_state # Now wait for the auto-off @@ -1351,52 +1182,128 @@ async def test_template_with_trigger_templated_delay_on( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == final_state -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( - ("config", "delay_state"), + ("count", "style", "state_template", "extra_config"), [ ( + 1, + ConfigurationStyle.TRIGGER, + "{{ True }}", { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer == 2 }}", - "device_class": "motion", - "delay_on": '{{ ({ "seconds": 10 }) }}', - }, - }, + "device_class": "motion", + "auto_off": '{{ ({ "seconds": 5 }) }}', }, - STATE_ON, - ), - ( - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": "{{ trigger.event.data.beer != 2 }}", - "device_class": "motion", - "delay_off": '{{ ({ "seconds": 10 }) }}', - }, - }, - }, - STATE_OFF, - ), + ) ], ) -@pytest.mark.usefixtures("start_ha") +@pytest.mark.usefixtures("setup_binary_sensor") +async def test_template_with_trigger_auto_off_cancel( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor template with template auto off.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {}, context=context) + await hass.async_block_till_done() + + # State should still be unknown + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + # Now wait for the on delay + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + hass.bus.async_fire("test_event", {}, context=context) + await hass.async_block_till_done() + + # Now wait for the on delay + freezer.tick(timedelta(seconds=4)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_ON + + # Now wait for the auto-off + freezer.tick(timedelta(seconds=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_OFF + + +@pytest.mark.parametrize( + ("count", "style", "extra_config", "attribute_value"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + {"device_class": "motion"}, + "{{ states('sensor.test_attribute') }}", + ) + ], +) +@pytest.mark.parametrize( + ("state_template", "attribute"), + [ + ("{{ True }}", "delay_on"), + ("{{ False }}", "delay_off"), + ("{{ True }}", "auto_off"), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") +async def test_trigger_with_negative_time_periods( + hass: HomeAssistant, attribute: str, caplog: pytest.LogCaptureFixture +) -> None: + """Test binary sensor template with template negative time periods.""" + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + hass.states.async_set(TEST_ATTRIBUTE_ENTITY_ID, "-5") + await hass.async_block_till_done() + + assert f"Error rendering {attribute} template: " in caplog.text + + +@pytest.mark.parametrize( + ("count", "style", "extra_config", "attribute_value"), + [ + ( + 1, + ConfigurationStyle.TRIGGER, + {"device_class": "motion"}, + "{{ ({ 'seconds': 10 }) }}", + ) + ], +) +@pytest.mark.parametrize( + ("state_template", "attribute", "delay_state"), + [ + ("{{ trigger.event.data.beer == 2 }}", "delay_on", STATE_ON), + ("{{ trigger.event.data.beer != 2 }}", "delay_off", STATE_OFF), + ], +) +@pytest.mark.usefixtures("setup_single_attribute_binary_sensor") async def test_trigger_template_delay_with_multiple_triggers( hass: HomeAssistant, delay_state: str, freezer: FrozenDateTimeFactory ) -> None: """Test trigger based binary sensor with multiple triggers occurring during the delay.""" for _ in range(10): # State should still be unknown - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_UNKNOWN hass.bus.async_fire("test_event", {"beer": 2}, context=Context()) @@ -1406,32 +1313,10 @@ async def test_trigger_template_delay_with_multiple_triggers( async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == delay_state -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "picture": "{{ '/local/dogs.png' }}", - "icon": "{{ 'mdi:pirate' }}", - "attributes": { - "plus_one": "{{ trigger.event.data.beer + 1 }}", - "another": "{{ trigger.event.data.uno_mas or 1 }}", - }, - }, - }, - }, - ], -) @pytest.mark.parametrize( ("restored_state", "initial_state", "initial_attributes"), [ @@ -1443,9 +1328,6 @@ async def test_trigger_template_delay_with_multiple_triggers( ) async def test_trigger_entity_restore_state( hass: HomeAssistant, - count: int, - domain: str, - config: ConfigType, restored_state: str, initial_state: str, initial_attributes: list[str], @@ -1459,7 +1341,7 @@ async def test_trigger_entity_restore_state( } fake_state = State( - "binary_sensor.test", + TEST_ENTITY_ID, restored_state, restored_attributes, ) @@ -1467,18 +1349,23 @@ async def test_trigger_entity_restore_state( "auto_off_time": None, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + { + "device_class": "motion", + "picture": "{{ '/local/dogs.png' }}", + "icon": "{{ 'mdi:pirate' }}", + "attributes": { + "plus_one": "{{ trigger.event.data.beer + 1 }}", + "another": "{{ trigger.event.data.uno_mas or 1 }}", + }, + }, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == initial_state for attr, value in restored_attributes.items(): if attr in initial_attributes: @@ -1490,7 +1377,7 @@ async def test_trigger_entity_restore_state( hass.bus.async_fire("test_event", {"beer": 2}) await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_ON assert state.attributes["icon"] == "mdi:pirate" assert state.attributes["entity_picture"] == "/local/dogs.png" @@ -1498,40 +1385,16 @@ async def test_trigger_entity_restore_state( assert state.attributes["another"] == 1 -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, - }, - }, - ], -) @pytest.mark.parametrize("restored_state", [STATE_ON, STATE_OFF]) async def test_trigger_entity_restore_state_auto_off( hass: HomeAssistant, - count: int, - domain: str, - config: ConfigType, restored_state: str, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State( - "binary_sensor.test", - restored_state, - {}, - ) + fake_state = State(TEST_ENTITY_ID, restored_state, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1539,18 +1402,15 @@ async def test_trigger_entity_restore_state_auto_off( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == restored_state # Now wait for the auto-off @@ -1558,42 +1418,18 @@ async def test_trigger_entity_restore_state_auto_off( await hass.async_block_till_done() await hass.async_block_till_done() - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF -@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) -@pytest.mark.parametrize( - "config", - [ - { - "template": { - "trigger": {"platform": "event", "event_type": "test_event"}, - "binary_sensor": { - "name": "test", - "state": _BEER_TRIGGER_VALUE_TEMPLATE, - "device_class": "motion", - "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', - }, - }, - }, - ], -) async def test_trigger_entity_restore_state_auto_off_expired( hass: HomeAssistant, - count: int, - domain: str, - config: ConfigType, freezer: FrozenDateTimeFactory, ) -> None: """Test restoring trigger template binary sensor.""" freezer.move_to("2022-02-02 12:02:00+00:00") - fake_state = State( - "binary_sensor.test", - STATE_ON, - {}, - ) + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) fake_extra_data = { "auto_off_time": { "__type": "", @@ -1601,21 +1437,132 @@ async def test_trigger_entity_restore_state_auto_off_expired( }, } mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) - with assert_setup_component(count, domain): - assert await async_setup_component( - hass, - domain, - config, - ) + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) - await hass.async_block_till_done() - await hass.async_start() - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.test") + state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_OFF +async def test_saving_auto_off( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test we restore state integration.""" + restored_attributes = { + "entity_picture": "/local/cats.png", + "icon": "mdi:ship", + "plus_one": 55, + } + + freezer.move_to("2022-02-02 02:02:00+00:00") + fake_extra_data = { + "auto_off_time": { + "__type": "", + "isoformat": "2022-02-02T02:02:02+00:00", + }, + } + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + "{{ True }}", + { + "device_class": "motion", + "auto_off": '{{ ({ "seconds": 1 + 1 }) }}', + "attributes": restored_attributes, + }, + ) + + hass.states.async_set(TEST_STATE_ENTITY_ID, STATE_ON) + await hass.async_block_till_done() + + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == TEST_ENTITY_ID + + for attr, value in restored_attributes.items(): + assert state["attributes"][attr] == value + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == fake_extra_data + + +async def test_trigger_entity_restore_invalid_auto_off_time_data( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test restoring trigger template binary sensor.""" + + freezer.move_to("2022-02-02 12:02:00+00:00") + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_extra_data = { + "auto_off_time": { + "_type": "", + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), + }, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + await async_mock_restore_state_shutdown_restart(hass) + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == fake_extra_data + + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + +async def test_trigger_entity_restore_invalid_auto_off_time_key( + hass: HomeAssistant, + hass_storage: dict[str, Any], + freezer: FrozenDateTimeFactory, +) -> None: + """Test restoring trigger template binary sensor.""" + + freezer.move_to("2022-02-02 12:02:00+00:00") + fake_state = State(TEST_ENTITY_ID, STATE_ON, {}) + fake_extra_data = { + "auto_off_timex": { + "__type": "", + "isoformat": datetime(2022, 2, 2, 12, 2, 0, tzinfo=UTC).isoformat(), + }, + } + mock_restore_cache_with_extra_data(hass, ((fake_state, fake_extra_data),)) + await async_mock_restore_state_shutdown_restart(hass) + + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert "auto_off_timex" in extra_data + assert extra_data == fake_extra_data + + await async_setup_binary_sensor( + hass, + 1, + ConfigurationStyle.TRIGGER, + _BEER_TRIGGER_VALUE_TEMPLATE, + {"device_class": "motion", "auto_off": '{{ ({ "seconds": 1 + 1 }) }}'}, + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state.state == STATE_UNKNOWN + + async def test_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -1653,3 +1600,16 @@ async def test_device_id( template_entity = entity_registry.async_get("binary_sensor.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the config flow preview.""" + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + binary_sensor.DOMAIN, + {"name": "My template", "state": "{{ 'on' }}"}, + ) + assert state["state"] == "on" From 57083d877e0a8b0a53a89c8b58400948e7acceff Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 9 Jul 2025 19:52:16 +0200 Subject: [PATCH 1275/1664] Add repairs from issue registry to integration diagnostics (#148498) --- .../components/diagnostics/__init__.py | 16 ++++- tests/components/diagnostics/test_init.py | 60 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index 7bc43f2c3f5..715285d184e 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -19,6 +19,7 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, integration_platform, + issue_registry as ir, ) from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.json import ( @@ -187,6 +188,7 @@ def async_format_manifest(manifest: Manifest) -> Manifest: async def _async_get_json_file_response( hass: HomeAssistant, data: Mapping[str, Any], + data_issues: list[dict[str, Any]] | None, filename: str, domain: str, d_id: str, @@ -213,6 +215,8 @@ async def _async_get_json_file_response( "setup_times": async_get_domain_setup_times(hass, domain), "data": data, } + if data_issues is not None: + payload["issues"] = data_issues try: json_data = json.dumps(payload, indent=2, cls=ExtendedJSONEncoder) except TypeError: @@ -275,6 +279,14 @@ class DownloadDiagnosticsView(http.HomeAssistantView): filename = f"{config_entry.domain}-{config_entry.entry_id}" + issue_registry = ir.async_get(hass) + issues = issue_registry.issues + data_issues = [ + issue_reg.to_json() + for issue_id, issue_reg in issues.items() + if issue_id[0] == config_entry.domain + ] + if not device_diagnostics: # Config entry diagnostics if info.config_entry_diagnostics is None: @@ -282,7 +294,7 @@ class DownloadDiagnosticsView(http.HomeAssistantView): data = await info.config_entry_diagnostics(hass, config_entry) filename = f"{DiagnosticsType.CONFIG_ENTRY}-{filename}" return await _async_get_json_file_response( - hass, data, filename, config_entry.domain, d_id + hass, data, data_issues, filename, config_entry.domain, d_id ) # Device diagnostics @@ -300,5 +312,5 @@ class DownloadDiagnosticsView(http.HomeAssistantView): data = await info.device_diagnostics(hass, config_entry, device) return await _async_get_json_file_response( - hass, data, filename, config_entry.domain, d_id, sub_id + hass, data, data_issues, filename, config_entry.domain, d_id, sub_id ) diff --git a/tests/components/diagnostics/test_init.py b/tests/components/diagnostics/test_init.py index ffed7e21f60..fe62efeebac 100644 --- a/tests/components/diagnostics/test_init.py +++ b/tests/components/diagnostics/test_init.py @@ -1,13 +1,15 @@ """Test the Diagnostics integration.""" +from datetime import datetime from http import HTTPStatus from unittest.mock import AsyncMock, Mock, patch +from freezegun import freeze_time import pytest from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.system_info import async_get_system_info from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -81,10 +83,20 @@ async def test_websocket( @pytest.mark.usefixtures("enable_custom_integrations") +@pytest.mark.parametrize( + "ignore_missing_translations", + [ + [ + "component.fake_integration.issues.test_issue.title", + "component.fake_integration.issues.test_issue.description", + ] + ], +) async def test_download_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, device_registry: dr.DeviceRegistry, + issue_registry: ir.IssueRegistry, ) -> None: """Test download diagnostics.""" config_entry = MockConfigEntry(domain="fake_integration") @@ -95,6 +107,18 @@ async def test_download_diagnostics( integration = await async_get_integration(hass, "fake_integration") original_manifest = integration.manifest.copy() original_manifest["codeowners"] = ["@test"] + + with freeze_time(datetime(2025, 7, 9, 14, 00, 00)): + issue_registry.async_get_or_create( + domain="fake_integration", + issue_id="test_issue", + breaks_in_ha_version="2023.10.0", + severity=ir.IssueSeverity.WARNING, + is_fixable=False, + is_persistent=True, + translation_key="test_issue", + ) + with patch.object(integration, "manifest", original_manifest): response = await _get_diagnostics_for_config_entry( hass, hass_client, config_entry @@ -179,6 +203,23 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"config_entry": "info"}, + "issues": [ + { + "breaks_in_ha_version": "2023.10.0", + "created": "2025-07-09T14:00:00+00:00", + "data": None, + "dismissed_version": None, + "domain": "fake_integration", + "is_fixable": False, + "is_persistent": True, + "issue_domain": None, + "issue_id": "test_issue", + "learn_more_url": None, + "severity": "warning", + "translation_key": "test_issue", + "translation_placeholders": None, + }, + ], } device = device_registry.async_get_or_create( @@ -266,6 +307,23 @@ async def test_download_diagnostics( "requirements": [], }, "data": {"device": "info"}, + "issues": [ + { + "breaks_in_ha_version": "2023.10.0", + "created": "2025-07-09T14:00:00+00:00", + "data": None, + "dismissed_version": None, + "domain": "fake_integration", + "is_fixable": False, + "is_persistent": True, + "issue_domain": None, + "issue_id": "test_issue", + "learn_more_url": None, + "severity": "warning", + "translation_key": "test_issue", + "translation_placeholders": None, + }, + ], "setup_times": {}, } From 1b5bbda6b011f1591f486f2b57960136208ef904 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 9 Jul 2025 20:37:00 +0200 Subject: [PATCH 1276/1664] Add response headers to action response of rest command (#148480) --- homeassistant/components/rest_command/__init__.py | 6 +++++- tests/components/rest_command/test_init.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index c6a4206de4a..0a9632b864d 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -205,7 +205,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "decoding_type": "text", }, ) from err - return {"content": _content, "status": response.status} + return { + "content": _content, + "status": response.status, + "headers": dict(response.headers), + } except TimeoutError as err: raise HomeAssistantError( diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 97ef29dfaca..5549aa67815 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -290,6 +290,7 @@ async def test_rest_command_get_response_plaintext( assert len(aioclient_mock.mock_calls) == 1 assert response["content"] == "success" assert response["status"] == 200 + assert response["headers"] == {"content-type": "text/plain"} async def test_rest_command_get_response_json( @@ -314,6 +315,7 @@ async def test_rest_command_get_response_json( assert response["content"]["status"] == "success" assert response["content"]["number"] == 42 assert response["status"] == 200 + assert response["headers"] == {"content-type": "application/json"} async def test_rest_command_get_response_malformed_json( From cbdc8e38004b55e8dee0d99020ead7aa3f34634d Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Wed, 9 Jul 2025 12:45:45 -0600 Subject: [PATCH 1277/1664] Bump pylitterbot to 2024.2.2 (#148505) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index a8945e482bf..33addd85ba2 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -13,5 +13,5 @@ "iot_class": "cloud_push", "loggers": ["pylitterbot"], "quality_scale": "bronze", - "requirements": ["pylitterbot==2024.2.1"] + "requirements": ["pylitterbot==2024.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d57393004b2..0b67ad8e1df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2118,7 +2118,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.1 +pylitterbot==2024.2.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a375ebee7f3..c377220d3c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1763,7 +1763,7 @@ pylibrespot-java==0.1.1 pylitejet==0.6.3 # homeassistant.components.litterrobot -pylitterbot==2024.2.1 +pylitterbot==2024.2.2 # homeassistant.components.lutron_caseta pylutron-caseta==0.24.0 From 5d43938f0d1a6be4e7c7a72fbc48fc278b934d06 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Jul 2025 21:20:38 +0200 Subject: [PATCH 1278/1664] Bump `imgw_pib` to version 1.2.0 (#148511) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 2 ++ tests/components/imgw_pib/snapshots/test_diagnostics.ambr | 8 ++++++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 42d536da8f5..631bce3fbc9 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.1.0"] + "requirements": ["imgw_pib==1.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b67ad8e1df..00636689c5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.1.0 +imgw_pib==1.2.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c377220d3c2..9c2b6a37d29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.1.0 +imgw_pib==1.2.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index a10b9b54532..e0b091e5ff3 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -23,6 +23,8 @@ HYDROLOGICAL_DATA = HydrologicalData( flood_warning=None, water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), + water_flow=SensorData(name="Water Flow", value=123.45), + water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 97453930c1e..08f3690136e 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -34,9 +34,17 @@ 'unit': None, 'value': None, }), + 'latitude': None, + 'longitude': None, 'river': 'River Name', 'station': 'Station Name', 'station_id': '123', + 'water_flow': dict({ + 'name': 'Water Flow', + 'unit': None, + 'value': 123.45, + }), + 'water_flow_measurement_date': '2024-04-27T10:05:00+00:00', 'water_level': dict({ 'name': 'Water Level', 'unit': None, From e012196af8c4ac7f1308e8c911c3706124d6c49b Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:22:31 +0200 Subject: [PATCH 1279/1664] Bump aioimmich to 0.10.2 (#148503) --- homeassistant/components/immich/manifest.json | 2 +- homeassistant/components/immich/update.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/immich/manifest.json b/homeassistant/components/immich/manifest.json index 80dcd87cd88..906356a4bc9 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.1"] + "requirements": ["aioimmich==0.10.2"] } diff --git a/homeassistant/components/immich/update.py b/homeassistant/components/immich/update.py index 9955e355c96..e0af5c1c67f 100644 --- a/homeassistant/components/immich/update.py +++ b/homeassistant/components/immich/update.py @@ -44,7 +44,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity): return self.coordinator.data.server_about.version @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Available new immich server version.""" assert self.coordinator.data.server_version_check return self.coordinator.data.server_version_check.release_version diff --git a/requirements_all.txt b/requirements_all.txt index 00636689c5e..bfff2521a61 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.1 +aioimmich==0.10.2 # homeassistant.components.apache_kafka aiokafka==0.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c2b6a37d29..862043fbfdc 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.1 +aioimmich==0.10.2 # homeassistant.components.apache_kafka aiokafka==0.10.0 From 84959a007737dc4f5878cccb3dc02fc6ea6cc614 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:33:07 +0200 Subject: [PATCH 1280/1664] Add platinum quality scale to Pegel Online (#131382) --- .../components/pegel_online/__init__.py | 8 +- .../components/pegel_online/manifest.json | 1 + .../pegel_online/quality_scale.yaml | 87 +++++++++++++++++++ .../components/pegel_online/sensor.py | 3 + script/hassfest/quality_scale.py | 2 - 5 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/pegel_online/quality_scale.yaml diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 1c71603e41e..c8388f40704 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION +from .const import CONF_STATION, DOMAIN from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) try: station = await api.async_get_station_details(station_uuid) except CONNECT_ERRORS as err: - raise ConfigEntryNotReady("Failed to connect") from err + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"error": str(err)}, + ) from err coordinator = PegelOnlineDataUpdateCoordinator(hass, entry, api, station) diff --git a/homeassistant/components/pegel_online/manifest.json b/homeassistant/components/pegel_online/manifest.json index 0a0f31532b1..c488eca34af 100644 --- a/homeassistant/components/pegel_online/manifest.json +++ b/homeassistant/components/pegel_online/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiopegelonline"], + "quality_scale": "platinum", "requirements": ["aiopegelonline==0.1.1"] } diff --git a/homeassistant/components/pegel_online/quality_scale.yaml b/homeassistant/components/pegel_online/quality_scale.yaml new file mode 100644 index 00000000000..aa0a153ee9c --- /dev/null +++ b/homeassistant/components/pegel_online/quality_scale.yaml @@ -0,0 +1,87 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: no actions/services are implemented + 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 actions/services are implemented + 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 actions/services are implemented + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: has no options flow + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: no authentication necessary + test-coverage: done + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: pure webservice, no discovery + discovery: + status: exempt + comment: pure webservice, no discovery + 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: + status: exempt + comment: not applicable - see stale-devices + 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: | + each config entry represents only one named measurement station, + so when the user wants to add another one, they can just add another config entry + repair-issues: + status: exempt + comment: no known use cases for repair issues or flows, yet + stale-devices: + status: exempt + comment: | + does not apply, since only one measurement station per config-entry + if a measurement station is removed from the data provider, + the user can just remove the related config entry + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index ee2e6750911..30d4edfb041 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -20,6 +20,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class PegelOnlineSensorEntityDescription(SensorEntityDescription): diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 6d4e536744f..b5fd8c3ad7a 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -763,7 +763,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [ "pandora", "panel_iframe", "peco", - "pegel_online", "pencom", "permobil", "persistent_notification", @@ -1818,7 +1817,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "palazzetti", "panel_iframe", "peco", - "pegel_online", "pencom", "permobil", "persistent_notification", From 283d0d16c05634f8a1b10c06d3e4d739cb65acd9 Mon Sep 17 00:00:00 2001 From: Mickael Goubin Date: Wed, 9 Jul 2025 21:33:15 +0200 Subject: [PATCH 1281/1664] Linkplay - when grouped, the first media player returned is the coordinator (#146295) Co-authored-by: Joost Lekkerkerker --- .../components/linkplay/media_player.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py index 89cc498ed01..ee1cdfe67e8 100644 --- a/homeassistant/components/linkplay/media_player.py +++ b/homeassistant/components/linkplay/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from linkplay.bridge import LinkPlayBridge from linkplay.consts import EqualizerMode, LoopMode, PlayingMode, PlayingStatus @@ -315,14 +315,19 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity): return [] shared_data = self.hass.data[DOMAIN][SHARED_DATA] + leader_id: str | None = None + followers = [] - return [ - entity_id - for entity_id, bridge in shared_data.entity_to_bridge.items() - if bridge - in [multiroom.leader.device.uuid] - + [follower.device.uuid for follower in multiroom.followers] - ] + # find leader and followers + for ent_id, uuid in shared_data.entity_to_bridge.items(): + if uuid == multiroom.leader.device.uuid: + leader_id = ent_id + elif uuid in {f.device.uuid for f in multiroom.followers}: + followers.append(ent_id) + + if TYPE_CHECKING: + assert leader_id is not None + return [leader_id, *followers] @property def media_image_url(self) -> str | None: From 2807f057dec574bff2bb779043f329171810c5ab Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:34:37 +0200 Subject: [PATCH 1282/1664] Fix flaky test in Husqvarna Automower (#148515) --- tests/components/husqvarna_automower/test_init.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index f2b468c4faf..9a45b2ad42d 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -262,7 +262,7 @@ async def test_constant_polling( test_values[TEST_MOWER_ID].battery.battery_percent = 77 - freezer.tick(SCAN_INTERVAL - timedelta(seconds=1)) + freezer.tick(SCAN_INTERVAL - timedelta(seconds=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -278,7 +278,7 @@ async def test_constant_polling( test_values[TEST_MOWER_ID].work_areas[123456].progress = 50 mock_automower_client.get_status.return_value = test_values - freezer.tick(timedelta(seconds=4)) + freezer.tick(timedelta(seconds=10)) async_fire_time_changed(hass) await hass.async_block_till_done() mock_automower_client.get_status.assert_awaited() From e42ca06173e4d796b9913e471654a5ff7a47b88a Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 10 Jul 2025 02:41:50 +0700 Subject: [PATCH 1283/1664] Bump openai to 1.93.3 (#148501) --- homeassistant/components/openai_conversation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json index d8c2c3a644c..83519821f79 100644 --- a/homeassistant/components/openai_conversation/manifest.json +++ b/homeassistant/components/openai_conversation/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/openai_conversation", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["openai==1.93.0"] + "requirements": ["openai==1.93.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index bfff2521a61..d4d0ab8439a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1597,7 +1597,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.93.0 +openai==1.93.3 # homeassistant.components.openerz openerz-api==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 862043fbfdc..6775ace063f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1365,7 +1365,7 @@ open-garage==0.2.0 open-meteo==0.3.2 # homeassistant.components.openai_conversation -openai==1.93.0 +openai==1.93.3 # homeassistant.components.openerz openerz-api==0.3.0 From ce5f06b1e545b6af9ed0c47467e8c4340b0c2446 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Jul 2025 21:43:02 +0200 Subject: [PATCH 1284/1664] Add new sensors to GIOS integration (#148510) --- homeassistant/components/gios/const.py | 2 + homeassistant/components/gios/icons.json | 3 + homeassistant/components/gios/sensor.py | 18 +++ homeassistant/components/gios/strings.json | 3 + tests/components/gios/fixtures/sensors.json | 14 +++ tests/components/gios/fixtures/station.json | 16 +++ .../gios/snapshots/test_diagnostics.ambr | 14 ++- .../gios/snapshots/test_sensor.ambr | 113 ++++++++++++++++++ 8 files changed, 181 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py index 2294e89c961..2d21b0b8d9e 100644 --- a/homeassistant/components/gios/const.py +++ b/homeassistant/components/gios/const.py @@ -19,6 +19,8 @@ API_TIMEOUT: Final = 30 ATTR_C6H6: Final = "c6h6" ATTR_CO: Final = "co" +ATTR_NO: Final = "no" +ATTR_NOX: Final = "nox" ATTR_NO2: Final = "no2" ATTR_O3: Final = "o3" ATTR_PM10: Final = "pm10" diff --git a/homeassistant/components/gios/icons.json b/homeassistant/components/gios/icons.json index e1d848e276b..2623ee1549d 100644 --- a/homeassistant/components/gios/icons.json +++ b/homeassistant/components/gios/icons.json @@ -13,6 +13,9 @@ "no2_index": { "default": "mdi:molecule" }, + "nox": { + "default": "mdi:molecule" + }, "o3_index": { "default": "mdi:molecule" }, diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index 67997a01dc6..b8583adfcf1 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -27,7 +27,9 @@ from .const import ( ATTR_AQI, ATTR_C6H6, ATTR_CO, + ATTR_NO, ATTR_NO2, + ATTR_NOX, ATTR_O3, ATTR_PM10, ATTR_PM25, @@ -74,6 +76,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, translation_key="co", ), + GiosSensorEntityDescription( + key=ATTR_NO, + value=lambda sensors: sensors.no.value if sensors.no else None, + suggested_display_precision=0, + device_class=SensorDeviceClass.NITROGEN_MONOXIDE, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), GiosSensorEntityDescription( key=ATTR_NO2, value=lambda sensors: sensors.no2.value if sensors.no2 else None, @@ -90,6 +100,14 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( options=["very_bad", "bad", "sufficient", "moderate", "good", "very_good"], translation_key="no2_index", ), + GiosSensorEntityDescription( + key=ATTR_NOX, + translation_key=ATTR_NOX, + value=lambda sensors: sensors.nox.value if sensors.nox else None, + suggested_display_precision=0, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + ), GiosSensorEntityDescription( key=ATTR_O3, value=lambda sensors: sensors.o3.value if sensors.o3 else None, diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json index eca23159a13..d19edd63717 100644 --- a/homeassistant/components/gios/strings.json +++ b/homeassistant/components/gios/strings.json @@ -77,6 +77,9 @@ } } }, + "nox": { + "name": "Nitrogen oxides" + }, "o3_index": { "name": "Ozone index", "state": { diff --git a/tests/components/gios/fixtures/sensors.json b/tests/components/gios/fixtures/sensors.json index 0fe387d3126..64cb9685f97 100644 --- a/tests/components/gios/fixtures/sensors.json +++ b/tests/components/gios/fixtures/sensors.json @@ -20,6 +20,13 @@ { "Data": "2020-07-31 13:00:00", "Wartość": 251.097 } ] }, + "no": { + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 5.1 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 4.0 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 5.2 } + ] + }, "no2": { "Lista danych pomiarowych": [ { "Data": "2020-07-31 15:00:00", "Wartość": 7.13411 }, @@ -27,6 +34,13 @@ { "Data": "2020-07-31 13:00:00", "Wartość": 9.32578 } ] }, + "nox": { + "Lista danych pomiarowych": [ + { "Data": "2020-07-31 15:00:00", "Wartość": 5.5 }, + { "Data": "2020-07-31 14:00:00", "Wartość": 6.3 }, + { "Data": "2020-07-31 13:00:00", "Wartość": 4.9 } + ] + }, "o3": { "Lista danych pomiarowych": [ { "Data": "2020-07-31 15:00:00", "Wartość": 95.7768 }, diff --git a/tests/components/gios/fixtures/station.json b/tests/components/gios/fixtures/station.json index 167e4db3aee..1d112c0947b 100644 --- a/tests/components/gios/fixtures/station.json +++ b/tests/components/gios/fixtures/station.json @@ -23,6 +23,14 @@ "Wskaźnik - kod": "CO", "Id wskaźnika": 8 }, + { + "Identyfikator stanowiska": 664, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenek azotu", + "Wskaźnik - wzór": "NO", + "Wskaźnik - kod": "NO", + "Id wskaźnika": 16 + }, { "Identyfikator stanowiska": 665, "Identyfikator stacji": 117, @@ -31,6 +39,14 @@ "Wskaźnik - kod": "NO2", "Id wskaźnika": 6 }, + { + "Identyfikator stanowiska": 666, + "Identyfikator stacji": 117, + "Wskaźnik": "tlenki azotu", + "Wskaźnik - wzór": "NOx", + "Wskaźnik - kod": "NOx", + "Id wskaźnika": 7 + }, { "Identyfikator stanowiska": 667, "Identyfikator stacji": 117, diff --git a/tests/components/gios/snapshots/test_diagnostics.ambr b/tests/components/gios/snapshots/test_diagnostics.ambr index 4095bf8bf53..722d14e3681 100644 --- a/tests/components/gios/snapshots/test_diagnostics.ambr +++ b/tests/components/gios/snapshots/test_diagnostics.ambr @@ -42,14 +42,24 @@ 'name': 'carbon monoxide', 'value': 251.874, }), - 'no': None, + 'no': dict({ + 'id': 664, + 'index': None, + 'name': 'nitrogen monoxide', + 'value': 5.1, + }), 'no2': dict({ 'id': 665, 'index': 'good', 'name': 'nitrogen dioxide', 'value': 7.13411, }), - 'nox': None, + 'nox': dict({ + 'id': 666, + 'index': None, + 'name': 'nitrogen oxides', + 'value': 5.5, + }), 'o3': dict({ 'id': 667, 'index': 'good', diff --git a/tests/components/gios/snapshots/test_sensor.ambr b/tests/components/gios/snapshots/test_sensor.ambr index fd74cc222c8..2a0afcc72b1 100644 --- a/tests/components/gios/snapshots/test_sensor.ambr +++ b/tests/components/gios/snapshots/test_sensor.ambr @@ -302,6 +302,119 @@ 'state': 'good', }) # --- +# name: test_sensor[sensor.home_nitrogen_monoxide-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.home_nitrogen_monoxide', + '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': 'Nitrogen monoxide', + 'platform': 'gios', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123-no', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_monoxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by GIOŚ', + 'device_class': 'nitrogen_monoxide', + 'friendly_name': 'Home Nitrogen monoxide', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_monoxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.1', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_oxides-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.home_nitrogen_oxides', + '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': 'Nitrogen oxides', + 'platform': 'gios', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nox', + 'unique_id': '123-nox', + 'unit_of_measurement': 'µg/m³', + }) +# --- +# name: test_sensor[sensor.home_nitrogen_oxides-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by GIOŚ', + 'friendly_name': 'Home Nitrogen oxides', + 'state_class': , + 'unit_of_measurement': 'µg/m³', + }), + 'context': , + 'entity_id': 'sensor.home_nitrogen_oxides', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.5', + }) +# --- # name: test_sensor[sensor.home_ozone-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 8aaf5756e022e043077acb484d5ba08ed690e779 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 9 Jul 2025 21:44:50 +0200 Subject: [PATCH 1285/1664] Add workaround for sub units without main device in AVM Fritz!SmartHome (#148507) --- .../components/fritzbox/coordinator.py | 13 ++++-- tests/components/fritzbox/test_coordinator.py | 46 ++++++++++++++++++- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 8a37ebf63e4..a95af62da6c 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -171,14 +171,19 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat for device in new_data.devices.values(): # create device registry entry for new main devices - if ( - device.ain not in self.data.devices - and device.device_and_unit_id[1] is None + if device.ain not in self.data.devices and ( + device.device_and_unit_id[1] is None + or ( + # workaround for sub units without a main device, e.g. Energy 250 + # https://github.com/home-assistant/core/issues/145204 + device.device_and_unit_id[1] == "1" + and device.device_and_unit_id[0] not in new_data.devices + ) ): dr.async_get(self.hass).async_get_or_create( config_entry_id=self.config_entry.entry_id, name=device.name, - identifiers={(DOMAIN, device.ain)}, + identifiers={(DOMAIN, device.device_and_unit_id[0])}, manufacturer=device.manufacturer, model=device.productname, sw_version=device.fw_version, diff --git a/tests/components/fritzbox/test_coordinator.py b/tests/components/fritzbox/test_coordinator.py index 61de0c99940..794d6ac4397 100644 --- a/tests/components/fritzbox/test_coordinator.py +++ b/tests/components/fritzbox/test_coordinator.py @@ -15,7 +15,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from . import FritzDeviceCoverMock, FritzDeviceSwitchMock, FritzEntityBaseMock +from . import ( + FritzDeviceCoverMock, + FritzDeviceSensorMock, + FritzDeviceSwitchMock, + FritzEntityBaseMock, +) from .const import MOCK_CONFIG from tests.common import MockConfigEntry, async_fire_time_changed @@ -140,3 +145,42 @@ async def test_coordinator_automatic_registry_cleanup( assert len(er.async_entries_for_config_entry(entity_registry, entry.entry_id)) == 12 assert len(dr.async_entries_for_config_entry(device_registry, entry.entry_id)) == 1 + + +async def test_coordinator_workaround_sub_units_without_main_device( + hass: HomeAssistant, + fritz: Mock, + device_registry: dr.DeviceRegistry, +) -> None: + """Test the workaround for sub units without main device.""" + fritz().get_devices.return_value = [ + FritzDeviceSensorMock( + ain="bad_device-1", + device_and_unit_id=("bad_device", "1"), + name="bad_sensor_sub", + ), + FritzDeviceSensorMock( + ain="good_device", + device_and_unit_id=("good_device", None), + name="good_sensor", + ), + FritzDeviceSensorMock( + ain="good_device-1", + device_and_unit_id=("good_device", "1"), + name="good_sensor_sub", + ), + ] + + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG[DOMAIN][CONF_DEVICES][0], + unique_id="any", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + assert len(device_entries) == 2 + assert device_entries[0].identifiers == {(DOMAIN, "good_device")} + assert device_entries[1].identifiers == {(DOMAIN, "bad_device")} From a7e879714b4da40f5cff1fd62fb35ff0e0ed2881 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 9 Jul 2025 21:59:08 +0200 Subject: [PATCH 1286/1664] Add `water flow` sensor to IMGW PIB integration (#148517) --- homeassistant/components/imgw_pib/icons.json | 3 + homeassistant/components/imgw_pib/sensor.py | 11 +++- .../components/imgw_pib/strings.json | 3 + .../imgw_pib/snapshots/test_sensor.ambr | 57 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index 29aa19a4b56..b9226276af6 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "water_flow": { + "default": "mdi:waves-arrow-right" + }, "water_level": { "default": "mdi:waves" }, diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 7871006b2ae..1c49bfb2dc0 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -14,7 +14,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import UnitOfLength, UnitOfTemperature +from homeassistant.const import UnitOfLength, UnitOfTemperature, UnitOfVolumeFlowRate from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -36,6 +36,15 @@ class ImgwPibSensorEntityDescription(SensorEntityDescription): SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="water_flow", + translation_key="water_flow", + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_SECOND, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value=lambda data: data.water_flow.value, + ), ImgwPibSensorEntityDescription( key="water_level", translation_key="water_level", diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index 9b7f132da6f..fc92ca573ab 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,9 @@ }, "entity": { "sensor": { + "water_flow": { + "name": "Water flow" + }, "water_level": { "name": "Water level" }, diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 5b588af4518..97bb6eefef3 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_water_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': None, + 'entity_id': 'sensor.river_name_station_name_water_flow', + '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': 'Water flow', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_flow', + 'unique_id': '123_water_flow', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_flow-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'volume_flow_rate', + 'friendly_name': 'River Name (Station Name) Water flow', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_flow', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '123.45', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From da255af8de97334c0261fae221d2742730281d2f Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:02:31 +0200 Subject: [PATCH 1287/1664] Bump aioautomower to 1.2.2 (#148497) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 046c20c1ddd..fb717a5615f 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==1.2.0"] + "requirements": ["aioautomower==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index d4d0ab8439a..3fae8953386 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==1.2.0 +aioautomower==1.2.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6775ace063f..827b088fdab 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==1.2.0 +aioautomower==1.2.2 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index d1e1f08f867..c58a12ad007 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -63,7 +63,8 @@ 'stay_out_zones': True, 'work_areas': True, }), - 'messages': None, + 'messages': list([ + ]), 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-06-05T00:00:00+00:00', From 330713244135bf90132944ab10ce0689adf688ca Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Wed, 9 Jul 2025 23:50:09 +0300 Subject: [PATCH 1288/1664] Jewish calendar: appropriate polling for sensors (2/3) (#144906) Co-authored-by: Joost Lekkerkerker --- .../components/jewish_calendar/entity.py | 4 +- .../components/jewish_calendar/sensor.py | 160 +++++++++++------- .../snapshots/test_diagnostics.ambr | 60 +------ 3 files changed, 100 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index b92d30048f0..9d713aad0eb 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -19,9 +19,7 @@ type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] class JewishCalendarDataResults: """Jewish Calendar results dataclass.""" - daytime_date: HDateInfo - after_shkia_date: HDateInfo - after_tzais_date: HDateInfo + dateinfo: HDateInfo zmanim: Zmanim diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 91c618e1c1c..6479a61c713 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -16,10 +16,10 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.const import EntityCategory +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.sun import get_astral_event_date from homeassistant.util import dt as dt_util from .entity import ( @@ -37,15 +37,19 @@ class JewishCalendarBaseSensorDescription(SensorEntityDescription): """Base class describing Jewish Calendar sensor entities.""" value_fn: Callable | None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None @dataclass(frozen=True, kw_only=True) class JewishCalendarSensorDescription(JewishCalendarBaseSensorDescription): """Class describing Jewish Calendar sensor entities.""" - value_fn: Callable[[JewishCalendarDataResults], str | int] - attr_fn: Callable[[JewishCalendarDataResults], dict[str, str]] | None = None + value_fn: Callable[[HDateInfo], str | int] + attr_fn: Callable[[HDateInfo], dict[str, str]] | None = None options_fn: Callable[[bool], list[str]] | None = None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None = ( + lambda zmanim: zmanim.shkia.local + ) @dataclass(frozen=True, kw_only=True) @@ -55,17 +59,18 @@ class JewishCalendarTimestampSensorDescription(JewishCalendarBaseSensorDescripti value_fn: ( Callable[[HDateInfo, Callable[[dt.date], Zmanim]], dt.datetime | None] | None ) = None + next_update_fn: Callable[[Zmanim], dt.datetime | None] | None = None INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( JewishCalendarSensorDescription( key="date", translation_key="hebrew_date", - value_fn=lambda results: str(results.after_shkia_date.hdate), - attr_fn=lambda results: { - "hebrew_year": str(results.after_shkia_date.hdate.year), - "hebrew_month_name": str(results.after_shkia_date.hdate.month), - "hebrew_day": str(results.after_shkia_date.hdate.day), + value_fn=lambda info: str(info.hdate), + attr_fn=lambda info: { + "hebrew_year": str(info.hdate.year), + "hebrew_month_name": str(info.hdate.month), + "hebrew_day": str(info.hdate.day), }, ), JewishCalendarSensorDescription( @@ -73,24 +78,19 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( translation_key="weekly_portion", device_class=SensorDeviceClass.ENUM, options_fn=lambda _: [str(p) for p in Parasha], - value_fn=lambda results: results.after_tzais_date.upcoming_shabbat.parasha, + value_fn=lambda info: info.upcoming_shabbat.parasha, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarSensorDescription( key="holiday", translation_key="holiday", device_class=SensorDeviceClass.ENUM, options_fn=lambda diaspora: HolidayDatabase(diaspora).get_all_names(), - value_fn=lambda results: ", ".join( - str(holiday) for holiday in results.after_shkia_date.holidays - ), - attr_fn=lambda results: { - "id": ", ".join( - holiday.name for holiday in results.after_shkia_date.holidays - ), + value_fn=lambda info: ", ".join(str(holiday) for holiday in info.holidays), + attr_fn=lambda info: { + "id": ", ".join(holiday.name for holiday in info.holidays), "type": ", ".join( - dict.fromkeys( - _holiday.type.name for _holiday in results.after_shkia_date.holidays - ) + dict.fromkeys(_holiday.type.name for _holiday in info.holidays) ), }, ), @@ -98,13 +98,13 @@ INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = ( key="omer_count", translation_key="omer_count", entity_registry_enabled_default=False, - value_fn=lambda results: results.after_shkia_date.omer.total_days, + value_fn=lambda info: info.omer.total_days, ), JewishCalendarSensorDescription( key="daf_yomi", translation_key="daf_yomi", entity_registry_enabled_default=False, - value_fn=lambda results: results.daytime_date.daf_yomi, + value_fn=lambda info: info.daf_yomi, ), ) @@ -184,12 +184,14 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat.previous_day.gdate ).candle_lighting, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_shabbat_havdalah", translation_key="upcoming_shabbat_havdalah", entity_registry_enabled_default=False, value_fn=lambda at_date, mz: mz(at_date.upcoming_shabbat.gdate).havdalah, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_candle_lighting", @@ -197,6 +199,7 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate ).candle_lighting, + next_update_fn=lambda zmanim: zmanim.havdalah, ), JewishCalendarTimestampSensorDescription( key="upcoming_havdalah", @@ -204,6 +207,7 @@ TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = ( value_fn=lambda at_date, mz: mz( at_date.upcoming_shabbat_or_yom_tov.last_day.gdate ).havdalah, + next_update_fn=lambda zmanim: zmanim.havdalah, ), ) @@ -227,46 +231,79 @@ async def async_setup_entry( class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): """Base class for Jewish calendar sensors.""" + _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC + _update_unsub: CALLBACK_TYPE | None = None - async def async_update(self) -> None: - """Update the state of the sensor.""" + entity_description: JewishCalendarBaseSensorDescription + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._schedule_update() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + + def _schedule_update(self) -> None: + """Schedule the next update of the sensor.""" now = dt_util.now() + zmanim = self.make_zmanim(now.date()) + update = None + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(zmanim) + next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) + if update is None or now > update: + update = next_midnight + if self._update_unsub: + self._update_unsub() + self._update_unsub = event.async_track_point_in_time( + self.hass, self._update_data, update + ) + + @callback + def _update_data(self, now: dt.datetime | None = None) -> None: + """Update the sensor data.""" + self._update_unsub = None + self._schedule_update() + self.create_results(now) + self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) today = now.date() - event_date = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, today) + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) - if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) - return + def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: + """Get the next date info.""" + if self.data.results is None: + self.create_results() + assert self.data.results is not None, "Results should be available" - sunset = dt_util.as_local(event_date) + if now is None: + now = dt_util.now() - _LOGGER.debug("Now: %s Sunset: %s", now, sunset) + today = now.date() + zmanim = self.make_zmanim(today) + update = None + if self.entity_description.next_update_fn: + update = self.entity_description.next_update_fn(zmanim) - daytime_date = HDateInfo(today, diaspora=self.data.diaspora) - - # The Jewish day starts after darkness (called "tzais") and finishes at - # sunset ("shkia"). The time in between is a gray area - # (aka "Bein Hashmashot" # codespell:ignore - # - literally: "in between the sun and the moon"). - - # For some sensors, it is more interesting to consider the date to be - # tomorrow based on sunset ("shkia"), for others based on "tzais". - # Hence the following variables. - after_tzais_date = after_shkia_date = daytime_date - today_times = self.make_zmanim(today) - - if now > sunset: - after_shkia_date = daytime_date.next_day - - if today_times.havdalah and now > today_times.havdalah: - after_tzais_date = daytime_date.next_day - - self.data.results = JewishCalendarDataResults( - daytime_date, after_shkia_date, after_tzais_date, today_times - ) + _LOGGER.debug("Today: %s, update: %s", today, update) + if update is not None and now >= update: + return self.data.results.dateinfo.next_day + return self.data.results.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -288,18 +325,14 @@ class JewishCalendarSensor(JewishCalendarBaseSensor): @property def native_value(self) -> str | int | dt.datetime | None: """Return the state of the sensor.""" - if self.data.results is None: - return None - return self.entity_description.value_fn(self.data.results) + return self.entity_description.value_fn(self.get_dateinfo()) @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" - if self.data.results is None: + if self.entity_description.attr_fn is None: return {} - if self.entity_description.attr_fn is not None: - return self.entity_description.attr_fn(self.data.results) - return {} + return self.entity_description.attr_fn(self.get_dateinfo()) class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @@ -312,9 +345,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" if self.data.results is None: - return None + self.create_results() + assert self.data.results is not None, "Results should be available" if self.entity_description.value_fn is None: return self.data.results.zmanim.zmanim[self.entity_description.key].local - return self.entity_description.value_fn( - self.data.results.after_tzais_date, self.make_zmanim - ) + return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 3c8acde6e72..0a392e101c5 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -18,25 +18,7 @@ }), }), 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ + 'dateinfo': dict({ 'date': dict({ 'day': 21, 'month': 10, @@ -92,25 +74,7 @@ }), }), 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ + 'dateinfo': dict({ 'date': dict({ 'day': 21, 'month': 10, @@ -166,25 +130,7 @@ }), }), 'results': dict({ - 'after_shkia_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'after_tzais_date': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), - 'daytime_date': dict({ + 'dateinfo': dict({ 'date': dict({ 'day': 21, 'month': 10, From 15544769b68e3c0282c6f45f185501df735f16e0 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 9 Jul 2025 23:08:24 +0200 Subject: [PATCH 1289/1664] Add action for activity reactions to Bring! (#138175) --- homeassistant/components/bring/__init__.py | 13 ++ homeassistant/components/bring/const.py | 5 +- homeassistant/components/bring/icons.json | 3 + homeassistant/components/bring/services.py | 110 +++++++++++ homeassistant/components/bring/services.yaml | 25 +++ homeassistant/components/bring/strings.json | 27 +++ tests/components/bring/test_services.py | 190 +++++++++++++++++++ 7 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/bring/services.py create mode 100644 tests/components/bring/test_services.py diff --git a/homeassistant/components/bring/__init__.py b/homeassistant/components/bring/__init__.py index 6c0b34c66f0..943b4863aac 100644 --- a/homeassistant/components/bring/__init__.py +++ b/homeassistant/components/bring/__init__.py @@ -8,20 +8,33 @@ from bring_api import Bring from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, 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 .const import DOMAIN from .coordinator import ( BringActivityCoordinator, BringConfigEntry, BringCoordinators, BringDataUpdateCoordinator, ) +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS: list[Platform] = [Platform.EVENT, Platform.SENSOR, Platform.TODO] _LOGGER = logging.getLogger(__name__) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Bring! services.""" + + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: BringConfigEntry) -> bool: """Set up Bring! from a config entry.""" diff --git a/homeassistant/components/bring/const.py b/homeassistant/components/bring/const.py index 911c08a835d..f8a10d5c26b 100644 --- a/homeassistant/components/bring/const.py +++ b/homeassistant/components/bring/const.py @@ -7,5 +7,8 @@ DOMAIN = "bring" ATTR_SENDER: Final = "sender" ATTR_ITEM_NAME: Final = "item" ATTR_NOTIFICATION_TYPE: Final = "message" - +ATTR_REACTION: Final = "reaction" +ATTR_ACTIVITY: Final = "uuid" +ATTR_RECEIVER: Final = "publicUserUuid" SERVICE_PUSH_NOTIFICATION = "send_message" +SERVICE_ACTIVITY_STREAM_REACTION = "send_reaction" diff --git a/homeassistant/components/bring/icons.json b/homeassistant/components/bring/icons.json index ea4f4e877bc..288921c41b4 100644 --- a/homeassistant/components/bring/icons.json +++ b/homeassistant/components/bring/icons.json @@ -35,6 +35,9 @@ "services": { "send_message": { "service": "mdi:cellphone-message" + }, + "send_reaction": { + "service": "mdi:thumb-up" } } } diff --git a/homeassistant/components/bring/services.py b/homeassistant/components/bring/services.py new file mode 100644 index 00000000000..e648fcdd2f1 --- /dev/null +++ b/homeassistant/components/bring/services.py @@ -0,0 +1,110 @@ +"""Actions for Bring! integration.""" + +import logging +from typing import TYPE_CHECKING + +from bring_api import ( + ActivityType, + BringAuthException, + BringNotificationType, + BringRequestException, + ReactionType, +) +import voluptuous as vol + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import config_validation as cv, entity_registry as er + +from .const import ( + ATTR_ACTIVITY, + ATTR_REACTION, + ATTR_RECEIVER, + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, +) +from .coordinator import BringConfigEntry + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_REACTION): vol.All( + vol.Upper, + vol.Coerce(ReactionType), + ), + } +) + + +def get_config_entry(hass: HomeAssistant, entry_id: str) -> BringConfigEntry: + """Return config entry or raise if not found or not loaded.""" + entry = hass.config_entries.async_get_entry(entry_id) + if TYPE_CHECKING: + assert entry + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entry_not_loaded", + ) + return entry + + +def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Bring! integration.""" + + async def async_send_activity_stream_reaction(call: ServiceCall) -> None: + """Send a reaction in response to recent activity of a list member.""" + + if ( + not (state := hass.states.get(call.data[ATTR_ENTITY_ID])) + or not (entity := er.async_get(hass).async_get(call.data[ATTR_ENTITY_ID])) + or not entity.config_entry_id + ): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="entity_not_found", + translation_placeholders={ + ATTR_ENTITY_ID: call.data[ATTR_ENTITY_ID], + }, + ) + config_entry = get_config_entry(hass, entity.config_entry_id) + + coordinator = config_entry.runtime_data.data + + list_uuid = entity.unique_id.split("_")[1] + + activity = state.attributes[ATTR_EVENT_TYPE] + + reaction: ReactionType = call.data[ATTR_REACTION] + + if not activity: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="activity_not_found", + ) + try: + await coordinator.bring.notify( + list_uuid, + BringNotificationType.LIST_ACTIVITY_STREAM_REACTION, + receiver=state.attributes[ATTR_RECEIVER], + activity=state.attributes[ATTR_ACTIVITY], + activity_type=ActivityType(activity.upper()), + reaction=reaction, + ) + except (BringRequestException, BringAuthException) as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="reaction_request_failed", + ) from e + + hass.services.async_register( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + async_send_activity_stream_reaction, + SERVICE_ACTIVITY_STREAM_REACTION_SCHEMA, + ) diff --git a/homeassistant/components/bring/services.yaml b/homeassistant/components/bring/services.yaml index 98d5c68de13..087b12604a9 100644 --- a/homeassistant/components/bring/services.yaml +++ b/homeassistant/components/bring/services.yaml @@ -21,3 +21,28 @@ send_message: required: false selector: text: +send_reaction: + fields: + entity_id: + required: true + selector: + entity: + filter: + - integration: bring + domain: event + example: event.shopping_list + reaction: + required: true + selector: + select: + options: + - label: 👍🏼 + value: thumbs_up + - label: 🧐 + value: monocle + - label: 🤤 + value: drooling + - label: ❤️ + value: heart + mode: dropdown + example: thumbs_up diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json index 2c30af5adce..48677d52523 100644 --- a/homeassistant/components/bring/strings.json +++ b/homeassistant/components/bring/strings.json @@ -144,6 +144,19 @@ }, "notify_request_failed": { "message": "Failed to send push notification for Bring! due to a connection error, try again later" + }, + "reaction_request_failed": { + "message": "Failed to send reaction for Bring! due to a connection error, try again later" + }, + "activity_not_found": { + "message": "Failed to send reaction for Bring! — No recent activity found" + }, + "entity_not_found": { + "message": "Failed to send reaction for Bring! — Unknown entity {entity_id}" + }, + + "entry_not_loaded": { + "message": "The account associated with this Bring! list is either not loaded or disabled in Home Assistant." } }, "services": { @@ -164,6 +177,20 @@ "description": "Item name(s) to include in an urgent message e.g. 'Attention! Attention! - We still urgently need: [Items]'" } } + }, + "send_reaction": { + "name": "Send reaction", + "description": "Sends a reaction to a recent activity on a Bring! list by a member of the shared list.", + "fields": { + "entity_id": { + "name": "Activities", + "description": "Select the Bring! activities event entity for reacting to its most recent event" + }, + "reaction": { + "name": "Reaction", + "description": "Type of reaction to send in response." + } + } } }, "selector": { diff --git a/tests/components/bring/test_services.py b/tests/components/bring/test_services.py new file mode 100644 index 00000000000..d010c2b86a0 --- /dev/null +++ b/tests/components/bring/test_services.py @@ -0,0 +1,190 @@ +"""Test actions of Bring! integration.""" + +from unittest.mock import AsyncMock + +from bring_api import ( + ActivityType, + BringActivityResponse, + BringNotificationType, + BringRequestException, + ReactionType, +) +import pytest + +from homeassistant.components.bring.const import ( + ATTR_REACTION, + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("reaction", "call_arg"), + [ + ("drooling", ReactionType.DROOLING), + ("heart", ReactionType.HEART), + ("monocle", ReactionType.MONOCLE), + ("thumbs_up", ReactionType.THUMBS_UP), + ], +) +async def test_send_reaction( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, + reaction: str, + call_arg: ReactionType, +) -> None: + """Test send activity stream reaction.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: reaction, + }, + blocking=True, + ) + + mock_bring_client.notify.assert_called_once_with( + "e542eef6-dba7-4c31-a52c-29e6ab9d83a5", + BringNotificationType.LIST_ACTIVITY_STREAM_REACTION, + receiver="9a21fdfc-63a4-441a-afc1-ef3030605a9d", + activity="673594a9-f92d-4cb6-adf1-d2f7a83207a4", + activity_type=ActivityType.LIST_ITEMS_CHANGED, + reaction=call_arg, + ) + + +async def test_send_reaction_exception( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test send activity stream reaction with exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + mock_bring_client.notify.side_effect = BringRequestException + with pytest.raises( + HomeAssistantError, + match="Failed to send reaction for Bring! due to a connection error, try again later", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_send_reaction_config_entry_not_loaded( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, +) -> None: + """Test send activity stream reaction config entry not loaded exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert await hass.config_entries.async_unload(bring_config_entry.entry_id) + + assert bring_config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises( + ServiceValidationError, + match="The account associated with this Bring! list is either not loaded or disabled in Home Assistant", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +@pytest.mark.usefixtures("mock_bring_client") +async def test_send_reaction_unknown_entity( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test send activity stream reaction unknown entity exception.""" + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + entity_registry.async_update_entity( + "event.einkauf_activities", disabled_by=er.RegistryEntryDisabler.USER + ) + with pytest.raises( + ServiceValidationError, + match="Failed to send reaction for Bring! — Unknown entity event.einkauf_activities", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) + + +async def test_send_reaction_not_found( + hass: HomeAssistant, + bring_config_entry: MockConfigEntry, + mock_bring_client: AsyncMock, +) -> None: + """Test send activity stream reaction not found validation error.""" + mock_bring_client.get_activity.return_value = BringActivityResponse.from_dict( + {"timeline": [], "timestamp": "2025-01-01T03:09:33.036Z", "totalEvents": 0} + ) + + bring_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(bring_config_entry.entry_id) + await hass.async_block_till_done() + + assert bring_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + HomeAssistantError, + match="Failed to send reaction for Bring! — No recent activity found", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ACTIVITY_STREAM_REACTION, + service_data={ + ATTR_ENTITY_ID: "event.einkauf_activities", + ATTR_REACTION: "heart", + }, + blocking=True, + ) From a4b9efa1b10d042bc3af6a184dfefad12afb8714 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:23:04 -0500 Subject: [PATCH 1290/1664] Support AM/FM channel name in Russound RIO (#148421) --- homeassistant/components/russound_rio/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 29944de09b0..a4b86a85e94 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -132,7 +132,7 @@ class RussoundZoneDevice(RussoundBaseEntity, MediaPlayerEntity): @property def media_title(self) -> str | None: """Title of current playing media.""" - return self._source.song_name + return self._source.song_name or self._source.channel @property def media_artist(self) -> str | None: From 24a7ebd2bb9e288b086dd0014eb8da21f8a8b156 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 9 Jul 2025 23:51:40 +0200 Subject: [PATCH 1291/1664] Move KNXModule class to separate module (#146100) --- homeassistant/components/knx/__init__.py | 302 +----------------- homeassistant/components/knx/binary_sensor.py | 4 +- homeassistant/components/knx/button.py | 4 +- homeassistant/components/knx/climate.py | 4 +- homeassistant/components/knx/const.py | 2 +- homeassistant/components/knx/cover.py | 4 +- homeassistant/components/knx/date.py | 4 +- homeassistant/components/knx/datetime.py | 4 +- homeassistant/components/knx/device.py | 2 +- .../components/knx/device_trigger.py | 2 +- homeassistant/components/knx/diagnostics.py | 2 +- homeassistant/components/knx/entity.py | 4 +- homeassistant/components/knx/expose.py | 2 +- homeassistant/components/knx/fan.py | 4 +- homeassistant/components/knx/knx_module.py | 301 +++++++++++++++++ homeassistant/components/knx/light.py | 4 +- homeassistant/components/knx/notify.py | 4 +- homeassistant/components/knx/number.py | 4 +- homeassistant/components/knx/scene.py | 4 +- homeassistant/components/knx/select.py | 4 +- homeassistant/components/knx/sensor.py | 4 +- homeassistant/components/knx/services.py | 2 +- .../components/knx/storage/__init__.py | 2 +- .../knx/storage/entity_store_validation.py | 2 +- homeassistant/components/knx/switch.py | 4 +- homeassistant/components/knx/text.py | 4 +- homeassistant/components/knx/time.py | 4 +- homeassistant/components/knx/trigger.py | 2 +- homeassistant/components/knx/weather.py | 4 +- homeassistant/components/knx/websocket.py | 2 +- tests/components/knx/test_events.py | 3 +- tests/components/knx/test_expose.py | 2 +- 32 files changed, 361 insertions(+), 339 deletions(-) create mode 100644 homeassistant/components/knx/knx_module.py diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 470f7891292..6fa4c8146ba 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -1,34 +1,17 @@ -"""Support KNX devices.""" +"""The KNX integration.""" from __future__ import annotations import contextlib -import logging from pathlib import Path from typing import Final import voluptuous as vol -from xknx import XKNX -from xknx.core import XknxConnectionState -from xknx.core.state_updater import StateTrackerType, TrackerOptions -from xknx.core.telegram_queue import TelegramQueue -from xknx.dpt import DPTBase -from xknx.exceptions import ConversionError, CouldNotParseTelegram, XKNXException -from xknx.io import ConnectionConfig, ConnectionType, SecureConfig -from xknx.telegram import AddressFilter, Telegram -from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress -from xknx.telegram.apci import GroupValueResponse, GroupValueWrite +from xknx.exceptions import XKNXException from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_EVENT, - CONF_HOST, - CONF_PORT, - CONF_TYPE, - EVENT_HOMEASSISTANT_STOP, - Platform, -) -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.reload import async_integration_yaml_config @@ -36,40 +19,17 @@ from homeassistant.helpers.storage import STORAGE_DIR from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_KNX_CONNECTION_TYPE, CONF_KNX_EXPOSE, - CONF_KNX_INDIVIDUAL_ADDRESS, CONF_KNX_KNXKEY_FILENAME, - CONF_KNX_KNXKEY_PASSWORD, - CONF_KNX_LOCAL_IP, - CONF_KNX_MCAST_GRP, - CONF_KNX_MCAST_PORT, - CONF_KNX_RATE_LIMIT, - CONF_KNX_ROUTE_BACK, - CONF_KNX_ROUTING, - CONF_KNX_ROUTING_BACKBONE_KEY, - CONF_KNX_ROUTING_SECURE, - CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE, - CONF_KNX_SECURE_DEVICE_AUTHENTICATION, - CONF_KNX_SECURE_USER_ID, - CONF_KNX_SECURE_USER_PASSWORD, - CONF_KNX_STATE_UPDATER, - CONF_KNX_TELEGRAM_LOG_SIZE, - CONF_KNX_TUNNEL_ENDPOINT_IA, - CONF_KNX_TUNNELING, - CONF_KNX_TUNNELING_TCP, - CONF_KNX_TUNNELING_TCP_SECURE, DATA_HASS_CONFIG, DOMAIN, - KNX_ADDRESS, KNX_MODULE_KEY, SUPPORTED_PLATFORMS_UI, SUPPORTED_PLATFORMS_YAML, - TELEGRAM_LOG_DEFAULT, ) -from .device import KNXInterfaceDevice -from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure -from .project import STORAGE_KEY as PROJECT_STORAGE_KEY, KNXProject +from .expose import create_knx_exposure +from .knx_module import KNXModule +from .project import STORAGE_KEY as PROJECT_STORAGE_KEY from .schema import ( BinarySensorSchema, ButtonSchema, @@ -92,12 +52,10 @@ from .schema import ( WeatherSchema, ) from .services import async_setup_services -from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore -from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams +from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY +from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY from .websocket import register_panel -_LOGGER = logging.getLogger(__name__) - _KNX_YAML_CONFIG: Final = "knx_yaml_config" CONFIG_SCHEMA = vol.Schema( @@ -162,6 +120,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[KNX_MODULE_KEY] = knx_module + entry.async_on_unload(entry.add_update_listener(async_update_entry)) + if CONF_KNX_EXPOSE in config: for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( @@ -255,243 +215,3 @@ async def async_remove_config_entry_device( if entity.device_id == device_entry.id: await knx_module.config_store.delete_entity(entity.entity_id) return True - - -class KNXModule: - """Representation of KNX Object.""" - - def __init__( - self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry - ) -> None: - """Initialize KNX module.""" - self.hass = hass - self.config_yaml = config - self.connected = False - self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] - self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} - self.entry = entry - - self.project = KNXProject(hass=hass, entry=entry) - self.config_store = KNXConfigStore(hass=hass, config_entry=entry) - - default_state_updater = ( - TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) - if self.entry.data[CONF_KNX_STATE_UPDATER] - else TrackerOptions( - tracker_type=StateTrackerType.INIT, update_interval_min=60 - ) - ) - self.xknx = XKNX( - address_format=self.project.get_address_format(), - connection_config=self.connection_config(), - rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], - state_updater=default_state_updater, - ) - self.xknx.connection_manager.register_connection_state_changed_cb( - self.connection_state_changed_cb - ) - self.telegrams = Telegrams( - hass=hass, - xknx=self.xknx, - project=self.project, - log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), - ) - self.interface_device = KNXInterfaceDevice( - hass=hass, entry=entry, xknx=self.xknx - ) - - self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} - self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} - self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() - - self.entry.async_on_unload( - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) - ) - self.entry.async_on_unload(self.entry.add_update_listener(async_update_entry)) - - async def start(self) -> None: - """Start XKNX object. Connect to tunneling or Routing device.""" - await self.project.load_project(self.xknx) - await self.config_store.load_data() - await self.telegrams.load_history() - await self.xknx.start() - - async def stop(self, event: Event | None = None) -> None: - """Stop XKNX object. Disconnect from tunneling or Routing device.""" - await self.xknx.stop() - await self.telegrams.save_history() - - def connection_config(self) -> ConnectionConfig: - """Return the connection_config.""" - _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] - _knxkeys_file: str | None = ( - self.hass.config.path( - STORAGE_DIR, - self.entry.data[CONF_KNX_KNXKEY_FILENAME], - ) - if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None - else None - ) - if _conn_type == CONF_KNX_ROUTING: - return ConnectionConfig( - connection_type=ConnectionType.ROUTING, - individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], - multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], - multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING, - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING_TCP: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING_TCP, - individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - auto_reconnect=True, - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: - return ConnectionConfig( - connection_type=ConnectionType.TUNNELING_TCP_SECURE, - individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), - gateway_ip=self.entry.data[CONF_HOST], - gateway_port=self.entry.data[CONF_PORT], - secure_config=SecureConfig( - user_id=self.entry.data.get(CONF_KNX_SECURE_USER_ID), - user_password=self.entry.data.get(CONF_KNX_SECURE_USER_PASSWORD), - device_authentication_password=self.entry.data.get( - CONF_KNX_SECURE_DEVICE_AUTHENTICATION - ), - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - auto_reconnect=True, - threaded=True, - ) - if _conn_type == CONF_KNX_ROUTING_SECURE: - return ConnectionConfig( - connection_type=ConnectionType.ROUTING_SECURE, - individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], - multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], - multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], - local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), - secure_config=SecureConfig( - backbone_key=self.entry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY), - latency_ms=self.entry.data.get( - CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE - ), - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - auto_reconnect=True, - threaded=True, - ) - return ConnectionConfig( - auto_reconnect=True, - individual_address=self.entry.data.get( - CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload - ), - secure_config=SecureConfig( - knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), - knxkeys_file_path=_knxkeys_file, - ), - threaded=True, - ) - - def connection_state_changed_cb(self, state: XknxConnectionState) -> None: - """Call invoked after a KNX connection state change was received.""" - self.connected = state == XknxConnectionState.CONNECTED - for device in self.xknx.devices: - device.after_update() - - def telegram_received_cb(self, telegram: Telegram) -> None: - """Call invoked after a KNX telegram was received.""" - # Not all telegrams have serializable data. - data: int | tuple[int, ...] | None = None - value = None - if ( - isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) - and telegram.payload.value is not None - and isinstance( - telegram.destination_address, (GroupAddress, InternalGroupAddress) - ) - ): - data = telegram.payload.value.value - if transcoder := ( - self.group_address_transcoder.get(telegram.destination_address) - or next( - ( - _transcoder - for _filter, _transcoder in self._address_filter_transcoder.items() - if _filter.match(telegram.destination_address) - ), - None, - ) - ): - try: - value = transcoder.from_knx(telegram.payload.value) - except (ConversionError, CouldNotParseTelegram) as err: - _LOGGER.warning( - ( - "Error in `knx_event` at decoding type '%s' from" - " telegram %s\n%s" - ), - transcoder.__name__, - telegram, - err, - ) - - self.hass.bus.async_fire( - "knx_event", - { - "data": data, - "destination": str(telegram.destination_address), - "direction": telegram.direction.value, - "value": value, - "source": str(telegram.source_address), - "telegramtype": telegram.payload.__class__.__name__, - }, - ) - - def register_event_callback(self) -> TelegramQueue.Callback: - """Register callback for knx_event within XKNX TelegramQueue.""" - address_filters = [] - for filter_set in self.config_yaml[CONF_EVENT]: - _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) - address_filters.extend(_filters) - if (dpt := filter_set.get(CONF_TYPE)) and ( - transcoder := DPTBase.parse_transcoder(dpt) - ): - self._address_filter_transcoder.update( - dict.fromkeys(_filters, transcoder) - ) - - return self.xknx.telegram_queue.register_telegram_received_cb( - self.telegram_received_cb, - address_filters=address_filters, - group_addresses=[], - match_for_outgoing=True, - ) diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py index 1bad8bafdf0..947d382a12c 100644 --- a/homeassistant/components/knx/binary_sensor.py +++ b/homeassistant/components/knx/binary_sensor.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP binary sensors.""" +"""Support for KNX binary sensor entities.""" from __future__ import annotations @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( ATTR_COUNTER, ATTR_SOURCE, @@ -39,6 +38,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .storage.const import CONF_ENTITY, CONF_GA_SENSOR from .storage.util import ConfigExtractor diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py index 538299a0556..2c2baa3a218 100644 --- a/homeassistant/components/knx/button.py +++ b/homeassistant/components/knx/button.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP buttons.""" +"""Support for KNX button entities.""" from __future__ import annotations @@ -11,9 +11,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_PAYLOAD_LENGTH, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index fdce5e0c470..f59d48de629 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP climate devices.""" +"""Support for KNX climate entities.""" from __future__ import annotations @@ -37,9 +37,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONTROLLER_MODES, CURRENT_HVAC_ACTIONS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import ClimateSchema ATTR_COMMAND_VALUE = "command_value" diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index 3ce79b4ca7a..dbc02f08245 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -14,7 +14,7 @@ from homeassistant.const import Platform from homeassistant.util.hass_dict import HassKey if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule DOMAIN: Final = "knx" KNX_MODULE_KEY: HassKey[KNXModule] = HassKey(DOMAIN) diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py index f5d482b9d14..ef7084661f1 100644 --- a/homeassistant/components/knx/cover.py +++ b/homeassistant/components/knx/cover.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP covers.""" +"""Support for KNX cover entities.""" from __future__ import annotations @@ -28,9 +28,9 @@ from homeassistant.helpers.entity_platform import ( ) from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_MODULE_KEY, CoverConf from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import CoverSchema from .storage.const import ( CONF_ENTITY, diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py index 7980e6a2bc3..a4fc8d276bc 100644 --- a/homeassistant/components/knx/date.py +++ b/homeassistant/components/knx/date.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP date.""" +"""Support for KNX date entities.""" from __future__ import annotations @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -31,6 +30,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py index 7701597a8ef..04d04527241 100644 --- a/homeassistant/components/knx/datetime.py +++ b/homeassistant/components/knx/datetime.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP datetime.""" +"""Support for KNX datetime entities.""" from __future__ import annotations @@ -23,7 +23,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -32,6 +31,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/device.py b/homeassistant/components/knx/device.py index b43b5926d86..44fa7163360 100644 --- a/homeassistant/components/knx/device.py +++ b/homeassistant/components/knx/device.py @@ -1,4 +1,4 @@ -"""Handle KNX Devices.""" +"""Handle Home Assistant Devices for the KNX integration.""" from __future__ import annotations diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py index 2eb1f86e7fc..e4a48c9c68d 100644 --- a/homeassistant/components/knx/device_trigger.py +++ b/homeassistant/components/knx/device_trigger.py @@ -1,4 +1,4 @@ -"""Provides device triggers for KNX.""" +"""Provide device triggers for KNX.""" from __future__ import annotations diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 974a6b3b448..6d523dda0f5 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -1,4 +1,4 @@ -"""Diagnostics support for KNX.""" +"""Diagnostics support for the KNX integration.""" from __future__ import annotations diff --git a/homeassistant/components/knx/entity.py b/homeassistant/components/knx/entity.py index a042c2b4c6b..c4379bcf869 100644 --- a/homeassistant/components/knx/entity.py +++ b/homeassistant/components/knx/entity.py @@ -1,4 +1,4 @@ -"""Base class for KNX devices.""" +"""Base classes for KNX entities.""" from __future__ import annotations @@ -17,7 +17,7 @@ from .storage.config_store import PlatformControllerBase from .storage.const import CONF_DEVICE_INFO if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule class KnxUiEntityPlatformController(PlatformControllerBase): diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 461e6f25879..0a42b6018ba 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -1,4 +1,4 @@ -"""Exposures to KNX bus.""" +"""Expose Home Assistant entity states to KNX.""" from __future__ import annotations diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index 926b6458706..23f25dc8469 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP fans.""" +"""Support for KNX fan entities.""" from __future__ import annotations @@ -19,9 +19,9 @@ from homeassistant.util.percentage import ( ) from homeassistant.util.scaling import int_states_in_range -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import FanSchema DEFAULT_PERCENTAGE: Final = 50 diff --git a/homeassistant/components/knx/knx_module.py b/homeassistant/components/knx/knx_module.py new file mode 100644 index 00000000000..8974cad1baa --- /dev/null +++ b/homeassistant/components/knx/knx_module.py @@ -0,0 +1,301 @@ +"""Base module for the KNX integration.""" + +from __future__ import annotations + +import logging + +from xknx import XKNX +from xknx.core import XknxConnectionState +from xknx.core.state_updater import StateTrackerType, TrackerOptions +from xknx.core.telegram_queue import TelegramQueue +from xknx.dpt import DPTBase +from xknx.exceptions import ConversionError, CouldNotParseTelegram +from xknx.io import ConnectionConfig, ConnectionType, SecureConfig +from xknx.telegram import AddressFilter, Telegram +from xknx.telegram.address import DeviceGroupAddress, GroupAddress, InternalGroupAddress +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_EVENT, + CONF_HOST, + CONF_PORT, + CONF_TYPE, + EVENT_HOMEASSISTANT_STOP, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_KNX_CONNECTION_TYPE, + CONF_KNX_INDIVIDUAL_ADDRESS, + CONF_KNX_KNXKEY_FILENAME, + CONF_KNX_KNXKEY_PASSWORD, + CONF_KNX_LOCAL_IP, + CONF_KNX_MCAST_GRP, + CONF_KNX_MCAST_PORT, + CONF_KNX_RATE_LIMIT, + CONF_KNX_ROUTE_BACK, + CONF_KNX_ROUTING, + CONF_KNX_ROUTING_BACKBONE_KEY, + CONF_KNX_ROUTING_SECURE, + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE, + CONF_KNX_SECURE_DEVICE_AUTHENTICATION, + CONF_KNX_SECURE_USER_ID, + CONF_KNX_SECURE_USER_PASSWORD, + CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, + CONF_KNX_TUNNEL_ENDPOINT_IA, + CONF_KNX_TUNNELING, + CONF_KNX_TUNNELING_TCP, + CONF_KNX_TUNNELING_TCP_SECURE, + KNX_ADDRESS, + TELEGRAM_LOG_DEFAULT, +) +from .device import KNXInterfaceDevice +from .expose import KNXExposeSensor, KNXExposeTime +from .project import KNXProject +from .storage.config_store import KNXConfigStore +from .telegrams import Telegrams + +_LOGGER = logging.getLogger(__name__) + + +class KNXModule: + """Representation of KNX Object.""" + + def __init__( + self, hass: HomeAssistant, config: ConfigType, entry: ConfigEntry + ) -> None: + """Initialize KNX module.""" + self.hass = hass + self.config_yaml = config + self.connected = False + self.exposures: list[KNXExposeSensor | KNXExposeTime] = [] + self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} + self.entry = entry + + self.project = KNXProject(hass=hass, entry=entry) + self.config_store = KNXConfigStore(hass=hass, config_entry=entry) + + default_state_updater = ( + TrackerOptions(tracker_type=StateTrackerType.EXPIRE, update_interval_min=60) + if self.entry.data[CONF_KNX_STATE_UPDATER] + else TrackerOptions( + tracker_type=StateTrackerType.INIT, update_interval_min=60 + ) + ) + self.xknx = XKNX( + address_format=self.project.get_address_format(), + connection_config=self.connection_config(), + rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], + state_updater=default_state_updater, + ) + self.xknx.connection_manager.register_connection_state_changed_cb( + self.connection_state_changed_cb + ) + self.telegrams = Telegrams( + hass=hass, + xknx=self.xknx, + project=self.project, + log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), + ) + self.interface_device = KNXInterfaceDevice( + hass=hass, entry=entry, xknx=self.xknx + ) + + self._address_filter_transcoder: dict[AddressFilter, type[DPTBase]] = {} + self.group_address_transcoder: dict[DeviceGroupAddress, type[DPTBase]] = {} + self.knx_event_callback: TelegramQueue.Callback = self.register_event_callback() + + self.entry.async_on_unload( + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop) + ) + + async def start(self) -> None: + """Start XKNX object. Connect to tunneling or Routing device.""" + await self.project.load_project(self.xknx) + await self.config_store.load_data() + await self.telegrams.load_history() + await self.xknx.start() + + async def stop(self, event: Event | None = None) -> None: + """Stop XKNX object. Disconnect from tunneling or Routing device.""" + await self.xknx.stop() + await self.telegrams.save_history() + + def connection_config(self) -> ConnectionConfig: + """Return the connection_config.""" + _conn_type: str = self.entry.data[CONF_KNX_CONNECTION_TYPE] + _knxkeys_file: str | None = ( + self.hass.config.path( + STORAGE_DIR, + self.entry.data[CONF_KNX_KNXKEY_FILENAME], + ) + if self.entry.data.get(CONF_KNX_KNXKEY_FILENAME) is not None + else None + ) + if _conn_type == CONF_KNX_ROUTING: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING, + individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], + multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], + multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING, + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + route_back=self.entry.data.get(CONF_KNX_ROUTE_BACK, False), + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING_TCP: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING_TCP, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + auto_reconnect=True, + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + if _conn_type == CONF_KNX_TUNNELING_TCP_SECURE: + return ConnectionConfig( + connection_type=ConnectionType.TUNNELING_TCP_SECURE, + individual_address=self.entry.data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), + gateway_ip=self.entry.data[CONF_HOST], + gateway_port=self.entry.data[CONF_PORT], + secure_config=SecureConfig( + user_id=self.entry.data.get(CONF_KNX_SECURE_USER_ID), + user_password=self.entry.data.get(CONF_KNX_SECURE_USER_PASSWORD), + device_authentication_password=self.entry.data.get( + CONF_KNX_SECURE_DEVICE_AUTHENTICATION + ), + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + auto_reconnect=True, + threaded=True, + ) + if _conn_type == CONF_KNX_ROUTING_SECURE: + return ConnectionConfig( + connection_type=ConnectionType.ROUTING_SECURE, + individual_address=self.entry.data[CONF_KNX_INDIVIDUAL_ADDRESS], + multicast_group=self.entry.data[CONF_KNX_MCAST_GRP], + multicast_port=self.entry.data[CONF_KNX_MCAST_PORT], + local_ip=self.entry.data.get(CONF_KNX_LOCAL_IP), + secure_config=SecureConfig( + backbone_key=self.entry.data.get(CONF_KNX_ROUTING_BACKBONE_KEY), + latency_ms=self.entry.data.get( + CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE + ), + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + auto_reconnect=True, + threaded=True, + ) + return ConnectionConfig( + auto_reconnect=True, + individual_address=self.entry.data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, # may be configured at knxkey upload + ), + secure_config=SecureConfig( + knxkeys_password=self.entry.data.get(CONF_KNX_KNXKEY_PASSWORD), + knxkeys_file_path=_knxkeys_file, + ), + threaded=True, + ) + + def connection_state_changed_cb(self, state: XknxConnectionState) -> None: + """Call invoked after a KNX connection state change was received.""" + self.connected = state == XknxConnectionState.CONNECTED + for device in self.xknx.devices: + device.after_update() + + def telegram_received_cb(self, telegram: Telegram) -> None: + """Call invoked after a KNX telegram was received.""" + # Not all telegrams have serializable data. + data: int | tuple[int, ...] | None = None + value = None + if ( + isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)) + and telegram.payload.value is not None + and isinstance( + telegram.destination_address, (GroupAddress, InternalGroupAddress) + ) + ): + data = telegram.payload.value.value + if transcoder := ( + self.group_address_transcoder.get(telegram.destination_address) + or next( + ( + _transcoder + for _filter, _transcoder in self._address_filter_transcoder.items() + if _filter.match(telegram.destination_address) + ), + None, + ) + ): + try: + value = transcoder.from_knx(telegram.payload.value) + except (ConversionError, CouldNotParseTelegram) as err: + _LOGGER.warning( + ( + "Error in `knx_event` at decoding type '%s' from" + " telegram %s\n%s" + ), + transcoder.__name__, + telegram, + err, + ) + + self.hass.bus.async_fire( + "knx_event", + { + "data": data, + "destination": str(telegram.destination_address), + "direction": telegram.direction.value, + "value": value, + "source": str(telegram.source_address), + "telegramtype": telegram.payload.__class__.__name__, + }, + ) + + def register_event_callback(self) -> TelegramQueue.Callback: + """Register callback for knx_event within XKNX TelegramQueue.""" + address_filters = [] + for filter_set in self.config_yaml[CONF_EVENT]: + _filters = list(map(AddressFilter, filter_set[KNX_ADDRESS])) + address_filters.extend(_filters) + if (dpt := filter_set.get(CONF_TYPE)) and ( + transcoder := DPTBase.parse_transcoder(dpt) + ): + self._address_filter_transcoder.update( + dict.fromkeys(_filters, transcoder) + ) + + return self.xknx.telegram_queue.register_telegram_received_cb( + self.telegram_received_cb, + address_filters=address_filters, + group_addresses=[], + match_for_outgoing=True, + ) diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index ff0f4538089..cbecb878e12 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP lights.""" +"""Support for KNX light entities.""" from __future__ import annotations @@ -28,9 +28,9 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType from homeassistant.util import color as color_util -from . import KNXModule from .const import CONF_SYNC_STATE, DOMAIN, KNX_ADDRESS, KNX_MODULE_KEY, ColorTempModes from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import LightSchema from .storage.const import ( CONF_COLOR_TEMP_MAX, diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index 97980ab3d36..d64bac80d9d 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP notifications.""" +"""Support for KNX notify entities.""" from __future__ import annotations @@ -12,9 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 67e8778accc..30efb5e01ee 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP numeric values.""" +"""Support for KNX number entities.""" from __future__ import annotations @@ -22,9 +22,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import NumberSchema diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index f5361a6e7da..39e627ca8ff 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -1,4 +1,4 @@ -"""Support for KNX scenes.""" +"""Support for KNX scene entities.""" from __future__ import annotations @@ -13,9 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SceneSchema diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py index e80fa66f9d4..0dc2584876d 100644 --- a/homeassistant/components/knx/select.py +++ b/homeassistant/components/knx/select.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP select entities.""" +"""Support for KNX select entities.""" from __future__ import annotations @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_PAYLOAD_LENGTH, CONF_RESPOND_TO_READ, @@ -30,6 +29,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SelectSchema diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py index 8e537ea234e..e75d1f180e2 100644 --- a/homeassistant/components/knx/sensor.py +++ b/homeassistant/components/knx/sensor.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP sensors.""" +"""Support for KNX sensor entities.""" from __future__ import annotations @@ -33,9 +33,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.util.enum import try_parse_enum -from . import KNXModule from .const import ATTR_SOURCE, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import SensorSchema SCAN_INTERVAL = timedelta(seconds=10) diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 04803e140fd..f63612f97ef 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -35,7 +35,7 @@ from .expose import create_knx_exposure from .schema import ExposeSchema, dpt_base_type_validator, ga_validator if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/knx/storage/__init__.py b/homeassistant/components/knx/storage/__init__.py index 25d84406d03..a588a3d154e 100644 --- a/homeassistant/components/knx/storage/__init__.py +++ b/homeassistant/components/knx/storage/__init__.py @@ -1 +1 @@ -"""Helpers for KNX.""" +"""Handle persistent storage for the KNX integration.""" diff --git a/homeassistant/components/knx/storage/entity_store_validation.py b/homeassistant/components/knx/storage/entity_store_validation.py index 9bad5297853..1da7b58378d 100644 --- a/homeassistant/components/knx/storage/entity_store_validation.py +++ b/homeassistant/components/knx/storage/entity_store_validation.py @@ -1,4 +1,4 @@ -"""KNX Entity Store Validation.""" +"""KNX entity store validation.""" from typing import Literal, TypedDict diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py index 5a01457d8d3..4d6ca288dc6 100644 --- a/homeassistant/components/knx/switch.py +++ b/homeassistant/components/knx/switch.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP switches.""" +"""Support for KNX switch entities.""" from __future__ import annotations @@ -25,7 +25,6 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_INVERT, CONF_RESPOND_TO_READ, @@ -35,6 +34,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxUiEntity, KnxUiEntityPlatformController, KnxYamlEntity +from .knx_module import KNXModule from .schema import SwitchSchema from .storage.const import CONF_ENTITY, CONF_GA_SWITCH from .storage.util import ConfigExtractor diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py index 9c2bb88f92b..14c9af11ad3 100644 --- a/homeassistant/components/knx/text.py +++ b/homeassistant/components/knx/text.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP text.""" +"""Support for KNX text entities.""" from __future__ import annotations @@ -22,9 +22,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, KNX_ADDRESS, KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py index 2c74ab18af3..3bc171cae31 100644 --- a/homeassistant/components/knx/time.py +++ b/homeassistant/components/knx/time.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP time.""" +"""Support for KNX time entities.""" from __future__ import annotations @@ -22,7 +22,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import ( CONF_RESPOND_TO_READ, CONF_STATE_ADDRESS, @@ -31,6 +30,7 @@ from .const import ( KNX_MODULE_KEY, ) from .entity import KnxYamlEntity +from .knx_module import KNXModule async def async_setup_entry( diff --git a/homeassistant/components/knx/trigger.py b/homeassistant/components/knx/trigger.py index ae3ba088357..ba8bfff5d3b 100644 --- a/homeassistant/components/knx/trigger.py +++ b/homeassistant/components/knx/trigger.py @@ -1,4 +1,4 @@ -"""Offer knx telegram automation triggers.""" +"""Provide KNX automation triggers.""" from typing import Final diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 342ab445611..e8f0036f5bb 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -1,4 +1,4 @@ -"""Support for KNX/IP weather station.""" +"""Support for KNX weather entities.""" from __future__ import annotations @@ -19,9 +19,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import ConfigType -from . import KNXModule from .const import KNX_MODULE_KEY from .entity import KnxYamlEntity +from .knx_module import KNXModule from .schema import WeatherSchema diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 9ba3e0ccff6..31c5e8297e0 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -36,7 +36,7 @@ from .storage.entity_store_validation import ( from .telegrams import SIGNAL_KNX_TELEGRAM, TelegramDict if TYPE_CHECKING: - from . import KNXModule + from .knx_module import KNXModule URL_BASE: Final = "/knx_static" diff --git a/tests/components/knx/test_events.py b/tests/components/knx/test_events.py index 2228781ba89..a40109d167e 100644 --- a/tests/components/knx/test_events.py +++ b/tests/components/knx/test_events.py @@ -4,7 +4,8 @@ import logging import pytest -from homeassistant.components.knx import CONF_EVENT, CONF_TYPE, KNX_ADDRESS +from homeassistant.components.knx.const import KNX_ADDRESS +from homeassistant.const import CONF_EVENT, CONF_TYPE from homeassistant.core import HomeAssistant from .conftest import KNXTestKit diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index f7a3f4e94f2..331678f0683 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -6,7 +6,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS +from homeassistant.components.knx.const import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import ( CONF_ATTRIBUTE, From 49baa65f61af5631c512de9134920a69d749b7d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 10 Jul 2025 00:26:13 +0200 Subject: [PATCH 1292/1664] Add Home Connect resume command button when an appliance is paused (#148512) --- .../components/home_connect/coordinator.py | 30 ++++++++- tests/components/home_connect/test_button.py | 63 ++++++++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 3c9d33424a8..76faaefa931 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -41,7 +41,12 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN +from .const import ( + API_DEFAULT_RETRY_AFTER, + APPLIANCES_WITH_PROGRAMS, + BSH_OPERATION_STATE_PAUSE, + DOMAIN, +) from .utils import get_dict_from_home_connect_error _LOGGER = logging.getLogger(__name__) @@ -66,6 +71,7 @@ class HomeConnectApplianceData: def update(self, other: HomeConnectApplianceData) -> None: """Update data with data from other instance.""" + self.commands.clear() self.commands.update(other.commands) self.events.update(other.events) self.info.connected = other.info.connected @@ -201,6 +207,28 @@ class HomeConnectCoordinator( raw_key=status_key.value, value=event.value, ) + if ( + status_key == StatusKey.BSH_COMMON_OPERATION_STATE + and event.value == BSH_OPERATION_STATE_PAUSE + and CommandKey.BSH_COMMON_RESUME_PROGRAM + not in ( + commands := self.data[ + event_message_ha_id + ].commands + ) + ): + # All the appliances that can be paused + # should have the resume command available. + commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM) + for ( + listener, + context, + ) in self._special_listeners.values(): + if ( + EventKey.BSH_COMMON_APPLIANCE_DEPAIRED + not in context + ): + listener() self._call_event_listener(event_message) case EventType.NOTIFY: diff --git a/tests/components/home_connect/test_button.py b/tests/components/home_connect/test_button.py index ee4d5f1d729..e61ec5e2b1f 100644 --- a/tests/components/home_connect/test_button.py +++ b/tests/components/home_connect/test_button.py @@ -1,12 +1,14 @@ """Tests for home_connect button entities.""" from collections.abc import Awaitable, Callable -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, MagicMock from aiohomeconnect.model import ( ArrayOfCommands, CommandKey, + Event, + EventKey, EventMessage, HomeAppliance, ) @@ -317,3 +319,62 @@ async def test_stop_program_button_exception( {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + + +@pytest.mark.parametrize("appliance", ["Washer"], indirect=True) +async def test_enable_resume_command_on_pause( + hass: HomeAssistant, + client: MagicMock, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + appliance: HomeAppliance, +) -> None: + """Test if all commands enabled option works as expected.""" + entity_id = "button.washer_resume_program" + + original_get_available_commands = client.get_available_commands + + async def get_available_commands_side_effect(ha_id: str) -> ArrayOfCommands: + array_of_commands = cast( + ArrayOfCommands, await original_get_available_commands(ha_id) + ) + if ha_id == appliance.ha_id: + for command in array_of_commands.commands: + if command.key == CommandKey.BSH_COMMON_RESUME_PROGRAM: + # Simulate that the resume command is not available initially + array_of_commands.commands.remove(command) + break + return array_of_commands + + client.get_available_commands = AsyncMock( + side_effect=get_available_commands_side_effect + ) + + assert await integration_setup(client) + assert config_entry.state is ConfigEntryState.LOADED + + assert not hass.states.get(entity_id) + + await client.add_events( + [ + EventMessage( + appliance.ha_id, + EventType.STATUS, + data=ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE, + raw_key=EventKey.BSH_COMMON_STATUS_OPERATION_STATE.value, + timestamp=0, + level="", + handling="", + value="BSH.Common.EnumType.OperationState.Pause", + ) + ] + ), + ) + ] + ) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) From c2bc4a990eb3aafce6d60a613f4f67a081cedb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Thu, 10 Jul 2025 09:35:30 +0200 Subject: [PATCH 1293/1664] Use the link to the issue instead of creating new issues at Home Connect (#148523) --- homeassistant/components/home_connect/coordinator.py | 5 +---- homeassistant/components/home_connect/strings.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 76faaefa931..bb419f6bd7c 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -655,10 +655,7 @@ class HomeConnectCoordinator( "times": str(MAX_EXECUTIONS), "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_new_issue_url": ( - "https://github.com/home-assistant/core/issues/new?template=bug_report.yml" - f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/" - ), + "home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299", }, ) return True diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index 99c89ec8788..e1c0b42ca0b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -130,7 +130,7 @@ "step": { "confirm": { "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})." + "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})." } } } From cbe2fbdc34d419d9f22435a3b93fafad0dba6e0b Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Thu, 10 Jul 2025 15:46:10 +0700 Subject: [PATCH 1294/1664] Encrypted reasoning items support for OpenAI Conversation (#148279) --- .../components/openai_conversation/entity.py | 4 ++-- .../components/openai_conversation/conftest.py | 18 +++++++++++++++++- .../openai_conversation/test_conversation.py | 6 ++++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 69ca4c9a1eb..7351cbccbfa 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -293,6 +293,7 @@ class OpenAIBaseLLMEntity(Entity): "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), "user": chat_log.conversation_id, + "store": False, "stream": True, } if tools: @@ -304,8 +305,7 @@ class OpenAIBaseLLMEntity(Entity): CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT ) } - else: - model_args["store"] = False + model_args["include"] = ["reasoning.encrypted_content"] try: result = await client.responses.create(**model_args) diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index b8944d837be..628c1846e16 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -5,7 +5,10 @@ from unittest.mock import patch import pytest -from homeassistant.components.openai_conversation.const import DEFAULT_CONVERSATION_NAME +from homeassistant.components.openai_conversation.const import ( + CONF_CHAT_MODEL, + DEFAULT_CONVERSATION_NAME, +) from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API from homeassistant.core import HomeAssistant @@ -59,6 +62,19 @@ def mock_config_entry_with_assist( return mock_config_entry +@pytest.fixture +def mock_config_entry_with_reasoning_model( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> MockConfigEntry: + """Mock a config entry with assist.""" + hass.config_entries.async_update_subentry( + mock_config_entry, + next(iter(mock_config_entry.subentries.values())), + data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST, CONF_CHAT_MODEL: "o4-mini"}, + ) + return mock_config_entry + + @pytest.fixture async def mock_init_component( hass: HomeAssistant, mock_config_entry: MockConfigEntry diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 3d662cb0f00..7a3bcb21768 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -499,6 +499,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven summary=[], type="reasoning", status=None, + encrypted_content="AAA", ), output_index=output_index, sequence_number=0, @@ -510,6 +511,7 @@ def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEven summary=[], type="reasoning", status=None, + encrypted_content="AAABBB", ), output_index=output_index, sequence_number=0, @@ -566,7 +568,7 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve async def test_function_call( hass: HomeAssistant, - mock_config_entry_with_assist: MockConfigEntry, + mock_config_entry_with_reasoning_model: MockConfigEntry, mock_init_component, mock_create_stream: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 @@ -617,7 +619,7 @@ async def test_function_call( "id": "rs_A", "summary": [], "type": "reasoning", - "encrypted_content": None, + "encrypted_content": "AAABBB", } assert result.response.response_type == intent.IntentResponseType.ACTION_DONE # Don't test the prompt, as it's not deterministic From c75b34a91114acf1aac887509ac41d225f8ca635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristof=20Mari=C3=ABn?= Date: Thu, 10 Jul 2025 10:52:03 +0200 Subject: [PATCH 1295/1664] Fix for Renson set Breeze fan speed (#148537) --- homeassistant/components/renson/fan.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 474ab640943..c82cad012c3 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -196,7 +196,7 @@ class RensonFan(RensonEntity, FanEntity): all_data = self.coordinator.data breeze_temp = self.api.get_field_value(all_data, BREEZE_TEMPERATURE_FIELD) await self.hass.async_add_executor_job( - self.api.set_breeze, cmd.name, breeze_temp, True + self.api.set_breeze, cmd, breeze_temp, True ) else: await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) From c37b0a8f1d10b045f91ebdc95e43c791621b0a30 Mon Sep 17 00:00:00 2001 From: Josh Barnard Date: Thu, 10 Jul 2025 02:21:44 -0700 Subject: [PATCH 1296/1664] Adding precision for voltage and wind speed sensors in Ecowitt (#148462) --- homeassistant/components/ecowitt/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 7d37aa40b86..ccaaeaae3de 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -106,6 +106,7 @@ ECOWITT_SENSORS_MAPPING: Final = { native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, + suggested_display_precision=1, ), EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( key="CO2_PPM", @@ -191,12 +192,14 @@ ECOWITT_SENSORS_MAPPING: Final = { device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), EcoWittSensorTypes.SPEED_MPH: SensorEntityDescription( key="SPEED_MPH", device_class=SensorDeviceClass.WIND_SPEED, native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, ), EcoWittSensorTypes.PRESSURE_HPA: SensorEntityDescription( key="PRESSURE_HPA", From a00f61f7be7dc82d73addc9355b3de38249997ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Thu, 10 Jul 2025 12:09:24 +0200 Subject: [PATCH 1297/1664] Remove vg argument from miele auth flow (#148541) --- homeassistant/components/miele/config_flow.py | 8 -------- tests/components/miele/test_config_flow.py | 4 ---- 2 files changed, 12 deletions(-) diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py index d3c7dbba12b..191cd9a0454 100644 --- a/homeassistant/components/miele/config_flow.py +++ b/homeassistant/components/miele/config_flow.py @@ -26,14 +26,6 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - @property - def extra_authorize_data(self) -> dict: - """Extra data that needs to be appended to the authorize url.""" - # "vg" is mandatory but the value doesn't seem to matter - return { - "vg": "sv-SE", - } - async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/tests/components/miele/test_config_flow.py b/tests/components/miele/test_config_flow.py index bbe5844c1cd..5ce129b255d 100644 --- a/tests/components/miele/test_config_flow.py +++ b/tests/components/miele/test_config_flow.py @@ -46,7 +46,6 @@ async def test_full_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -118,7 +117,6 @@ async def test_flow_reauth_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -187,7 +185,6 @@ async def test_flow_reconfigure_abort( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() @@ -247,7 +244,6 @@ async def test_zeroconf_flow( f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" f"&redirect_uri={REDIRECT_URL}" f"&state={state}" - "&vg=sv-SE" ) client = await hass_client_no_auth() From 8881919efde3b0fd18005666d08ef1b84915c99a Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 10 Jul 2025 18:10:15 +0800 Subject: [PATCH 1298/1664] Add YS8009 support to Yolink (#148538) --- homeassistant/components/yolink/manifest.json | 2 +- homeassistant/components/yolink/sensor.py | 19 ++++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 779b830637b..89001f98c16 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.5.5"] + "requirements": ["yolink-api==0.5.7"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index bc32d0eea83..2845f8ee533 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -21,6 +21,7 @@ from yolink.const import ( ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SOIL_TH_SENSOR, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_THERMOSTAT, @@ -42,6 +43,7 @@ from homeassistant.const import ( PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, + UnitOfConductivity, UnitOfEnergy, UnitOfLength, UnitOfPower, @@ -103,6 +105,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, + ATTR_DEVICE_SOIL_TH_SENSOR, ] BATTERY_POWER_SENSOR = [ @@ -122,6 +125,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_WATER_DEPTH_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SOIL_TH_SENSOR, ] MCU_DEV_TEMPERATURE_SENSOR = [ @@ -182,7 +186,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, exists_fn=lambda device: ( - device.device_type in [ATTR_DEVICE_TH_SENSOR] + device.device_type in [ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_SOIL_TH_SENSOR] and device.device_model_name not in NONE_HUMIDITY_SENSOR_MODELS ), ), @@ -191,7 +195,8 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_TH_SENSOR], + exists_fn=lambda device: device.device_type + in [ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_SOIL_TH_SENSOR], ), # mcu temperature YoLinkSensorEntityDescription( @@ -206,7 +211,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( key="loraInfo", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, - value=lambda value: value["signal"] if value is not None else None, + value=lambda value: value.get("signal") if value is not None else None, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -302,6 +307,14 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( exists_fn=lambda device: device.device_model_name in POWER_SUPPORT_MODELS, value=lambda value: value / 100 if value is not None else None, ), + YoLinkSensorEntityDescription( + key="conductivity", + device_class=SensorDeviceClass.CONDUCTIVITY, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + state_class=SensorStateClass.MEASUREMENT, + exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SOIL_TH_SENSOR], + should_update_entity=lambda value: value is not None, + ), ) diff --git a/requirements_all.txt b/requirements_all.txt index 3fae8953386..b4a53c7dba7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3163,7 +3163,7 @@ yeelight==0.7.16 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.5.5 +yolink-api==0.5.7 # homeassistant.components.youless youless-api==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 827b088fdab..3173b1443b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2610,7 +2610,7 @@ yalexs==8.10.0 yeelight==0.7.16 # homeassistant.components.yolink -yolink-api==0.5.5 +yolink-api==0.5.7 # homeassistant.components.youless youless-api==2.2.0 From 2829cc1248f2c5d2a02baaa41b143337a131aef3 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Thu, 10 Jul 2025 04:24:54 -0600 Subject: [PATCH 1299/1664] Add visits today sensor for pets (#147459) --- .../components/litterrobot/coordinator.py | 3 +++ homeassistant/components/litterrobot/icons.json | 3 +++ homeassistant/components/litterrobot/sensor.py | 16 +++++++++++++++- .../components/litterrobot/strings.json | 4 ++++ tests/components/litterrobot/common.py | 9 +++++++++ tests/components/litterrobot/conftest.py | 16 +++++++++++++++- tests/components/litterrobot/test_sensor.py | 10 ++++++++++ 7 files changed, 59 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/litterrobot/coordinator.py b/homeassistant/components/litterrobot/coordinator.py index c99d4794ff6..581257ab2db 100644 --- a/homeassistant/components/litterrobot/coordinator.py +++ b/homeassistant/components/litterrobot/coordinator.py @@ -48,6 +48,9 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]): """Update all device states from the Litter-Robot API.""" await self.account.refresh_robots() await self.account.load_pets() + for pet in self.account.pets: + # Need to fetch weight history for `get_visits_since` + await pet.fetch_weight_history() async def _async_setup(self) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/litterrobot/icons.json b/homeassistant/components/litterrobot/icons.json index 2e0cafe43d9..86a95b59b18 100644 --- a/homeassistant/components/litterrobot/icons.json +++ b/homeassistant/components/litterrobot/icons.json @@ -49,6 +49,9 @@ }, "total_cycles": { "default": "mdi:counter" + }, + "visits_today": { + "default": "mdi:counter" } }, "switch": { diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py index b7ddf3c3249..aa7c3a451be 100644 --- a/homeassistant/components/litterrobot/sensor.py +++ b/homeassistant/components/litterrobot/sensor.py @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -39,6 +40,7 @@ class RobotSensorEntityDescription(SensorEntityDescription, Generic[_WhiskerEnti """A class that describes robot sensor entities.""" icon_fn: Callable[[Any], str | None] = lambda _: None + last_reset_fn: Callable[[], datetime | None] = lambda: None value_fn: Callable[[_WhiskerEntityT], float | datetime | str | None] @@ -179,7 +181,14 @@ PET_SENSORS: list[RobotSensorEntityDescription] = [ native_unit_of_measurement=UnitOfMass.POUNDS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda pet: pet.weight, - ) + ), + RobotSensorEntityDescription[Pet]( + key="visits_today", + translation_key="visits_today", + state_class=SensorStateClass.TOTAL, + last_reset_fn=dt_util.start_of_local_day, + value_fn=lambda pet: pet.get_visits_since(dt_util.start_of_local_day()), + ), ] @@ -225,3 +234,8 @@ class LitterRobotSensorEntity(LitterRobotEntity[_WhiskerEntityT], SensorEntity): if (icon := self.entity_description.icon_fn(self.state)) is not None: return icon return super().icon + + @property + def last_reset(self) -> datetime | None: + """Return the time when the sensor was last reset, if any.""" + return self.entity_description.last_reset_fn() or super().last_reset diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 160f5edb6a0..35aff0f9105 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -122,6 +122,10 @@ "name": "Total cycles", "unit_of_measurement": "cycles" }, + "visits_today": { + "name": "Visits today", + "unit_of_measurement": "visits" + }, "waste_drawer": { "name": "Waste drawer" } diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index d96ce06ca59..19c0c3600ea 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -159,6 +159,15 @@ PET_DATA = { "gender": "FEMALE", "lastWeightReading": 9.1, "breeds": ["sphynx"], + "weightHistory": [ + {"weight": 6.48, "timestamp": "2025-06-13T16:12:36"}, + {"weight": 6.6, "timestamp": "2025-06-14T03:52:00"}, + {"weight": 6.59, "timestamp": "2025-06-14T17:20:32"}, + {"weight": 6.5, "timestamp": "2025-06-14T19:22:48"}, + {"weight": 6.35, "timestamp": "2025-06-15T03:12:15"}, + {"weight": 6.45, "timestamp": "2025-06-15T15:27:21"}, + {"weight": 6.25, "timestamp": "2025-06-15T15:29:26"}, + ], } VACUUM_ENTITY_ID = "vacuum.test_litter_box" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index a6058c75bca..aa67db23d89 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -52,6 +52,20 @@ def create_mock_robot( return robot +def create_mock_pet( + pet_data: dict | None, + account: Account, + side_effect: Any | None = None, +) -> Pet: + """Create a mock Pet.""" + if not pet_data: + pet_data = {} + + pet = Pet(data={**PET_DATA, **pet_data}, session=account.session) + pet.fetch_weight_history = AsyncMock(side_effect=side_effect) + return pet + + def create_mock_account( robot_data: dict | None = None, side_effect: Any | None = None, @@ -69,7 +83,7 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) - account.pets = [Pet(PET_DATA, account.session)] if pet else [] + account.pets = [create_mock_pet(PET_DATA, account, side_effect)] if pet else [] return account diff --git a/tests/components/litterrobot/test_sensor.py b/tests/components/litterrobot/test_sensor.py index 76c567f5417..d1101a4231d 100644 --- a/tests/components/litterrobot/test_sensor.py +++ b/tests/components/litterrobot/test_sensor.py @@ -124,6 +124,16 @@ async def test_pet_weight_sensor( assert sensor.attributes["unit_of_measurement"] == UnitOfMass.POUNDS +@pytest.mark.freeze_time("2025-06-15 12:00:00+00:00") +async def test_pet_visits_today_sensor( + hass: HomeAssistant, mock_account_with_pet: MagicMock +) -> None: + """Tests pet visits today sensors.""" + await setup_integration(hass, mock_account_with_pet, PLATFORM_DOMAIN) + sensor = hass.states.get("sensor.kitty_visits_today") + assert sensor.state == "2" + + async def test_litterhopper_sensor( hass: HomeAssistant, mock_account_with_litterhopper: MagicMock ) -> None: From 7e405d4ddb88a6d3e54791f806b31ac5842c7fef Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 10 Jul 2025 04:21:19 -0700 Subject: [PATCH 1300/1664] 100% test coverage in Google Assistant SDK (#148536) --- .../google_assistant_sdk/test_init.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/components/google_assistant_sdk/test_init.py b/tests/components/google_assistant_sdk/test_init.py index 9bb08c802c2..caddf9ba797 100644 --- a/tests/components/google_assistant_sdk/test_init.py +++ b/tests/components/google_assistant_sdk/test_init.py @@ -116,6 +116,25 @@ async def test_expired_token_refresh_failure( assert entries[0].state is expected_state +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_setup_client_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test setup handling aiohttp.ClientError.""" + aioclient_mock.post( + "https://oauth2.googleapis.com/token", + exc=aiohttp.ClientError, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + @pytest.mark.parametrize( ("configured_language_code", "expected_language_code"), [("", "en-US"), ("en-US", "en-US"), ("es-ES", "es-ES")], From 12f913e7370077fa1c16c36dc20b175dcb3ed62f Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Thu, 10 Jul 2025 13:38:42 +0200 Subject: [PATCH 1301/1664] Improve names and descriptions of `rainmachine.push_weather_data` (#148534) --- .../components/rainmachine/strings.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index aad61458e88..49731df5b6f 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -196,12 +196,12 @@ "description": "UNIX timestamp for the weather data. If omitted, the RainMachine device's local time at the time of the call is used." }, "mintemp": { - "name": "Min temp", - "description": "Minimum temperature (°C)." + "name": "Min temperature", + "description": "Minimum temperature in current period (°C)." }, "maxtemp": { - "name": "Max temp", - "description": "Maximum temperature (°C)." + "name": "Max temperature", + "description": "Maximum temperature in current period (°C)." }, "temperature": { "name": "Temperature", @@ -209,11 +209,11 @@ }, "wind": { "name": "Wind speed", - "description": "Wind speed (m/s)." + "description": "Current wind speed (m/s)." }, "solarrad": { "name": "Solar radiation", - "description": "Solar radiation (MJ/m²/h)." + "description": "Current solar radiation (MJ/m²/h)." }, "et": { "name": "Evapotranspiration", @@ -229,11 +229,11 @@ }, "minrh": { "name": "Min relative humidity", - "description": "Min relative humidity (%RH)." + "description": "Minimum relative humidity in current period (%RH)." }, "maxrh": { "name": "Max relative humidity", - "description": "Max relative humidity (%RH)." + "description": "Maximum relative humidity in current period (%RH)." }, "condition": { "name": "Weather condition code", @@ -241,11 +241,11 @@ }, "pressure": { "name": "Barametric pressure", - "description": "Barametric pressure (kPa)." + "description": "Current barametric pressure (kPa)." }, "dewpoint": { "name": "Dew point", - "description": "Dew point (°C)." + "description": "Current dew point (°C)." } } }, From eb20292683ecb9cfc06439971286b46e09e2eb60 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:54:05 +0200 Subject: [PATCH 1302/1664] Move tuya models to separate module (#148550) --- .../components/tuya/alarm_control_panel.py | 3 +- homeassistant/components/tuya/climate.py | 3 +- homeassistant/components/tuya/cover.py | 3 +- homeassistant/components/tuya/entity.py | 120 +---------------- homeassistant/components/tuya/fan.py | 3 +- homeassistant/components/tuya/humidifier.py | 3 +- homeassistant/components/tuya/light.py | 3 +- homeassistant/components/tuya/models.py | 124 ++++++++++++++++++ homeassistant/components/tuya/number.py | 3 +- homeassistant/components/tuya/sensor.py | 3 +- homeassistant/components/tuya/vacuum.py | 3 +- 11 files changed, 144 insertions(+), 127 deletions(-) create mode 100644 homeassistant/components/tuya/models.py diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 4972fe88339..61985fb7622 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -20,7 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 991c3589e12..734f6ba7f7a 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -25,7 +25,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 015daae4212..a385a35d903 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -21,7 +21,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index cc258560067..4158650b062 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -2,11 +2,7 @@ from __future__ import annotations -import base64 -from dataclasses import dataclass -import json -import struct -from typing import Any, Literal, Self, overload +from typing import Any, Literal, overload from tuya_sharing import CustomerDevice, Manager @@ -15,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType -from .util import remap_value +from .models import EnumTypeData, IntegerTypeData _DPTYPE_MAPPING: dict[str, DPType] = { "Bitmap": DPType.RAW, @@ -29,118 +25,6 @@ _DPTYPE_MAPPING: dict[str, DPType] = { } -@dataclass -class IntegerTypeData: - """Integer Type Data.""" - - dpcode: DPCode - min: int - max: int - scale: float - step: float - unit: str | None = None - type: str | None = None - - @property - def max_scaled(self) -> float: - """Return the max scaled.""" - return self.scale_value(self.max) - - @property - def min_scaled(self) -> float: - """Return the min scaled.""" - return self.scale_value(self.min) - - @property - def step_scaled(self) -> float: - """Return the step scaled.""" - return self.step / (10**self.scale) - - def scale_value(self, value: float) -> float: - """Scale a value.""" - return value / (10**self.scale) - - def scale_value_back(self, value: float) -> int: - """Return raw value for scaled.""" - return int(value * (10**self.scale)) - - def remap_value_to( - self, - value: float, - to_min: float = 0, - to_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from this range to a new range.""" - return remap_value(value, self.min, self.max, to_min, to_max, reverse) - - def remap_value_from( - self, - value: float, - from_min: float = 0, - from_max: float = 255, - reverse: bool = False, - ) -> float: - """Remap a value from its current range to this range.""" - return remap_value(value, from_min, from_max, self.min, self.max, reverse) - - @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: - """Load JSON string and return a IntegerTypeData object.""" - if not (parsed := json.loads(data)): - return None - - return cls( - dpcode, - min=int(parsed["min"]), - max=int(parsed["max"]), - scale=float(parsed["scale"]), - step=max(float(parsed["step"]), 1), - unit=parsed.get("unit"), - type=parsed.get("type"), - ) - - -@dataclass -class EnumTypeData: - """Enum Type Data.""" - - dpcode: DPCode - range: list[str] - - @classmethod - def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: - """Load JSON string and return a EnumTypeData object.""" - if not (parsed := json.loads(data)): - return None - return cls(dpcode, **parsed) - - -@dataclass -class ElectricityTypeData: - """Electricity Type Data.""" - - electriccurrent: str | None = None - power: str | None = None - voltage: str | None = None - - @classmethod - def from_json(cls, data: str) -> Self: - """Load JSON string and return a ElectricityTypeData object.""" - return cls(**json.loads(data.lower())) - - @classmethod - def from_raw(cls, data: str) -> Self: - """Decode base64 string and return a ElectricityTypeData object.""" - raw = base64.b64decode(data) - voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 - electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 - power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 - return cls( - electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) - ) - - class TuyaEntity(Entity): """Tuya base device.""" diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index f2d856b6d86..f96ea2c0a65 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -22,7 +22,8 @@ from homeassistant.util.percentage import ( from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData, IntegerTypeData TUYA_SUPPORT_TYPE = { "cs", # Dehumidifier diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index f8fd9237ffc..6539d98e9d8 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -19,7 +19,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 37c79b952d4..3f8fc7d0fb9 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -25,7 +25,8 @@ from homeassistant.util import color as color_util from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData from .util import remap_value diff --git a/homeassistant/components/tuya/models.py b/homeassistant/components/tuya/models.py new file mode 100644 index 00000000000..b4afca83a85 --- /dev/null +++ b/homeassistant/components/tuya/models.py @@ -0,0 +1,124 @@ +"""Tuya Home Assistant Base Device Model.""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass +import json +import struct +from typing import Self + +from .const import DPCode +from .util import remap_value + + +@dataclass +class IntegerTypeData: + """Integer Type Data.""" + + dpcode: DPCode + min: int + max: int + scale: float + step: float + unit: str | None = None + type: str | None = None + + @property + def max_scaled(self) -> float: + """Return the max scaled.""" + return self.scale_value(self.max) + + @property + def min_scaled(self) -> float: + """Return the min scaled.""" + return self.scale_value(self.min) + + @property + def step_scaled(self) -> float: + """Return the step scaled.""" + return self.step / (10**self.scale) + + def scale_value(self, value: float) -> float: + """Scale a value.""" + return value / (10**self.scale) + + def scale_value_back(self, value: float) -> int: + """Return raw value for scaled.""" + return int(value * (10**self.scale)) + + def remap_value_to( + self, + value: float, + to_min: float = 0, + to_max: float = 255, + reverse: bool = False, + ) -> float: + """Remap a value from this range to a new range.""" + return remap_value(value, self.min, self.max, to_min, to_max, reverse) + + def remap_value_from( + self, + value: float, + from_min: float = 0, + from_max: float = 255, + reverse: bool = False, + ) -> float: + """Remap a value from its current range to this range.""" + return remap_value(value, from_min, from_max, self.min, self.max, reverse) + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> IntegerTypeData | None: + """Load JSON string and return a IntegerTypeData object.""" + if not (parsed := json.loads(data)): + return None + + return cls( + dpcode, + min=int(parsed["min"]), + max=int(parsed["max"]), + scale=float(parsed["scale"]), + step=max(float(parsed["step"]), 1), + unit=parsed.get("unit"), + type=parsed.get("type"), + ) + + +@dataclass +class EnumTypeData: + """Enum Type Data.""" + + dpcode: DPCode + range: list[str] + + @classmethod + def from_json(cls, dpcode: DPCode, data: str) -> EnumTypeData | None: + """Load JSON string and return a EnumTypeData object.""" + if not (parsed := json.loads(data)): + return None + return cls(dpcode, **parsed) + + +@dataclass +class ElectricityTypeData: + """Electricity Type Data.""" + + electriccurrent: str | None = None + power: str | None = None + voltage: str | None = None + + @classmethod + def from_json(cls, data: str) -> Self: + """Load JSON string and return a ElectricityTypeData object.""" + return cls(**json.loads(data.lower())) + + @classmethod + def from_raw(cls, data: str) -> Self: + """Decode base64 string and return a ElectricityTypeData object.""" + raw = base64.b64decode(data) + voltage = struct.unpack(">H", raw[0:2])[0] / 10.0 + electriccurrent = struct.unpack(">L", b"\x00" + raw[2:5])[0] / 1000.0 + power = struct.unpack(">L", b"\x00" + raw[5:8])[0] / 1000.0 + return cls( + electriccurrent=str(electriccurrent), power=str(power), voltage=str(voltage) + ) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index ddee46b8799..b5b8437ea8b 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -16,7 +16,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import IntegerTypeData # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 5151f39eb26..b45b8214bff 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -35,7 +35,8 @@ from .const import ( DPType, UnitOfMeasurement, ) -from .entity import ElectricityTypeData, EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import ElectricityTypeData, EnumTypeData, IntegerTypeData @dataclass(frozen=True) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index f722fd918ca..d61a624f027 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -17,7 +17,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry from .const import TUYA_DISCOVERY_NEW, DPCode, DPType -from .entity import EnumTypeData, IntegerTypeData, TuyaEntity +from .entity import TuyaEntity +from .models import EnumTypeData, IntegerTypeData TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { From d23321cf54a787b0d809f8c11396bf4a9c700e38 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:55:03 +0200 Subject: [PATCH 1303/1664] Add tuya snapshot tests for dlq category (#148549) --- tests/components/tuya/__init__.py | 9 + tests/components/tuya/conftest.py | 2 +- .../fixtures/dlq_earu_electric_eawcpt.json | 247 +++++++ .../tuya/fixtures/dlq_metering_3pn_wifi.json | 137 ++++ .../tuya/snapshots/test_sensor.ambr | 672 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 96 +++ 6 files changed, 1162 insertions(+), 1 deletion(-) create mode 100644 tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json create mode 100644 tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index b308df7e2f9..011b2fc7d31 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -40,6 +40,15 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "dlq_earu_electric_eawcpt": [ + # https://github.com/home-assistant/core/issues/102769 + Platform.SENSOR, + Platform.SWITCH, + ], + "dlq_metering_3pn_wifi": [ + # https://github.com/home-assistant/core/issues/143499 + Platform.SENSOR, + ], "kg_smart_valve": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 9aa8e8ea147..3d89e1d6f92 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -143,7 +143,7 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev hass, f"{mock_device_code}.json", DOMAIN ) device = MagicMock(spec=CustomerDevice) - device.id = details["id"] + device.id = details.get("id", "mocked_device_id") device.name = details["name"] device.category = details["category"] device.product_id = details["product_id"] diff --git a/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json b/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json new file mode 100644 index 00000000000..32535964a7e --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_earu_electric_eawcpt.json @@ -0,0 +1,247 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "48", + "app_type": "tuyaSmart", + "mqtt_connected": null, + "disabled_by": null, + "disabled_polling": false, + "name": "\u4e00\u8def\u5e26\u8ba1\u91cf\u78c1\u4fdd\u6301\u901a\u65ad\u5668", + "model": "", + "category": "dlq", + "product_id": "0tnvg2xaisqdadcf", + "product_name": "\u4e00\u8def\u5e26\u8ba1\u91cf\u78c1\u4fdd\u6301\u901a\u65ad\u5668", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2023-11-25T21:50:37+00:00", + "create_time": "2023-11-25T21:49:06+00:00", + "update_time": "2023-11-28T16:32:28+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "add_ele": { + "type": "Integer", + "value": { + "min": 0, + "max": 50000, + "scale": 3, + "step": 100 + } + }, + "cur_current": { + "type": "Integer", + "value": { + "unit": "mA", + "min": 0, + "max": 100000, + "scale": 0, + "step": 1 + } + }, + "cur_power": { + "type": "Integer", + "value": { + "unit": "W", + "min": 0, + "max": 99999, + "scale": 1, + "step": 1 + } + }, + "cur_voltage": { + "type": "Integer", + "value": { + "unit": "V", + "min": 0, + "max": 5000, + "scale": 1, + "step": 1 + } + }, + "test_bit": { + "type": "Integer", + "value": { + "min": 0, + "max": 5, + "scale": 0, + "step": 1 + } + }, + "voltage_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electric_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "power_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "electricity_coe": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000000, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "ov_cr", + "ov_vol", + "ov_pwr", + "ls_cr", + "ls_vol", + "ls_pow", + "short_circuit_alarm", + "overload_alarm", + "leakagecurr_alarm", + "self_test_alarm", + "high_temp", + "unbalance_alarm", + "miss_phase_alarm" + ] + } + }, + "relay_status": { + "type": "Enum", + "value": { + "range": ["power_off", "power_on", "last"] + } + }, + "light_mode": { + "type": "Enum", + "value": { + "range": ["relay", "pos", "none", "on"] + } + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "cycle_time": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + } + }, + "status": { + "switch": true, + "countdown_1": 0, + "add_ele": 100, + "cur_current": 2198, + "cur_power": 4953, + "cur_voltage": 2314, + "test_bit": 2, + "voltage_coe": 0, + "electric_coe": 0, + "power_coe": 0, + "electricity_coe": 0, + "fault": 0, + "relay_status": "last", + "light_mode": "relay", + "child_lock": false, + "cycle_time": "", + "temp_value": 0, + "alarm_set_1": "", + "alarm_set_2": "AQAAAAMAAAAEAAAA" + } +} diff --git a/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json b/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json new file mode 100644 index 00000000000..8e9a06cc9a9 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_metering_3pn_wifi.json @@ -0,0 +1,137 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1733006572651YokbqV", + "mqtt_connected": null, + "disabled_by": null, + "disabled_polling": false, + "id": "bf5e5bde2c52cb5994cd27", + "name": "Metering_3PN_WiFi_stable", + "category": "dlq", + "product_id": "kxdr6su0c55p7bbo", + "product_name": "Metering_3PN_WiFi", + "online": true, + "sub": false, + "time_zone": "+03:00", + "active_time": "2024-12-08T17:37:45+00:00", + "create_time": "2024-12-08T17:37:45+00:00", + "update_time": "2024-12-08T17:37:45+00:00", + "function": { + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "forward_energy_total": 2435416, + "phase_a": "CKMAAn0AAGw=", + "phase_b": "CIsAK8MACWo=", + "phase_c": "CJwAA5EAAFw=", + "fault": 0, + "energy_reset": "", + "alarm_set_1": "BwEADQ==", + "alarm_set_2": "AQEAPAMBAP0EAQC0BQEAAAcBAAAIAQAeCQAAAA==", + "breaker_number": "SPM02_6588", + "supply_frequency": 5000, + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index ac34dc615b7..946d0bc004d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -528,6 +528,678 @@ 'state': '121.7', }) # --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', + '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': 'Current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current', + 'unique_id': 'tuya.mocked_device_idcur_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': '一路带计量磁保持通断器 Current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.198', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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': 'Power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'tuya.mocked_device_idcur_power', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': '一路带计量磁保持通断器 Power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '495.3', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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': 'Voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voltage', + 'unique_id': 'tuya.mocked_device_idcur_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': '一路带计量磁保持通断器 Voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '231.4', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_current-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.metering_3pn_wifi_stable_phase_a_current', + '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': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.637', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_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.metering_3pn_wifi_stable_phase_a_power', + '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': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.108', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_voltage-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.metering_3pn_wifi_stable_phase_a_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': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '221.1', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_current-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.metering_3pn_wifi_stable_phase_b_current', + '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': 'Phase B current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_current', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_belectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '11.203', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_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.metering_3pn_wifi_stable_phase_b_power', + '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': 'Phase B power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_power', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_bpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.41', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_voltage-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.metering_3pn_wifi_stable_phase_b_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': 'Phase B voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_b_voltage', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_bvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_b_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase B voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_b_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '218.7', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_current-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.metering_3pn_wifi_stable_phase_c_current', + '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': 'Phase C current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_current', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_celectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.913', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_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.metering_3pn_wifi_stable_phase_c_power', + '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': 'Phase C power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_power', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_cpower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.092', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_voltage-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.metering_3pn_wifi_stable_phase_c_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': 'Phase C voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_c_voltage', + 'unique_id': 'tuya.bf5e5bde2c52cb5994cd27phase_cvoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[dlq_metering_3pn_wifi][sensor.metering_3pn_wifi_stable_phase_c_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Metering_3PN_WiFi_stable Phase C voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_phase_c_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '220.4', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][sensor.door_garage_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 0f042cbce52..77943ccdd29 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -386,6 +386,102 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_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.mocked_device_idchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Child lock', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-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': None, + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.mocked_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': '一路带计量磁保持通断器 Switch', + }), + 'context': , + 'entity_id': 'switch.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[kg_smart_valve][switch.qt_switch_switch_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 058e1ede10c288d09248f6ab72afbc1d7faa7f66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 15:55:22 +0200 Subject: [PATCH 1304/1664] Add tuya snapshot tests for wsdcg and zndb category (#148554) --- tests/components/tuya/__init__.py | 8 + .../fixtures/wsdcg_temperature_humidity.json | 158 +++++++++ .../tuya/fixtures/zndb_smart_meter.json | 79 +++++ .../tuya/snapshots/test_sensor.ambr | 330 ++++++++++++++++++ 4 files changed, 575 insertions(+) create mode 100644 tests/components/tuya/fixtures/wsdcg_temperature_humidity.json create mode 100644 tests/components/tuya/fixtures/zndb_smart_meter.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 011b2fc7d31..5e182f936de 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -83,6 +83,14 @@ DEVICE_MOCKS = { Platform.CLIMATE, Platform.SWITCH, ], + "wsdcg_temperature_humidity": [ + # https://github.com/home-assistant/core/issues/102769 + Platform.SENSOR, + ], + "zndb_smart_meter": [ + # https://github.com/home-assistant/core/issues/138372 + Platform.SENSOR, + ], } diff --git a/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json b/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json new file mode 100644 index 00000000000..06d07a4c506 --- /dev/null +++ b/tests/components/tuya/fixtures/wsdcg_temperature_humidity.json @@ -0,0 +1,158 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "17150293164666xhFUk", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + + "id": "bf316b8707b061f044th18", + "name": "NP DownStairs North", + "category": "wsdcg", + "product_id": "g2y6z3p3ja2qhyav", + "product_name": "\u6e29\u6e7f\u5ea6\u4f20\u611f\u5668wifi", + "online": true, + "sub": false, + "time_zone": "+10:30", + "active_time": "2023-12-22T03:38:57+00:00", + "create_time": "2023-12-22T03:38:57+00:00", + "update_time": "2023-12-22T03:38:57+00:00", + "function": { + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "va_temperature": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "va_humidity": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "maxtemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "minitemp_set": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -200, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "maxhum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "minihum_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + }, + "hum_alarm": { + "type": "Enum", + "value": { + "range": ["loweralarm", "upperalarm", "cancel"] + } + } + }, + "status": { + "va_temperature": 185, + "va_humidity": 47, + "battery_percentage": 0, + "maxtemp_set": 600, + "minitemp_set": -100, + "maxhum_set": 100, + "minihum_set": 0, + "temp_alarm": "cancel", + "hum_alarm": "cancel" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_smart_meter.json b/tests/components/tuya/fixtures/zndb_smart_meter.json new file mode 100644 index 00000000000..139cf814347 --- /dev/null +++ b/tests/components/tuya/fixtures/zndb_smart_meter.json @@ -0,0 +1,79 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1739198173271wpFacM", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bfe33b4c74661f1f1bgacy", + "name": "Meter", + "category": "zndb", + "product_id": "ze8faryrxr0glqnn", + "product_name": "PJ2101A 1P WiFi Smart Meter ", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-08-24T11:22:33+00:00", + "create_time": "2024-08-24T11:22:33+00:00", + "update_time": "2024-08-24T11:22:33+00:00", + "function": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "energy_month": { + "type": "raw", + "value": {} + }, + "energy_daily": { + "type": "raw", + "value": {} + } + }, + "status_range": { + "energy_month": { + "type": "raw", + "value": {} + }, + "energy_daily": { + "type": "raw", + "value": {} + }, + "phase_a": { + "type": "raw", + "value": {} + }, + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "energy_month": "GAkYCQAAANQ=", + "energy_daily": "", + "phase_a": "CSIAFfQABKE=" + }, + "set_up": true, + "support_local": false +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 946d0bc004d..5e52c0e063c 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1305,3 +1305,333 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf316b8707b061f044th18battery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'NP DownStairs North Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_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.np_downstairs_north_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf316b8707b061f044th18va_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'NP DownStairs North Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '47.0', + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_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.np_downstairs_north_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf316b8707b061f044th18va_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'NP DownStairs North Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.np_downstairs_north_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.5', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-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.meter_phase_a_current', + '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': 'Phase A current', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_current', + 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_aelectriccurrent', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Meter Phase A current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.62', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_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.meter_phase_a_power', + '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': 'Phase A power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_power', + 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_apower', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Meter Phase A power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.185', + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_voltage-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.meter_phase_a_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': 'Phase A voltage', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'phase_a_voltage', + 'unique_id': 'tuya.bfe33b4c74661f1f1bgacyphase_avoltage', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Meter Phase A voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.meter_phase_a_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '233.8', + }) +# --- From 4f27058a687707e578f17a9a1491a68409c7c076 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 10 Jul 2025 16:15:07 +0200 Subject: [PATCH 1305/1664] Add fault binary sensors to tuya dehumidifer (#148485) --- .../components/tuya/binary_sensor.py | 75 ++++++++- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/entity.py | 3 +- homeassistant/components/tuya/strings.json | 9 ++ tests/components/tuya/__init__.py | 1 + .../tuya/snapshots/test_binary_sensor.ambr | 147 ++++++++++++++++++ tests/components/tuya/test_binary_sensor.py | 35 +++++ 7 files changed, 264 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index a613661149f..4fef11a7335 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -15,9 +15,10 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity @@ -31,6 +32,9 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): # Value or values to consider binary sensor to be "on" on_value: bool | float | int | str | set[bool | float | int | str] = True + # For DPType.BITMAP, the bitmap_key is used to extract the bit mask + bitmap_key: str | None = None + # Commonly used sensors TAMPER_BINARY_SENSOR = TuyaBinarySensorEntityDescription( @@ -71,6 +75,34 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { ), TAMPER_BINARY_SENSOR, ), + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs": ( + TuyaBinarySensorEntityDescription( + key="tankfull", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="tankfull", + translation_key="tankfull", + ), + TuyaBinarySensorEntityDescription( + key="defrost", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="defrost", + translation_key="defrost", + ), + TuyaBinarySensorEntityDescription( + key="wet", + dpcode=DPCode.FAULT, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + bitmap_key="wet", + translation_key="wet", + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( @@ -343,6 +375,22 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { } +def _get_bitmap_bit_mask( + device: CustomerDevice, dpcode: str, bitmap_key: str | None +) -> int | None: + """Get the bit mask for a given bitmap description.""" + if ( + bitmap_key is None + or (status_range := device.status_range.get(dpcode)) is None + or status_range.type != DPType.BITMAP + or not isinstance(bitmap_values := json_loads(status_range.values), dict) + or not isinstance(bitmap_labels := bitmap_values.get("label"), list) + or bitmap_key not in bitmap_labels + ): + return None + return bitmap_labels.index(bitmap_key) + + async def async_setup_entry( hass: HomeAssistant, entry: TuyaConfigEntry, @@ -361,12 +409,23 @@ async def async_setup_entry( for description in descriptions: dpcode = description.dpcode or description.key if dpcode in device.status: - entities.append( - TuyaBinarySensorEntity( - device, hass_data.manager, description - ) + mask = _get_bitmap_bit_mask( + device, dpcode, description.bitmap_key ) + if ( + description.bitmap_key is None # Regular binary sensor + or mask is not None # Bitmap sensor with valid mask + ): + entities.append( + TuyaBinarySensorEntity( + device, + hass_data.manager, + description, + mask, + ) + ) + async_add_entities(entities) async_discover_device([*hass_data.manager.device_map]) @@ -386,11 +445,13 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): device: CustomerDevice, device_manager: Manager, description: TuyaBinarySensorEntityDescription, + bit_mask: int | None = None, ) -> None: """Init Tuya binary sensor.""" super().__init__(device, device_manager) self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" + self._bit_mask = bit_mask @property def is_on(self) -> bool: @@ -399,6 +460,10 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity): if dpcode not in self.device.status: return False + if self._bit_mask is not None: + # For bitmap sensors, check the specific bit mask + return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0 + if isinstance(self.entity_description.on_value, set): return self.device.status[dpcode] in self.entity_description.on_value diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 922aaab193b..abf5223175c 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -82,6 +82,7 @@ class WorkMode(StrEnum): class DPType(StrEnum): """Data point types.""" + BITMAP = "Bitmap" BOOLEAN = "Boolean" ENUM = "Enum" INTEGER = "Integer" diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index 4158650b062..fbddfb0ab83 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -14,8 +14,7 @@ from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY, DPCode, DPType from .models import EnumTypeData, IntegerTypeData _DPTYPE_MAPPING: dict[str, DPType] = { - "Bitmap": DPType.RAW, - "bitmap": DPType.RAW, + "bitmap": DPType.BITMAP, "bool": DPType.BOOLEAN, "enum": DPType.ENUM, "json": DPType.JSON, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a96f805f248..5964be5ce34 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -56,6 +56,15 @@ }, "tilt": { "name": "Tilt" + }, + "tankfull": { + "name": "Tank full" + }, + "defrost": { + "name": "Defrost" + }, + "wet": { + "name": "Wet" } }, "button": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5e182f936de..bf8af8835cf 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -19,6 +19,7 @@ DEVICE_MOCKS = { Platform.LIGHT, ], "cs_arete_two_12l_dehumidifier_air_purifier": [ + Platform.BINARY_SENSOR, Platform.FAN, Platform.HUMIDIFIER, Platform.SELECT, diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index b269664a2d4..efd995b3280 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,4 +1,151 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-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': , + 'entity_id': 'binary_sensor.dehumidifier_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqdefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_tank_full-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': , + 'entity_id': 'binary_sensor.dehumidifier_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqtankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_wet-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': , + 'entity_id': 'binary_sensor.dehumidifier_wet', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Wet', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'wet', + 'unique_id': 'tuya.bf3fce6af592f12df3gbgqwet', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_wet-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifier Wet', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifier_wet', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_binary_sensor.py b/tests/components/tuya/test_binary_sensor.py index c77be47fb2d..f59e325b6cc 100644 --- a/tests/components/tuya/test_binary_sensor.py +++ b/tests/components/tuya/test_binary_sensor.py @@ -56,3 +56,38 @@ 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"], +) +@pytest.mark.parametrize( + ("fault_value", "tankfull", "defrost", "wet"), + [ + (0, "off", "off", "off"), + (0x1, "on", "off", "off"), + (0x2, "off", "on", "off"), + (0x80, "off", "off", "on"), + (0x83, "on", "on", "on"), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_bitmap( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + fault_value: int, + tankfull: str, + defrost: str, + wet: str, +) -> None: + """Test BITMAP fault sensor on cs_arete_two_12l_dehumidifier_air_purifier.""" + mock_device.status["fault"] = fault_value + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert hass.states.get("binary_sensor.dehumidifier_tank_full").state == tankfull + assert hass.states.get("binary_sensor.dehumidifier_defrost").state == defrost + assert hass.states.get("binary_sensor.dehumidifier_wet").state == wet From d15baf9f9fdadd8b5d45da4f1d4e16cffed85361 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 10 Jul 2025 08:30:54 -0700 Subject: [PATCH 1306/1664] Drop homeassistant agent and assist_pipeline migration code (#147968) Co-authored-by: Franck Nijhof Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/__init__.py | 4 - .../components/assist_pipeline/const.py | 1 - .../components/assist_pipeline/pipeline.py | 47 +------ .../components/conversation/__init__.py | 11 -- .../components/conversation/agent_manager.py | 9 +- .../components/conversation/const.py | 1 - .../conversation.py | 5 +- .../components/ollama/conversation.py | 5 +- .../openai_conversation/conversation.py | 5 +- script/hassfest/dependencies.py | 2 - tests/components/assist_pipeline/test_init.py | 8 +- .../assist_pipeline/test_pipeline.py | 52 +------- .../conversation/snapshots/test_http.ambr | 31 ----- .../conversation/snapshots/test_init.ambr | 124 ------------------ tests/components/conversation/test_http.py | 9 +- tests/components/conversation/test_init.py | 13 +- 16 files changed, 26 insertions(+), 301 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 59bd987d90e..8f4c6efd355 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -38,8 +38,6 @@ from .pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, - async_run_migrations, async_setup_pipeline_store, async_update_pipeline, ) @@ -61,7 +59,6 @@ __all__ = ( "WakeWordSettings", "async_create_default_pipeline", "async_get_pipelines", - "async_migrate_engine", "async_pipeline_from_audio_stream", "async_setup", "async_update_pipeline", @@ -87,7 +84,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_LAST_WAKE_UP] = {} await async_setup_pipeline_store(hass) - await async_run_migrations(hass) async_register_websocket_api(hass) return True diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 300cb5aad2a..52583cf21a4 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -3,7 +3,6 @@ DOMAIN = "assist_pipeline" DATA_CONFIG = f"{DOMAIN}.config" -DATA_MIGRATIONS = f"{DOMAIN}_migrations" DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a1b6ea53445..0cd593e9666 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -13,7 +13,7 @@ from pathlib import Path from queue import Empty, Queue from threading import Thread import time -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast import wave import hass_nabucasa @@ -49,7 +49,6 @@ from .const import ( CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, - DATA_MIGRATIONS, DOMAIN, MS_PER_CHUNK, SAMPLE_CHANNELS, @@ -2059,50 +2058,6 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: return PipelineData(pipeline_store) -@callback -def async_migrate_engine( - hass: HomeAssistant, - engine_type: Literal["conversation", "stt", "tts", "wake_word"], - old_value: str, - new_value: str, -) -> None: - """Register a migration of an engine used in pipelines.""" - hass.data.setdefault(DATA_MIGRATIONS, {})[engine_type] = (old_value, new_value) - - # Run migrations when config is already loaded - if DATA_CONFIG in hass.data: - hass.async_create_background_task( - async_run_migrations(hass), "assist_pipeline_migration", eager_start=True - ) - - -async def async_run_migrations(hass: HomeAssistant) -> None: - """Run pipeline migrations.""" - if not (migrations := hass.data.get(DATA_MIGRATIONS)): - return - - engine_attr = { - "conversation": "conversation_engine", - "stt": "stt_engine", - "tts": "tts_engine", - "wake_word": "wake_word_entity", - } - - updates = [] - - for pipeline in async_get_pipelines(hass): - attr_updates = {} - for engine_type, (old_value, new_value) in migrations.items(): - if getattr(pipeline, engine_attr[engine_type]) == old_value: - attr_updates[engine_attr[engine_type]] = new_value - - if attr_updates: - updates.append((pipeline, attr_updates)) - - for pipeline, attr_updates in updates: - await async_update_pipeline(hass, pipeline, **attr_updates) - - @dataclass class PipelineConversationData: """Hold data for the duration of a conversation.""" diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index cf62704b34d..66a5735e6b6 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -51,7 +51,6 @@ from .const import ( DATA_DEFAULT_ENTITY, DOMAIN, HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, SERVICE_PROCESS, SERVICE_RELOAD, ConversationEntityFeature, @@ -65,7 +64,6 @@ from .trace import ConversationTraceEventType, async_conversation_trace_append __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", - "OLD_HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", "ChatLog", @@ -270,15 +268,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) ) - # Temporary migration. We can remove this in 2024.10 - from homeassistant.components.assist_pipeline import ( # noqa: PLC0415 - async_migrate_engine, - ) - - async_migrate_engine( - hass, "conversation", OLD_HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT - ) - async def handle_process(service: ServiceCall) -> ServiceResponse: """Parse text into commands.""" text = service.data[ATTR_TEXT] diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 38c0ca8db6b..6203525ac01 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -12,12 +12,7 @@ from homeassistant.core import Context, HomeAssistant, async_get_hass, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton -from .const import ( - DATA_COMPONENT, - DATA_DEFAULT_ENTITY, - HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, -) +from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT from .entity import ConversationEntity from .models import ( AbstractConversationAgent, @@ -54,7 +49,7 @@ def async_get_agent( hass: HomeAssistant, agent_id: str | None = None ) -> AbstractConversationAgent | ConversationEntity | None: """Get specified agent.""" - if agent_id is None or agent_id in (HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT): + if agent_id is None or agent_id == HOME_ASSISTANT_AGENT: return hass.data[DATA_DEFAULT_ENTITY] if "." in agent_id: diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 619a41fd002..266a9f15b83 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -16,7 +16,6 @@ if TYPE_CHECKING: DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" -OLD_HOME_ASSISTANT_AGENT = "homeassistant" ATTR_TEXT = "text" ATTR_LANGUAGE = "language" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0b24e8bbc38..3525fba3af5 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -57,9 +57,6 @@ class GoogleGenerativeAIConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index f151f8524a0..e0b64702cb4 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -52,9 +52,6 @@ class OllamaConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 1ec17163f69..25e89577ef3 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,7 +2,7 @@ from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -57,9 +57,6 @@ class OpenAIConversationEntity( async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index ee932280201..447b3ec79b8 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -140,8 +140,6 @@ IGNORE_VIOLATIONS = { ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", - # Temporary needed for migration until 2024.10 - ("conversation", "assist_pipeline"), } diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 0294f9953db..a6a449bddd4 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -12,7 +12,7 @@ import hass_nabucasa import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, stt +from homeassistant.components import assist_pipeline, conversation, stt from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, @@ -116,7 +116,7 @@ async def test_pipeline_from_audio_stream_legacy( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -184,7 +184,7 @@ async def test_pipeline_from_audio_stream_entity( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -252,7 +252,7 @@ async def test_pipeline_from_audio_stream_no_stt( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 1302925dab9..3a4895440dc 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -29,7 +29,6 @@ from homeassistant.components.assist_pipeline.pipeline import ( async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, async_update_pipeline, ) from homeassistant.const import MATCH_ALL @@ -162,12 +161,6 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" - async_migrate_engine( - hass, - "conversation", - conversation.OLD_HOME_ASSISTANT_AGENT, - conversation.HOME_ASSISTANT_AGENT, - ) id_1 = "01GX8ZWBAQYWNB1XV3EXEZ75DY" hass_storage[STORAGE_KEY] = { "version": STORAGE_VERSION, @@ -176,7 +169,7 @@ async def test_loading_pipelines_from_storage( "data": { "items": [ { - "conversation_engine": conversation.OLD_HOME_ASSISTANT_AGENT, + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "language_1", "id": id_1, "language": "language_1", @@ -668,43 +661,6 @@ async def test_update_pipeline( } -@pytest.mark.usefixtures("init_supporting_components") -async def test_migrate_after_load(hass: HomeAssistant) -> None: - """Test migrating an engine after done loading.""" - assert await async_setup_component(hass, "assist_pipeline", {}) - - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 - - assert ( - await async_create_default_pipeline( - hass, - stt_engine_id="bla", - tts_engine_id="bla", - pipeline_name="Bla pipeline", - ) - is None - ) - pipeline = await async_create_default_pipeline( - hass, - stt_engine_id="test", - tts_engine_id="test", - pipeline_name="Test pipeline", - ) - assert pipeline is not None - - async_migrate_engine(hass, "stt", "test", "stt.test") - async_migrate_engine(hass, "tts", "test", "tts.test") - - await hass.async_block_till_done(wait_background_tasks=True) - - pipeline_updated = async_get_pipeline(hass, pipeline.id) - - assert pipeline_updated.stt_engine == "stt.test" - assert pipeline_updated.tts_engine == "tts.test" - - def test_fallback_intent_filter() -> None: """Test that we filter the right things.""" assert ( @@ -1364,7 +1320,7 @@ async def test_stt_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1440,7 +1396,7 @@ async def test_tts_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1516,7 +1472,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 5179409deb0..391fb609d65 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -327,37 +327,6 @@ }), }) # --- -# name: test_http_processing_intent[homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_ws_api[payload0] dict({ 'continue_conversation': False, diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index a853faa7a3d..779bb256180 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -108,37 +108,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -201,37 +170,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ 'continue_conversation': False, @@ -294,37 +232,6 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -387,34 +294,3 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 77fa97ad845..29cd567e904 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -8,7 +8,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -22,8 +25,6 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -187,7 +188,7 @@ async def test_http_api_wrong_data( }, { "text": "Test Text", - "agent_id": "homeassistant", + "agent_id": HOME_ASSISTANT_AGENT, }, ], ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index c3de5f1127c..e757c56042b 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -14,7 +14,10 @@ from homeassistant.components.conversation import ( async_handle_sentence_triggers, default_agent, ) -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -28,8 +31,6 @@ from tests.typing import ClientSessionGenerator AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -205,8 +206,8 @@ async def test_get_agent_info( """Test get agent info.""" agent_info = conversation.async_get_agent_info(hass) # Test it's the default - assert conversation.async_get_agent_info(hass, "homeassistant") == agent_info - assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == agent_info + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == snapshot assert ( conversation.async_get_agent_info(hass, mock_conversation_agent.agent_id) == snapshot @@ -223,7 +224,7 @@ async def test_get_agent_info( default_agent = conversation.async_get_agent(hass) default_agent._attr_supports_streaming = True assert ( - conversation.async_get_agent_info(hass, "homeassistant").supports_streaming + conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT).supports_streaming is True ) From f0a636949af7484f55e83463cbef2060ddfa9285 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:29:48 -0700 Subject: [PATCH 1307/1664] Support all Energy units in Energy integration (#148566) --- homeassistant/components/energy/sensor.py | 9 ++---- homeassistant/components/energy/validate.py | 19 +++--------- tests/components/energy/test_sensor.py | 6 +++- tests/components/energy/test_validate.py | 34 +++++++++------------ 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 3dc857d75d9..1105e6f6b86 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -41,13 +41,8 @@ SUPPORTED_STATE_CLASSES = { SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, } -VALID_ENERGY_UNITS: set[str] = { - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, -} +VALID_ENERGY_UNITS: set[str] = set(UnitOfEnergy) + VALID_ENERGY_UNITS_GAS = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0f46678994f..3590ee9e848 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -21,14 +21,9 @@ from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ) + sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy) } + ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units ) @@ -39,13 +34,9 @@ GAS_USAGE_DEVICE_CLASSES = ( sensor.SensorDeviceClass.GAS, ) GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ), + sensor.SensorDeviceClass.ENERGY: ENERGY_USAGE_UNITS[ + sensor.SensorDeviceClass.ENERGY + ], sensor.SensorDeviceClass.GAS: ( UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a9a249a8498..b7ccbadbe1c 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import _WH_TO_CAL, _WH_TO_J from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -748,10 +749,12 @@ async def test_cost_sensor_price_entity_total_no_reset( @pytest.mark.parametrize( ("energy_unit", "factor"), [ + (UnitOfEnergy.MILLIWATT_HOUR, 1e6), (UnitOfEnergy.WATT_HOUR, 1000), (UnitOfEnergy.KILO_WATT_HOUR, 1), (UnitOfEnergy.MEGA_WATT_HOUR, 0.001), - (UnitOfEnergy.GIGA_JOULE, 0.001 * 3.6), + (UnitOfEnergy.GIGA_JOULE, _WH_TO_J / 1e6), + (UnitOfEnergy.CALORIE, _WH_TO_CAL * 1e3), ], ) async def test_cost_sensor_handle_energy_units( @@ -815,6 +818,7 @@ async def test_cost_sensor_handle_energy_units( @pytest.mark.parametrize( ("price_unit", "factor"), [ + (f"EUR/{UnitOfEnergy.MILLIWATT_HOUR}", 1e-6), (f"EUR/{UnitOfEnergy.WATT_HOUR}", 0.001), (f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", 1), (f"EUR/{UnitOfEnergy.MEGA_WATT_HOUR}", 1000), diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 6389ac0b372..9e7a2151b04 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -12,6 +12,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component +ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy)) + +ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy)) + @pytest.fixture def mock_is_entity_recorded(): @@ -69,6 +73,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("state_class", "energy_unit", "extra"), [ + ("total_increasing", UnitOfEnergy.MILLIWATT_HOUR, {}), ("total_increasing", UnitOfEnergy.KILO_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.MEGA_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.WATT_HOUR, {}), @@ -76,6 +81,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: ("total", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("measurement", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("total_increasing", UnitOfEnergy.GIGA_JOULE, {}), + ("total_increasing", UnitOfEnergy.CALORIE, {}), ], ) async def test_validation( @@ -235,9 +241,7 @@ async def test_validation_device_consumption_entity_unexpected_unit( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.unexpected_unit", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -325,9 +329,7 @@ async def test_validation_solar( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.solar_production", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -378,9 +380,7 @@ async def test_validation_battery( ("sensor.battery_import", "beers"), ("sensor.battery_export", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, ] ], @@ -449,9 +449,7 @@ async def test_validation_grid( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -538,9 +536,7 @@ async def test_validation_grid_external_cost_compensation( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -710,9 +706,7 @@ async def test_validation_grid_auto_cost_entity_errors( { "type": "entity_unexpected_unit_energy_price", "affected_entities": {("sensor.grid_price_1", "$/Ws")}, - "translation_placeholders": { - "price_units": "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh" - }, + "translation_placeholders": {"price_units": ENERGY_PRICE_UNITS_STRING}, }, ), ], @@ -855,7 +849,7 @@ async def test_validation_gas( "type": "entity_unexpected_unit_gas", "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh", + "energy_units": ENERGY_UNITS_STRING, "gas_units": "CCF, ft³, m³, L", }, }, @@ -885,7 +879,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" + f"{ENERGY_PRICE_UNITS_STRING}, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" ) }, }, From 0e09a47476e5d5e9f898fcde182394cc744f3c52 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:08:56 +0200 Subject: [PATCH 1308/1664] Add OpenAI AI Task entity (#148295) --- .../openai_conversation/__init__.py | 57 +-- .../components/openai_conversation/ai_task.py | 77 ++++ .../openai_conversation/config_flow.py | 88 +++-- .../components/openai_conversation/const.py | 13 + .../components/openai_conversation/entity.py | 99 +++-- .../openai_conversation/strings.json | 46 +++ .../openai_conversation/__init__.py | 240 ++++++++++++ .../openai_conversation/conftest.py | 125 ++++++- .../snapshots/test_init.ambr | 4 +- .../openai_conversation/test_ai_task.py | 124 +++++++ .../openai_conversation/test_config_flow.py | 127 ++++++- .../openai_conversation/test_conversation.py | 343 +----------------- .../openai_conversation/test_entity.py | 77 ++++ .../openai_conversation/test_init.py | 195 ++++++++-- 14 files changed, 1152 insertions(+), 463 deletions(-) create mode 100644 homeassistant/components/openai_conversation/ai_task.py create mode 100644 tests/components/openai_conversation/test_ai_task.py create mode 100644 tests/components/openai_conversation/test_entity.py diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 721ab44639f..77b71ae372d 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from types import MappingProxyType import openai from openai.types.images_response import ImagesResponse @@ -45,9 +46,11 @@ from .const import ( CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_AI_TASK_NAME, DEFAULT_NAME, DOMAIN, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, @@ -59,7 +62,7 @@ from .entity import async_prepare_files_for_prompt SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_CONTENT = "generate_content" -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] @@ -153,28 +156,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EasyInputMessageParam(type="message", role="user", content=content) ] - try: - model_args = { - "model": model, - "input": messages, - "max_output_tokens": conversation_subentry.data.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": conversation_subentry.data.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - "user": call.context.user_id, - "store": False, + model_args = { + "model": model, + "input": messages, + "max_output_tokens": conversation_subentry.data.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": conversation_subentry.data.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": conversation_subentry.data.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) } - if model.startswith("o"): - model_args["reasoning"] = { - "effort": conversation_subentry.data.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - + try: response: Response = await client.responses.create(**model_args) except openai.OpenAIError as err: @@ -361,6 +364,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py new file mode 100644 index 00000000000..ff8c6e62520 --- /dev/null +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -0,0 +1,77 @@ +"""AI Task integration for OpenAI.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads + +from .entity import OpenAIBaseLLMEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OpenAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenAITaskEntity( + ai_task.AITaskEntity, + OpenAIBaseLLMEntity, +): + """OpenAI AI Task entity.""" + + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.name, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + _LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError("Error with OpenAI structured response") from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ae1e2f31a85..ce6872c7c20 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -55,9 +55,12 @@ from .const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, @@ -77,12 +80,6 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -99,7 +96,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -129,10 +126,16 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ], ) @@ -146,11 +149,14 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": OpenAISubentryFlowHandler, + "ai_task_data": OpenAISubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" +class OpenAISubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing OpenAI subentries.""" last_rendered_recommended = False options: dict[str, Any] @@ -164,7 +170,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Add a subentry.""" - self.options = RECOMMENDED_OPTIONS.copy() + if self._subentry_type == "ai_task_data": + self.options = RECOMMENDED_AI_TASK_OPTIONS.copy() + else: + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() return await self.async_step_init() async def async_step_reconfigure( @@ -181,6 +190,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): # abort if entry is not loaded if self._get_entry().state != ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") + options = self.options hass_apis: list[SelectOptionDict] = [ @@ -198,28 +208,32 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): step_schema: VolDictType = {} if self._is_new: - step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = ( - str + if self._subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME + step_schema[vol.Required(CONF_NAME, default=default_name)] = str + + if self._subentry_type == "conversation": + step_schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } ) - step_schema.update( - { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API): SelectSelector( - SelectSelectorConfig(options=hass_apis, multiple=True) - ), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } - ) + step_schema[ + vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)) + ] = bool if user_input is not None: if not user_input.get(CONF_LLM_HASS_API): @@ -320,7 +334,9 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) - if not model.startswith(tuple(UNSUPPORTED_WEB_SEARCH_MODELS)): + if self._subentry_type == "conversation" and not model.startswith( + tuple(UNSUPPORTED_WEB_SEARCH_MODELS) + ): step_schema.update( { vol.Optional( @@ -362,7 +378,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): if not step_schema: if self._is_new: return self.async_create_entry( - title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + title=options.pop(CONF_NAME), data=options, ) return self.async_update_and_abort( @@ -384,7 +400,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow): options.update(user_input) if self._is_new: return self.async_create_entry( - title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + title=options.pop(CONF_NAME), data=options, ) return self.async_update_and_abort( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 6a6a5b2ce6e..777ded55657 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -2,10 +2,14 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" @@ -51,3 +55,12 @@ UNSUPPORTED_WEB_SEARCH_MODELS: list[str] = [ "o1", "o3-mini", ] + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 7351cbccbfa..97f3bd0ccfe 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -39,6 +39,7 @@ from openai.types.responses import ( ) from openai.types.responses.response_input_param import FunctionCallOutput from openai.types.responses.web_search_tool_param import UserLocation +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -47,6 +48,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify from .const import ( CONF_CHAT_MODEL, @@ -79,6 +81,47 @@ if TYPE_CHECKING: MAX_TOOL_ITERATIONS = 10 +def _adjust_schema(schema: dict[str, Any]) -> None: + """Adjust the schema to be compatible with OpenAI API.""" + if schema["type"] == "object": + if "properties" not in schema: + return + + if "required" not in schema: + schema["required"] = [] + + # Ensure all properties are required + for prop, prop_info in schema["properties"].items(): + _adjust_schema(prop_info) + if prop not in schema["required"]: + prop_info["type"] = [prop_info["type"], "null"] + schema["required"].append(prop) + + elif schema["type"] == "array": + if "items" not in schema: + return + + _adjust_schema(schema["items"]) + + +def _format_structured_output( + schema: vol.Schema, llm_api: llm.APIInstance | None +) -> dict[str, Any]: + """Format the schema to be compatible with OpenAI API.""" + result: dict[str, Any] = convert( + schema, + custom_serializer=( + llm_api.custom_serializer if llm_api else llm.selector_serializer + ), + ) + + _adjust_schema(result) + + result["strict"] = True + result["additionalProperties"] = False + return result + + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None ) -> FunctionToolParam: @@ -243,6 +286,8 @@ class OpenAIBaseLLMEntity(Entity): async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -273,39 +318,47 @@ class OpenAIBaseLLMEntity(Entity): tools = [] tools.append(web_search) - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_args = { + "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + "input": [], + "max_output_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + "store": False, + "stream": True, + } + if tools: + model_args["tools"] = tools + + if model_args["model"].startswith("o"): + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + else: + model_args["store"] = False + messages = [ m for content in chat_log.content for m in _convert_content_to_param(content) ] + if structure and structure_name: + model_args["text"] = { + "format": { + "type": "json_schema", + "name": slugify(structure_name), + "schema": _format_structured_output(structure, chat_log.llm_api), + }, + } client = self.entry.runtime_data # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "input": messages, - "max_output_tokens": options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "store": False, - "stream": True, - } - if tools: - model_args["tools"] = tools - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - model_args["include"] = ["reasoning.encrypted_content"] + model_args["input"] = messages try: result = await client.responses.create(**model_args) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index ffbe84337b7..5011fc9cf99 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -68,6 +68,52 @@ "error": { "model_not_supported": "This model is not supported, please select a different model" } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add Generate data with AI service", + "reconfigure": "Reconfigure Generate data with AI service" + }, + "entry_type": "Generate data with AI service", + "step": { + "init": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::openai_conversation::config_subentries::conversation::step::init::data::recommended%]" + } + }, + "advanced": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]", + "data": { + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]", + "temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]", + "top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]" + } + }, + "model": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]", + "data": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]" + }, + "data_description": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]" + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]" + }, + "error": { + "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]" + } } }, "selector": { diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index dda2fe16a63..11dc978250a 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1 +1,241 @@ """Tests for the OpenAI Conversation integration.""" + +from openai.types.responses import ( + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, +) +from openai.types.responses.response_function_web_search import ActionSearch + + +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + text="".join(text), + sequence_number=0, + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAA", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAABBB", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + + +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, + status="in_progress", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, + status="completed", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 628c1846e16..84c907a7c2e 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,13 +1,30 @@ """Tests helpers.""" +from collections.abc import Generator from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ResponseInProgressEvent, + ResponseOutputItemDoneEvent, + ResponseTextConfig, +) +from openai.types.responses.response import IncompleteDetails import pytest from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + RECOMMENDED_AI_TASK_OPTIONS, ) from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API @@ -19,14 +36,14 @@ from tests.common import MockConfigEntry @pytest.fixture -def mock_subentry_data() -> dict[str, Any]: +def mock_conversation_subentry_data() -> dict[str, Any]: """Mock subentry data.""" return {} @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_subentry_data: dict[str, Any] + hass: HomeAssistant, mock_conversation_subentry_data: dict[str, Any] ) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( @@ -36,13 +53,20 @@ def mock_config_entry( "api_key": "bla", }, version=2, + minor_version=3, subentries_data=[ ConfigSubentryData( - data=mock_subentry_data, + data=mock_conversation_subentry_data, subentry_type="conversation", title=DEFAULT_CONVERSATION_NAME, unique_id=None, - ) + ), + ConfigSubentryData( + data=RECOMMENDED_AI_TASK_OPTIONS, + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), ], ) entry.add_to_hass(hass) @@ -91,3 +115,94 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_create_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools", []), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + sequence_number=0, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + sequence_number=0, + type="response.in_progress", + ) + response.status = "completed" + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + + yield value + + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + sequence_number=0, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + sequence_number=0, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + sequence_number=0, + type="response.completed", + ) + + with patch( + "openai.resources.responses.AsyncResponses.create", + AsyncMock(), + ) as mock_create: + mock_create.side_effect = lambda **kwargs: mock_generator( + mock_create.return_value.pop(0), **kwargs + ) + + yield mock_create diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index 8648e47474e..4eff869b016 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_devices[mock_subentry_data0] +# name: test_devices[mock_conversation_subentry_data0] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -26,7 +26,7 @@ 'via_device_id': None, }) # --- -# name: test_devices[mock_subentry_data1] +# name: test_devices[mock_conversation_subentry_data1] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py new file mode 100644 index 00000000000..4541e11f5f8 --- /dev/null +++ b/tests/components/openai_conversation/test_ai_task.py @@ -0,0 +1,124 @@ +"""Test AI Task platform of OpenAI Conversation integration.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from . import create_message_item + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.openai_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="The test data", output_index=0) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "The test data" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task structured data generation.""" + # Mock the OpenAI response stream with JSON data + mock_create_stream.return_value = [ + create_message_item( + id="msg_A", text='{"characters": ["Mario", "Luigi"]}', output_index=0 + ) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + assert result.data == {"characters": ["Mario", "Luigi"]} + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with invalid JSON response.""" + # Mock the OpenAI response stream with invalid JSON + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="INVALID JSON RESPONSE", output_index=0) + ] + + with pytest.raises( + HomeAssistantError, match="Error with OpenAI structured response" + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index e845828570c..0ccbc39160a 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -8,7 +8,9 @@ from openai.types.responses import Response, ResponseOutputMessage, ResponseOutp import pytest from homeassistant import config_entries -from homeassistant.components.openai_conversation.config_flow import RECOMMENDED_OPTIONS +from homeassistant.components.openai_conversation.config_flow import ( + RECOMMENDED_CONVERSATION_OPTIONS, +) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, @@ -24,8 +26,10 @@ from homeassistant.components.openai_conversation.const import ( CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_P, @@ -77,10 +81,16 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -131,14 +141,14 @@ async def test_creating_conversation_subentry( result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {"name": "My Custom Agent", **RECOMMENDED_OPTIONS}, + {"name": "My Custom Agent", **RECOMMENDED_CONVERSATION_OPTIONS}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "My Custom Agent" - processed_options = RECOMMENDED_OPTIONS.copy() + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() assert result2["data"] == processed_options @@ -709,3 +719,110 @@ async def test_subentry_web_search_user_location( CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + original ai_task + assert len(mock_config_entry.subentries) == 2 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + CONF_RECOMMENDED: True, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + CONF_RECOMMENDED: True, + } + + assert ( + len(mock_config_entry.subentries) == 3 + ) # Original conversation + original ai_task + new ai_task + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_creating_ai_task_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry with advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + + # Go to advanced settings + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Advanced AI Task", + CONF_RECOMMENDED: False, + }, + ) + + assert result2.get("type") is FlowResultType.FORM + assert result2.get("step_id") == "advanced" + + # Configure advanced settings + result3 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + + assert result3.get("type") is FlowResultType.CREATE_ENTRY + assert result3.get("title") == "Advanced AI Task" + assert result3.get("data") == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 7a3bcb21768..39cd129e1ba 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,41 +1,15 @@ """Tests for the OpenAI integration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import httpx from openai import AuthenticationError, RateLimitError -from openai.types import ResponseFormatText from openai.types.responses import ( - Response, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, ResponseError, ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseIncompleteEvent, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputText, - ResponseReasoningItem, ResponseStreamEvent, - ResponseTextConfig, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, - ResponseWebSearchCallCompletedEvent, - ResponseWebSearchCallInProgressEvent, - ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response import IncompleteDetails -from openai.types.responses.response_function_web_search import ActionSearch import pytest from syrupy.assertion import SnapshotAssertion @@ -55,6 +29,13 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import intent from homeassistant.setup import async_setup_component +from . import ( + create_function_tool_call_item, + create_message_item, + create_reasoning_item, + create_web_search_item, +) + from tests.common import MockConfigEntry from tests.components.conversation import ( MockChatLog, @@ -62,97 +43,6 @@ from tests.components.conversation import ( ) -@pytest.fixture -def mock_create_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(events, **kwargs): - response = Response( - id="resp_A", - created_at=1700000000, - error=None, - incomplete_details=None, - instructions=kwargs.get("instructions"), - metadata=kwargs.get("metadata", {}), - model=kwargs.get("model", "gpt-4o-mini"), - object="response", - output=[], - parallel_tool_calls=kwargs.get("parallel_tool_calls", True), - temperature=kwargs.get("temperature", 1.0), - tool_choice=kwargs.get("tool_choice", "auto"), - tools=kwargs.get("tools"), - top_p=kwargs.get("top_p", 1.0), - max_output_tokens=kwargs.get("max_output_tokens", 100000), - previous_response_id=kwargs.get("previous_response_id"), - reasoning=kwargs.get("reasoning"), - status="in_progress", - text=kwargs.get( - "text", ResponseTextConfig(format=ResponseFormatText(type="text")) - ), - truncation=kwargs.get("truncation", "disabled"), - usage=None, - user=kwargs.get("user"), - store=kwargs.get("store", True), - ) - yield ResponseCreatedEvent( - response=response, - sequence_number=0, - type="response.created", - ) - yield ResponseInProgressEvent( - response=response, - sequence_number=0, - type="response.in_progress", - ) - response.status = "completed" - - for value in events: - if isinstance(value, ResponseOutputItemDoneEvent): - response.output.append(value.item) - elif isinstance(value, IncompleteDetails): - response.status = "incomplete" - response.incomplete_details = value - break - if isinstance(value, ResponseError): - response.status = "failed" - response.error = value - break - - yield value - - if isinstance(value, ResponseErrorEvent): - return - - if response.status == "incomplete": - yield ResponseIncompleteEvent( - response=response, - sequence_number=0, - type="response.incomplete", - ) - elif response.status == "failed": - yield ResponseFailedEvent( - response=response, - sequence_number=0, - type="response.failed", - ) - else: - yield ResponseCompletedEvent( - response=response, - sequence_number=0, - type="response.completed", - ) - - with patch( - "openai.resources.responses.AsyncResponses.create", - AsyncMock(), - ) as mock_create: - mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0), **kwargs - ) - - yield mock_create - - async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -347,225 +237,6 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -def create_message_item( - id: str, text: str | list[str], output_index: int -) -> list[ResponseStreamEvent]: - """Create a message item.""" - if isinstance(text, str): - text = [text] - - content = ResponseOutputText(annotations=[], text="", type="output_text") - events = [ - ResponseOutputItemAddedEvent( - item=ResponseOutputMessage( - id=id, - content=[], - type="message", - role="assistant", - status="in_progress", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseContentPartAddedEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - sequence_number=0, - type="response.content_part.added", - ), - ] - - content.text = "".join(text) - events.extend( - ResponseTextDeltaEvent( - content_index=0, - delta=delta, - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.output_text.delta", - ) - for delta in text - ) - - events.extend( - [ - ResponseTextDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - text="".join(text), - sequence_number=0, - type="response.output_text.done", - ), - ResponseContentPartDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - sequence_number=0, - type="response.content_part.done", - ), - ResponseOutputItemDoneEvent( - item=ResponseOutputMessage( - id=id, - content=[content], - role="assistant", - status="completed", - type="message", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - ) - - return events - - -def create_function_tool_call_item( - id: str, arguments: str | list[str], call_id: str, name: str, output_index: int -) -> list[ResponseStreamEvent]: - """Create a function tool call item.""" - if isinstance(arguments, str): - arguments = [arguments] - - events = [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="", - call_id=call_id, - name=name, - type="function_call", - status="in_progress", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ) - ] - - events.extend( - ResponseFunctionCallArgumentsDeltaEvent( - delta=delta, - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.function_call_arguments.delta", - ) - for delta in arguments - ) - - events.append( - ResponseFunctionCallArgumentsDoneEvent( - arguments="".join(arguments), - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.function_call_arguments.done", - ) - ) - - events.append( - ResponseOutputItemDoneEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="".join(arguments), - call_id=call_id, - name=name, - type="function_call", - status="completed", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ) - ) - - return events - - -def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a reasoning item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - encrypted_content="AAA", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseOutputItemDoneEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - encrypted_content="AAABBB", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - - -def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a web search call item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionWebSearch( - id=id, - status="in_progress", - action=ActionSearch(query="query", type="search"), - type="web_search_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseWebSearchCallInProgressEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.in_progress", - ), - ResponseWebSearchCallSearchingEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.searching", - ), - ResponseWebSearchCallCompletedEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.completed", - ), - ResponseOutputItemDoneEvent( - item=ResponseFunctionWebSearch( - id=id, - status="completed", - action=ActionSearch(query="query", type="search"), - type="web_search_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - - async def test_function_call( hass: HomeAssistant, mock_config_entry_with_reasoning_model: MockConfigEntry, diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py new file mode 100644 index 00000000000..58187bd63e9 --- /dev/null +++ b/tests/components/openai_conversation/test_entity.py @@ -0,0 +1,77 @@ +"""Tests for the OpenAI Conversation entity.""" + +import voluptuous as vol + +from homeassistant.components.openai_conversation.entity import ( + _format_structured_output, +) +from homeassistant.helpers import selector + + +async def test_format_structured_output() -> None: + """Test the format_structured_output function.""" + schema = vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + vol.Optional("age"): selector.NumberSelector( + config=selector.NumberSelectorConfig( + min=0, + max=120, + ), + ), + vol.Required("stuff"): selector.ObjectSelector( + { + "multiple": True, + "fields": { + "item_name": { + "selector": {"text": None}, + }, + "item_value": { + "selector": {"text": None}, + }, + }, + } + ), + } + ) + assert _format_structured_output(schema, None) == { + "additionalProperties": False, + "properties": { + "age": { + "maximum": 120.0, + "minimum": 0.0, + "type": [ + "number", + "null", + ], + }, + "name": { + "type": "string", + }, + "stuff": { + "items": { + "properties": { + "item_name": { + "type": ["string", "null"], + }, + "item_value": { + "type": ["string", "null"], + }, + }, + "required": [ + "item_name", + "item_value", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "stuff", + "age", + ], + "strict": True, + "type": "object", + } diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3e13cb3dd1c..7af1151075c 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -17,7 +17,10 @@ from syrupy.assertion import SnapshotAssertion from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL -from homeassistant.components.openai_conversation.const import DOMAIN +from homeassistant.components.openai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -534,7 +537,7 @@ async def test_generate_content_service_error( ) -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -582,17 +585,33 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} - assert len(mock_config_entry.subentries) == 1 + assert len(mock_config_entry.subentries) == 2 - subentry = next(iter(mock_config_entry.subentries.values())) - assert subentry.unique_id is None - assert subentry.title == "ChatGPT" - assert subentry.subentry_type == "conversation" - assert subentry.data == OPTIONS + # Find the conversation subentry + conversation_subentry = None + ai_task_subentry = None + for subentry in mock_config_entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + elif subentry.subentry_type == "ai_task_data": + ai_task_subentry = subentry + assert conversation_subentry is not None + assert conversation_subentry.unique_id is None + assert conversation_subentry.title == "ChatGPT" + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == OPTIONS + + assert ai_task_subentry is not None + assert ai_task_subentry.unique_id is None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.subentry_type == "ai_task_data" + + # Use conversation subentry for the rest of the assertions + subentry = conversation_subentry migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None @@ -617,12 +636,12 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_keys( +async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with different API keys.""" + """Test migration from version 1 with different API keys.""" # Create two v1 config entries with different API keys options = { "recommended": True, @@ -695,28 +714,38 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options - assert len(entry.subentries) == 1 - subentry = list(entry.subentries.values())[0] - assert subentry.subentry_type == "conversation" - assert subentry.data == options - assert subentry.title == f"ChatGPT {idx + 1}" + assert len(entry.subentries) == 2 + + conversation_subentry = None + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + break + + assert conversation_subentry is not None + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == options + assert conversation_subentry.title == f"ChatGPT {idx + 1}" + + # Use conversation subentry for device assertions + subentry = conversation_subentry dev = device_registry.async_get_device( - identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None assert dev.config_entries == {entry.entry_id} assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} -async def test_migration_from_v1_to_v2_with_same_keys( +async def test_migration_from_v1_with_same_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with same API keys consolidates entries.""" + """Test migration from version 1 with same API keys consolidates entries.""" # Create two v1 config entries with the same API key options = { "recommended": True, @@ -790,17 +819,28 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options - assert len(entry.subentries) == 2 # Two subentries from the two original entries + assert ( + len(entry.subentries) == 3 + ) # Two conversation subentries + one AI task subentry - # Check both subentries exist with correct data - subentries = list(entry.subentries.values()) - titles = [sub.title for sub in subentries] + # Check both conversation subentries exist with correct data + conversation_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "conversation" + ] + ai_task_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "ai_task_data" + ] + + assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 + + titles = [sub.title for sub in conversation_subentries] assert "ChatGPT" in titles assert "ChatGPT 2" in titles - for subentry in subentries: + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -815,12 +855,12 @@ async def test_migration_from_v1_to_v2_with_same_keys( } -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core 2025.7.0b0-2025.7.0b1: @@ -913,16 +953,22 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "ChatGPT" - assert len(entry.subentries) == 2 + assert len(entry.subentries) == 3 # 2 conversation + 1 AI task conversation_subentries = [ subentry for subentry in entry.subentries.values() if subentry.subentry_type == "conversation" ] + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -972,7 +1018,9 @@ async def test_migration_from_v2_1_to_v2_2( } -@pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) +@pytest.mark.parametrize( + "mock_conversation_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}] +) async def test_devices( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -980,12 +1028,89 @@ async def test_devices( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Assert exception when invalid config entry is provided.""" + """Test devices are correctly created for subentries.""" devices = dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ) - assert len(devices) == 1 + assert len(devices) == 2 # One for conversation, one for AI task + + # Use the first device for snapshot comparison device = devices[0] assert device == snapshot(exclude=props("identifiers")) - subentry = next(iter(mock_config_entry.subentries.values())) - assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + # Verify the device has identifiers matching one of the subentries + expected_identifiers = [ + {(DOMAIN, subentry.subentry_id)} + for subentry in mock_config_entry.subentries.values() + ] + assert device.identifiers in expected_identifiers + + +async def test_migration_from_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with a conversation subentry + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=2, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + + # Check conversation subentry is still there + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 1 + conversation_subentry = conversation_subentries[0] + assert conversation_subentry.data == options + + # Check AI Task subentry was added + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + ai_task_subentry = ai_task_subentries[0] + assert ai_task_subentry.data == {"recommended": True} + assert ai_task_subentry.title == "OpenAI AI Task" From 6eeec948a8d64d458f4bc988343d9de183a38114 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 10 Jul 2025 23:09:47 +0200 Subject: [PATCH 1309/1664] Update frontend to 20250702.2 (#148573) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 748d8f0c6f0..a7582ebc5e2 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250702.1"] + "requirements": ["home-assistant-frontend==20250702.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b73a458b7ec..52cfa22212d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b4a53c7dba7..dd5108a807a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3173b1443b6..4cd94a3f6ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 18a89d58156de33233120f995a166490cd53c223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Jul 2025 11:10:48 -1000 Subject: [PATCH 1310/1664] Bump aiohttp to 3.12.14 (#148565) --- homeassistant/components/http/ban.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 7e55191639b..71f3d54bef6 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N """Initialize bans when app starts up.""" await app[KEY_BAN_MANAGER].async_load() - app.on_startup.append(ban_startup) # type: ignore[arg-type] + app.on_startup.append(ban_startup) @middleware diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 52cfa22212d..89ff2238f61 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 3841d234ddf..3ea2a9c9f1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.13", + "aiohttp==3.12.14", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index c246af65758..118d2bedfa6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.1 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1 From a2220cc2e657bab50dba5bfa1408df3886c84bab Mon Sep 17 00:00:00 2001 From: Harry Heymann Date: Thu, 10 Jul 2025 17:36:51 -0400 Subject: [PATCH 1311/1664] Add LED intensity custom attributes for Matter Inovelli Dimmers (#148074) Co-authored-by: Norbert Rittel --- homeassistant/components/matter/number.py | 32 +++++ homeassistant/components/matter/strings.json | 6 + .../matter/snapshots/test_number.ambr | 114 ++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py index c948f39834a..ea348c20012 100644 --- a/homeassistant/components/matter/number.py +++ b/homeassistant/components/matter/number.py @@ -339,4 +339,36 @@ DISCOVERY_SCHEMAS = [ clusters.TemperatureControl.Attributes.MaxTemperature, ), ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="InovelliLEDIndicatorIntensityOff", + entity_category=EntityCategory.CONFIG, + translation_key="led_indicator_intensity_off", + native_max_value=75, + native_min_value=0, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOff, + ), + ), + MatterDiscoverySchema( + platform=Platform.NUMBER, + entity_description=MatterNumberEntityDescription( + key="InovelliLEDIndicatorIntensityOn", + entity_category=EntityCategory.CONFIG, + translation_key="led_indicator_intensity_on", + native_max_value=75, + native_min_value=0, + native_step=1, + mode=NumberMode.BOX, + ), + entity_class=MatterNumber, + required_attributes=( + custom_clusters.InovelliCluster.Attributes.LEDIndicatorIntensityOn, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 6d167e4136e..20d7eb69ba4 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -194,6 +194,12 @@ }, "auto_relock_timer": { "name": "Autorelock time" + }, + "led_indicator_intensity_off": { + "name": "LED off intensity" + }, + "led_indicator_intensity_on": { + "name": "LED on intensity" } }, "light": { diff --git a/tests/components/matter/snapshots/test_number.ambr b/tests/components/matter/snapshots/test_number.ambr index 8d27c4b4691..da709615610 100644 --- a/tests/components/matter/snapshots/test_number.ambr +++ b/tests/components/matter/snapshots/test_number.ambr @@ -750,6 +750,120 @@ 'state': 'unavailable', }) # --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_off_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.inovelli_led_off_intensity', + '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': 'LED off intensity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_indicator_intensity_off', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOff-305134641-305070178', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_off_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli LED off intensity', + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.inovelli_led_off_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_on_intensity-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.inovelli_led_on_intensity', + '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': 'LED on intensity', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'led_indicator_intensity_on', + 'unique_id': '00000000000004D2-00000000000000C5-MatterNodeDevice-1-InovelliLEDIndicatorIntensityOn-305134641-305070177', + 'unit_of_measurement': None, + }) +# --- +# name: test_numbers[multi_endpoint_light][number.inovelli_led_on_intensity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Inovelli LED on intensity', + 'max': 75, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'context': , + 'entity_id': 'number.inovelli_led_on_intensity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33', + }) +# --- # name: test_numbers[multi_endpoint_light][number.inovelli_off_transition_time-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 19b3b6cb2821c030648a3cbf2010134bd915fc09 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:45:11 +0200 Subject: [PATCH 1312/1664] Add attachment support to Google Gemini (#148208) --- .../ai_task.py | 7 +- .../entity.py | 26 ++++- .../test_ai_task.py | 96 ++++++++++++++++++- .../test_init.py | 1 - 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index b4f9d73e38d..80d5a1dfa06 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -37,7 +37,10 @@ class GoogleGenerativeAITaskEntity( ): """Google Generative AI AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, @@ -45,7 +48,7 @@ class GoogleGenerativeAITaskEntity( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log, task.structure) + await self._async_handle_chat_log(chat_log, task.structure, task.attachments) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index 8f8edea18cb..fce1fdd40e7 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -8,7 +8,7 @@ from collections.abc import AsyncGenerator, Callable from dataclasses import replace import mimetypes from pathlib import Path -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from google.genai import Client from google.genai.errors import APIError, ClientError @@ -30,8 +30,8 @@ from google.genai.types import ( import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components import conversation -from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm @@ -60,6 +60,9 @@ from .const import ( TIMEOUT_MILLIS, ) +if TYPE_CHECKING: + from . import GoogleGenerativeAIConfigEntry + # Max number of back and forth with the LLM to generate a response MAX_TOOL_ITERATIONS = 10 @@ -313,7 +316,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): def __init__( self, - entry: ConfigEntry, + entry: GoogleGenerativeAIConfigEntry, subentry: ConfigSubentry, default_model: str = RECOMMENDED_CHAT_MODEL, ) -> None: @@ -335,6 +338,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): self, chat_log: conversation.ChatLog, structure: vol.Schema | None = None, + attachments: list[ai_task.PlayMediaWithId] | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -438,6 +442,18 @@ class GoogleGenerativeAILLMBaseEntity(Entity): user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) chat_request: str | list[Part] = user_message.content + if attachments: + if any(a.path is None for a in attachments): + raise HomeAssistantError( + "Only local attachments are currently supported" + ) + files = await async_prepare_files_for_prompt( + self.hass, + self._genai_client, + [a.path for a in attachments], # type: ignore[misc] + ) + chat_request = [chat_request, *files] + # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): try: @@ -508,7 +524,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity): async def async_prepare_files_for_prompt( hass: HomeAssistant, client: Client, files: list[Path] ) -> list[File]: - """Append files to a prompt. + """Upload files so they can be attached to a prompt. Caller needs to ensure that the files are allowed. """ diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index b2b44aa1cd6..653b41fcb6e 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -1,12 +1,13 @@ """Test AI Task platform of Google Generative AI Conversation integration.""" -from unittest.mock import AsyncMock +from pathlib import Path +from unittest.mock import AsyncMock, patch -from google.genai.types import GenerateContentResponse +from google.genai.types import File, FileState, GenerateContentResponse import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -64,6 +65,93 @@ async def test_generate_data( ) assert result.data == "Hi there!" + # Test with attachments + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + file1 = File(name="doorbell_snapshot.jpg", state=FileState.ACTIVE) + file2 = File(name="context.txt", state=FileState.ACTIVE) + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "google.genai.files.Files.upload", + side_effect=[file1, file2], + ) as mock_upload, + patch("pathlib.Path.exists", return_value=True), + patch.object(hass.config, "is_allowed_path", return_value=True), + patch("mimetypes.guess_type", return_value=["image/jpeg"]), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + outgoing_message = mock_send_message_stream.mock_calls[1][2]["message"] + assert outgoing_message == ["Test prompt", file1, file2] + + assert result.data == "Hi there!" + assert len(mock_upload.mock_calls) == 2 + assert mock_upload.mock_calls[0][2]["file"] == Path("doorbell_snapshot.jpg") + assert mock_upload.mock_calls[1][2]["file"] == Path("context.txt") + + # Test attachments require play media with a path + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=None, + ), + ], + ), + pytest.raises( + HomeAssistantError, match="Only local attachments are currently supported" + ), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + # Test with structure mock_send_message_stream.return_value = [ [ GenerateContentResponse( @@ -97,7 +185,7 @@ async def test_generate_data( ) assert result.data == {"characters": ["Mario", "Luigi"]} - assert len(mock_chat_create.mock_calls) == 2 + assert len(mock_chat_create.mock_calls) == 4 config = mock_chat_create.mock_calls[-1][2]["config"] assert config.response_mime_type == "application/json" assert config.response_schema == { diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 351895c89fb..351293e7ac0 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -87,7 +87,6 @@ async def test_generate_content_service_with_image( ), patch("pathlib.Path.exists", return_value=True), patch.object(hass.config, "is_allowed_path", return_value=True), - patch("builtins.open", mock_open(read_data="this is an image")), patch("mimetypes.guess_type", return_value=["image/jpeg"]), ): response = await hass.services.async_call( From e6702d2392fe0f47cddcf0e31e1f56ae94182298 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:45:56 +0200 Subject: [PATCH 1313/1664] Serialize Object Selector correctly if a field is required (#148577) --- homeassistant/helpers/llm.py | 14 ++++++++++---- tests/helpers/test_llm.py | 2 ++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index b239ad99119..784288375e9 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -779,13 +779,19 @@ def selector_serializer(schema: Any) -> Any: # noqa: C901 if isinstance(schema, selector.ObjectSelector): result = {"type": "object"} if fields := schema.config.get("fields"): - result["properties"] = { - field: convert( + properties = {} + required = [] + for field, field_schema in fields.items(): + properties[field] = convert( selector.selector(field_schema["selector"]), custom_serializer=selector_serializer, ) - for field, field_schema in fields.items() - } + if field_schema.get("required"): + required.append(field) + result["properties"] = properties + + if required: + result["required"] = required else: result["additionalProperties"] = True if schema.config.get("multiple"): diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index 78ff675f0b6..9ba93cef4ca 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1161,6 +1161,7 @@ async def test_selector_serializer( "name": {"type": "string"}, "percentage": {"type": "number", "minimum": 30, "maximum": 100}, }, + "required": ["name"], } assert selector_serializer( selector.ObjectSelector( @@ -1190,6 +1191,7 @@ async def test_selector_serializer( "maximum": 100, }, }, + "required": ["name"], }, } assert selector_serializer( From 193b32218f8398fd88c3e94dddcbf210def07022 Mon Sep 17 00:00:00 2001 From: jlestel Date: Fri, 11 Jul 2025 01:41:03 +0200 Subject: [PATCH 1314/1664] Fix domain validation in Tesla Fleet (#148555) --- homeassistant/components/tesla_fleet/config_flow.py | 4 +++- tests/components/tesla_fleet/test_config_flow.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tesla_fleet/config_flow.py b/homeassistant/components/tesla_fleet/config_flow.py index ac55a380abb..48eb736ae56 100644 --- a/homeassistant/components/tesla_fleet/config_flow.py +++ b/homeassistant/components/tesla_fleet/config_flow.py @@ -226,5 +226,7 @@ class OAuth2FlowHandler( def _is_valid_domain(self, domain: str) -> bool: """Validate domain format.""" # Basic domain validation regex - domain_pattern = re.compile(r"^(?:[a-zA-Z0-9]+\.)+[a-zA-Z0-9-]+$") + domain_pattern = re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$" + ) return bool(domain_pattern.match(domain)) diff --git a/tests/components/tesla_fleet/test_config_flow.py b/tests/components/tesla_fleet/test_config_flow.py index 4a8142a2d85..98806a27268 100644 --- a/tests/components/tesla_fleet/test_config_flow.py +++ b/tests/components/tesla_fleet/test_config_flow.py @@ -713,8 +713,11 @@ async def test_reauth_confirm_form(hass: HomeAssistant) -> None: ("domain", "expected_valid"), [ ("example.com", True), + ("exa-mple.com", True), ("test.example.com", True), + ("tes-t.example.com", True), ("sub.domain.example.org", True), + ("su-b.dom-ain.exam-ple.org", True), ("https://example.com", False), ("invalid-domain", False), ("", False), @@ -722,6 +725,8 @@ async def test_reauth_confirm_form(hass: HomeAssistant) -> None: ("example.", False), (".example.com", False), ("exam ple.com", False), + ("-example.com", False), + ("domain-.example.com", False), ], ) def test_is_valid_domain(domain: str, expected_valid: bool) -> None: From c6c622797d74b3e2a09f2c199e586272c75c8532 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 11 Jul 2025 13:55:13 +0800 Subject: [PATCH 1315/1664] Add YoLink YS7A12 support (#148588) --- homeassistant/components/yolink/binary_sensor.py | 8 ++++++-- homeassistant/components/yolink/sensor.py | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 7f965650354..d57e942734e 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -12,6 +12,7 @@ from yolink.const import ( ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMOKE_ALARM, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ) @@ -53,6 +54,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, + ATTR_DEVICE_SMOKE_ALARM, ] @@ -90,8 +92,10 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="smoke_detected", device_class=BinarySensorDeviceClass.SMOKE, - value=lambda state: state.get("smokeAlarm"), - exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, + value=lambda state: state.get("smokeAlarm") is True + or state.get("denseSmokeAlarm") is True, + exists_fn=lambda device: device.device_type + in [ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_SMOKE_ALARM], ), YoLinkBinarySensorEntityDescription( key="pipe_leak_detected", diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 2845f8ee533..37cd763194d 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -21,6 +21,7 @@ from yolink.const import ( ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, + ATTR_DEVICE_SMOKE_ALARM, ATTR_DEVICE_SOIL_TH_SENSOR, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, @@ -106,6 +107,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_GARAGE_DOOR_CONTROLLER, ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] BATTERY_POWER_SENSOR = [ @@ -126,12 +128,14 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_WATER_METER_CONTROLLER, ATTR_DEVICE_MULTI_WATER_METER_CONTROLLER, ATTR_DEVICE_SOIL_TH_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] MCU_DEV_TEMPERATURE_SENSOR = [ ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_CO_SMOKE_SENSOR, + ATTR_DEVICE_SMOKE_ALARM, ] NONE_HUMIDITY_SENSOR_MODELS = [ From 32121a073c97cc8c95f4d6892d7428c96654e936 Mon Sep 17 00:00:00 2001 From: Robin Thoni Date: Fri, 11 Jul 2025 07:56:23 +0200 Subject: [PATCH 1316/1664] Add release URL for Tessie updates (#148548) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tessie/update.py | 7 +++++++ tests/components/tessie/snapshots/test_update.ambr | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py index e9af673b1f4..cd3c3b32857 100644 --- a/homeassistant/components/tessie/update.py +++ b/homeassistant/components/tessie/update.py @@ -88,6 +88,13 @@ class TessieUpdateEntity(TessieEntity, UpdateEntity): return self.get("vehicle_state_software_update_install_perc") return None + @property + def release_url(self) -> str | None: + """URL to the full release notes of the latest version available.""" + if self.latest_version is None: + return None + return f"https://stats.tessie.com/versions/{self.latest_version}" + async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: diff --git a/tests/components/tessie/snapshots/test_update.ambr b/tests/components/tessie/snapshots/test_update.ambr index 8780f64bb09..ff298f97ecd 100644 --- a/tests/components/tessie/snapshots/test_update.ambr +++ b/tests/components/tessie/snapshots/test_update.ambr @@ -45,7 +45,7 @@ 'installed_version': '2023.38.6', 'latest_version': '2023.44.30.4', 'release_summary': None, - 'release_url': None, + 'release_url': 'https://stats.tessie.com/versions/2023.44.30.4', 'skipped_version': None, 'supported_features': , 'title': None, From cd73824e3e42390920e823f1480fe7792dca6571 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 11 Jul 2025 09:06:18 +0200 Subject: [PATCH 1317/1664] Ensure response is fully read to prevent premature connection closure in rest command (#148532) --- .../components/rest_command/__init__.py | 5 ++++ tests/components/rest_command/test_init.py | 26 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 0a9632b864d..0ea5fc60472 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -178,6 +178,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) if not service.return_response: + # always read the response to avoid closing the connection + # before the server has finished sending it, while avoiding excessive memory usage + async for _ in response.content.iter_chunked(1024): + pass + return None _content = None diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 5549aa67815..b9c1096f26a 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -328,7 +328,7 @@ async def test_rest_command_get_response_malformed_json( aioclient_mock.get( TEST_URL, - content='{"status": "failure", 42', + content=b'{"status": "failure", 42', headers={"content-type": "application/json"}, ) @@ -381,3 +381,27 @@ async def test_rest_command_get_response_none( ) assert not response + + +async def test_rest_command_response_iter_chunked( + hass: HomeAssistant, + setup_component: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Ensure response is consumed when return_response is False.""" + await setup_component() + + png = base64.decodebytes( + b"iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAIAAAB7QOjdAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQ" + b"UAAAAJcEhZcwAAFiUAABYlAUlSJPAAAAAPSURBVBhXY/h/ku////8AECAE1JZPvDAAAAAASUVORK5CYII=" + ) + aioclient_mock.get(TEST_URL, content=png) + + with patch("aiohttp.StreamReader.iter_chunked", autospec=True) as mock_iter_chunked: + response = await hass.services.async_call(DOMAIN, "get_test", {}, blocking=True) + + # Ensure the response is not returned + assert response is None + + # Verify iter_chunked was called with a chunk size + assert mock_iter_chunked.called From 5a4c8373282503a40623120aaeb163c14369dcef Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 11 Jul 2025 11:19:54 +0200 Subject: [PATCH 1318/1664] Fix entity_id should be based on object_id the first time an entity is added (#148484) --- homeassistant/components/mqtt/entity.py | 35 ++++++++++++------- tests/components/mqtt/test_discovery.py | 46 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 338779f32cb..f1594a7b034 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -389,16 +389,6 @@ def async_setup_entity_entry_helper( _async_setup_entities() -def init_entity_id_from_config( - hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str -) -> None: - """Set entity_id from object_id if defined in config.""" - if CONF_OBJECT_ID in config: - entity.entity_id = async_generate_entity_id( - entity_id_format, config[CONF_OBJECT_ID], None, hass - ) - - class MqttAttributesMixin(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -1312,6 +1302,7 @@ class MqttEntity( _attr_should_poll = False _default_name: str | None _entity_id_format: str + _update_registry_entity_id: str | None = None def __init__( self, @@ -1346,13 +1337,33 @@ class MqttEntity( def _init_entity_id(self) -> None: """Set entity_id from object_id if defined in config.""" - init_entity_id_from_config( - self.hass, self, self._config, self._entity_id_format + if CONF_OBJECT_ID not in self._config: + return + self.entity_id = async_generate_entity_id( + self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass ) + if self.unique_id is None: + return + # Check for previous deleted entities + entity_registry = er.async_get(self.hass) + entity_platform = self._entity_id_format.split(".")[0] + if ( + deleted_entry := entity_registry.deleted_entities.get( + (entity_platform, DOMAIN, self.unique_id) + ) + ) and deleted_entry.entity_id != self.entity_id: + # Plan to update the entity_id basis on `object_id` if a deleted entity was found + self._update_registry_entity_id = self.entity_id @final async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" + if self._update_registry_entity_id is not None: + entity_registry = er.async_get(self.hass) + entity_registry.async_update_entity( + self.entity_id, new_entity_id=self._update_registry_entity_id + ) + await super().async_added_to_hass() self._subscriptions = {} self._prepare_subscribe_topics() diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 35a9a0494a6..04b4bda0d79 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1496,6 +1496,52 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered +async def test_discovery_with_object_id_for_previous_deleted_entity( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test discovering an MQTT entity with object_id and unique_id.""" + + topic = "homeassistant/sensor/object/bla/config" + config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "hello_id", "state_topic": "test-topic" }' + ) + new_config = ( + '{ "name": "Hello World 11", "unique_id": "very_unique", ' + '"obj_id": "updated_hello_id", "state_topic": "test-topic" }' + ) + initial_entity_id = "sensor.hello_id" + new_entity_id = "sensor.updated_hello_id" + name = "Hello World 11" + domain = "sensor" + + await mqtt_mock_entry() + async_fire_mqtt_message(hass, topic, config) + await hass.async_block_till_done() + + state = hass.states.get(initial_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + # Delete the entity + async_fire_mqtt_message(hass, topic, "") + await hass.async_block_till_done() + assert (domain, "object bla") not in hass.data["mqtt"].discovery_already_discovered + + # Rediscover with new object_id + async_fire_mqtt_message(hass, topic, new_config) + await hass.async_block_till_done() + + state = hass.states.get(new_entity_id) + + assert state is not None + assert state.name == name + assert (domain, "object bla") in hass.data["mqtt"].discovery_already_discovered + + async def test_discovery_incl_nodeid( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator ) -> None: From 22828568e2831031a85b10a94505f6d030881889 Mon Sep 17 00:00:00 2001 From: Hessel Date: Fri, 11 Jul 2025 11:37:24 +0200 Subject: [PATCH 1319/1664] Wallbox Integration - Type Config Entry (#148594) --- homeassistant/components/wallbox/__init__.py | 12 +++++++----- homeassistant/components/wallbox/coordinator.py | 6 ++++-- homeassistant/components/wallbox/lock.py | 5 ++--- homeassistant/components/wallbox/number.py | 7 +++---- homeassistant/components/wallbox/select.py | 5 ++--- homeassistant/components/wallbox/sensor.py | 5 ++--- homeassistant/components/wallbox/switch.py | 5 ++--- 7 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index c2983d540df..43b5d3ef91f 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -4,13 +4,17 @@ from __future__ import annotations from wallbox import Wallbox -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from .const import UPDATE_INTERVAL -from .coordinator import InvalidAuth, WallboxCoordinator, async_validate_input +from .coordinator import ( + InvalidAuth, + WallboxConfigEntry, + WallboxCoordinator, + async_validate_input, +) PLATFORMS = [ Platform.LOCK, @@ -20,8 +24,6 @@ PLATFORMS = [ Platform.SWITCH, ] -type WallboxConfigEntry = ConfigEntry[WallboxCoordinator] - async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool: """Set up Wallbox from a config entry.""" @@ -45,6 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> b return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WallboxConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index ffd235157ac..82a807e4d09 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -77,6 +77,8 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 210: ChargerStatus.LOCKED_CAR_CONNECTED, } +type WallboxConfigEntry = ConfigEntry[WallboxCoordinator] + def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( func: Callable[Concatenate[_WallboxCoordinatorT, _P], Any], @@ -118,10 +120,10 @@ async def async_validate_input(hass: HomeAssistant, wallbox: Wallbox) -> None: class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" - config_entry: ConfigEntry + config_entry: WallboxConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, wallbox: Wallbox + self, hass: HomeAssistant, config_entry: WallboxConfigEntry, wallbox: Wallbox ) -> None: """Initialize.""" self._station = config_entry.data[CONF_STATION] diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 6ba9058db96..f48ac000110 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.lock import LockEntity, LockEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -14,7 +13,7 @@ from .const import ( CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -27,7 +26,7 @@ LOCK_TYPES: dict[str, LockEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox lock entities in HASS.""" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index af4fbe2c38b..6bc37778a61 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from typing import cast from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -24,7 +23,7 @@ from .const import ( CHARGER_PART_NUMBER_KEY, CHARGER_SERIAL_NUMBER_KEY, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -79,7 +78,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox number entities in HASS.""" @@ -103,7 +102,7 @@ class WallboxNumber(WallboxEntity, NumberEntity): def __init__( self, coordinator: WallboxCoordinator, - entry: ConfigEntry, + entry: WallboxConfigEntry, description: WallboxNumberEntityDescription, ) -> None: """Initialize a Wallbox number entity.""" diff --git a/homeassistant/components/wallbox/select.py b/homeassistant/components/wallbox/select.py index 10ac4e61189..8d4cf252344 100644 --- a/homeassistant/components/wallbox/select.py +++ b/homeassistant/components/wallbox/select.py @@ -8,7 +8,6 @@ from dataclasses import dataclass from requests import HTTPError from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -23,7 +22,7 @@ from .const import ( DOMAIN, EcoSmartMode, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -58,7 +57,7 @@ SELECT_TYPES: dict[str, WallboxSelectEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox select entities in HASS.""" diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 7d5e5b56309..b59e1e5319d 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -11,7 +11,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfElectricCurrent, @@ -44,7 +43,7 @@ from .const import ( CHARGER_STATE_OF_CHARGE_KEY, CHARGER_STATUS_DESCRIPTION_KEY, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity @@ -169,7 +168,7 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index 7a28f863c4d..74f1783f539 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -5,7 +5,6 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -16,7 +15,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, ChargerStatus, ) -from .coordinator import WallboxCoordinator +from .coordinator import WallboxConfigEntry, WallboxCoordinator from .entity import WallboxEntity SWITCH_TYPES: dict[str, SwitchEntityDescription] = { @@ -29,7 +28,7 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = { async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WallboxConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Create wallbox sensor entities in HASS.""" From 0b2ce73eac477659d3dd2a502b6313fe3757411e Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Fri, 11 Jul 2025 11:43:29 +0200 Subject: [PATCH 1320/1664] Fix description of `html5.dismiss` action (#148591) --- homeassistant/components/html5/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/html5/strings.json b/homeassistant/components/html5/strings.json index 2c68223581a..ee844f320bc 100644 --- a/homeassistant/components/html5/strings.json +++ b/homeassistant/components/html5/strings.json @@ -29,7 +29,7 @@ "services": { "dismiss": { "name": "Dismiss", - "description": "Dismisses a html5 notification.", + "description": "Dismisses an HTML5 notification.", "fields": { "target": { "name": "Target", From 87aecf0ed966039609932d09916e560e18d4e3c5 Mon Sep 17 00:00:00 2001 From: Arjan <44190435+vingerha@users.noreply.github.com> Date: Fri, 11 Jul 2025 12:45:21 +0200 Subject: [PATCH 1321/1664] Linkplay: add select entity to set Audio Output hardware (#143329) --- homeassistant/components/linkplay/const.py | 2 +- homeassistant/components/linkplay/icons.json | 5 + homeassistant/components/linkplay/select.py | 112 ++++++++++++++++++ .../components/linkplay/strings.json | 11 ++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/linkplay/select.py diff --git a/homeassistant/components/linkplay/const.py b/homeassistant/components/linkplay/const.py index 74b87f4aae9..ec85e5af97c 100644 --- a/homeassistant/components/linkplay/const.py +++ b/homeassistant/components/linkplay/const.py @@ -19,5 +19,5 @@ class LinkPlaySharedData: DOMAIN = "linkplay" SHARED_DATA = "shared_data" SHARED_DATA_KEY: HassKey[LinkPlaySharedData] = HassKey(SHARED_DATA) -PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER] +PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SELECT] DATA_SESSION = "session" diff --git a/homeassistant/components/linkplay/icons.json b/homeassistant/components/linkplay/icons.json index c0fe86d9ac7..26f7202943f 100644 --- a/homeassistant/components/linkplay/icons.json +++ b/homeassistant/components/linkplay/icons.json @@ -4,6 +4,11 @@ "timesync": { "default": "mdi:clock" } + }, + "select": { + "audio_output_hardware_mode": { + "default": "mdi:transit-connection-horizontal" + } } }, "services": { diff --git a/homeassistant/components/linkplay/select.py b/homeassistant/components/linkplay/select.py new file mode 100644 index 00000000000..ebf5a05512a --- /dev/null +++ b/homeassistant/components/linkplay/select.py @@ -0,0 +1,112 @@ +"""Support for LinkPlay select.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from linkplay.bridge import LinkPlayBridge, LinkPlayPlayer +from linkplay.consts import AudioOutputHwMode +from linkplay.manufacturers import MANUFACTURER_WIIM + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import LinkPlayConfigEntry +from .entity import LinkPlayBaseEntity, exception_wrap + +_LOGGER = logging.getLogger(__name__) + +AUDIO_OUTPUT_HW_MODE_MAP: dict[AudioOutputHwMode, str] = { + AudioOutputHwMode.OPTICAL: "optical", + AudioOutputHwMode.LINE_OUT: "line_out", + AudioOutputHwMode.COAXIAL: "coaxial", + AudioOutputHwMode.HEADPHONES: "headphones", +} + +AUDIO_OUTPUT_HW_MODE_MAP_INV: dict[str, AudioOutputHwMode] = { + v: k for k, v in AUDIO_OUTPUT_HW_MODE_MAP.items() +} + + +async def _get_current_option(bridge: LinkPlayBridge) -> str: + """Get the current hardware mode.""" + modes = await bridge.player.get_audio_output_hw_mode() + return AUDIO_OUTPUT_HW_MODE_MAP[modes.hardware] + + +@dataclass(frozen=True, kw_only=True) +class LinkPlaySelectEntityDescription(SelectEntityDescription): + """Class describing LinkPlay select entities.""" + + set_option_fn: Callable[[LinkPlayPlayer, str], Coroutine[Any, Any, None]] + current_option_fn: Callable[[LinkPlayPlayer], Awaitable[str]] + + +SELECT_TYPES_WIIM: tuple[LinkPlaySelectEntityDescription, ...] = ( + LinkPlaySelectEntityDescription( + key="audio_output_hardware_mode", + translation_key="audio_output_hardware_mode", + current_option_fn=_get_current_option, + set_option_fn=( + lambda linkplay_bridge, + option: linkplay_bridge.player.set_audio_output_hw_mode( + AUDIO_OUTPUT_HW_MODE_MAP_INV[option] + ) + ), + options=list(AUDIO_OUTPUT_HW_MODE_MAP_INV), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LinkPlayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the LinkPlay select from config entry.""" + + # add entities + if config_entry.runtime_data.bridge.device.manufacturer == MANUFACTURER_WIIM: + async_add_entities( + LinkPlaySelect(config_entry.runtime_data.bridge, description) + for description in SELECT_TYPES_WIIM + ) + + +class LinkPlaySelect(LinkPlayBaseEntity, SelectEntity): + """Representation of LinkPlay select.""" + + entity_description: LinkPlaySelectEntityDescription + + def __init__( + self, + bridge: LinkPlayPlayer, + description: LinkPlaySelectEntityDescription, + ) -> None: + """Initialize LinkPlay select.""" + super().__init__(bridge) + self.entity_description = description + self._attr_unique_id = f"{bridge.device.uuid}-{description.key}" + + async def async_update(self) -> None: + """Get the current value from the device.""" + try: + # modes = await self.entity_description.current_option_fn(self._bridge) + self._attr_current_option = await self.entity_description.current_option_fn( + self._bridge + ) + + except ValueError as ex: + _LOGGER.debug( + "Cannot retrieve hardware mode value from device with error:, %s", ex + ) + self._attr_current_option = None + + @exception_wrap + async def async_select_option(self, option: str) -> None: + """Set the option.""" + await self.entity_description.set_option_fn(self._bridge, option) diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json index 5d68754879c..7b0a6cbefe1 100644 --- a/homeassistant/components/linkplay/strings.json +++ b/homeassistant/components/linkplay/strings.json @@ -40,6 +40,17 @@ "timesync": { "name": "Sync time" } + }, + "select": { + "audio_output_hardware_mode": { + "name": "Audio output hardware mode", + "state": { + "optical": "Optical", + "line_out": "Line out", + "coaxial": "Coaxial", + "headphones": "Headphones" + } + } } }, "exceptions": { From ec5991bc686c3ed74185bb581abe4cc84ce0ab1e Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 11 Jul 2025 21:42:50 +1000 Subject: [PATCH 1322/1664] Add support for LIFX 26"x13" Ceiling (#148459) Signed-off-by: Avi Miller --- homeassistant/components/lifx/const.py | 1 + homeassistant/components/lifx/coordinator.py | 50 +- tests/components/lifx/__init__.py | 11 + .../lifx/snapshots/test_diagnostics.ambr | 1276 +++++++++++++++++ tests/components/lifx/test_diagnostics.py | 100 ++ 5 files changed, 1437 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py index ecc572aa006..f0505f9a4fd 100644 --- a/homeassistant/components/lifx/const.py +++ b/homeassistant/components/lifx/const.py @@ -70,6 +70,7 @@ INFRARED_BRIGHTNESS_VALUES_MAP = { } LIFX_CEILING_PRODUCT_IDS = {176, 177, 201, 202} +LIFX_128ZONE_CEILING_PRODUCT_IDS = {201, 202} _LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/lifx/coordinator.py b/homeassistant/components/lifx/coordinator.py index 79ce843b339..c96f53d8f77 100644 --- a/homeassistant/components/lifx/coordinator.py +++ b/homeassistant/components/lifx/coordinator.py @@ -41,6 +41,7 @@ from .const import ( DEFAULT_ATTEMPTS, DOMAIN, IDENTIFY_WAVEFORM, + LIFX_128ZONE_CEILING_PRODUCT_IDS, MAX_ATTEMPTS_PER_UPDATE_REQUEST_MESSAGE, MAX_UPDATE_TIME, MESSAGE_RETRIES, @@ -183,6 +184,11 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): """Return true if this is a matrix device.""" return bool(lifx_features(self.device)["matrix"]) + @cached_property + def is_128zone_matrix(self) -> bool: + """Return true if this is a 128-zone matrix device.""" + return bool(self.device.product in LIFX_128ZONE_CEILING_PRODUCT_IDS) + async def diagnostics(self) -> dict[str, Any]: """Return diagnostic information about the device.""" features = lifx_features(self.device) @@ -216,6 +222,16 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): "last_result": self.device.last_hev_cycle_result, } + if features["matrix"] is True: + device_data["matrix"] = { + "effect": self.device.effect, + "chain": self.device.chain, + "chain_length": self.device.chain_length, + "tile_devices": self.device.tile_devices, + "tile_devices_count": self.device.tile_devices_count, + "tile_device_width": self.device.tile_device_width, + } + if features["infrared"] is True: device_data["infrared"] = {"brightness": self.device.infrared_brightness} @@ -291,6 +307,37 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): return calls + @callback + def _async_build_get64_update_requests(self) -> list[Callable]: + """Build one or more get64 update requests.""" + if self.device.tile_device_width == 0: + return [] + + calls: list[Callable] = [] + calls.append( + partial( + self.device.get64, + tile_index=0, + length=1, + x=0, + y=0, + width=self.device.tile_device_width, + ) + ) + if self.is_128zone_matrix: + # For 128-zone ceiling devices, we need another get64 request for the next set of zones + calls.append( + partial( + self.device.get64, + tile_index=0, + length=1, + x=0, + y=4, + width=self.device.tile_device_width, + ) + ) + return calls + async def _async_update_data(self) -> None: """Fetch all device data from the api.""" device = self.device @@ -312,9 +359,9 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): [ self.device.get_tile_effect, self.device.get_device_chain, - self.device.get64, ] ) + methods.extend(self._async_build_get64_update_requests()) if self.is_extended_multizone: methods.append(self.device.get_extended_color_zones) elif self.is_legacy_multizone: @@ -339,6 +386,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]): if self.is_matrix or self.is_extended_multizone or self.is_legacy_multizone: self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")] + if self.is_legacy_multizone and num_zones != self.get_number_of_zones(): # The number of zones has changed so we need # to update the zones again. This happens rarely. diff --git a/tests/components/lifx/__init__.py b/tests/components/lifx/__init__.py index 81b913da6ce..95f6154030b 100644 --- a/tests/components/lifx/__init__.py +++ b/tests/components/lifx/__init__.py @@ -199,6 +199,17 @@ def _mocked_ceiling() -> Light: return bulb +def _mocked_128zone_ceiling() -> Light: + bulb = _mocked_bulb() + bulb.product = 201 # LIFX 26"x13" Ceiling + bulb.effect = {"effect": "OFF"} + bulb.get_tile_effect = MockLifxCommand(bulb) + bulb.set_tile_effect = MockLifxCommand(bulb) + bulb.get64 = MockLifxCommand(bulb) + bulb.get_device_chain = MockLifxCommand(bulb) + return bulb + + def _mocked_bulb_old_firmware() -> Light: bulb = _mocked_bulb() bulb.host_firmware_version = "2.77" diff --git a/tests/components/lifx/snapshots/test_diagnostics.ambr b/tests/components/lifx/snapshots/test_diagnostics.ambr index 82499c3632e..3e095252159 100644 --- a/tests/components/lifx/snapshots/test_diagnostics.ambr +++ b/tests/components/lifx/snapshots/test_diagnostics.ambr @@ -1,4 +1,834 @@ # serializer version: 1 +# name: test_128zone_matrix_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': True, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'matrix': dict({ + 'chain': dict({ + '0': list([ + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + ]), + }), + 'chain_length': 1, + 'effect': dict({ + 'effect': 'OFF', + }), + 'tile_device_width': 16, + 'tile_devices': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 2000, + 'device_version_product': 201, + 'device_version_vendor': 1, + 'firmware_build': 1729829374000000000, + 'firmware_version_major': 4, + 'firmware_version_minor': 10, + 'height': 16, + 'supported_frame_buffers': 5, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'tile_devices_count': 1, + }), + 'power': 0, + 'product_id': 201, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- # name: test_bulb_diagnostics dict({ 'data': dict({ @@ -199,6 +1029,452 @@ }), }) # --- +# name: test_matrix_diagnostics + dict({ + 'data': dict({ + 'brightness': 3, + 'features': dict({ + 'buttons': False, + 'chain': False, + 'color': True, + 'extended_multizone': False, + 'hev': False, + 'infrared': False, + 'matrix': True, + 'max_kelvin': 9000, + 'min_kelvin': 1500, + 'multizone': False, + 'relays': False, + }), + 'firmware': '3.00', + 'hue': 1, + 'kelvin': 4, + 'matrix': dict({ + 'chain': dict({ + '0': list([ + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + list([ + 0, + 0, + 0, + 3500, + ]), + ]), + }), + 'chain_length': 1, + 'effect': dict({ + 'effect': 'OFF', + }), + 'tile_device_width': 8, + 'tile_devices': list([ + dict({ + 'accel_meas_x': 0, + 'accel_meas_y': 0, + 'accel_meas_z': 2000, + 'device_version_product': 176, + 'device_version_vendor': 1, + 'firmware_build': 1729829374000000000, + 'firmware_version_major': 4, + 'firmware_version_minor': 10, + 'height': 8, + 'supported_frame_buffers': 5, + 'user_x': 0.0, + 'user_y': 0.0, + 'width': 8, + }), + ]), + 'tile_devices_count': 1, + }), + 'power': 0, + 'product_id': 176, + 'saturation': 2, + 'vendor': None, + }), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + }), + 'title': 'My Bulb', + }), + }) +# --- # name: test_multizone_bulb_diagnostics dict({ 'data': dict({ diff --git a/tests/components/lifx/test_diagnostics.py b/tests/components/lifx/test_diagnostics.py index 5883ac046e7..830dc26829a 100644 --- a/tests/components/lifx/test_diagnostics.py +++ b/tests/components/lifx/test_diagnostics.py @@ -12,7 +12,9 @@ from . import ( IP_ADDRESS, SERIAL, MockLifxCommand, + _mocked_128zone_ceiling, _mocked_bulb, + _mocked_ceiling, _mocked_clean_bulb, _mocked_infrared_bulb, _mocked_light_strip, @@ -209,3 +211,101 @@ async def test_multizone_bulb_diagnostics( diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) assert diag == snapshot + + +async def test_matrix_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_ceiling() + bulb.effect = {"effect": "OFF"} + bulb.tile_devices_count = 1 + bulb.tile_device_width = 8 + bulb.tile_devices = [ + { + "accel_meas_x": 0, + "accel_meas_y": 0, + "accel_meas_z": 2000, + "user_x": 0.0, + "user_y": 0.0, + "width": 8, + "height": 8, + "supported_frame_buffers": 5, + "device_version_vendor": 1, + "device_version_product": 176, + "firmware_build": 1729829374000000000, + "firmware_version_minor": 10, + "firmware_version_major": 4, + } + ] + bulb.chain = {0: [(0, 0, 0, 3500)] * 64} + bulb.chain_length = 1 + + 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() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot + + +async def test_128zone_matrix_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for a standard bulb.""" + config_entry = MockConfigEntry( + domain=lifx.DOMAIN, + title=DEFAULT_ENTRY_TITLE, + data={CONF_HOST: IP_ADDRESS}, + unique_id=SERIAL, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_128zone_ceiling() + bulb.effect = {"effect": "OFF"} + bulb.tile_devices_count = 1 + bulb.tile_device_width = 16 + bulb.tile_devices = [ + { + "accel_meas_x": 0, + "accel_meas_y": 0, + "accel_meas_z": 2000, + "user_x": 0.0, + "user_y": 0.0, + "width": 8, + "height": 16, + "supported_frame_buffers": 5, + "device_version_vendor": 1, + "device_version_product": 201, + "firmware_build": 1729829374000000000, + "firmware_version_minor": 10, + "firmware_version_major": 4, + } + ] + bulb.chain = {0: [(0, 0, 0, 3500)] * 128} + bulb.chain_length = 1 + + 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() + + diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert diag == snapshot From 73c9d99abf11e88d3ef65f5b856711c19cc81a55 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 11 Jul 2025 13:55:01 +0200 Subject: [PATCH 1323/1664] Add tuya snapshot tests for wxkg category (#148609) --- tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/wxkg_wireless_switch.json | 50 ++++++++ .../components/tuya/snapshots/test_event.ambr | 119 ++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 53 ++++++++ tests/components/tuya/test_event.py | 57 +++++++++ 5 files changed, 284 insertions(+) create mode 100644 tests/components/tuya/fixtures/wxkg_wireless_switch.json create mode 100644 tests/components/tuya/snapshots/test_event.ambr create mode 100644 tests/components/tuya/test_event.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index bf8af8835cf..90a49fc2372 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -88,6 +88,11 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, ], + "wxkg_wireless_switch": [ + # https://github.com/home-assistant/core/issues/93975 + Platform.EVENT, + Platform.SENSOR, + ], "zndb_smart_meter": [ # https://github.com/home-assistant/core/issues/138372 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/wxkg_wireless_switch.json b/tests/components/tuya/fixtures/wxkg_wireless_switch.json new file mode 100644 index 00000000000..376276099cc --- /dev/null +++ b/tests/components/tuya/fixtures/wxkg_wireless_switch.json @@ -0,0 +1,50 @@ +{ + "endpoint": "https://openapi.tuyaeu.com", + "auth_type": 0, + "country_code": "44", + "app_type": "smartlife", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "name": "Bathroom Smart Switch", + "model": "LKWSW201", + "category": "wxkg", + "product_id": "l8yaz4um5b3pwyvf", + "product_name": "Wireless Switch", + "online": true, + "sub": false, + "time_zone": "+00:00", + "active_time": "2023-01-05T20:12:39+00:00", + "create_time": "2023-01-05T20:12:39+00:00", + "update_time": "2023-05-30T17:17:47+00:00", + "function": {}, + "status_range": { + "switch_mode1": { + "type": "Enum", + "value": { + "range": ["click", "press"] + } + }, + "switch_mode2": { + "type": "Enum", + "value": { + "range": ["click", "press"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_mode1": "click", + "switch_mode2": "click", + "battery_percentage": 100 + } +} diff --git a/tests/components/tuya/snapshots/test_event.ambr b/tests/components/tuya/snapshots/test_event.ambr new file mode 100644 index 00000000000..085ebd3ec8b --- /dev/null +++ b/tests/components/tuya/snapshots/test_event.ambr @@ -0,0 +1,119 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bathroom_smart_switch_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 1', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.mocked_device_idswitch_mode1', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'press', + ]), + 'friendly_name': 'Bathroom Smart Switch Button 1', + }), + 'context': , + 'entity_id': 'event.bathroom_smart_switch_button_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'click', + 'press', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'entity_id': 'event.bathroom_smart_switch_button_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Button 2', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'numbered_button', + 'unique_id': 'tuya.mocked_device_idswitch_mode2', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][event.bathroom_smart_switch_button_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'button', + 'event_type': None, + 'event_types': list([ + 'click', + 'press', + ]), + 'friendly_name': 'Bathroom Smart Switch Button 2', + }), + 'context': , + 'entity_id': 'event.bathroom_smart_switch_button_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 5e52c0e063c..3704aa4f067 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1467,6 +1467,59 @@ 'state': '18.5', }) # --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][sensor.bathroom_smart_switch_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.bathroom_smart_switch_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.mocked_device_idbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wxkg_wireless_switch][sensor.bathroom_smart_switch_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Bathroom Smart Switch Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.bathroom_smart_switch_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[zndb_smart_meter][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_event.py b/tests/components/tuya/test_event.py new file mode 100644 index 00000000000..3a332dbe5c7 --- /dev/null +++ b/tests/components/tuya/test_event.py @@ -0,0 +1,57 @@ +"""Test Tuya event platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.EVENT in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.EVENT not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From a34264f345ff6445ccfc3c57976eb9691a183910 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 11 Jul 2025 14:01:11 +0200 Subject: [PATCH 1324/1664] Add SmartThings RVC fixture (#148552) --- tests/components/smartthings/conftest.py | 1 + .../device_status/da_rvc_map_01011.json | 994 ++++++++++++++++++ .../fixtures/devices/da_rvc_map_01011.json | 353 +++++++ .../smartthings/snapshots/test_init.ambr | 33 + .../smartthings/snapshots/test_select.ambr | 57 + .../smartthings/snapshots/test_sensor.ambr | 534 ++++++++++ .../smartthings/snapshots/test_switch.ambr | 48 + 7 files changed, 2020 insertions(+) create mode 100644 tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json create mode 100644 tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index e8cde67122b..93f505872f4 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -130,6 +130,7 @@ def mock_smartthings() -> Generator[AsyncMock]: "da_wm_wm_000001_1", "da_wm_sc_000001", "da_rvc_normal_000001", + "da_rvc_map_01011", "da_ks_microwave_0101x", "da_ks_cooktop_31001", "da_ks_range_0101x", 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 new file mode 100644 index 00000000000..14244935308 --- /dev/null +++ b/tests/components/smartthings/fixtures/device_status/da_rvc_map_01011.json @@ -0,0 +1,994 @@ +{ + "components": { + "refill-drainage-kit": { + "samsungce.connectionState": { + "connectionState": { + "value": null + } + }, + "samsungce.activationState": { + "activationState": { + "value": null + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.drainFilter", + "samsungce.connectionState", + "samsungce.activationState" + ], + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "samsungce.drainFilter": { + "drainFilterUsageStep": { + "value": null + }, + "drainFilterStatus": { + "value": null + }, + "drainFilterLastResetDate": { + "value": null + }, + "drainFilterResetType": { + "value": null + }, + "drainFilterUsage": { + "value": null + } + } + }, + "station": { + "custom.hepaFilter": { + "hepaFilterCapacity": { + "value": null + }, + "hepaFilterStatus": { + "value": "normal", + "timestamp": "2025-07-02T04:35:14.449Z" + }, + "hepaFilterResetType": { + "value": ["replaceable"], + "timestamp": "2025-07-02T04:35:14.449Z" + }, + "hepaFilterUsageStep": { + "value": null + }, + "hepaFilterUsage": { + "value": null + }, + "hepaFilterLastResetDate": { + "value": null + } + }, + "samsungce.robotCleanerDustBag": { + "supportedStatus": { + "value": ["full", "normal"], + "timestamp": "2025-07-02T04:35:14.620Z" + }, + "status": { + "value": "normal", + "timestamp": "2025-07-02T04:35:14.620Z" + } + } + }, + "main": { + "mediaPlayback": { + "supportedPlaybackCommands": { + "value": null + }, + "playbackStatus": { + "value": null + } + }, + "robotCleanerTurboMode": { + "robotCleanerTurboMode": { + "value": "extraSilence", + "timestamp": "2025-07-10T11:00:38.909Z" + } + }, + "ocf": { + "st": { + "value": "2024-01-01T09:00:15Z", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mndt": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnfv": { + "value": "20250123.105306", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnhw": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "di": { + "value": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnsl": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "dmv": { + "value": "res.1.1.0,sh.1.1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "n": { + "value": "[robot vacuum] Samsung", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnmo": { + "value": "JETBOT_COMBO_9X00_24K|50029141|80010b0002d8411f0100000000000000", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "vid": { + "value": "DA-RVC-MAP-01011", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnmn": { + "value": "Samsung Electronics", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnml": { + "value": "", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnpv": { + "value": "1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "mnos": { + "value": "Tizen", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "pi": { + "value": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "timestamp": "2025-06-20T14:12:57.924Z" + }, + "icv": { + "value": "core.1.1.0", + "timestamp": "2025-06-20T14:12:57.924Z" + } + }, + "custom.disabledCapabilities": { + "disabledCapabilities": { + "value": [ + "samsungce.robotCleanerAudioClip", + "custom.hepaFilter", + "imageCapture", + "mediaPlaybackRepeat", + "mediaPlayback", + "mediaTrackControl", + "samsungce.robotCleanerPatrol", + "samsungce.musicPlaylist", + "audioVolume", + "audioMute", + "videoCapture", + "samsungce.robotCleanerWelcome", + "samsungce.microphoneSettings", + "samsungce.robotCleanerGuidedPatrol", + "samsungce.robotCleanerSafetyPatrol", + "soundDetection", + "samsungce.soundDetectionSensitivity", + "audioTrackAddressing", + "samsungce.robotCleanerMonitoringAutomation" + ], + "timestamp": "2025-06-20T14:12:58.125Z" + } + }, + "logTrigger": { + "logState": { + "value": "idle", + "timestamp": "2025-07-02T04:35:14.401Z" + }, + "logRequestState": { + "value": "idle", + "timestamp": "2025-07-02T04:35:14.401Z" + }, + "logInfo": { + "value": null + } + }, + "samsungce.driverVersion": { + "versionNumber": { + "value": 25040102, + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "sec.diagnosticsInformation": { + "logType": { + "value": ["errCode", "dump"], + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "endpoint": { + "value": "PIPER", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "minVersion": { + "value": "3.0", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "signinPermission": { + "value": null + }, + "setupId": { + "value": "VR0", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "protocolType": { + "value": "ble_ocf", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "tsId": { + "value": "DA10", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "mnId": { + "value": "0AJT", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "dumpType": { + "value": "file", + "timestamp": "2025-07-02T04:35:13.556Z" + } + }, + "custom.hepaFilter": { + "hepaFilterCapacity": { + "value": null + }, + "hepaFilterStatus": { + "value": null + }, + "hepaFilterResetType": { + "value": null + }, + "hepaFilterUsageStep": { + "value": null + }, + "hepaFilterUsage": { + "value": null + }, + "hepaFilterLastResetDate": { + "value": null + } + }, + "samsungce.robotCleanerMapCleaningInfo": { + "area": { + "value": "None", + "timestamp": "2025-07-10T09:37:08.648Z" + }, + "cleanedExtent": { + "value": -1, + "unit": "m\u00b2", + "timestamp": "2025-07-10T09:37:08.648Z" + }, + "nearObject": { + "value": "None", + "timestamp": "2025-07-02T04:35:13.567Z" + }, + "remainingTime": { + "value": -1, + "unit": "minute", + "timestamp": "2025-07-10T06:42:57.820Z" + } + }, + "audioVolume": { + "volume": { + "value": null + } + }, + "powerConsumptionReport": { + "powerConsumption": { + "value": { + "energy": 981, + "deltaEnergy": 21, + "power": 0, + "powerEnergy": 0.0, + "persistedEnergy": 0, + "energySaved": 0, + "start": "2025-07-10T11:11:22Z", + "end": "2025-07-10T11:20:22Z" + }, + "timestamp": "2025-07-10T11:20:22.600Z" + } + }, + "samsungce.robotCleanerMapList": { + "maps": { + "value": [ + { + "id": "1", + "name": "Map1", + "userEdited": false, + "createdTime": "2025-07-01T08:23:29Z", + "updatedTime": "2025-07-01T08:23:29Z", + "areaInfo": [ + { + "id": "1", + "name": "Room", + "userEdited": false + }, + { + "id": "2", + "name": "Room 2", + "userEdited": false + }, + { + "id": "3", + "name": "Room 3", + "userEdited": false + }, + { + "id": "4", + "name": "Room 4", + "userEdited": false + } + ], + "objectInfo": [] + } + ], + "timestamp": "2025-07-02T04:35:14.204Z" + } + }, + "samsungce.robotCleanerPatrol": { + "timezone": { + "value": null + }, + "patrolStatus": { + "value": null + }, + "areaIds": { + "value": null + }, + "timeOffset": { + "value": null + }, + "waypoints": { + "value": null + }, + "enabled": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "blockingStatus": { + "value": null + }, + "mapId": { + "value": null + }, + "startTime": { + "value": null + }, + "interval": { + "value": null + }, + "endTime": { + "value": null + }, + "obsoleted": { + "value": null + } + }, + "samsungce.robotCleanerAudioClip": { + "enabled": { + "value": null + } + }, + "samsungce.musicPlaylist": { + "currentTrack": { + "value": null + }, + "playlist": { + "value": null + } + }, + "audioNotification": {}, + "samsungce.robotCleanerPetMonitorReport": { + "report": { + "value": null + } + }, + "execute": { + "data": { + "value": null + } + }, + "samsungce.energyPlanner": { + "data": { + "value": null + }, + "plan": { + "value": "none", + "timestamp": "2025-07-02T04:35:14.341Z" + } + }, + "samsungce.robotCleanerFeatureVisibility": { + "invisibleFeatures": { + "value": [ + "Start", + "Dock", + "SelectRoom", + "DustEmit", + "SelectSpot", + "CleaningMethod", + "MopWash", + "MopDry" + ], + "timestamp": "2025-07-10T09:52:40.298Z" + }, + "visibleFeatures": { + "value": [ + "Stop", + "Suction", + "Repeat", + "MapMerge", + "MapDivide", + "MySchedule", + "Homecare", + "CleanReport", + "CleanHistory", + "DND", + "Sound", + "NoEntryZone", + "RenameRoom", + "ResetMap", + "Accessory", + "CleaningOption", + "ObjectEdit", + "WaterLevel", + "ClimbZone" + ], + "timestamp": "2025-07-10T09:52:40.298Z" + } + }, + "sec.wifiConfiguration": { + "autoReconnection": { + "value": true, + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "minVersion": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "supportedWiFiFreq": { + "value": ["2.4G", "5G"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "supportedAuthType": { + "value": ["OPEN", "WEP", "WPA-PSK", "WPA2-PSK", "SAE"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "protocolType": { + "value": ["helper_hotspot", "ble_ocf"], + "timestamp": "2025-07-02T04:35:14.461Z" + } + }, + "samsungce.softwareVersion": { + "versions": { + "value": [ + { + "id": "0", + "swType": "Software", + "versionNumber": "25012310" + }, + { + "id": "1", + "swType": "Software", + "versionNumber": "25012310" + }, + { + "id": "2", + "swType": "Firmware", + "versionNumber": "25012100" + }, + { + "id": "3", + "swType": "Firmware", + "versionNumber": "24012200" + }, + { + "id": "4", + "swType": "Bixby", + "versionNumber": "(null)" + }, + { + "id": "5", + "swType": "Firmware", + "versionNumber": "25012200" + } + ], + "timestamp": "2025-07-02T04:35:13.556Z" + } + }, + "samsungce.softwareUpdate": { + "targetModule": { + "value": { + "newVersion": "00000000", + "currentVersion": "00000000", + "moduleType": "mainController" + }, + "timestamp": "2025-07-09T23:00:32.385Z" + }, + "otnDUID": { + "value": "JHCDM7UU7UJWQ", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "lastUpdatedDate": { + "value": null + }, + "availableModules": { + "value": [], + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "newVersionAvailable": { + "value": false, + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "operatingState": { + "value": "none", + "timestamp": "2025-07-02T04:35:19.823Z" + }, + "progress": { + "value": 0, + "unit": "%", + "timestamp": "2025-07-02T04:35:19.823Z" + } + }, + "samsungce.robotCleanerReservation": { + "reservations": { + "value": [ + { + "id": "2", + "enabled": true, + "dayOfWeek": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + "startTime": "02:32", + "repeatMode": "weekly", + "cleaningMode": "auto" + } + ], + "timestamp": "2025-07-02T04:35:13.844Z" + }, + "maxNumberOfReservations": { + "value": null + } + }, + "audioMute": { + "mute": { + "value": null + } + }, + "mediaTrackControl": { + "supportedTrackControlCommands": { + "value": null + } + }, + "samsungce.robotCleanerMotorFilter": { + "motorFilterResetType": { + "value": ["washable"], + "timestamp": "2025-07-02T04:35:13.496Z" + }, + "motorFilterStatus": { + "value": "normal", + "timestamp": "2025-07-02T04:35:13.496Z" + } + }, + "samsungce.robotCleanerCleaningType": { + "cleaningType": { + "value": "vacuumAndMopTogether", + "timestamp": "2025-07-09T12:44:06.437Z" + }, + "supportedCleaningTypes": { + "value": ["vacuum", "mop", "vacuumAndMopTogether", "mopAfterVacuum"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "soundDetection": { + "soundDetectionState": { + "value": null + }, + "supportedSoundTypes": { + "value": null + }, + "soundDetected": { + "value": null + } + }, + "samsungce.robotCleanerWelcome": { + "coordinates": { + "value": null + } + }, + "samsungce.robotCleanerPetMonitor": { + "areaIds": { + "value": null + }, + "originator": { + "value": null + }, + "waypoints": { + "value": null + }, + "enabled": { + "value": null + }, + "excludeHolidays": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "monitoringStatus": { + "value": null + }, + "blockingStatus": { + "value": null + }, + "mapId": { + "value": null + }, + "startTime": { + "value": null + }, + "interval": { + "value": null + }, + "endTime": { + "value": null + }, + "obsoleted": { + "value": null + } + }, + "battery": { + "quantity": { + "value": null + }, + "battery": { + "value": 59, + "unit": "%", + "timestamp": "2025-07-10T11:24:13.441Z" + }, + "type": { + "value": null + } + }, + "samsungce.deviceIdentification": { + "micomAssayCode": { + "value": "50029141", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "modelName": { + "value": null + }, + "serialNumber": { + "value": null + }, + "serialNumberExtra": { + "value": null + }, + "modelClassificationCode": { + "value": "80010b0002d8411f0100000000000000", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "description": { + "value": "Jet Bot V/C", + "timestamp": "2025-07-02T04:35:13.556Z" + }, + "releaseYear": { + "value": null + }, + "binaryId": { + "value": "JETBOT_COMBO_9X00_24K", + "timestamp": "2025-07-09T23:00:26.764Z" + } + }, + "samsungce.robotCleanerSystemSoundMode": { + "soundMode": { + "value": "mute", + "timestamp": "2025-07-05T18:17:55.940Z" + }, + "supportedSoundModes": { + "value": ["mute", "beep", "voice"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "switch": { + "switch": { + "value": "on", + "timestamp": "2025-07-09T23:00:26.829Z" + } + }, + "samsungce.robotCleanerPetCleaningSchedule": { + "excludeHolidays": { + "value": null + }, + "dayOfWeek": { + "value": null + }, + "mapId": { + "value": null + }, + "areaIds": { + "value": null + }, + "startTime": { + "value": null + }, + "originator": { + "value": null + }, + "obsoleted": { + "value": true, + "timestamp": "2025-07-02T04:35:14.317Z" + }, + "enabled": { + "value": null + } + }, + "samsungce.quickControl": { + "version": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.234Z" + } + }, + "samsungce.microphoneSettings": { + "mute": { + "value": null + } + }, + "samsungce.robotCleanerMapAreaInfo": { + "areaInfo": { + "value": [ + { + "id": "1", + "name": "Room" + }, + { + "id": "2", + "name": "Room 2" + }, + { + "id": "3", + "name": "Room 3" + }, + { + "id": "4", + "name": "Room 4" + } + ], + "timestamp": "2025-07-03T02:33:15.133Z" + } + }, + "samsungce.audioVolumeLevel": { + "volumeLevel": { + "value": 0, + "timestamp": "2025-07-05T18:17:55.915Z" + }, + "volumeLevelRange": { + "value": { + "minimum": 0, + "maximum": 3, + "step": 1 + }, + "timestamp": "2025-07-02T04:35:13.837Z" + } + }, + "robotCleanerMovement": { + "robotCleanerMovement": { + "value": "cleaning", + "timestamp": "2025-07-10T09:38:52.938Z" + } + }, + "samsungce.robotCleanerSafetyPatrol": { + "personDetection": { + "value": null + } + }, + "sec.calmConnectionCare": { + "role": { + "value": ["things"], + "timestamp": "2025-07-02T04:35:14.461Z" + }, + "protocols": { + "value": null + }, + "version": { + "value": "1.0", + "timestamp": "2025-07-02T04:35:14.461Z" + } + }, + "custom.disabledComponents": { + "disabledComponents": { + "value": ["refill-drainage-kit"], + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "videoCapture": { + "stream": { + "value": null + }, + "clip": { + "value": null + } + }, + "samsungce.robotCleanerWaterSprayLevel": { + "availableWaterSprayLevels": { + "value": null + }, + "waterSprayLevel": { + "value": "mediumLow", + "timestamp": "2025-07-10T11:00:35.545Z" + }, + "supportedWaterSprayLevels": { + "value": ["high", "mediumHigh", "medium", "mediumLow", "low"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "samsungce.robotCleanerMapMetadata": { + "cellSize": { + "value": 20, + "unit": "mm", + "timestamp": "2025-06-20T14:12:57.135Z" + } + }, + "samsungce.robotCleanerGuidedPatrol": { + "mapId": { + "value": null + }, + "waypoints": { + "value": null + } + }, + "audioTrackAddressing": {}, + "refresh": {}, + "samsungce.robotCleanerOperatingState": { + "supportedOperatingState": { + "value": [ + "homing", + "charging", + "charged", + "chargingForRemainingJob", + "moving", + "cleaning", + "paused", + "idle", + "error", + "powerSaving", + "factoryReset", + "relocal", + "exploring", + "processing", + "emitDust", + "washingMop", + "sterilizingMop", + "dryingMop", + "supplyingWater", + "preparingWater", + "spinDrying", + "flexCharged", + "descaling", + "drainingWater", + "waitingForDescaling" + ], + "timestamp": "2025-06-20T14:12:58.012Z" + }, + "operatingState": { + "value": "dryingMop", + "timestamp": "2025-07-10T09:52:40.510Z" + }, + "cleaningStep": { + "value": "none", + "timestamp": "2025-07-10T09:37:07.214Z" + }, + "homingReason": { + "value": "none", + "timestamp": "2025-07-10T09:37:45.152Z" + }, + "isMapBasedOperationAvailable": { + "value": false, + "timestamp": "2025-07-10T09:37:55.690Z" + } + }, + "samsungce.soundDetectionSensitivity": { + "level": { + "value": null + }, + "supportedLevels": { + "value": null + } + }, + "samsungce.robotCleanerMonitoringAutomation": {}, + "mediaPlaybackRepeat": { + "playbackRepeatMode": { + "value": null + } + }, + "imageCapture": { + "image": { + "value": null + }, + "encrypted": { + "value": null + }, + "captureTime": { + "value": null + } + }, + "samsungce.robotCleanerCleaningMode": { + "supportedCleaningMode": { + "value": [ + "auto", + "area", + "spot", + "stop", + "uncleanedObject", + "patternMap" + ], + "timestamp": "2025-06-20T14:12:58.012Z" + }, + "repeatModeEnabled": { + "value": true, + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "supportRepeatMode": { + "value": true, + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "cleaningMode": { + "value": "stop", + "timestamp": "2025-07-10T09:37:07.214Z" + } + }, + "samsungce.robotCleanerAvpRegistration": { + "registrationStatus": { + "value": null + } + }, + "samsungce.robotCleanerDrivingMode": { + "drivingMode": { + "value": "areaThenWalls", + "timestamp": "2025-07-02T04:35:13.646Z" + }, + "supportedDrivingModes": { + "value": ["areaThenWalls", "wallFirst", "quickCleaningZigzagPattern"], + "timestamp": "2025-07-02T04:35:13.646Z" + } + }, + "robotCleanerCleaningMode": { + "robotCleanerCleaningMode": { + "value": "stop", + "timestamp": "2025-07-10T09:37:07.214Z" + } + }, + "custom.doNotDisturbMode": { + "doNotDisturb": { + "value": "off", + "timestamp": "2025-07-02T04:35:13.622Z" + }, + "startTime": { + "value": "0000", + "timestamp": "2025-07-02T04:35:13.622Z" + }, + "endTime": { + "value": "0000", + "timestamp": "2025-07-02T04:35:13.622Z" + } + }, + "samsungce.lamp": { + "brightnessLevel": { + "value": "on", + "timestamp": "2025-07-10T11:20:40.419Z" + }, + "supportedBrightnessLevel": { + "value": ["on", "off"], + "timestamp": "2025-06-20T14:12:57.383Z" + } + } + } + } +} diff --git a/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json b/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json new file mode 100644 index 00000000000..f25797f2dcf --- /dev/null +++ b/tests/components/smartthings/fixtures/devices/da_rvc_map_01011.json @@ -0,0 +1,353 @@ +{ + "items": [ + { + "deviceId": "05accb39-2017-c98b-a5ab-04a81f4d3d9a", + "name": "[robot vacuum] Samsung", + "label": "Robot vacuum", + "manufacturerName": "Samsung Electronics", + "presentationId": "DA-RVC-MAP-01011", + "deviceManufacturerCode": "Samsung Electronics", + "locationId": "d31d0982-9bf9-4f0c-afd4-ad3d78842541", + "ownerId": "85532262-6537-54d9-179a-333db98dbcc0", + "roomId": "572f5713-53a9-4fb8-85fd-60515e44f1ed", + "deviceTypeName": "Samsung OCF Robot Vacuum", + "components": [ + { + "id": "main", + "label": "main", + "capabilities": [ + { + "id": "audioMute", + "version": 1 + }, + { + "id": "audioNotification", + "version": 1 + }, + { + "id": "audioTrackAddressing", + "version": 1 + }, + { + "id": "audioVolume", + "version": 1 + }, + { + "id": "battery", + "version": 1 + }, + { + "id": "execute", + "version": 1 + }, + { + "id": "imageCapture", + "version": 1 + }, + { + "id": "logTrigger", + "version": 1 + }, + { + "id": "mediaPlayback", + "version": 1 + }, + { + "id": "mediaPlaybackRepeat", + "version": 1 + }, + { + "id": "mediaTrackControl", + "version": 1 + }, + { + "id": "ocf", + "version": 1 + }, + { + "id": "powerConsumptionReport", + "version": 1 + }, + { + "id": "refresh", + "version": 1 + }, + { + "id": "robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "robotCleanerMovement", + "version": 1 + }, + { + "id": "robotCleanerTurboMode", + "version": 1 + }, + { + "id": "soundDetection", + "version": 1 + }, + { + "id": "switch", + "version": 1 + }, + { + "id": "videoCapture", + "version": 1 + }, + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "custom.disabledComponents", + "version": 1 + }, + { + "id": "custom.doNotDisturbMode", + "version": 1 + }, + { + "id": "custom.hepaFilter", + "version": 1 + }, + { + "id": "samsungce.audioVolumeLevel", + "version": 1 + }, + { + "id": "samsungce.deviceIdentification", + "version": 1 + }, + { + "id": "samsungce.driverVersion", + "version": 1 + }, + { + "id": "samsungce.lamp", + "version": 1 + }, + { + "id": "samsungce.microphoneSettings", + "version": 1 + }, + { + "id": "samsungce.softwareUpdate", + "version": 1 + }, + { + "id": "samsungce.musicPlaylist", + "version": 1 + }, + { + "id": "samsungce.robotCleanerDrivingMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningMode", + "version": 1 + }, + { + "id": "samsungce.robotCleanerCleaningType", + "version": 1 + }, + { + "id": "samsungce.robotCleanerOperatingState", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapAreaInfo", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapCleaningInfo", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPatrol", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetCleaningSchedule", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetMonitor", + "version": 1 + }, + { + "id": "samsungce.robotCleanerPetMonitorReport", + "version": 1 + }, + { + "id": "samsungce.robotCleanerReservation", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMotorFilter", + "version": 1 + }, + { + "id": "samsungce.robotCleanerAvpRegistration", + "version": 1 + }, + { + "id": "samsungce.soundDetectionSensitivity", + "version": 1 + }, + { + "id": "samsungce.robotCleanerWaterSprayLevel", + "version": 1 + }, + { + "id": "samsungce.robotCleanerWelcome", + "version": 1 + }, + { + "id": "samsungce.robotCleanerAudioClip", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMonitoringAutomation", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapMetadata", + "version": 1 + }, + { + "id": "samsungce.robotCleanerMapList", + "version": 1 + }, + { + "id": "samsungce.robotCleanerSystemSoundMode", + "version": 1 + }, + { + "id": "samsungce.energyPlanner", + "version": 1 + }, + { + "id": "samsungce.quickControl", + "version": 1 + }, + { + "id": "samsungce.robotCleanerFeatureVisibility", + "version": 1 + }, + { + "id": "samsungce.softwareVersion", + "version": 1 + }, + { + "id": "samsungce.robotCleanerGuidedPatrol", + "version": 1 + }, + { + "id": "samsungce.robotCleanerSafetyPatrol", + "version": 1 + }, + { + "id": "sec.calmConnectionCare", + "version": 1 + }, + { + "id": "sec.diagnosticsInformation", + "version": 1 + }, + { + "id": "sec.wifiConfiguration", + "version": 1 + } + ], + "categories": [ + { + "name": "RobotCleaner", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "station", + "label": "station", + "capabilities": [ + { + "id": "custom.hepaFilter", + "version": 1 + }, + { + "id": "samsungce.robotCleanerDustBag", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + }, + { + "id": "refill-drainage-kit", + "label": "refill-drainage-kit", + "capabilities": [ + { + "id": "custom.disabledCapabilities", + "version": 1 + }, + { + "id": "samsungce.drainFilter", + "version": 1 + }, + { + "id": "samsungce.connectionState", + "version": 1 + }, + { + "id": "samsungce.activationState", + "version": 1 + } + ], + "categories": [ + { + "name": "Other", + "categoryType": "manufacturer" + } + ], + "optional": false + } + ], + "createTime": "2025-06-20T14:12:56.260Z", + "profile": { + "id": "5d345d41-a497-3fc7-84fe-eaaee50f0509" + }, + "ocf": { + "ocfDeviceType": "oic.d.robotcleaner", + "name": "[robot vacuum] Samsung", + "specVersion": "core.1.1.0", + "verticalDomainSpecVersion": "res.1.1.0,sh.1.1.0", + "manufacturerName": "Samsung Electronics", + "modelNumber": "JETBOT_COMBO_9X00_24K|50029141|80010b0002d8411f0100000000000000", + "platformVersion": "1.0", + "platformOS": "Tizen", + "hwVersion": "", + "firmwareVersion": "20250123.105306", + "vendorId": "DA-RVC-MAP-01011", + "vendorResourceClientServerVersion": "4.0.38", + "lastSignupTime": "2025-06-20T14:12:56.202953160Z", + "transferCandidate": false, + "additionalAuthCodeRequired": false, + "modelCode": "NONE" + }, + "type": "OCF", + "restrictionTier": 0, + "allowed": null, + "executionContext": "CLOUD", + "relationships": [] + } + ], + "_links": {} +} diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 446eca63fb2..6ce3992d2b4 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -728,6 +728,39 @@ 'via_device_id': None, }) # --- +# name: test_devices[da_rvc_map_01011] + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': 'https://account.smartthings.com', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'id': , + 'identifiers': set({ + tuple( + 'smartthings', + '05accb39-2017-c98b-a5ab-04a81f4d3d9a', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Samsung Electronics', + 'model': 'JETBOT_COMBO_9X00_24K', + 'model_id': None, + 'name': 'Robot vacuum', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '20250123.105306', + 'via_device_id': None, + }) +# --- # name: test_devices[da_rvc_normal_000001] DeviceRegistryEntrySnapshot({ 'area_id': 'theater', diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 7dd57e89c6a..8950846ba21 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -172,6 +172,63 @@ 'state': 'extra_high', }) # --- +# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_lamp-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.robot_vacuum_lamp', + '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': 'Lamp', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'lamp', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_samsungce.lamp_brightnessLevel_brightnessLevel', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][select.robot_vacuum_lamp-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum Lamp', + 'options': list([ + 'on', + 'off', + ]), + }), + 'context': , + 'entity_id': 'select.robot_vacuum_lamp', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index f88524116ee..169359118da 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -6066,6 +6066,540 @@ 'state': '97', }) # --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_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': , + 'entity_id': 'sensor.robot_vacuum_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': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_battery_battery_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '59', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_cleaning_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Cleaning mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_cleaning_mode', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerCleaningMode_robotCleanerCleaningMode_robotCleanerCleaningMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_cleaning_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Cleaning mode', + 'options': list([ + 'auto', + 'part', + 'repeat', + 'manual', + 'stop', + 'map', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaning_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'stop', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy-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.robot_vacuum_energy', + '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': 'Energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_energy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.981', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_difference-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.robot_vacuum_energy_difference', + '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': 'Energy difference', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_difference', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_difference-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy difference', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy_difference', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.021', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_saved-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.robot_vacuum_energy_saved', + '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': 'Energy saved', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'energy_saved', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_energySaved_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_energy_saved-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Energy saved', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_energy_saved', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_movement-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_movement', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Movement', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_movement', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerMovement_robotCleanerMovement_robotCleanerMovement', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_movement-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Movement', + 'options': list([ + 'homing', + 'idle', + 'charging', + 'alarm', + 'off', + 'reserve', + 'point', + 'after', + 'cleaning', + 'pause', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_movement', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cleaning', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_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.robot_vacuum_power', + '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': 'Power', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_power_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Robot vacuum Power', + 'power_consumption_end': '2025-07-10T11:20:22Z', + 'power_consumption_start': '2025-07-10T11:11:22Z', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power_energy-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.robot_vacuum_power_energy', + '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': 'Power energy', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power_energy', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_power_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Robot vacuum Power energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_power_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_turbo_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Turbo mode', + 'platform': 'smartthings', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'robot_cleaner_turbo_mode', + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_robotCleanerTurboMode_robotCleanerTurboMode_robotCleanerTurboMode', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][sensor.robot_vacuum_turbo_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum Turbo mode', + 'options': list([ + 'on', + 'off', + 'silence', + 'extra_silence', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_turbo_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'extra_silence', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][sensor.robot_vacuum_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/snapshots/test_switch.ambr b/tests/components/smartthings/snapshots/test_switch.ambr index d0ea3dbcdad..1aaeb35205f 100644 --- a/tests/components/smartthings/snapshots/test_switch.ambr +++ b/tests/components/smartthings/snapshots/test_switch.ambr @@ -623,6 +623,54 @@ 'state': 'off', }) # --- +# name: test_all_entities[da_rvc_map_01011][switch.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': 'switch', + 'entity_category': None, + 'entity_id': 'switch.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': 0, + 'translation_key': None, + 'unique_id': '05accb39-2017-c98b-a5ab-04a81f4d3d9a_main_switch_switch_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[da_rvc_map_01011][switch.robot_vacuum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Robot vacuum', + }), + 'context': , + 'entity_id': 'switch.robot_vacuum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_all_entities[da_rvc_normal_000001][switch.robot_vacuum-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From d393d5fdbbc70e84cebdff81bd0a2e081315ccd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 11 Jul 2025 15:27:06 +0100 Subject: [PATCH 1325/1664] Use non-autospec mock for Reolink's util and view tests (#148579) --- tests/components/reolink/conftest.py | 2 ++ tests/components/reolink/test_util.py | 12 +++++------- tests/components/reolink/test_views.py | 23 +++++++++++------------ 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/components/reolink/conftest.py b/tests/components/reolink/conftest.py index d34a27045fe..1ca6bb4eb55 100644 --- a/tests/components/reolink/conftest.py +++ b/tests/components/reolink/conftest.py @@ -84,6 +84,8 @@ def _init_host_mock(host_mock: MagicMock) -> None: host_mock.set_whiteled = AsyncMock() host_mock.set_state_light = AsyncMock() host_mock.renew = AsyncMock() + host_mock.get_vod_source = AsyncMock() + host_mock.expire_session = AsyncMock() host_mock.is_nvr = True host_mock.is_hub = False host_mock.mac_address = TEST_MAC diff --git a/tests/components/reolink/test_util.py b/tests/components/reolink/test_util.py index 181249b8bff..8b730bc708b 100644 --- a/tests/components/reolink/test_util.py +++ b/tests/components/reolink/test_util.py @@ -103,12 +103,12 @@ DEV_ID_STANDALONE_CAM = f"{TEST_UID_CAM}" async def test_try_function( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, side_effect: ReolinkError, expected: HomeAssistantError, ) -> None: """Test try_function error translations using number entity.""" - 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) @@ -117,7 +117,7 @@ async def test_try_function( entity_id = f"{Platform.NUMBER}.{TEST_NVR_NAME}_volume" - reolink_connect.set_volume.side_effect = side_effect + reolink_host.set_volume.side_effect = side_effect with pytest.raises(expected.__class__) as err: await hass.services.async_call( NUMBER_DOMAIN, @@ -128,8 +128,6 @@ async def test_try_function( assert err.value.translation_key == expected.translation_key - reolink_connect.set_volume.reset_mock(side_effect=True) - @pytest.mark.parametrize( ("identifiers"), @@ -141,12 +139,12 @@ async def test_try_function( async def test_get_device_uid_and_ch( hass: HomeAssistant, config_entry: MockConfigEntry, - reolink_connect: MagicMock, + reolink_host: MagicMock, device_registry: dr.DeviceRegistry, identifiers: set[tuple[str, str]], ) -> None: """Test get_device_uid_and_ch with multiple identifiers.""" - reolink_connect.channels = [0] + reolink_host.channels = [0] dev_entry = device_registry.async_get_or_create( identifiers=identifiers, diff --git a/tests/components/reolink/test_views.py b/tests/components/reolink/test_views.py index 992e47f0575..6da9fbd29ca 100644 --- a/tests/components/reolink/test_views.py +++ b/tests/components/reolink/test_views.py @@ -64,14 +64,14 @@ def get_mock_session( ) async def test_playback_proxy( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, caplog: pytest.LogCaptureFixture, content_type: str, ) -> None: """Test successful playback proxy URL.""" - 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) mock_session = get_mock_session(content_type=content_type) @@ -100,12 +100,12 @@ async def test_playback_proxy( async def test_proxy_get_source_error( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test error while getting source for playback proxy URL.""" - reolink_connect.get_vod_source.side_effect = ReolinkError(TEST_ERROR) + reolink_host.get_vod_source.side_effect = ReolinkError(TEST_ERROR) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -123,12 +123,11 @@ async def test_proxy_get_source_error( assert await response.content.read() == bytes(TEST_ERROR, "utf-8") assert response.status == HTTPStatus.BAD_REQUEST - reolink_connect.get_vod_source.side_effect = None async def test_proxy_invalid_config_entry_id( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: @@ -156,12 +155,12 @@ async def test_proxy_invalid_config_entry_id( async def test_playback_proxy_timeout( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test playback proxy URL with a timeout in the second chunk.""" - 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) mock_session = get_mock_session([b"test", TimeoutError()], 4) @@ -190,13 +189,13 @@ async def test_playback_proxy_timeout( @pytest.mark.parametrize(("content_type"), [("video/x-flv"), ("text/html")]) async def test_playback_wrong_content( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, content_type: str, ) -> None: """Test playback proxy URL with a wrong content type in the response.""" - 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) mock_session = get_mock_session(content_type=content_type) @@ -223,12 +222,12 @@ async def test_playback_wrong_content( async def test_playback_connect_error( hass: HomeAssistant, - reolink_connect: MagicMock, + reolink_host: MagicMock, config_entry: MockConfigEntry, hass_client: ClientSessionGenerator, ) -> None: """Test playback proxy URL with a connection error.""" - 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) mock_session = Mock() mock_session.get = AsyncMock(side_effect=ClientConnectionError(TEST_ERROR)) From e0179a7d451a1bb8f31923d5ac5c525db8f9defe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C6=B0u=20Quang=20V=C5=A9?= Date: Sat, 12 Jul 2025 01:53:38 +0700 Subject: [PATCH 1326/1664] Fix Google Cloud 504 Deadline Exceeded (#148589) --- homeassistant/components/google_cloud/stt.py | 2 +- homeassistant/components/google_cloud/tts.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py index cd5055383ea..8a548cde8bb 100644 --- a/homeassistant/components/google_cloud/stt.py +++ b/homeassistant/components/google_cloud/stt.py @@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity): try: responses = await self._client.streaming_recognize( requests=request_generator(), - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 16519645dee..817c424d1fc 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -218,7 +218,7 @@ class BaseGoogleCloudProvider: response = await self._client.synthesize_speech( request, - timeout=10, + timeout=30, retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0), ) From 2dca78efbb403e7a48363017a21d915c206b9648 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 11 Jul 2025 20:56:50 +0200 Subject: [PATCH 1327/1664] Improve entity registry handling of device changes (#148425) --- homeassistant/helpers/device_registry.py | 21 ++++++--- homeassistant/helpers/entity_registry.py | 60 +++++++++++++++--------- tests/helpers/test_device_registry.py | 13 ++++- tests/helpers/test_entity_registry.py | 35 ++++++++------ 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index bad772abaff..bc6e7c810bf 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -144,13 +144,21 @@ DEVICE_INFO_KEYS = set.union(*(itm for itm in DEVICE_INFO_TYPES.values())) LOW_PRIO_CONFIG_ENTRY_DOMAINS = {"homekit_controller", "matter", "mqtt", "upnp"} -class _EventDeviceRegistryUpdatedData_CreateRemove(TypedDict): - """EventDeviceRegistryUpdated data for action type 'create' and 'remove'.""" +class _EventDeviceRegistryUpdatedData_Create(TypedDict): + """EventDeviceRegistryUpdated data for action type 'create'.""" - action: Literal["create", "remove"] + action: Literal["create"] device_id: str +class _EventDeviceRegistryUpdatedData_Remove(TypedDict): + """EventDeviceRegistryUpdated data for action type 'remove'.""" + + action: Literal["remove"] + device_id: str + device: DeviceEntry + + class _EventDeviceRegistryUpdatedData_Update(TypedDict): """EventDeviceRegistryUpdated data for action type 'update'.""" @@ -160,7 +168,8 @@ class _EventDeviceRegistryUpdatedData_Update(TypedDict): type EventDeviceRegistryUpdatedData = ( - _EventDeviceRegistryUpdatedData_CreateRemove + _EventDeviceRegistryUpdatedData_Create + | _EventDeviceRegistryUpdatedData_Remove | _EventDeviceRegistryUpdatedData_Update ) @@ -1309,8 +1318,8 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): self.async_update_device(other_device.id, via_device_id=None) self.hass.bus.async_fire_internal( EVENT_DEVICE_REGISTRY_UPDATED, - _EventDeviceRegistryUpdatedData_CreateRemove( - action="remove", device_id=device_id + _EventDeviceRegistryUpdatedData_Remove( + action="remove", device_id=device_id, device=device ), ) self.async_schedule_save() diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 0b61c3e8f16..ddb25c7b0a8 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1103,8 +1103,17 @@ class EntityRegistry(BaseRegistry): entities = async_entries_for_device( self, event.data["device_id"], include_disabled_entities=True ) + removed_device = event.data["device"] for entity in entities: - self.async_remove(entity.entity_id) + config_entry_id = entity.config_entry_id + if ( + config_entry_id in removed_device.config_entries + and entity.config_subentry_id + in removed_device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) + else: + self.async_update_entity(entity.entity_id, device_id=None) return if event.data["action"] != "update": @@ -1121,29 +1130,38 @@ class EntityRegistry(BaseRegistry): # Remove entities which belong to config entries no longer associated with the # device - entities = async_entries_for_device( - self, event.data["device_id"], include_disabled_entities=True - ) - for entity in entities: - if ( - entity.config_entry_id is not None - and entity.config_entry_id not in device.config_entries - ): - self.async_remove(entity.entity_id) + if old_config_entries := event.data["changes"].get("config_entries"): + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + config_entry_id = entity.config_entry_id + if ( + entity.config_entry_id in old_config_entries + and entity.config_entry_id not in device.config_entries + ): + self.async_remove(entity.entity_id) # Remove entities which belong to config subentries no longer associated with the # device - entities = async_entries_for_device( - self, event.data["device_id"], include_disabled_entities=True - ) - for entity in entities: - if ( - (config_entry_id := entity.config_entry_id) is not None - and config_entry_id in device.config_entries - and entity.config_subentry_id - not in device.config_entries_subentries[config_entry_id] - ): - self.async_remove(entity.entity_id) + if old_config_entries_subentries := event.data["changes"].get( + "config_entries_subentries" + ): + entities = async_entries_for_device( + self, event.data["device_id"], include_disabled_entities=True + ) + for entity in entities: + config_entry_id = entity.config_entry_id + config_subentry_id = entity.config_subentry_id + if ( + config_entry_id in device.config_entries + and config_entry_id in old_config_entries_subentries + and config_subentry_id + in old_config_entries_subentries[config_entry_id] + and config_subentry_id + not in device.config_entries_subentries[config_entry_id] + ): + self.async_remove(entity.entity_id) # Re-enable disabled entities if the device is no longer disabled if not device.disabled: diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 58933ca4314..23a451dd06c 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1652,6 +1652,7 @@ async def test_removing_config_entries( assert update_events[4].data == { "action": "remove", "device_id": entry3.id, + "device": entry3, } @@ -1724,10 +1725,12 @@ async def test_deleted_device_removing_config_entries( assert update_events[3].data == { "action": "remove", "device_id": entry.id, + "device": entry2, } assert update_events[4].data == { "action": "remove", "device_id": entry3.id, + "device": entry3, } device_registry.async_clear_config_entry(config_entry_1.entry_id) @@ -1973,6 +1976,7 @@ async def test_removing_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry.id, + "device": entry, } @@ -2102,6 +2106,7 @@ async def test_deleted_device_removing_config_subentries( assert update_events[4].data == { "action": "remove", "device_id": entry.id, + "device": entry4, } device_registry.async_clear_config_subentry(config_entry_1.entry_id, None) @@ -2925,6 +2930,7 @@ async def test_update_remove_config_entries( assert update_events[6].data == { "action": "remove", "device_id": entry3.id, + "device": entry3, } @@ -3104,6 +3110,7 @@ async def test_update_remove_config_subentries( config_entry_3.entry_id: {None}, } + entry_before_remove = entry entry = device_registry.async_update_device( entry_id, remove_config_entry_id=config_entry_3.entry_id, @@ -3201,6 +3208,7 @@ async def test_update_remove_config_subentries( assert update_events[7].data == { "action": "remove", "device_id": entry_id, + "device": entry_before_remove, } @@ -3422,7 +3430,7 @@ async def test_restore_device( ) # Apply user customizations - device_registry.async_update_device( + entry = device_registry.async_update_device( entry.id, area_id="12345A", disabled_by=dr.DeviceEntryDisabler.USER, @@ -3543,6 +3551,7 @@ async def test_restore_device( assert update_events[2].data == { "action": "remove", "device_id": entry.id, + "device": entry, } assert update_events[3].data == { "action": "create", @@ -3865,6 +3874,7 @@ async def test_restore_shared_device( assert update_events[3].data == { "action": "remove", "device_id": entry.id, + "device": updated_device, } assert update_events[4].data == { "action": "create", @@ -3873,6 +3883,7 @@ async def test_restore_shared_device( assert update_events[5].data == { "action": "remove", "device_id": entry.id, + "device": entry2, } assert update_events[6].data == { "action": "create", diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 5afffebb5f6..40a26295cbb 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1684,20 +1684,23 @@ async def test_remove_config_entry_from_device_removes_entities_2( await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) + # Entities which are not tied to the removed config entry should not be removed assert entity_registry.async_is_registered(entry_1.entity_id) - # Entities with a config entry not in the device are removed - assert not entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) - # Remove the second config entry from the device + # Remove the second config entry from the device (this removes the device) device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_2.entry_id ) await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # The device is removed, both entities are now removed - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) + # Entities which are not tied to a config entry in the device should not be removed + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + # Check the device link is set to None + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None async def test_remove_config_subentry_from_device_removes_entities( @@ -1921,12 +1924,12 @@ async def test_remove_config_subentry_from_device_removes_entities_2( await hass.async_block_till_done() assert device_registry.async_get(device_entry.id) + # Entities with a config subentry not in the device are not removed assert entity_registry.async_is_registered(entry_1.entity_id) - # Entities with a config subentry not in the device are removed - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) - # Remove the second config subentry from the device + # Remove the second config subentry from the device, this removes the device device_registry.async_update_device( device_entry.id, remove_config_entry_id=config_entry_1.entry_id, @@ -1935,10 +1938,14 @@ async def test_remove_config_subentry_from_device_removes_entities_2( await hass.async_block_till_done() assert not device_registry.async_get(device_entry.id) - # All entities are now removed - assert not entity_registry.async_is_registered(entry_1.entity_id) - assert not entity_registry.async_is_registered(entry_2.entity_id) - assert not entity_registry.async_is_registered(entry_3.entity_id) + # Entities with a config subentry not in the device are not removed + assert entity_registry.async_is_registered(entry_1.entity_id) + assert entity_registry.async_is_registered(entry_2.entity_id) + assert entity_registry.async_is_registered(entry_3.entity_id) + # Check the device link is set to None + assert entity_registry.async_get(entry_1.entity_id).device_id is None + assert entity_registry.async_get(entry_2.entity_id).device_id is None + assert entity_registry.async_get(entry_3.entity_id).device_id is None async def test_update_device_race( From 1920edd71203e3e419a08e9debbd7d26d07b94bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 22:10:12 +0200 Subject: [PATCH 1328/1664] Update Google Generative AI Conversation max tokens to 3000 (#148625) Co-authored-by: Claude --- .../components/google_generative_ai_conversation/const.py | 2 +- .../snapshots/test_diagnostics.ambr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index e7c5ba6bd22..b7091fe0222 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -25,7 +25,7 @@ RECOMMENDED_TOP_P = 0.95 CONF_TOP_K = "top_k" RECOMMENDED_TOP_K = 64 CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 1500 +RECOMMENDED_MAX_TOKENS = 3000 CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold" CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index bf44b1cbc04..d3e27eb99d2 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -21,7 +21,7 @@ 'dangerous_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'harassment_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', 'hate_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', - 'max_tokens': 1500, + 'max_tokens': 3000, 'prompt': 'Speak like a pirate', 'recommended': False, 'sexual_block_threshold': 'BLOCK_MEDIUM_AND_ABOVE', From 017cd0bf4563c9b83d37d48cca7639ce8c393164 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 22:59:51 +0200 Subject: [PATCH 1329/1664] Update OpenAI conversation max tokens to 3000 (#148623) Co-authored-by: Claude --- homeassistant/components/openai_conversation/const.py | 2 +- tests/components/openai_conversation/test_init.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 777ded55657..a15f71118c0 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -28,7 +28,7 @@ CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" -RECOMMENDED_MAX_TOKENS = 150 +RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" RECOMMENDED_TEMPERATURE = 1.0 RECOMMENDED_TOP_P = 1.0 diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 7af1151075c..e728d0019b6 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -381,7 +381,7 @@ async def test_generate_content_service( """Test generate content service.""" service_data["config_entry"] = mock_config_entry.entry_id expected_args["model"] = "gpt-4o-mini" - expected_args["max_output_tokens"] = 150 + expected_args["max_output_tokens"] = 3000 expected_args["top_p"] = 1.0 expected_args["temperature"] = 1.0 expected_args["user"] = None From 6ecaca753dfc28c95a458c4282d6f91828a3a9a4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 23:00:04 +0200 Subject: [PATCH 1330/1664] Update Anthropic max tokens to 3000 and recommended model to claude-3-5-haiku-latest (#148624) Co-authored-by: Claude --- homeassistant/components/anthropic/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py index d7e10dd7af2..a1637a8cef6 100644 --- a/homeassistant/components/anthropic/const.py +++ b/homeassistant/components/anthropic/const.py @@ -10,9 +10,9 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation" CONF_RECOMMENDED = "recommended" CONF_PROMPT = "prompt" CONF_CHAT_MODEL = "chat_model" -RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307" +RECOMMENDED_CHAT_MODEL = "claude-3-5-haiku-latest" CONF_MAX_TOKENS = "max_tokens" -RECOMMENDED_MAX_TOKENS = 1024 +RECOMMENDED_MAX_TOKENS = 3000 CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 CONF_THINKING_BUDGET = "thinking_budget" From 87e641bf5952fd4afccbe31d0b77cf9f2e423ef3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 11 Jul 2025 23:15:13 +0200 Subject: [PATCH 1331/1664] Update recommended model for Ollama to Qwen3 (#148627) --- homeassistant/components/ollama/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 7e80570bd5e..093e20f5140 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -158,7 +158,7 @@ MODEL_NAMES = [ # https://ollama.com/library "yi", "zephyr", ] -DEFAULT_MODEL = "llama3.2:latest" +DEFAULT_MODEL = "qwen3:4b" DEFAULT_CONVERSATION_NAME = "Ollama Conversation" DEFAULT_AI_TASK_NAME = "Ollama AI Task" From ad881d892ba354ffa09160e2d6ab0fc18ed2574c Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Fri, 11 Jul 2025 23:45:57 +0200 Subject: [PATCH 1332/1664] Keep entities of dead Z-Wave devices available (#148611) --- homeassistant/components/zwave_js/entity.py | 22 +---------- homeassistant/components/zwave_js/update.py | 19 ++++------ tests/components/zwave_js/test_init.py | 42 ++++++++++++++++++++- tests/components/zwave_js/test_lock.py | 5 ++- tests/components/zwave_js/test_update.py | 14 +------ 5 files changed, 54 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index d1ab9009308..08a587d8d20 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Sequence from typing import Any -from zwave_js_server.const import NodeStatus from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import ( @@ -27,8 +26,6 @@ from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id EVENT_VALUE_REMOVED = "value removed" -EVENT_DEAD = "dead" -EVENT_ALIVE = "alive" class ZWaveBaseEntity(Entity): @@ -141,11 +138,6 @@ class ZWaveBaseEntity(Entity): ) ) - for status_event in (EVENT_ALIVE, EVENT_DEAD): - self.async_on_remove( - self.info.node.on(status_event, self._node_status_alive_or_dead) - ) - self.async_on_remove( async_dispatcher_connect( self.hass, @@ -211,19 +203,7 @@ class ZWaveBaseEntity(Entity): @property def available(self) -> bool: """Return entity availability.""" - return ( - self.driver.client.connected - and bool(self.info.node.ready) - and self.info.node.status != NodeStatus.DEAD - ) - - @callback - def _node_status_alive_or_dead(self, event_data: dict) -> None: - """Call when node status changes to alive or dead. - - Should not be overridden by subclasses. - """ - self.async_write_ha_state() + return self.driver.client.connected and bool(self.info.node.ready) @callback def _value_changed(self, event_data: dict) -> None: diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 4355857f5df..89fb4dd4aba 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -199,18 +199,13 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): ) return - # If device is asleep/dead, wait for it to wake up/become alive before - # attempting an update - for status, event_name in ( - (NodeStatus.ASLEEP, "wake up"), - (NodeStatus.DEAD, "alive"), - ): - if self.node.status == status: - if not self._status_unsub: - self._status_unsub = self.node.once( - event_name, self._update_on_status_change - ) - return + # If device is asleep, wait for it to wake up before attempting an update + if self.node.status == NodeStatus.ASLEEP: + if not self._status_unsub: + self._status_unsub = self.node.once( + "wake up", self._update_on_status_change + ) + return try: # Retrieve all firmware updates including non-stable ones but filter diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 4350d7f7649..324a0f14941 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -37,7 +37,11 @@ from homeassistant.helpers import ( ) from homeassistant.setup import async_setup_component -from .common import AIR_TEMPERATURE_SENSOR, EATON_RF9640_ENTITY +from .common import ( + AIR_TEMPERATURE_SENSOR, + BULB_6_MULTI_COLOR_LIGHT_ENTITY, + EATON_RF9640_ENTITY, +) from tests.common import ( MockConfigEntry, @@ -2168,3 +2172,39 @@ async def test_factory_reset_node( assert len(notifications) == 1 assert list(notifications)[0] == msg_id assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] + + +async def test_entity_available_when_node_dead( + hass: HomeAssistant, client, bulb_6_multi_color, integration +) -> None: + """Test that entities remain available even when the node is dead.""" + + node = bulb_6_multi_color + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + + assert state + assert state.state != STATE_UNAVAILABLE + + # Send dead event to the node + event = Event( + "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should remain available even though the node is dead + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE + + # Send alive event to bring the node back + event = Event( + "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} + ) + node.receive_event(event) + await hass.async_block_till_done() + + # Entity should still be available + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 1011026ac68..9e36810872f 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -28,7 +28,7 @@ from homeassistant.components.zwave_js.lock import ( SERVICE_SET_LOCK_CONFIGURATION, SERVICE_SET_LOCK_USERCODE, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -295,7 +295,8 @@ async def test_door_lock( assert node.status == NodeStatus.DEAD state = hass.states.get(SCHLAGE_BE469_LOCK_ENTITY) assert state - assert state.state == STATE_UNAVAILABLE + # The state should still be locked, even if the node is dead + assert state.state == LockState.LOCKED async def test_only_one_lock( diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index fc225d529a6..17f154f4f78 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -277,7 +277,7 @@ async def test_update_entity_dead( zen_31, integration, ) -> None: - """Test update occurs when device is dead after it becomes alive.""" + """Test update occurs even when device is dead.""" event = Event( "dead", data={"source": "node", "event": "dead", "nodeId": zen_31.node_id}, @@ -290,17 +290,7 @@ async def test_update_entity_dead( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5, days=1)) await hass.async_block_till_done() - # Because node is asleep we shouldn't attempt to check for firmware updates - assert len(client.async_send_command.call_args_list) == 0 - - event = Event( - "alive", - data={"source": "node", "event": "alive", "nodeId": zen_31.node_id}, - ) - zen_31.receive_event(event) - await hass.async_block_till_done() - - # Now that the node is up we can check for updates + # Checking for firmware updates should proceed even for dead nodes assert len(client.async_send_command.call_args_list) > 0 args = client.async_send_command.call_args_list[0][0][0] From 28994152aeac51d8674360ca0f7635170ce907a2 Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 12 Jul 2025 12:24:59 +0200 Subject: [PATCH 1333/1664] Wallbox - Add translation to exception (#148644) --- homeassistant/components/wallbox/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 82a807e4d09..6b0bcf4dde2 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -108,7 +108,9 @@ def _validate(wallbox: Wallbox) -> None: wallbox.authenticate() except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error + raise InvalidAuth( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error raise ConnectionError from wallbox_connection_error From cf2ef4cec1e92e99a553a1eb62895327ae73c101 Mon Sep 17 00:00:00 2001 From: 0xEF <48224539+hexEF@users.noreply.github.com> Date: Sat, 12 Jul 2025 20:30:26 +0200 Subject: [PATCH 1334/1664] Bump nyt_games to 0.5.0 (#148654) --- .../components/nyt_games/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/nyt_games/fixtures/latest.json | 57 ++++++++++--------- .../nyt_games/fixtures/new_account.json | 45 ++++++++------- .../nyt_games/snapshots/test_sensor.ambr | 8 +-- 6 files changed, 61 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/nyt_games/manifest.json b/homeassistant/components/nyt_games/manifest.json index c32de754782..db3ad6a85f1 100644 --- a/homeassistant/components/nyt_games/manifest.json +++ b/homeassistant/components/nyt_games/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nyt_games", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["nyt_games==0.4.4"] + "requirements": ["nyt_games==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dd5108a807a..49b5c63c06e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1555,7 +1555,7 @@ numato-gpio==0.13.0 numpy==2.3.0 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4cd94a3f6ff..d3f35ebf92d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.13.0 numpy==2.3.0 # homeassistant.components.nyt_games -nyt_games==0.4.4 +nyt_games==0.5.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/tests/components/nyt_games/fixtures/latest.json b/tests/components/nyt_games/fixtures/latest.json index 73a6f440fc0..16601243052 100644 --- a/tests/components/nyt_games/fixtures/latest.json +++ b/tests/components/nyt_games/fixtures/latest.json @@ -25,43 +25,46 @@ }, "wordle": { "legacyStats": { - "gamesPlayed": 70, - "gamesWon": 51, + "gamesPlayed": 1111, + "gamesWon": 1069, "guesses": { "1": 0, - "2": 1, - "3": 7, - "4": 11, - "5": 20, - "6": 12, - "fail": 19 + "2": 8, + "3": 83, + "4": 440, + "5": 372, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonDayOffset": 1189, + "currentStreak": 229, + "maxStreak": 229, + "lastWonDayOffset": 1472, "hasPlayed": true, - "autoOptInTimestamp": 1708273168957, - "hasMadeStatsChoice": false, - "timestamp": 1726831978 + "autoOptInTimestamp": 1712205417018, + "hasMadeStatsChoice": true, + "timestamp": 1751255756 }, "calculatedStats": { - "gamesPlayed": 33, - "gamesWon": 26, + "currentStreak": 237, + "maxStreak": 241, + "lastWonPrintDate": "2025-07-08", + "lastCompletedPrintDate": "2025-07-08", + "hasPlayed": true + }, + "totalStats": { + "gamesWon": 1077, + "gamesPlayed": 1119, "guesses": { "1": 0, - "2": 1, - "3": 4, - "4": 7, - "5": 10, - "6": 4, - "fail": 7 + "2": 8, + "3": 83, + "4": 444, + "5": 376, + "6": 166, + "fail": 42 }, - "currentStreak": 1, - "maxStreak": 5, - "lastWonPrintDate": "2024-09-20", - "lastCompletedPrintDate": "2024-09-20", "hasPlayed": true, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/fixtures/new_account.json b/tests/components/nyt_games/fixtures/new_account.json index ad4d8e2e416..d35ce4cdebc 100644 --- a/tests/components/nyt_games/fixtures/new_account.json +++ b/tests/components/nyt_games/fixtures/new_account.json @@ -7,26 +7,6 @@ "stats": { "wordle": { "legacyStats": { - "gamesPlayed": 1, - "gamesWon": 1, - "guesses": { - "1": 0, - "2": 0, - "3": 0, - "4": 0, - "5": 1, - "6": 0, - "fail": 0 - }, - "currentStreak": 0, - "maxStreak": 1, - "lastWonDayOffset": 1118, - "hasPlayed": true, - "autoOptInTimestamp": 1727357874700, - "hasMadeStatsChoice": false, - "timestamp": 1727358123 - }, - "calculatedStats": { "gamesPlayed": 0, "gamesWon": 0, "guesses": { @@ -38,12 +18,35 @@ "6": 0, "fail": 0 }, + "currentStreak": 0, + "maxStreak": 1, + "lastWonDayOffset": 1118, + "hasPlayed": true, + "autoOptInTimestamp": 1727357874700, + "hasMadeStatsChoice": false, + "timestamp": 1727358123 + }, + "calculatedStats": { "currentStreak": 0, "maxStreak": 1, "lastWonPrintDate": "", "lastCompletedPrintDate": "", + "hasPlayed": false + }, + "totalStats": { + "gamesPlayed": 1, + "gamesWon": 1, + "guesses": { + "1": 0, + "2": 0, + "3": 0, + "4": 0, + "5": 1, + "6": 0, + "fail": 0 + }, "hasPlayed": false, - "generation": 1 + "hasPlayedArchive": false } } } diff --git a/tests/components/nyt_games/snapshots/test_sensor.ambr b/tests/components/nyt_games/snapshots/test_sensor.ambr index 5a1aa384f0f..10fddcfa365 100644 --- a/tests/components/nyt_games/snapshots/test_sensor.ambr +++ b/tests/components/nyt_games/snapshots/test_sensor.ambr @@ -473,7 +473,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '237', }) # --- # name: test_all_entities[sensor.wordle_highest_streak-entry] @@ -529,7 +529,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': '241', }) # --- # name: test_all_entities[sensor.wordle_played-entry] @@ -581,7 +581,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '70', + 'state': '1119', }) # --- # name: test_all_entities[sensor.wordle_won-entry] @@ -633,6 +633,6 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '51', + 'state': '1077', }) # --- From 72dc2b15d5e10550f751658f7575e24c92083b9d Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 12 Jul 2025 20:40:39 +0200 Subject: [PATCH 1335/1664] Wallbox Add translation to exception config entry auth failed (#148649) --- homeassistant/components/wallbox/coordinator.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 6b0bcf4dde2..23b028330d1 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -94,7 +94,9 @@ def _require_authentication[_WallboxCoordinatorT: WallboxCoordinator, **_P]( return func(self, *args, **kwargs) except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, translation_key="invalid_auth" + ) from wallbox_connection_error raise HomeAssistantError( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error From 531f1f196434f52e74ea2951dcf5ed5ecc3922e7 Mon Sep 17 00:00:00 2001 From: falconindy Date: Sat, 12 Jul 2025 14:46:03 -0400 Subject: [PATCH 1336/1664] snoo: use correct value for right safety clip binary sensor (#148647) --- homeassistant/components/snoo/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py index 3c91db5b86d..c4eaddcc1fe 100644 --- a/homeassistant/components/snoo/binary_sensor.py +++ b/homeassistant/components/snoo/binary_sensor.py @@ -38,7 +38,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [ SnooBinarySensorEntityDescription( key="right_clip", translation_key="right_clip", - value_fn=lambda data: data.left_safety_clip, + value_fn=lambda data: data.right_safety_clip, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), From ccc1f01ff6cd30ed2c4ac6c35eba95a8d565e40e Mon Sep 17 00:00:00 2001 From: jvits227 <133175738+jvits227@users.noreply.github.com> Date: Sat, 12 Jul 2025 14:51:09 -0400 Subject: [PATCH 1337/1664] Add lamp states to smartthings selector (#148302) Co-authored-by: Joostlek --- homeassistant/components/smartthings/select.py | 5 +++++ tests/components/smartthings/snapshots/test_select.ambr | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py index 99dc7a09f87..3106aba5e49 100644 --- a/homeassistant/components/smartthings/select.py +++ b/homeassistant/components/smartthings/select.py @@ -18,6 +18,11 @@ from .entity import SmartThingsEntity LAMP_TO_HA = { "extraHigh": "extra_high", + "high": "high", + "mid": "mid", + "low": "low", + "on": "on", + "off": "off", } WASHER_SOIL_LEVEL_TO_HA = { diff --git a/tests/components/smartthings/snapshots/test_select.ambr b/tests/components/smartthings/snapshots/test_select.ambr index 8950846ba21..d36132cc1ef 100644 --- a/tests/components/smartthings/snapshots/test_select.ambr +++ b/tests/components/smartthings/snapshots/test_select.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_all_entities[da_ks_oven_01061][select.oven_lamp-entry] @@ -112,7 +112,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'high', }) # --- # name: test_all_entities[da_ks_range_0101x][select.vulcan_lamp-entry] @@ -226,7 +226,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_all_entities[da_wm_dw_000001][select.dishwasher-entry] From 5287f4de812cd24fd61b2550e8853c04dbd04e2a Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Sat, 12 Jul 2025 23:52:26 +0300 Subject: [PATCH 1338/1664] Bump pyatv to 0.16.1 (#148659) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index b10a14af32b..fe500d2bfb0 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.16.0"], + "requirements": ["pyatv==0.16.1"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 49b5c63c06e..23ac894f93f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1848,7 +1848,7 @@ pyatag==0.3.5.3 pyatmo==9.2.1 # homeassistant.components.apple_tv -pyatv==0.16.0 +pyatv==0.16.1 # homeassistant.components.aussie_broadband pyaussiebb==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3f35ebf92d..2be2e935cd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1553,7 +1553,7 @@ pyatag==0.3.5.3 pyatmo==9.2.1 # homeassistant.components.apple_tv -pyatv==0.16.0 +pyatv==0.16.1 # homeassistant.components.aussie_broadband pyaussiebb==0.1.5 From fca6dc264f388554b59db4d31d9a87d1e4c27b4e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 01:11:37 +0200 Subject: [PATCH 1339/1664] Update bleak to 1.0.1 (#147742) Co-authored-by: J. Nick Koston --- .../components/bluetooth/manifest.json | 8 ++-- .../components/eq3btsmart/manifest.json | 2 +- .../components/esphome/manifest.json | 2 +- .../components/keymitt_ble/__init__.py | 32 ++++++++++++- homeassistant/package_constraints.txt | 8 ++-- requirements_all.txt | 10 ++-- requirements_test_all.txt | 10 ++-- script/hassfest/requirements.py | 19 ++------ tests/components/bluetooth/__init__.py | 48 +++++++++---------- tests/components/bluetooth/test_api.py | 2 - .../components/bluetooth/test_base_scanner.py | 9 ---- .../components/bluetooth/test_diagnostics.py | 2 +- tests/components/bluetooth/test_manager.py | 29 ++++------- tests/components/bluetooth/test_models.py | 17 ++----- tests/components/bluetooth/test_usage.py | 2 - .../bluetooth/test_websocket_api.py | 14 ++---- tests/components/bluetooth/test_wrappers.py | 47 +++++++++--------- .../esphome/bluetooth/test_client.py | 2 +- 18 files changed, 121 insertions(+), 142 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 33914f3457f..cf3ee8e0db9 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,12 +15,12 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.22.3", - "bleak-retry-connector==3.9.0", - "bluetooth-adapters==0.21.4", + "bleak==1.0.1", + "bleak-retry-connector==4.0.0", + "bluetooth-adapters==2.0.0", "bluetooth-auto-recovery==1.5.2", "bluetooth-data-tools==1.28.2", "dbus-fast==2.43.0", - "habluetooth==3.49.0" + "habluetooth==4.0.1" ] } diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json index 62128077f2f..472384fdf7d 100644 --- a/homeassistant/components/eq3btsmart/manifest.json +++ b/homeassistant/components/eq3btsmart/manifest.json @@ -22,5 +22,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["eq3btsmart"], - "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"] + "requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 9099af63ad9..e094fd5daa7 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -19,7 +19,7 @@ "requirements": [ "aioesphomeapi==34.2.0", "esphome-dashboard-api==1.3.0", - "bleak-esphome==2.16.0" + "bleak-esphome==3.1.0" ], "zeroconf": ["_esphomelib._tcp.local."] } diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 01948006852..0f71519e420 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,14 +2,42 @@ from __future__ import annotations -from microbot import MicroBotApiClient +from collections.abc import Generator +from contextlib import contextmanager + +import bleak from homeassistant.components import bluetooth from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator + +@contextmanager +def patch_unused_bleak_discover_import() -> Generator[None]: + """Patch bleak.discover import in microbot. It is unused and was removed in bleak 1.0.0.""" + + def getattr_bleak(name: str) -> object: + if name == "discover": + return None + raise AttributeError + + original_func = bleak.__dict__.get("__getattr__") + bleak.__dict__["__getattr__"] = getattr_bleak + try: + yield + finally: + if original_func is not None: + bleak.__dict__["__getattr__"] = original_func + + +with patch_unused_bleak_discover_import(): + from microbot import MicroBotApiClient + +from .coordinator import ( # noqa: E402 + MicroBotConfigEntry, + MicroBotDataUpdateCoordinator, +) PLATFORMS: list[str] = [Platform.SWITCH] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 89ff2238f61..9e21c5830e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,9 +20,9 @@ audioop-lts==0.2.1 av==13.1.0 awesomeversion==25.5.0 bcrypt==4.3.0 -bleak-retry-connector==3.9.0 -bleak==0.22.3 -bluetooth-adapters==0.21.4 +bleak-retry-connector==4.0.0 +bleak==1.0.1 +bluetooth-adapters==2.0.0 bluetooth-auto-recovery==1.5.2 bluetooth-data-tools==1.28.2 cached-ipaddress==0.10.0 @@ -34,7 +34,7 @@ dbus-fast==2.43.0 fnv-hash-fast==1.5.0 go2rtc-client==0.2.1 ha-ffmpeg==3.2.2 -habluetooth==3.49.0 +habluetooth==4.0.1 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 23ac894f93f..13742320bf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -616,13 +616,13 @@ bizkaibus==0.1.1 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.16.0 +bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.9.0 +bleak-retry-connector==4.0.0 # homeassistant.components.bluetooth -bleak==0.22.3 +bleak==1.0.1 # homeassistant.components.blebox blebox-uniapi==2.5.0 @@ -643,7 +643,7 @@ bluemaestro-ble==0.4.1 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.4 +bluetooth-adapters==2.0.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.5.2 @@ -1124,7 +1124,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.49.0 +habluetooth==4.0.1 # homeassistant.components.cloud hass-nabucasa==0.106.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2be2e935cd7..098c474d2f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -550,13 +550,13 @@ bimmer-connected[china]==0.17.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome -bleak-esphome==2.16.0 +bleak-esphome==3.1.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.9.0 +bleak-retry-connector==4.0.0 # homeassistant.components.bluetooth -bleak==0.22.3 +bleak==1.0.1 # homeassistant.components.blebox blebox-uniapi==2.5.0 @@ -574,7 +574,7 @@ bluemaestro-ble==0.4.1 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.21.4 +bluetooth-adapters==2.0.0 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.5.2 @@ -985,7 +985,7 @@ ha-silabs-firmware-client==0.2.0 habiticalib==0.4.0 # homeassistant.components.bluetooth -habluetooth==3.49.0 +habluetooth==4.0.1 # homeassistant.components.cloud hass-nabucasa==0.106.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index d7d064fff28..b334b75451e 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -27,6 +27,7 @@ PACKAGE_CHECK_VERSION_RANGE = { "aiohttp": "SemVer", "attrs": "CalVer", "awesomeversion": "CalVer", + "bleak": "SemVer", "grpcio": "SemVer", "httpx": "SemVer", "mashumaro": "SemVer", @@ -297,10 +298,6 @@ PYTHON_VERSION_CHECK_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # - domain is the integration domain # - package is the package (can be transitive) referencing the dependency # - dependencyX should be the name of the referenced dependency - "bluetooth": { - # https://github.com/hbldh/bleak/pull/1718 (not yet released) - "homeassistant": {"bleak"} - }, "python_script": { # Security audits are needed for each Python version "homeassistant": {"restrictedpython"} @@ -501,17 +498,9 @@ def get_requirements(integration: Integration, packages: set[str]) -> set[str]: continue # Check for restrictive version limits on Python - if ( - (requires_python := metadata(package)["Requires-Python"]) - and not all( - _is_dependency_version_range_valid(version_part, "SemVer") - for version_part in requires_python.split(",") - ) - # "bleak" is a transient dependency of 53 integrations, and we don't - # want to add the whole list to PYTHON_VERSION_CHECK_EXCEPTIONS - # This extra check can be removed when bleak is updated - # https://github.com/hbldh/bleak/pull/1718 - and (package in packages or package != "bleak") + if (requires_python := metadata(package)["Requires-Python"]) and not all( + _is_dependency_version_range_valid(version_part, "SemVer") + for version_part in requires_python.split(",") ): needs_python_version_check_exception = True integration.add_warning_or_error( diff --git a/tests/components/bluetooth/__init__.py b/tests/components/bluetooth/__init__.py index 31d301e2dac..d439f46bb71 100644 --- a/tests/components/bluetooth/__init__.py +++ b/tests/components/bluetooth/__init__.py @@ -1,11 +1,11 @@ """Tests for the Bluetooth integration.""" -from collections.abc import Iterable +from collections.abc import Generator, Iterable from contextlib import contextmanager import itertools import time from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from bleak import BleakClient from bleak.backends.scanner import AdvertisementData, BLEDevice @@ -53,7 +53,6 @@ ADVERTISEMENT_DATA_DEFAULTS = { BLE_DEVICE_DEFAULTS = { "name": None, - "rssi": -127, "details": None, } @@ -89,7 +88,6 @@ def generate_ble_device( address: str | None = None, name: str | None = None, details: Any | None = None, - rssi: int | None = None, **kwargs: Any, ) -> BLEDevice: """Generate a BLEDevice with defaults.""" @@ -100,8 +98,6 @@ def generate_ble_device( new["name"] = name if details is not None: new["details"] = details - if rssi is not None: - new["rssi"] = rssi for key, value in BLE_DEVICE_DEFAULTS.items(): new.setdefault(key, value) return BLEDevice(**new) @@ -215,34 +211,35 @@ def inject_bluetooth_service_info( @contextmanager -def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> None: +def patch_all_discovered_devices(mock_discovered: list[BLEDevice]) -> Generator[None]: """Mock all the discovered devices from all the scanners.""" manager = _get_manager() - original_history = {} scanners = list( itertools.chain( manager._connectable_scanners, manager._non_connectable_scanners ) ) - for scanner in scanners: - data = scanner.discovered_devices_and_advertisement_data - original_history[scanner] = data.copy() - data.clear() - if scanners: - data = scanners[0].discovered_devices_and_advertisement_data - data.clear() - data.update( - {device.address: (device, MagicMock()) for device in mock_discovered} - ) - yield - for scanner in scanners: - data = scanner.discovered_devices_and_advertisement_data - data.clear() - data.update(original_history[scanner]) + if scanners and getattr(scanners[0], "scanner", None): + with patch.object( + scanners[0].scanner.__class__, + "discovered_devices_and_advertisement_data", + new=PropertyMock( + side_effect=[ + { + device.address: (device, MagicMock()) + for device in mock_discovered + }, + ] + + [{}] * (len(scanners)) + ), + ): + yield + else: + yield @contextmanager -def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> None: +def patch_discovered_devices(mock_discovered: list[BLEDevice]) -> Generator[None]: """Mock the combined best path to discovered devices from all the scanners.""" manager = _get_manager() original_all_history = manager._all_history @@ -305,6 +302,9 @@ class MockBleakClient(BleakClient): """Mock clear_cache.""" return True + def set_disconnected_callback(self, callback, **kwargs): + """Mock set_disconnected_callback.""" + class FakeScannerMixin: def get_discovered_device_advertisement_data( diff --git a/tests/components/bluetooth/test_api.py b/tests/components/bluetooth/test_api.py index 1468367fd9a..74373da6865 100644 --- a/tests/components/bluetooth/test_api.py +++ b/tests/components/bluetooth/test_api.py @@ -82,7 +82,6 @@ async def test_async_scanner_devices_by_address_connectable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -116,7 +115,6 @@ async def test_async_scanner_devices_by_address_non_connectable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index 25dc1b9738d..f2aa3d87778 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -54,7 +54,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -67,7 +66,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", name_2, {}, - rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( local_name=name_2, @@ -80,7 +78,6 @@ async def test_remote_scanner(hass: HomeAssistant, name_2: str | None) -> None: "44:44:33:11:23:45", "wohandlonger", {}, - rssi=-100, ) switchbot_device_adv_3 = generate_advertisement_data( local_name="wohandlonger", @@ -146,7 +143,6 @@ async def test_remote_scanner_expires_connectable(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -199,7 +195,6 @@ async def test_remote_scanner_expires_non_connectable(hass: HomeAssistant) -> No "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -272,7 +267,6 @@ async def test_base_scanner_connecting_behavior(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -376,7 +370,6 @@ async def test_device_with_ten_minute_advertising_interval(hass: HomeAssistant) "44:44:33:11:23:45", "bparasite", {}, - rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", @@ -501,7 +494,6 @@ async def test_scanner_stops_responding(hass: HomeAssistant) -> None: "44:44:33:11:23:45", "bparasite", {}, - rssi=-100, ) bparasite_device_adv = generate_advertisement_data( local_name="bparasite", @@ -545,7 +537,6 @@ async def test_remote_scanner_bluetooth_config_entry( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 540bf1bfbd1..5c4d8bda70d 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -37,7 +37,7 @@ class FakeHaScanner(FakeScannerMixin, HaScanner): """Return the discovered devices and advertisement data.""" return { "44:44:33:11:23:45": ( - generate_ble_device(name="x", rssi=-127, address="44:44:33:11:23:45"), + generate_ble_device(name="x", address="44:44:33:11:23:45"), generate_advertisement_data(local_name="x"), ) } diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index 7488aa6e33c..f34afba01ef 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -78,11 +78,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS @@ -93,11 +91,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_100 ) - switchbot_device_signal_99 = generate_ble_device( - address, "wohand_signal_99", rssi=-99 - ) + switchbot_device_signal_99 = generate_ble_device(address, "wohand_signal_99") switchbot_adv_signal_99 = generate_advertisement_data( - local_name="wohand_signal_99", service_uuids=[] + local_name="wohand_signal_99", service_uuids=[], rssi=-99 ) inject_advertisement_with_source( hass, switchbot_device_signal_99, switchbot_adv_signal_99, HCI0_SOURCE_ADDRESS @@ -108,11 +104,9 @@ async def test_advertisements_do_not_switch_adapters_for_no_reason( is switchbot_device_signal_99 ) - switchbot_device_signal_98 = generate_ble_device( - address, "wohand_good_signal", rssi=-98 - ) + switchbot_device_signal_98 = generate_ble_device(address, "wohand_good_signal") switchbot_adv_signal_98 = generate_advertisement_data( - local_name="wohand_good_signal", service_uuids=[] + local_name="wohand_good_signal", service_uuids=[], rssi=-98 ) inject_advertisement_with_source( hass, switchbot_device_signal_98, switchbot_adv_signal_98, HCI1_SOURCE_ADDRESS @@ -805,13 +799,11 @@ async def test_goes_unavailable_connectable_only_and_recovers( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_non_connectable = generate_ble_device( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -978,7 +970,6 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1394,7 +1385,6 @@ async def test_bluetooth_rediscover( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1571,7 +1561,6 @@ async def test_bluetooth_rediscover_no_match( "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", @@ -1693,11 +1682,9 @@ async def test_async_register_disappeared_callback( """Test bluetooth async_register_disappeared_callback handles failures.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, "hci0" diff --git a/tests/components/bluetooth/test_models.py b/tests/components/bluetooth/test_models.py index d36741b4d5d..af367dec187 100644 --- a/tests/components/bluetooth/test_models.py +++ b/tests/components/bluetooth/test_models.py @@ -124,7 +124,7 @@ async def test_wrapped_bleak_client_local_adapter_only(hass: HomeAssistant) -> N "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True ), ): - assert await client.connect() is True + await client.connect() assert client.is_connected is True client.set_disconnected_callback(lambda client: None) await client.disconnect() @@ -145,7 +145,6 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( "source": "esp32_has_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( local_name="wohand", @@ -215,7 +214,7 @@ async def test_wrapped_bleak_client_set_disconnected_callback_after_connected( "bleak.backends.bluezdbus.client.BleakClientBlueZDBus.is_connected", True ), ): - assert await client.connect() is True + await client.connect() assert client.is_connected is True client.set_disconnected_callback(lambda client: None) await client.disconnect() @@ -236,10 +235,9 @@ async def test_ble_device_with_proxy_client_out_of_connections_no_scanners( "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) inject_advertisement_with_source( @@ -275,10 +273,9 @@ async def test_ble_device_with_proxy_client_out_of_connections( "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @@ -340,10 +337,9 @@ async def test_ble_device_with_proxy_clear_cache(hass: HomeAssistant) -> None: "source": "esp32", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_adv = generate_advertisement_data( - local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"} + local_name="wohand", service_uuids=[], manufacturer_data={1: b"\x01"}, rssi=-30 ) class FakeScanner(FakeScannerMixin, BaseHaRemoteScanner): @@ -417,7 +413,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "source": "esp32_has_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-40, ) switchbot_proxy_device_adv_has_connection_slot = generate_advertisement_data( local_name="wohand", @@ -511,7 +506,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "source": "esp32_no_connection_slot", "path": "/org/bluez/hci0/dev_44_44_33_11_23_45", }, - rssi=-30, ) switchbot_proxy_device_no_connection_slot_adv = generate_advertisement_data( local_name="wohand", @@ -538,7 +532,6 @@ async def test_ble_device_with_proxy_client_out_of_connections_uses_best_availab "44:44:33:11:23:45", "wohand", {}, - rssi=-100, ) switchbot_device_adv = generate_advertisement_data( local_name="wohand", diff --git a/tests/components/bluetooth/test_usage.py b/tests/components/bluetooth/test_usage.py index d5d4e7ad9d0..9c3c8c6cebb 100644 --- a/tests/components/bluetooth/test_usage.py +++ b/tests/components/bluetooth/test_usage.py @@ -17,9 +17,7 @@ from . import generate_ble_device MOCK_BLE_DEVICE = generate_ble_device( "00:00:00:00:00:00", "any", - delegate="", details={"path": "/dev/hci0/device"}, - rssi=-127, ) diff --git a/tests/components/bluetooth/test_websocket_api.py b/tests/components/bluetooth/test_websocket_api.py index 57199d04078..2e613932f3c 100644 --- a/tests/components/bluetooth/test_websocket_api.py +++ b/tests/components/bluetooth/test_websocket_api.py @@ -38,11 +38,9 @@ async def test_subscribe_advertisements( """Test bluetooth subscribe_advertisements.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS @@ -68,7 +66,7 @@ async def test_subscribe_advertisements( "connectable": True, "manufacturer_data": {}, "name": "wohand_signal_100", - "rssi": -127, + "rssi": -100, "service_data": {}, "service_uuids": [], "source": HCI0_SOURCE_ADDRESS, @@ -134,11 +132,9 @@ async def test_subscribe_connection_allocations( """Test bluetooth subscribe_connection_allocations.""" address = "44:44:33:11:23:12" - switchbot_device_signal_100 = generate_ble_device( - address, "wohand_signal_100", rssi=-100 - ) + switchbot_device_signal_100 = generate_ble_device(address, "wohand_signal_100") switchbot_adv_signal_100 = generate_advertisement_data( - local_name="wohand_signal_100", service_uuids=[] + local_name="wohand_signal_100", service_uuids=[], rssi=-100 ) inject_advertisement_with_source( hass, switchbot_device_signal_100, switchbot_adv_signal_100, HCI0_SOURCE_ADDRESS diff --git a/tests/components/bluetooth/test_wrappers.py b/tests/components/bluetooth/test_wrappers.py index bfe7445f614..413c96535a6 100644 --- a/tests/components/bluetooth/test_wrappers.py +++ b/tests/components/bluetooth/test_wrappers.py @@ -92,17 +92,13 @@ class FakeBleakClient(BaseFakeBleakClient): async def connect(self, *args, **kwargs): """Connect.""" + + @property + def is_connected(self): + """Connected.""" return True -class FakeBleakClientFailsToConnect(BaseFakeBleakClient): - """Fake bleak client that fails to connect.""" - - async def connect(self, *args, **kwargs): - """Connect.""" - return False - - class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Fake bleak client that raises on connect.""" @@ -110,6 +106,11 @@ class FakeBleakClientRaisesOnConnect(BaseFakeBleakClient): """Connect.""" raise ConnectionError("Test exception") + @property + def is_connected(self): + """Not connected.""" + return False + def _generate_ble_device_and_adv_data( interface: str, mac: str, rssi: int @@ -119,7 +120,6 @@ def _generate_ble_device_and_adv_data( generate_ble_device( mac, "any", - delegate="", details={"path": f"/org/bluez/{interface}/dev_{mac}"}, ), generate_advertisement_data(rssi=rssi), @@ -144,16 +144,6 @@ def mock_platform_client_fixture(): yield -@pytest.fixture(name="mock_platform_client_that_fails_to_connect") -def mock_platform_client_that_fails_to_connect_fixture(): - """Fixture that mocks the platform client that fails to connect.""" - with patch( - "habluetooth.wrappers.get_platform_client_backend_type", - return_value=FakeBleakClientFailsToConnect, - ): - yield - - @pytest.fixture(name="mock_platform_client_that_raises_on_connect") def mock_platform_client_that_raises_on_connect_fixture(): """Fixture that mocks the platform client that fails to connect.""" @@ -219,7 +209,8 @@ async def test_test_switch_adapters_when_out_of_slots( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is True + await client.connect() + assert client.is_connected is True assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 0 @@ -251,7 +242,8 @@ async def test_test_switch_adapters_when_out_of_slots( ): ble_device = hci0_device_advs["00:00:00:00:00:03"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is True + await client.connect() + assert client.is_connected is True assert release_slot_mock.call_count == 0 cancel_hci0() @@ -262,7 +254,7 @@ async def test_test_switch_adapters_when_out_of_slots( async def test_release_slot_on_connect_failure( hass: HomeAssistant, install_bleak_catcher, - mock_platform_client_that_fails_to_connect, + mock_platform_client_that_raises_on_connect, ) -> None: """Ensure the slot gets released on connection failure.""" manager = _get_manager() @@ -278,7 +270,9 @@ async def test_release_slot_on_connect_failure( ): ble_device = hci0_device_advs["00:00:00:00:00:01"][0] client = bleak.BleakClient(ble_device) - assert await client.connect() is False + with pytest.raises(ConnectionError): + await client.connect() + assert client.is_connected is False assert allocate_slot_mock.call_count == 1 assert release_slot_mock.call_count == 1 @@ -335,13 +329,18 @@ async def test_passing_subclassed_str_as_address( async def connect(self, *args, **kwargs): """Connect.""" + + @property + def is_connected(self): + """Connected.""" return True with patch( "habluetooth.wrappers.get_platform_client_backend_type", return_value=FakeBleakClient, ): - assert await client.connect() is True + await client.connect() + assert client.is_connected is True cancel_hci0() cancel_hci1() diff --git a/tests/components/esphome/bluetooth/test_client.py b/tests/components/esphome/bluetooth/test_client.py index 554f1725f4b..86db1fc3109 100644 --- a/tests/components/esphome/bluetooth/test_client.py +++ b/tests/components/esphome/bluetooth/test_client.py @@ -55,4 +55,4 @@ async def test_client_usage_while_not_connected(client_data: ESPHomeClientData) with pytest.raises( BleakError, match=f"{ESP_NAME}.*{ESP_MAC_ADDRESS}.*not connected" ): - assert await client.write_gatt_char("test", b"test") is False + assert await client.write_gatt_char("test", b"test", False) is False From d33f73fce2e3abc5af50d692446a025424cb9cac Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 04:26:31 +0200 Subject: [PATCH 1340/1664] Cleanup bleak warnings (#148665) --- tests/components/bluemaestro/__init__.py | 1 - tests/components/eq3btsmart/conftest.py | 2 +- tests/components/homekit_controller/conftest.py | 4 +--- tests/components/inkbird/__init__.py | 1 - tests/components/iron_os/conftest.py | 2 +- tests/components/kulersky/test_light.py | 4 +--- tests/components/leaone/__init__.py | 1 - tests/components/sensorpro/__init__.py | 1 - tests/components/sensorpush/__init__.py | 1 - tests/components/thermobeacon/__init__.py | 1 - tests/components/thermopro/__init__.py | 1 - 11 files changed, 4 insertions(+), 15 deletions(-) diff --git a/tests/components/bluemaestro/__init__.py b/tests/components/bluemaestro/__init__.py index e598eb34597..259457453b1 100644 --- a/tests/components/bluemaestro/__init__.py +++ b/tests/components/bluemaestro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/eq3btsmart/conftest.py b/tests/components/eq3btsmart/conftest.py index 92f1be29b70..ce55a1fccbd 100644 --- a/tests/components/eq3btsmart/conftest.py +++ b/tests/components/eq3btsmart/conftest.py @@ -28,7 +28,7 @@ def fake_service_info(): source="local", connectable=False, time=0, - device=generate_ble_device(address=MAC, name="CC-RT-BLE", rssi=0), + device=generate_ble_device(address=MAC, name="CC-RT-BLE"), advertisement=AdvertisementData( local_name="CC-RT-BLE", manufacturer_data={}, diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index 882d0d60e66..bf05efada72 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -66,9 +66,7 @@ def fake_ble_discovery() -> Generator[None]: """Fake BLE discovery.""" class FakeBLEDiscovery(FakeDiscovery): - device = BLEDevice( - address="AA:BB:CC:DD:EE:FF", name="TestDevice", rssi=-50, details=() - ) + device = BLEDevice(address="AA:BB:CC:DD:EE:FF", name="TestDevice", details=()) with patch("aiohomekit.testing.FakeDiscovery", FakeBLEDiscovery): yield diff --git a/tests/components/inkbird/__init__.py b/tests/components/inkbird/__init__.py index 7228f64448b..1daadc9ffe8 100644 --- a/tests/components/inkbird/__init__.py +++ b/tests/components/inkbird/__init__.py @@ -29,7 +29,6 @@ def _make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=MONOTONIC_TIME(), advertisement=None, diff --git a/tests/components/iron_os/conftest.py b/tests/components/iron_os/conftest.py index 479ee2fde7b..60abf8a8008 100644 --- a/tests/components/iron_os/conftest.py +++ b/tests/components/iron_os/conftest.py @@ -131,7 +131,7 @@ def mock_ble_device() -> Generator[MagicMock]: with patch( "homeassistant.components.bluetooth.async_ble_device_from_address", return_value=BLEDevice( - address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, rssi=-50, details={} + address="c0:ff:ee:c0:ff:ee", name=DEFAULT_NAME, details={} ), ) as ble_device: yield ble_device diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index bde60579af7..9521f98f523 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -40,9 +40,7 @@ def mock_ble_device() -> Generator[MagicMock]: """Mock BLEDevice.""" with patch( "homeassistant.components.kulersky.async_ble_device_from_address", - return_value=BLEDevice( - address="AA:BB:CC:11:22:33", name="Bedroom", rssi=-50, details={} - ), + return_value=BLEDevice(address="AA:BB:CC:11:22:33", name="Bedroom", details={}), ) as ble_device: yield ble_device diff --git a/tests/components/leaone/__init__.py b/tests/components/leaone/__init__.py index befc0a81028..900fe100940 100644 --- a/tests/components/leaone/__init__.py +++ b/tests/components/leaone/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/sensorpro/__init__.py b/tests/components/sensorpro/__init__.py index a63bdbe08dc..7f2a7b1f33e 100644 --- a/tests/components/sensorpro/__init__.py +++ b/tests/components/sensorpro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/sensorpush/__init__.py b/tests/components/sensorpush/__init__.py index 88fb2072961..6f1f80d777e 100644 --- a/tests/components/sensorpush/__init__.py +++ b/tests/components/sensorpush/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/thermobeacon/__init__.py b/tests/components/thermobeacon/__init__.py index 32b6d823ec2..9b43e3b33f2 100644 --- a/tests/components/thermobeacon/__init__.py +++ b/tests/components/thermobeacon/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, diff --git a/tests/components/thermopro/__init__.py b/tests/components/thermopro/__init__.py index 7ac593e6336..6971d72c460 100644 --- a/tests/components/thermopro/__init__.py +++ b/tests/components/thermopro/__init__.py @@ -32,7 +32,6 @@ def make_bluetooth_service_info( name=name, address=address, details={}, - rssi=rssi, ), time=monotonic_time_coarse(), advertisement=None, From ab6ac94af9f67e44e536ecb311d485ecb2e4ec50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jul 2025 18:49:59 -1000 Subject: [PATCH 1341/1664] Bump aioesphomeapi to 35.0.0 (#148666) --- 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 e094fd5daa7..c88fa7246fe 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==34.2.0", + "aioesphomeapi==35.0.0", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 13742320bf3..fe66f48a42a 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==34.2.0 +aioesphomeapi==35.0.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 098c474d2f9..3f0e3b62646 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==34.2.0 +aioesphomeapi==35.0.0 # homeassistant.components.flo aioflo==2021.11.0 From 1c35aff51061b6d60ac4d45b0ca058f8fe9e77da Mon Sep 17 00:00:00 2001 From: asafhas <121308170+asafhas@users.noreply.github.com> Date: Sun, 13 Jul 2025 08:55:37 +0300 Subject: [PATCH 1342/1664] Add configuration entities to Tuya multifunction alarm (#148556) --- homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/icons.json | 6 + homeassistant/components/tuya/strings.json | 6 + homeassistant/components/tuya/switch.py | 16 ++ tests/components/tuya/__init__.py | 5 + .../tuya/fixtures/mal_alarm_host.json | 225 ++++++++++++++++++ .../snapshots/test_alarm_control_panel.ambr | 53 +++++ .../tuya/snapshots/test_switch.ambr | 96 ++++++++ .../tuya/test_alarm_control_panel.py | 57 +++++ 9 files changed, 466 insertions(+) create mode 100644 tests/components/tuya/fixtures/mal_alarm_host.json create mode 100644 tests/components/tuya/snapshots/test_alarm_control_panel.ambr create mode 100644 tests/components/tuya/test_alarm_control_panel.py diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index abf5223175c..f9377114e47 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -315,6 +315,8 @@ class DPCode(StrEnum): SWITCH_6 = "switch_6" # Switch 6 SWITCH_7 = "switch_7" # Switch 7 SWITCH_8 = "switch_8" # Switch 8 + SWITCH_ALARM_LIGHT = "switch_alarm_light" + SWITCH_ALARM_SOUND = "switch_alarm_sound" SWITCH_BACKLIGHT = "switch_backlight" # Backlight switch SWITCH_CHARGE = "switch_charge" SWITCH_CONTROLLER = "switch_controller" diff --git a/homeassistant/components/tuya/icons.json b/homeassistant/components/tuya/icons.json index e28371f2b3d..40bbf41fd0d 100644 --- a/homeassistant/components/tuya/icons.json +++ b/homeassistant/components/tuya/icons.json @@ -370,6 +370,12 @@ }, "sterilization": { "default": "mdi:minus-circle-outline" + }, + "arm_beep": { + "default": "mdi:volume-high" + }, + "siren": { + "default": "mdi:alarm-light" } } } diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 5964be5ce34..a5302b2e88b 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -906,6 +906,12 @@ }, "sterilization": { "name": "Sterilization" + }, + "arm_beep": { + "name": "Arm beep" + }, + "siren": { + "name": "Siren" } } } diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 9b4cc332d94..f455424c2c1 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -431,6 +431,22 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + SwitchEntityDescription( + key=DPCode.SWITCH_ALARM_SOUND, + # This switch is called "Arm Beep" in the official Tuya app + translation_key="arm_beep", + entity_category=EntityCategory.CONFIG, + ), + SwitchEntityDescription( + key=DPCode.SWITCH_ALARM_LIGHT, + # This switch is called "Siren" in the official Tuya app + translation_key="siren", + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 90a49fc2372..80e21e84c2e 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -60,6 +60,11 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "mal_alarm_host": [ + # Alarm Host support + Platform.ALARM_CONTROL_PANEL, + Platform.SWITCH, + ], "mcs_door_sensor": [ # https://github.com/home-assistant/core/issues/108301 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/mal_alarm_host.json b/tests/components/tuya/fixtures/mal_alarm_host.json new file mode 100644 index 00000000000..1a25a84ec2c --- /dev/null +++ b/tests/components/tuya/fixtures/mal_alarm_host.json @@ -0,0 +1,225 @@ +{ + "id": "123123aba12312312dazub", + "name": "Multifunction alarm", + "category": "mal", + "product_id": "gyitctrjj1kefxp2", + "product_name": "Multifunction alarm", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2024-12-02T20:08:56+00:00", + "create_time": "2024-12-02T20:08:56+00:00", + "update_time": "2024-12-02T20:08:56+00:00", + "function": { + "master_mode": { + "type": "Enum", + "value": { + "range": ["disarmed", "arm", "home", "sos"] + } + }, + "delay_set": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_light": { + "type": "Boolean", + "value": {} + }, + "switch_mode_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_light": { + "type": "Boolean", + "value": {} + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_propel": { + "type": "Boolean", + "value": {} + }, + "alarm_delay_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "sub_class": { + "type": "Enum", + "value": { + "range": ["remote_controller", "detector"] + } + }, + "sub_admin": { + "type": "Raw", + "value": {} + } + }, + "status_range": { + "master_mode": { + "type": "Enum", + "value": { + "range": ["disarmed", "arm", "home", "sos"] + } + }, + "delay_set": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "switch_alarm_sound": { + "type": "Boolean", + "value": {} + }, + "switch_alarm_light": { + "type": "Boolean", + "value": {} + }, + "switch_mode_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_sound": { + "type": "Boolean", + "value": {} + }, + "switch_kb_light": { + "type": "Boolean", + "value": {} + }, + "telnet_state": { + "type": "Enum", + "value": { + "range": [ + "normal", + "network_no", + "phone_no", + "sim_card_no", + "network_search", + "signal_level_1", + "signal_level_2", + "signal_level_3", + "signal_level_4", + "signal_level_5" + ] + } + }, + "muffling": { + "type": "Boolean", + "value": {} + }, + "alarm_msg": { + "type": "Raw", + "value": {} + }, + "switch_alarm_propel": { + "type": "Boolean", + "value": {} + }, + "alarm_delay_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 999, + "scale": 0, + "step": 1 + } + }, + "master_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm"] + } + }, + "sub_class": { + "type": "Enum", + "value": { + "range": ["remote_controller", "detector"] + } + }, + "sub_admin": { + "type": "Raw", + "value": {} + }, + "sub_state": { + "type": "Enum", + "value": { + "range": ["normal", "alarm", "fault", "others"] + } + } + }, + "status": { + "master_mode": "disarmed", + "delay_set": 15, + "alarm_time": 3, + "switch_alarm_sound": true, + "switch_alarm_light": true, + "switch_mode_sound": true, + "switch_kb_sound": false, + "switch_kb_light": false, + "telnet_state": "sim_card_no", + "muffling": false, + "alarm_msg": "AFMAZQBuAHMAbwByACAATABvAHcAIABCAGEAdAB0AGUAcgB5AAoAWgBvAG4AZQA6ADAAMAA1AEUAbgB0AHIAYQBuAGMAZQ==", + "switch_alarm_propel": true, + "alarm_delay_time": 20, + "master_state": "normal", + "sub_class": "remote_controller", + "sub_admin": "AgEFCggC////HABLAGkAdABjAGgAZQBuACAAUwBtAG8AawBlACBjAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADFkAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADJlAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADNmAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADQ=", + "sub_state": "normal" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_alarm_control_panel.ambr b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr new file mode 100644 index 00000000000..97076d5e467 --- /dev/null +++ b/tests/components/tuya/snapshots/test_alarm_control_panel.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[mal_alarm_host][alarm_control_panel.multifunction_alarm-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'alarm_control_panel', + 'entity_category': None, + 'entity_id': 'alarm_control_panel.multifunction_alarm', + '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.123123aba12312312dazubmaster_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][alarm_control_panel.multifunction_alarm-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'changed_by': None, + 'code_arm_required': False, + 'code_format': None, + 'friendly_name': 'Multifunction alarm', + 'supported_features': , + }), + 'context': , + 'entity_id': 'alarm_control_panel.multifunction_alarm', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'disarmed', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 77943ccdd29..bf970a6ffbb 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -579,6 +579,102 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-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.multifunction_alarm_arm_beep', + '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': 'Arm beep', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_beep', + 'unique_id': 'tuya.123123aba12312312dazubswitch_alarm_sound', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Arm beep', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_arm_beep', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_siren-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.multifunction_alarm_siren', + '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': 'Siren', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren', + 'unique_id': 'tuya.123123aba12312312dazubswitch_alarm_light', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_siren-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Multifunction alarm Siren', + }), + 'context': , + 'entity_id': 'switch.multifunction_alarm_siren', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_alarm_control_panel.py b/tests/components/tuya/test_alarm_control_panel.py new file mode 100644 index 00000000000..71527bd83eb --- /dev/null +++ b/tests/components/tuya/test_alarm_control_panel.py @@ -0,0 +1,57 @@ +"""Test Tuya Alarm Control Panel platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.ALARM_CONTROL_PANEL in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", + [k for k, v in DEVICE_MOCKS.items() if Platform.ALARM_CONTROL_PANEL not in v], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 87fd45d4ab44555a09f483a7af9a91fc68835d5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 12 Jul 2025 20:12:14 -1000 Subject: [PATCH 1343/1664] Add device_id parameter to ESPHome command calls for sub-device support (#148667) --- .../components/esphome/alarm_control_panel.py | 35 ++++-- homeassistant/components/esphome/button.py | 2 +- homeassistant/components/esphome/climate.py | 20 ++-- homeassistant/components/esphome/cover.py | 32 ++++-- homeassistant/components/esphome/date.py | 8 +- homeassistant/components/esphome/datetime.py | 4 +- homeassistant/components/esphome/fan.py | 22 +++- homeassistant/components/esphome/light.py | 4 +- homeassistant/components/esphome/lock.py | 12 ++- .../components/esphome/media_player.py | 28 ++++- homeassistant/components/esphome/number.py | 4 +- homeassistant/components/esphome/select.py | 4 +- homeassistant/components/esphome/switch.py | 8 +- homeassistant/components/esphome/text.py | 4 +- homeassistant/components/esphome/time.py | 8 +- homeassistant/components/esphome/update.py | 12 ++- homeassistant/components/esphome/valve.py | 18 +++- .../esphome/test_alarm_control_panel.py | 16 +-- tests/components/esphome/test_button.py | 2 +- tests/components/esphome/test_climate.py | 25 +++-- tests/components/esphome/test_cover.py | 14 +-- tests/components/esphome/test_date.py | 2 +- tests/components/esphome/test_datetime.py | 2 +- tests/components/esphome/test_fan.py | 56 ++++++---- tests/components/esphome/test_light.py | 84 ++++++++++++--- tests/components/esphome/test_lock.py | 10 +- tests/components/esphome/test_media_player.py | 32 ++++-- tests/components/esphome/test_number.py | 2 +- tests/components/esphome/test_select.py | 2 +- tests/components/esphome/test_switch.py | 101 +++++++++++++++++- tests/components/esphome/test_text.py | 2 +- tests/components/esphome/test_time.py | 2 +- tests/components/esphome/test_update.py | 4 +- tests/components/esphome/test_valve.py | 12 +-- 34 files changed, 458 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py index ad455e620bb..70756c31f0f 100644 --- a/homeassistant/components/esphome/alarm_control_panel.py +++ b/homeassistant/components/esphome/alarm_control_panel.py @@ -100,49 +100,70 @@ class EsphomeAlarmControlPanel( async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.DISARM, code + self._key, + AlarmControlPanelCommand.DISARM, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_HOME, code + self._key, + AlarmControlPanelCommand.ARM_HOME, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_AWAY, code + self._key, + AlarmControlPanelCommand.ARM_AWAY, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_NIGHT, code + self._key, + AlarmControlPanelCommand.ARM_NIGHT, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code + self._key, + AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm away command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.ARM_VACATION, code + self._key, + AlarmControlPanelCommand.ARM_VACATION, + code, + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" self._client.alarm_control_panel_command( - self._key, AlarmControlPanelCommand.TRIGGER, code + self._key, + AlarmControlPanelCommand.TRIGGER, + code, + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 31121d98ff7..795a4bc4ed8 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -48,7 +48,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): @convert_api_error_ha_error async def async_press(self) -> None: """Press the button.""" - self._client.button_command(self._key) + self._client.button_command(self._key, device_id=self._static_info.device_id) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py index 667d5d00154..927ea87e0bf 100644 --- a/homeassistant/components/esphome/climate.py +++ b/homeassistant/components/esphome/climate.py @@ -287,18 +287,24 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW] if ATTR_TARGET_TEMP_HIGH in kwargs: data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH] - self._client.climate_command(**data) + self._client.climate_command(**data, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - self._client.climate_command(key=self._key, target_humidity=humidity) + self._client.climate_command( + key=self._key, + target_humidity=humidity, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" self._client.climate_command( - key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode) + key=self._key, + mode=_CLIMATE_MODES.from_hass(hvac_mode), + device_id=self._static_info.device_id, ) @convert_api_error_ha_error @@ -309,7 +315,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["custom_preset"] = preset_mode else: kwargs["preset"] = _PRESETS.from_hass(preset_mode) - self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -319,13 +325,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti kwargs["custom_fan_mode"] = fan_mode else: kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode) - self._client.climate_command(**kwargs) + self._client.climate_command(**kwargs, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new swing mode.""" self._client.climate_command( - key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode) + key=self._key, + swing_mode=_SWING_MODES.from_hass(swing_mode), + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4426724e3f4..f9ff944809a 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -90,38 +90,56 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): @convert_api_error_ha_error async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._client.cover_command(key=self._key, position=1.0) + self._client.cover_command( + key=self._key, position=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - self._client.cover_command(key=self._key, position=0.0) + self._client.cover_command( + key=self._key, position=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._client.cover_command(key=self._key, stop=True) + self._client.cover_command( + key=self._key, stop=True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100) + self._client.cover_command( + key=self._key, + position=kwargs[ATTR_POSITION] / 100, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - self._client.cover_command(key=self._key, tilt=1.0) + self._client.cover_command( + key=self._key, tilt=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - self._client.cover_command(key=self._key, tilt=0.0) + self._client.cover_command( + key=self._key, tilt=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt_position: int = kwargs[ATTR_TILT_POSITION] - self._client.cover_command(key=self._key, tilt=tilt_position / 100) + self._client.cover_command( + key=self._key, + tilt=tilt_position / 100, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py index ef446cceac6..fc125067553 100644 --- a/homeassistant/components/esphome/date.py +++ b/homeassistant/components/esphome/date.py @@ -28,7 +28,13 @@ class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity): async def async_set_value(self, value: date) -> None: """Update the current date.""" - self._client.date_command(self._key, value.year, value.month, value.day) + self._client.date_command( + self._key, + value.year, + value.month, + value.day, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py index 3ea285fa849..46c5c2da2d8 100644 --- a/homeassistant/components/esphome/datetime.py +++ b/homeassistant/components/esphome/datetime.py @@ -29,7 +29,9 @@ class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity async def async_set_value(self, value: datetime) -> None: """Update the current datetime.""" - self._client.datetime_command(self._key, int(value.timestamp())) + self._client.datetime_command( + self._key, int(value.timestamp()), device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index a4d840845a6..882cf3606e2 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -71,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): ORDERED_NAMED_FAN_SPEEDS, percentage ) data["speed"] = named_speed - self._client.fan_command(**data) + self._client.fan_command(**data, device_id=self._static_info.device_id) async def async_turn_on( self, @@ -85,24 +85,36 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" - self._client.fan_command(key=self._key, state=False) + self._client.fan_command( + key=self._key, state=False, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" - self._client.fan_command(key=self._key, oscillating=oscillating) + self._client.fan_command( + key=self._key, + oscillating=oscillating, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_set_direction(self, direction: str) -> None: """Set direction of the fan.""" self._client.fan_command( - key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction) + key=self._key, + direction=_FAN_DIRECTIONS.from_hass(direction), + device_id=self._static_info.device_id, ) @convert_api_error_ha_error async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of the fan.""" - self._client.fan_command(key=self._key, preset_mode=preset_mode) + self._client.fan_command( + key=self._key, + preset_mode=preset_mode, + device_id=self._static_info.device_id, + ) @property @esphome_state_property diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 3e278b5b2d6..67b8e755c87 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -280,7 +280,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): # (fewest capabilities set) data["color_mode"] = _least_complex_color_mode(color_modes) - self._client.light_command(**data) + self._client.light_command(**data, device_id=self._static_info.device_id) @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: @@ -290,7 +290,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: data["transition_length"] = kwargs[ATTR_TRANSITION] - self._client.light_command(**data) + self._client.light_command(**data, device_id=self._static_info.device_id) @property @esphome_state_property diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py index cfb9af614dd..d7e65470499 100644 --- a/homeassistant/components/esphome/lock.py +++ b/homeassistant/components/esphome/lock.py @@ -65,18 +65,24 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity): @convert_api_error_ha_error async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - self._client.lock_command(self._key, LockCommand.LOCK) + self._client.lock_command( + self._key, LockCommand.LOCK, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" code = kwargs.get(ATTR_CODE) - self._client.lock_command(self._key, LockCommand.UNLOCK, code) + self._client.lock_command( + self._key, LockCommand.UNLOCK, code, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" - self._client.lock_command(self._key, LockCommand.OPEN) + self._client.lock_command( + self._key, LockCommand.OPEN, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f18b5e7bf5c..2d43d40bfb3 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -132,7 +132,10 @@ class EsphomeMediaPlayer( media_id = proxy_url self._client.media_player_command( - self._key, media_url=media_id, announcement=announcement + self._key, + media_url=media_id, + announcement=announcement, + device_id=self._static_info.device_id, ) async def async_will_remove_from_hass(self) -> None: @@ -214,22 +217,36 @@ class EsphomeMediaPlayer( @convert_api_error_ha_error async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" - self._client.media_player_command(self._key, volume=volume) + self._client.media_player_command( + self._key, volume=volume, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_media_pause(self) -> None: """Send pause command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.PAUSE, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_media_play(self) -> None: """Send play command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.PLAY, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_media_stop(self) -> None: """Send stop command.""" - self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP) + self._client.media_player_command( + self._key, + command=MediaPlayerCommand.STOP, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_mute_volume(self, mute: bool) -> None: @@ -237,6 +254,7 @@ class EsphomeMediaPlayer( self._client.media_player_command( self._key, command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE, + device_id=self._static_info.device_id, ) diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index 4a6800e1041..59788eb6e1f 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -67,7 +67,9 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): @convert_api_error_ha_error async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - self._client.number_command(self._key, value) + self._client.number_command( + self._key, value, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py index d5451f69f0f..3834e4251ea 100644 --- a/homeassistant/components/esphome/select.py +++ b/homeassistant/components/esphome/select.py @@ -76,7 +76,9 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity): @convert_api_error_ha_error async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._client.select_command(self._key, option) + self._client.select_command( + self._key, option, device_id=self._static_info.device_id + ) class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect): diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py index 35edbf678ad..7e5223ae548 100644 --- a/homeassistant/components/esphome/switch.py +++ b/homeassistant/components/esphome/switch.py @@ -43,12 +43,16 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity): @convert_api_error_ha_error async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - self._client.switch_command(self._key, True) + self._client.switch_command( + self._key, True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - self._client.switch_command(self._key, False) + self._client.switch_command( + self._key, False, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py index c36621b8f4e..5ffc07ce08d 100644 --- a/homeassistant/components/esphome/text.py +++ b/homeassistant/components/esphome/text.py @@ -50,7 +50,9 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): @convert_api_error_ha_error async def async_set_value(self, value: str) -> None: """Update the current value.""" - self._client.text_command(self._key, value) + self._client.text_command( + self._key, value, device_id=self._static_info.device_id + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py index b0e586e1792..a416bb17a31 100644 --- a/homeassistant/components/esphome/time.py +++ b/homeassistant/components/esphome/time.py @@ -28,7 +28,13 @@ class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity): async def async_set_value(self, value: time) -> None: """Update the current time.""" - self._client.time_command(self._key, value.hour, value.minute, value.second) + self._client.time_command( + self._key, + value.hour, + value.minute, + value.second, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py index cc886f2ba4c..a6d053e1c4c 100644 --- a/homeassistant/components/esphome/update.py +++ b/homeassistant/components/esphome/update.py @@ -334,11 +334,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity): async def async_update(self) -> None: """Command device to check for update.""" if self.available: - self._client.update_command(key=self._key, command=UpdateCommand.CHECK) + self._client.update_command( + key=self._key, + command=UpdateCommand.CHECK, + device_id=self._static_info.device_id, + ) @convert_api_error_ha_error async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Command device to install update.""" - self._client.update_command(key=self._key, command=UpdateCommand.INSTALL) + self._client.update_command( + key=self._key, + command=UpdateCommand.INSTALL, + device_id=self._static_info.device_id, + ) diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py index f71a253c1f1..0fe9151a5a6 100644 --- a/homeassistant/components/esphome/valve.py +++ b/homeassistant/components/esphome/valve.py @@ -72,22 +72,32 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity): @convert_api_error_ha_error async def async_open_valve(self, **kwargs: Any) -> None: """Open the valve.""" - self._client.valve_command(key=self._key, position=1.0) + self._client.valve_command( + key=self._key, position=1.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_close_valve(self, **kwargs: Any) -> None: """Close valve.""" - self._client.valve_command(key=self._key, position=0.0) + self._client.valve_command( + key=self._key, position=0.0, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_stop_valve(self, **kwargs: Any) -> None: """Stop the valve.""" - self._client.valve_command(key=self._key, stop=True) + self._client.valve_command( + key=self._key, stop=True, device_id=self._static_info.device_id + ) @convert_api_error_ha_error async def async_set_valve_position(self, position: float) -> None: """Move the valve to a specific position.""" - self._client.valve_command(key=self._key, position=position / 100) + self._client.valve_command( + key=self._key, + position=position / 100, + device_id=self._static_info.device_id, + ) async_setup_entry = partial( diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index 62924404458..e06b88432a9 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -73,7 +73,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234")] + [call(1, AlarmControlPanelCommand.ARM_AWAY, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -87,7 +87,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234")] + [call(1, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -101,7 +101,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_HOME, "1234")] + [call(1, AlarmControlPanelCommand.ARM_HOME, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -115,7 +115,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234")] + [call(1, AlarmControlPanelCommand.ARM_NIGHT, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -129,7 +129,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234")] + [call(1, AlarmControlPanelCommand.ARM_VACATION, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -143,7 +143,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.TRIGGER, "1234")] + [call(1, AlarmControlPanelCommand.TRIGGER, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -157,7 +157,7 @@ async def test_generic_alarm_control_panel_requires_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.DISARM, "1234")] + [call(1, AlarmControlPanelCommand.DISARM, "1234", device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() @@ -203,7 +203,7 @@ async def test_generic_alarm_control_panel_no_code( blocking=True, ) mock_client.alarm_control_panel_command.assert_has_calls( - [call(1, AlarmControlPanelCommand.DISARM, None)] + [call(1, AlarmControlPanelCommand.DISARM, None, device_id=0)] ) mock_client.alarm_control_panel_command.reset_mock() diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index d3fec2a56d2..3cedc3526d4 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -39,7 +39,7 @@ async def test_button_generic_entity( {ATTR_ENTITY_ID: "button.test_my_button"}, blocking=True, ) - mock_client.button_command.assert_has_calls([call(1)]) + mock_client.button_command.assert_has_calls([call(1, device_id=0)]) state = hass.states.get("button.test_my_button") assert state is not None assert state.state != STATE_UNKNOWN diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 3c529adf21f..5c907eef3b1 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -93,7 +93,9 @@ async def test_climate_entity( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, target_temperature=25.0)]) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_temperature=25.0, device_id=0)] + ) mock_client.climate_command.reset_mock() @@ -167,6 +169,7 @@ async def test_climate_entity_with_step_and_two_point( mode=ClimateMode.AUTO, target_temperature_low=20.0, target_temperature_high=30.0, + device_id=0, ) ] ) @@ -232,7 +235,7 @@ async def test_climate_entity_with_step_and_target_temp( blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0)] + [call(key=1, mode=ClimateMode.AUTO, target_temperature=25.0, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -263,6 +266,7 @@ async def test_climate_entity_with_step_and_target_temp( call( key=1, mode=ClimateMode.HEAT, + device_id=0, ) ] ) @@ -279,6 +283,7 @@ async def test_climate_entity_with_step_and_target_temp( call( key=1, preset=ClimatePreset.AWAY, + device_id=0, ) ] ) @@ -290,7 +295,9 @@ async def test_climate_entity_with_step_and_target_temp( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_PRESET_MODE: "preset1"}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, custom_preset="preset1")]) + mock_client.climate_command.assert_has_calls( + [call(key=1, custom_preset="preset1", device_id=0)] + ) mock_client.climate_command.reset_mock() await hass.services.async_call( @@ -300,7 +307,7 @@ async def test_climate_entity_with_step_and_target_temp( blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, fan_mode=ClimateFanMode.HIGH)] + [call(key=1, fan_mode=ClimateFanMode.HIGH, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -310,7 +317,9 @@ async def test_climate_entity_with_step_and_target_temp( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_FAN_MODE: "fan2"}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, custom_fan_mode="fan2")]) + mock_client.climate_command.assert_has_calls( + [call(key=1, custom_fan_mode="fan2", device_id=0)] + ) mock_client.climate_command.reset_mock() await hass.services.async_call( @@ -320,7 +329,7 @@ async def test_climate_entity_with_step_and_target_temp( blocking=True, ) mock_client.climate_command.assert_has_calls( - [call(key=1, swing_mode=ClimateSwingMode.BOTH)] + [call(key=1, swing_mode=ClimateSwingMode.BOTH, device_id=0)] ) mock_client.climate_command.reset_mock() @@ -383,7 +392,9 @@ async def test_climate_entity_with_humidity( {ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_HUMIDITY: 23}, blocking=True, ) - mock_client.climate_command.assert_has_calls([call(key=1, target_humidity=23)]) + mock_client.climate_command.assert_has_calls( + [call(key=1, target_humidity=23, device_id=0)] + ) mock_client.climate_command.reset_mock() diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index f6ec9f20d6b..93524905f6b 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -74,7 +74,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -83,7 +83,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -92,7 +92,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_POSITION: 50}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.cover_command.assert_has_calls([call(key=1, position=0.5, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -101,7 +101,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.cover_command.assert_has_calls([call(key=1, stop=True, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -110,7 +110,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=1.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -119,7 +119,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover"}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.0, device_id=0)]) mock_client.cover_command.reset_mock() await hass.services.async_call( @@ -128,7 +128,7 @@ async def test_cover_entity( {ATTR_ENTITY_ID: "cover.test_my_cover", ATTR_TILT_POSITION: 50}, blocking=True, ) - mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5)]) + mock_client.cover_command.assert_has_calls([call(key=1, tilt=0.5, device_id=0)]) mock_client.cover_command.reset_mock() mock_device.set_state( diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 331c3d50bd4..387838e0b23 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -47,7 +47,7 @@ async def test_generic_date_entity( {ATTR_ENTITY_ID: "date.test_my_date", ATTR_DATE: "1999-01-01"}, blocking=True, ) - mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1)]) + mock_client.date_command.assert_has_calls([call(1, 1999, 1, 1, device_id=0)]) mock_client.date_command.reset_mock() diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 63ca02360fd..6fcfe7ed947 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -50,7 +50,7 @@ async def test_generic_datetime_entity( }, blocking=True, ) - mock_client.datetime_command.assert_has_calls([call(1, 946689825)]) + mock_client.datetime_command.assert_has_calls([call(1, 946689825, device_id=0)]) mock_client.datetime_command.reset_mock() diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index 558acb281b5..a33be1a6fca 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -77,7 +77,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.LOW, state=True)] + [call(key=1, speed=FanSpeed.LOW, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -88,7 +88,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.MEDIUM, state=True)] + [call(key=1, speed=FanSpeed.MEDIUM, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -99,7 +99,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.LOW, state=True)] + [call(key=1, speed=FanSpeed.LOW, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -110,7 +110,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.HIGH, state=True)] + [call(key=1, speed=FanSpeed.HIGH, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -120,7 +120,7 @@ async def test_fan_entity_with_all_features_old_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -130,7 +130,7 @@ async def test_fan_entity_with_all_features_old_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, speed=FanSpeed.HIGH, state=True)] + [call(key=1, speed=FanSpeed.HIGH, state=True, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -182,7 +182,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 20}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=1, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=1, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -191,7 +193,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 50}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=2, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -200,7 +204,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=2, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=2, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -209,7 +215,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=4, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -218,7 +226,7 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -227,7 +235,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 100}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, speed_level=4, state=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, speed_level=4, state=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -236,7 +246,7 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PERCENTAGE: 0}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -245,7 +255,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: True}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, oscillating=True)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, oscillating=True, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -254,7 +266,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_OSCILLATING: False}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, oscillating=False)]) + mock_client.fan_command.assert_has_calls( + [call(key=1, oscillating=False, device_id=0)] + ) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -264,7 +278,7 @@ async def test_fan_entity_with_all_features_new_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, direction=FanDirection.FORWARD)] + [call(key=1, direction=FanDirection.FORWARD, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -275,7 +289,7 @@ async def test_fan_entity_with_all_features_new_api( blocking=True, ) mock_client.fan_command.assert_has_calls( - [call(key=1, direction=FanDirection.REVERSE)] + [call(key=1, direction=FanDirection.REVERSE, device_id=0)] ) mock_client.fan_command.reset_mock() @@ -285,7 +299,9 @@ async def test_fan_entity_with_all_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan", ATTR_PRESET_MODE: "Preset1"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, preset_mode="Preset1")]) + mock_client.fan_command.assert_has_calls( + [call(key=1, preset_mode="Preset1", device_id=0)] + ) mock_client.fan_command.reset_mock() @@ -326,7 +342,7 @@ async def test_fan_entity_with_no_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=True)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=True, device_id=0)]) mock_client.fan_command.reset_mock() await hass.services.async_call( @@ -335,5 +351,5 @@ async def test_fan_entity_with_no_features_new_api( {ATTR_ENTITY_ID: "fan.test_my_fan"}, blocking=True, ) - mock_client.fan_command.assert_has_calls([call(key=1, state=False)]) + mock_client.fan_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.fan_command.reset_mock() diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 34ada36a4f8..4377a714b17 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -81,7 +81,7 @@ async def test_light_on_off( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF)] + [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF, device_id=0)] ) mock_client.light_command.reset_mock() @@ -123,7 +123,14 @@ async def test_light_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, + ) + ] ) mock_client.light_command.reset_mock() @@ -140,6 +147,7 @@ async def test_light_brightness( state=True, color_mode=LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -152,7 +160,7 @@ async def test_light_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=False, transition_length=2.0)] + [call(key=1, state=False, transition_length=2.0, device_id=0)] ) mock_client.light_command.reset_mock() @@ -163,7 +171,7 @@ async def test_light_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=False, flash_length=10.0)] + [call(key=1, state=False, flash_length=10.0, device_id=0)] ) mock_client.light_command.reset_mock() @@ -180,6 +188,7 @@ async def test_light_brightness( state=True, transition_length=2.0, color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -198,6 +207,7 @@ async def test_light_brightness( state=True, flash_length=2.0, color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -248,7 +258,14 @@ async def test_light_legacy_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.BRIGHTNESS)] + [ + call( + key=1, + state=True, + color_mode=LightColorCapability.BRIGHTNESS, + device_id=0, + ) + ] ) mock_client.light_command.reset_mock() @@ -303,6 +320,7 @@ async def test_light_brightness_on_off( key=1, state=True, color_mode=ESPColorMode.BRIGHTNESS.value, + device_id=0, ) ] ) @@ -321,6 +339,7 @@ async def test_light_brightness_on_off( state=True, color_mode=ESPColorMode.BRIGHTNESS.value, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -375,6 +394,7 @@ async def test_light_legacy_white_converted_to_brightness( color_mode=LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS | LightColorCapability.WHITE, + device_id=0, ) ] ) @@ -439,6 +459,7 @@ async def test_light_legacy_white_with_rgb( brightness=pytest.approx(0.23529411764705882), white=1.0, color_mode=color_mode, + device_id=0, ) ] ) @@ -496,6 +517,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( key=1, state=True, color_mode=LIGHT_COLOR_CAPABILITY_UNKNOWN, + device_id=0, ) ] ) @@ -514,6 +536,7 @@ async def test_light_brightness_on_off_with_unknown_color_mode( state=True, color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -560,7 +583,7 @@ async def test_light_on_and_brightness( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF)] + [call(key=1, state=True, color_mode=LightColorCapability.ON_OFF, device_id=0)] ) mock_client.light_command.reset_mock() @@ -618,6 +641,7 @@ async def test_rgb_color_temp_light( key=1, state=True, color_mode=ESPColorMode.BRIGHTNESS, + device_id=0, ) ] ) @@ -636,6 +660,7 @@ async def test_rgb_color_temp_light( state=True, color_mode=ESPColorMode.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -654,6 +679,7 @@ async def test_rgb_color_temp_light( state=True, color_mode=ESPColorMode.COLOR_TEMPERATURE, color_temperature=400, + device_id=0, ) ] ) @@ -706,6 +732,7 @@ async def test_light_rgb( color_mode=LightColorCapability.RGB | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -726,6 +753,7 @@ async def test_light_rgb( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -752,6 +780,7 @@ async def test_light_rgb( | LightColorCapability.BRIGHTNESS, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -773,6 +802,7 @@ async def test_light_rgb( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -843,6 +873,7 @@ async def test_light_rgbw( | LightColorCapability.WHITE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -864,6 +895,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -892,6 +924,7 @@ async def test_light_rgbw( white=0, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -915,6 +948,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, 0, 0), + device_id=0, ) ] ) @@ -938,6 +972,7 @@ async def test_light_rgbw( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1017,6 +1052,7 @@ async def test_light_rgbww_with_cold_warm_white_support( key=1, state=True, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, + device_id=0, ) ] ) @@ -1035,6 +1071,7 @@ async def test_light_rgbww_with_cold_warm_white_support( state=True, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1059,6 +1096,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_mode=ESPColorMode.RGB, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1078,6 +1116,7 @@ async def test_light_rgbww_with_cold_warm_white_support( color_brightness=1.0, color_mode=ESPColorMode.RGB, rgb=(1.0, 1.0, 1.0), + device_id=0, ) ] ) @@ -1098,6 +1137,7 @@ async def test_light_rgbww_with_cold_warm_white_support( white=1, color_mode=ESPColorMode.RGB_WHITE, rgb=(1.0, 1.0, 1.0), + device_id=0, ) ] ) @@ -1122,6 +1162,7 @@ async def test_light_rgbww_with_cold_warm_white_support( warm_white=1, color_mode=ESPColorMode.RGB_COLD_WARM_WHITE, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1140,6 +1181,7 @@ async def test_light_rgbww_with_cold_warm_white_support( state=True, color_temperature=400.0, color_mode=ESPColorMode.COLOR_TEMPERATURE, + device_id=0, ) ] ) @@ -1217,6 +1259,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1239,6 +1282,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1268,6 +1312,7 @@ async def test_light_rgbww_without_cold_warm_white_support( white=0, rgb=(pytest.approx(0.3333333333333333), 1.0, 0.0), brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1294,6 +1339,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, pytest.approx(0.5462962962962963), 1.0), + device_id=0, ) ] ) @@ -1319,6 +1365,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, pytest.approx(0.5462962962962963), 1.0), + device_id=0, ) ] ) @@ -1347,6 +1394,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(1, 1, 1), + device_id=0, ) ] ) @@ -1372,6 +1420,7 @@ async def test_light_rgbww_without_cold_warm_white_support( | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, rgb=(0, 0, 0), + device_id=0, ) ] ) @@ -1437,6 +1486,7 @@ async def test_light_color_temp( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1448,7 +1498,7 @@ async def test_light_color_temp( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1511,6 +1561,7 @@ async def test_light_color_temp_no_mireds_set( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1531,6 +1582,7 @@ async def test_light_color_temp_no_mireds_set( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1542,7 +1594,7 @@ async def test_light_color_temp_no_mireds_set( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1615,6 +1667,7 @@ async def test_light_color_temp_legacy( color_mode=LightColorCapability.COLOR_TEMPERATURE | LightColorCapability.ON_OFF | LightColorCapability.BRIGHTNESS, + device_id=0, ) ] ) @@ -1626,7 +1679,7 @@ async def test_light_color_temp_legacy( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() @@ -1695,6 +1748,7 @@ async def test_light_rgb_legacy( call( key=1, state=True, + device_id=0, ) ] ) @@ -1706,7 +1760,7 @@ async def test_light_rgb_legacy( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=False)]) + mock_client.light_command.assert_has_calls([call(key=1, state=False, device_id=0)]) mock_client.light_command.reset_mock() await hass.services.async_call( @@ -1722,6 +1776,7 @@ async def test_light_rgb_legacy( state=True, rgb=(1.0, 1.0, 1.0), color_brightness=1.0, + device_id=0, ) ] ) @@ -1780,6 +1835,7 @@ async def test_light_effects( state=True, color_mode=ESPColorMode.BRIGHTNESS, effect="effect1", + device_id=0, ) ] ) @@ -1843,7 +1899,7 @@ async def test_only_cold_warm_white_support( blocking=True, ) mock_client.light_command.assert_has_calls( - [call(key=1, state=True, color_mode=color_modes)] + [call(key=1, state=True, color_mode=color_modes, device_id=0)] ) mock_client.light_command.reset_mock() @@ -1860,6 +1916,7 @@ async def test_only_cold_warm_white_support( state=True, color_mode=color_modes, brightness=pytest.approx(0.4980392156862745), + device_id=0, ) ] ) @@ -1878,6 +1935,7 @@ async def test_only_cold_warm_white_support( state=True, color_mode=color_modes, color_temperature=400.0, + device_id=0, ) ] ) @@ -1922,5 +1980,7 @@ async def test_light_no_color_modes( {ATTR_ENTITY_ID: "light.test_my_light"}, blocking=True, ) - mock_client.light_command.assert_has_calls([call(key=1, state=True, color_mode=0)]) + mock_client.light_command.assert_has_calls( + [call(key=1, state=True, color_mode=0, device_id=0)] + ) mock_client.light_command.reset_mock() diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index ab16311fc68..eaa03947a7d 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -57,7 +57,7 @@ async def test_lock_entity_no_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK, device_id=0)]) mock_client.lock_command.reset_mock() @@ -122,7 +122,7 @@ async def test_lock_entity_supports_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.LOCK, device_id=0)]) mock_client.lock_command.reset_mock() await hass.services.async_call( @@ -131,7 +131,9 @@ async def test_lock_entity_supports_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.UNLOCK, None)]) + mock_client.lock_command.assert_has_calls( + [call(1, LockCommand.UNLOCK, None, device_id=0)] + ) mock_client.lock_command.reset_mock() await hass.services.async_call( @@ -140,4 +142,4 @@ async def test_lock_entity_supports_open( {ATTR_ENTITY_ID: "lock.test_my_lock"}, blocking=True, ) - mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN)]) + mock_client.lock_command.assert_has_calls([call(1, LockCommand.OPEN, device_id=0)]) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index ecd0ec4cb8b..6d7a3b220d1 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -85,7 +85,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.MUTE)] + [call(1, command=MediaPlayerCommand.MUTE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -99,7 +99,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.MUTE)] + [call(1, command=MediaPlayerCommand.MUTE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -112,7 +112,9 @@ async def test_media_player_entity( }, blocking=True, ) - mock_client.media_player_command.assert_has_calls([call(1, volume=0.5)]) + mock_client.media_player_command.assert_has_calls( + [call(1, volume=0.5, device_id=0)] + ) mock_client.media_player_command.reset_mock() await hass.services.async_call( @@ -124,7 +126,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.PAUSE)] + [call(1, command=MediaPlayerCommand.PAUSE, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -137,7 +139,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.PLAY)] + [call(1, command=MediaPlayerCommand.PLAY, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -150,7 +152,7 @@ async def test_media_player_entity( blocking=True, ) mock_client.media_player_command.assert_has_calls( - [call(1, command=MediaPlayerCommand.STOP)] + [call(1, command=MediaPlayerCommand.STOP, device_id=0)] ) mock_client.media_player_command.reset_mock() @@ -257,7 +259,14 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="http://www.example.com/xy.mp3", announcement=None)] + [ + call( + 1, + media_url="http://www.example.com/xy.mp3", + announcement=None, + device_id=0, + ) + ] ) client = await hass_ws_client() @@ -284,7 +293,14 @@ async def test_media_player_entity_with_source( ) mock_client.media_player_command.assert_has_calls( - [call(1, media_url="media-source://tts?message=hello", announcement=True)] + [ + call( + 1, + media_url="media-source://tts?message=hello", + announcement=True, + device_id=0, + ) + ] ) diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index 932d86c70e3..d7a59222d47 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -60,7 +60,7 @@ async def test_generic_number_entity( {ATTR_ENTITY_ID: "number.test_my_number", ATTR_VALUE: 50}, blocking=True, ) - mock_client.number_command.assert_has_calls([call(1, 50)]) + mock_client.number_command.assert_has_calls([call(1, 50, device_id=0)]) mock_client.number_command.reset_mock() diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index a30075b5833..6b7415889d8 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -89,7 +89,7 @@ async def test_select_generic_entity( {ATTR_ENTITY_ID: "select.test_my_select", ATTR_OPTION: "b"}, blocking=True, ) - mock_client.select_command.assert_has_calls([call(1, "b")]) + mock_client.select_command.assert_has_calls([call(1, "b", device_id=0)]) async def test_wake_word_select_no_wake_words( diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index 0efb3d86256..c62101125bd 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -2,17 +2,17 @@ from unittest.mock import call -from aioesphomeapi import APIClient, SwitchInfo, SwitchState +from aioesphomeapi import APIClient, SubDeviceInfo, SwitchInfo, SwitchState from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from .conftest import MockGenericDeviceEntryType +from .conftest import MockESPHomeDeviceType, MockGenericDeviceEntryType async def test_switch_generic_entity( @@ -47,7 +47,7 @@ async def test_switch_generic_entity( {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) - mock_client.switch_command.assert_has_calls([call(1, True)]) + mock_client.switch_command.assert_has_calls([call(1, True, device_id=0)]) await hass.services.async_call( SWITCH_DOMAIN, @@ -55,4 +55,95 @@ async def test_switch_generic_entity( {ATTR_ENTITY_ID: "switch.test_my_switch"}, blocking=True, ) - mock_client.switch_command.assert_has_calls([call(1, False)]) + mock_client.switch_command.assert_has_calls([call(1, False, device_id=0)]) + + +async def test_switch_sub_device_non_zero_device_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_esphome_device: MockESPHomeDeviceType, +) -> None: + """Test switch on sub-device with non-zero device_id passes through to API.""" + # Create sub-device + sub_devices = [ + SubDeviceInfo(device_id=11111111, name="Sub Device", area_id=0), + ] + device_info = { + "name": "test", + "devices": sub_devices, + } + # Create switches on both main device and sub-device + entity_info = [ + SwitchInfo( + object_id="main_switch", + key=1, + name="Main Switch", + unique_id="main_switch_1", + device_id=0, # Main device + ), + SwitchInfo( + object_id="sub_switch", + key=2, + name="Sub Switch", + unique_id="sub_switch_1", + device_id=11111111, # Sub-device + ), + ] + # States for both switches + states = [ + SwitchState(key=1, state=True, device_id=0), + SwitchState(key=2, state=False, device_id=11111111), + ] + await mock_esphome_device( + mock_client=mock_client, + device_info=device_info, + entity_info=entity_info, + states=states, + ) + + # Verify both entities exist with correct states + main_state = hass.states.get("switch.test_main_switch") + assert main_state is not None + assert main_state.state == STATE_ON + + sub_state = hass.states.get("switch.sub_device_sub_switch") + assert sub_state is not None + assert sub_state.state == STATE_OFF + + # Test turning on the sub-device switch - should pass device_id=11111111 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.sub_device_sub_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(2, True, device_id=11111111)]) + mock_client.switch_command.reset_mock() + + # Test turning off the sub-device switch - should pass device_id=11111111 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.sub_device_sub_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(2, False, device_id=11111111)]) + mock_client.switch_command.reset_mock() + + # Test main device switch still uses device_id=0 + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test_main_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, True, device_id=0)]) + mock_client.switch_command.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.test_main_switch"}, + blocking=True, + ) + mock_client.switch_command.assert_has_calls([call(1, False, device_id=0)]) diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index c8a7b2b9b45..f8c1d33e224 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -51,7 +51,7 @@ async def test_generic_text_entity( {ATTR_ENTITY_ID: "text.test_my_text", ATTR_VALUE: "goodbye"}, blocking=True, ) - mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.assert_has_calls([call(1, "goodbye", device_id=0)]) mock_client.text_command.reset_mock() diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 9342bd16055..75e2a0dc664 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -47,7 +47,7 @@ async def test_generic_time_entity( {ATTR_ENTITY_ID: "time.test_my_time", ATTR_TIME: "01:23:45"}, blocking=True, ) - mock_client.time_command.assert_has_calls([call(1, 1, 23, 45)]) + mock_client.time_command.assert_has_calls([call(1, 1, 23, 45, device_id=0)]) mock_client.time_command.reset_mock() diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index fd852949e65..96b77281485 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -544,7 +544,9 @@ async def test_generic_device_update_entity_has_update( assert state.attributes[ATTR_IN_PROGRESS] is True assert state.attributes[ATTR_UPDATE_PERCENTAGE] is None - mock_client.update_command.assert_called_with(key=1, command=UpdateCommand.CHECK) + mock_client.update_command.assert_called_with( + key=1, command=UpdateCommand.CHECK, device_id=0 + ) async def test_update_entity_release_notes( diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index d31e2bfb09e..aaa52551115 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -66,7 +66,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -75,7 +75,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -84,7 +84,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve", ATTR_POSITION: 50}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.5)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.5, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -93,7 +93,7 @@ async def test_valve_entity( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, stop=True)]) + mock_client.valve_command.assert_has_calls([call(key=1, stop=True, device_id=0)]) mock_client.valve_command.reset_mock() mock_device.set_state( @@ -164,7 +164,7 @@ async def test_valve_entity_without_position( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=0.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=0.0, device_id=0)]) mock_client.valve_command.reset_mock() await hass.services.async_call( @@ -173,7 +173,7 @@ async def test_valve_entity_without_position( {ATTR_ENTITY_ID: "valve.test_my_valve"}, blocking=True, ) - mock_client.valve_command.assert_has_calls([call(key=1, position=1.0)]) + mock_client.valve_command.assert_has_calls([call(key=1, position=1.0, device_id=0)]) mock_client.valve_command.reset_mock() mock_device.set_state( From 4122af1d3322be8674cd993c743164a1ae355980 Mon Sep 17 00:00:00 2001 From: Alex Leversen <91166616+leversonic@users.noreply.github.com> Date: Sun, 13 Jul 2025 03:04:01 -0400 Subject: [PATCH 1344/1664] Bump pyoctoprintapi version to 0.1.14 (#148651) --- homeassistant/components/octoprint/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/octoprint/__init__.py | 2 +- tests/components/octoprint/test_sensor.py | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json index 005cf5305d9..25e4062373c 100644 --- a/homeassistant/components/octoprint/manifest.json +++ b/homeassistant/components/octoprint/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/octoprint", "iot_class": "local_polling", "loggers": ["pyoctoprintapi"], - "requirements": ["pyoctoprintapi==0.1.12"], + "requirements": ["pyoctoprintapi==0.1.14"], "ssdp": [ { "manufacturer": "The OctoPrint Project", diff --git a/requirements_all.txt b/requirements_all.txt index fe66f48a42a..72e86bc3324 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2196,7 +2196,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.12 +pyoctoprintapi==0.1.14 # homeassistant.components.ombi pyombi==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3f0e3b62646..9a846910eb3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1829,7 +1829,7 @@ pynzbgetapi==0.2.0 pyobihai==1.4.2 # homeassistant.components.octoprint -pyoctoprintapi==0.1.12 +pyoctoprintapi==0.1.14 # homeassistant.components.openuv pyopenuv==2023.02.0 diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index dd3eda0e81f..3ddae7de587 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry DEFAULT_JOB = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50}, } diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 8c1c0a7712e..87485e46807 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -24,7 +24,7 @@ async def test_sensors( "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, } job = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Printing", } @@ -126,7 +126,7 @@ async def test_sensors_paused( "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } job = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } @@ -155,7 +155,7 @@ async def test_sensors_printer_disconnected( ) -> None: """Test the underlying sensors.""" job = { - "job": {}, + "job": {"file": {}}, "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, "state": "Paused", } From d22dd68119c0673dd9b046e7d3347561a1882af4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 13 Jul 2025 10:37:48 +0200 Subject: [PATCH 1345/1664] Fix exception in EntityRegistry.async_device_modified (#148645) --- homeassistant/helpers/entity_registry.py | 3 ++ tests/helpers/test_entity_registry.py | 64 +++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index ddb25c7b0a8..7051521b805 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1113,6 +1113,9 @@ class EntityRegistry(BaseRegistry): ): self.async_remove(entity.entity_id) else: + if entity.entity_id not in self.entities: + # Entity has been removed already, skip it + continue self.async_update_entity(entity.entity_id, device_id=None) return diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 40a26295cbb..e403333d8df 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -16,9 +16,10 @@ from homeassistant.const import ( STATE_UNAVAILABLE, EntityCategory, ) -from homeassistant.core import CoreState, HomeAssistant, callback +from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( @@ -1985,6 +1986,67 @@ async def test_update_device_race( assert not entity_registry.async_is_registered(entry.entity_id) +async def test_update_device_race_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test race when a device is removed. + + This test simulates the behavior of helpers which are removed when the + source entity is removed. + """ + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + + # Create device + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + # Add entity to the device, from the same config entry + entry_same_config_entry = entity_registry.async_get_or_create( + "light", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + # Add entity to the device, not from the same config entry + entry_no_config_entry = entity_registry.async_get_or_create( + "light", + "helper", + "abcd", + device_id=device_entry.id, + ) + # Add a third entity to the device, from the same config entry + entry_same_config_entry_2 = entity_registry.async_get_or_create( + "sensor", + "hue", + "5678", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Add a listener to remove the 2nd entity it when 1st entity is removed + @callback + def on_entity_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + if event.data["action"] == "remove": + entity_registry.async_remove(entry_no_config_entry.entity_id) + + async_track_entity_registry_updated_event( + hass, entry_same_config_entry.entity_id, on_entity_event + ) + + device_registry.async_remove_device(device_entry.id) + await hass.async_block_till_done() + + assert not entity_registry.async_is_registered(entry_same_config_entry.entity_id) + assert not entity_registry.async_is_registered(entry_no_config_entry.entity_id) + assert not entity_registry.async_is_registered(entry_same_config_entry_2.entity_id) + + async def test_disable_device_disables_entities( hass: HomeAssistant, device_registry: dr.DeviceRegistry, From bb17f34bae8a32b25da5151398a8e27d2f309185 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Sun, 13 Jul 2025 21:01:38 +1000 Subject: [PATCH 1346/1664] Remove history first refresh from Teslemetry (#148531) --- .../components/teslemetry/__init__.py | 5 --- .../components/teslemetry/coordinator.py | 1 + .../teslemetry/snapshots/test_sensor.ambr | 42 +++++++++---------- .../components/teslemetry/test_diagnostics.py | 9 ++++ tests/components/teslemetry/test_init.py | 14 ------- tests/components/teslemetry/test_sensor.py | 6 ++- 6 files changed, 35 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 49af8c1a08d..3ffc6c43efb 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -215,11 +215,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - energysite.info_coordinator.async_config_entry_first_refresh() for energysite in energysites ), - *( - energysite.history_coordinator.async_config_entry_first_refresh() - for energysite in energysites - if energysite.history_coordinator - ), ) # Add energy device models diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py index e6b453402e9..eed00ebc64f 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -183,6 +183,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]): update_interval=ENERGY_HISTORY_INTERVAL, ) self.api = api + self.data = {} async def _async_update_data(self) -> dict[str, Any]: """Update energy site data using Teslemetry API.""" diff --git a/tests/components/teslemetry/snapshots/test_sensor.ambr b/tests/components/teslemetry/snapshots/test_sensor.ambr index 57a0f49d949..1db8cf9612f 100644 --- a/tests/components/teslemetry/snapshots/test_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_sensor.ambr @@ -55,7 +55,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_charged-statealt] @@ -130,7 +130,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_discharged-statealt] @@ -205,7 +205,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_exported-statealt] @@ -280,7 +280,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_generator-statealt] @@ -355,7 +355,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_grid-statealt] @@ -430,7 +430,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.684', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_battery_imported_from_solar-statealt] @@ -580,7 +580,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.036', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_battery-statealt] @@ -655,7 +655,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_generator-statealt] @@ -730,7 +730,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_grid-statealt] @@ -805,7 +805,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.038', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_consumer_imported_from_solar-statealt] @@ -955,7 +955,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_generator_exported-statealt] @@ -1105,7 +1105,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported-statealt] @@ -1180,7 +1180,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_battery-statealt] @@ -1255,7 +1255,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_generator-statealt] @@ -1330,7 +1330,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.002', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_exported_from_solar-statealt] @@ -1405,7 +1405,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_imported-statealt] @@ -1555,7 +1555,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_exported-statealt] @@ -1630,7 +1630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.0', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_grid_services_imported-statealt] @@ -1780,7 +1780,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.074', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_home_usage-statealt] @@ -2087,7 +2087,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_exported-statealt] @@ -2162,7 +2162,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0.724', + 'state': 'unknown', }) # --- # name: test_sensors[sensor.energy_site_solar_generated-statealt] diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index fb8eb79a918..18182b14321 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,11 +1,14 @@ """Test the Telemetry Diagnostics.""" +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion +from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.core import HomeAssistant from . import setup_platform +from tests.common import async_fire_time_changed from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -14,10 +17,16 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, ) -> None: """Test diagnostics.""" entry = await setup_platform(hass) + # Wait for coordinator refresh + freezer.tick(VEHICLE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) assert diag == snapshot diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index d2ef5c38893..54c9ca0dad9 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -107,20 +107,6 @@ async def test_energy_site_refresh_error( assert entry.state is state -# Test Energy History Coordinator -@pytest.mark.parametrize(("side_effect", "state"), ERRORS) -async def test_energy_history_refresh_error( - hass: HomeAssistant, - mock_energy_history: AsyncMock, - side_effect: TeslaFleetError, - state: ConfigEntryState, -) -> None: - """Test coordinator refresh with an error.""" - mock_energy_history.side_effect = side_effect - entry = await setup_platform(hass) - assert entry.state is state - - async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index d2d6d88b3e3..296f9e8bff4 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -9,7 +9,7 @@ from teslemetry_stream import Signal from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -30,6 +30,8 @@ async def test_sensors( """Tests that the sensor entities with the legacy polling are correct.""" freezer.move_to("2024-01-01 00:00:00+00:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() # Force the vehicle to use polling with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): @@ -117,7 +119,7 @@ async def test_energy_history_no_time_series( entity_id = "sensor.energy_site_battery_discharged" state = hass.states.get(entity_id) - assert state.state == "0.036" + assert state.state == STATE_UNKNOWN mock_energy_history.return_value = ENERGY_HISTORY_EMPTY From f7d132b043c17f225f3716f19e083ba1d7ac853d Mon Sep 17 00:00:00 2001 From: Steven Tegreeny Date: Sun, 13 Jul 2025 07:46:37 -0400 Subject: [PATCH 1347/1664] Add Z-WAVE discovery entry for the GE/JASCO in-wall smart fan control (#148246) --- .../components/zwave_js/discovery.py | 10 +- tests/components/zwave_js/conftest.py | 14 + .../enbrighten_58446_zwa4013_state.json | 1116 +++++++++++++++++ tests/components/zwave_js/test_discovery.py | 14 + 4 files changed, 1151 insertions(+), 3 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 3b541a733cc..74ffedbc53f 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -263,7 +263,7 @@ WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( ) # For device class mapping see: -# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json +# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/ DISCOVERY_SCHEMAS = [ # ====== START OF DEVICE SPECIFIC MAPPING SCHEMAS ======= # Honeywell 39358 In-Wall Fan Control using switch multilevel CC @@ -291,12 +291,16 @@ DISCOVERY_SCHEMAS = [ FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]), ), ), - # GE/Jasco - In-Wall Smart Fan Control - 14287 / 55258 / ZW4002 + # GE/Jasco - In-Wall Smart Fan Controls ZWaveDiscoverySchema( platform=Platform.FAN, hint="has_fan_value_mapping", manufacturer_id={0x0063}, - product_id={0x3131, 0x3337}, + product_id={ + 0x3131, + 0x3337, # 14287 / 55258 / ZW4002 + 0x3533, # 58446 / ZWA4013 + }, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=FixedFanValueMappingDataTemplate( diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 138bcd63ede..1163da4971c 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -325,6 +325,12 @@ def ge_12730_state_fixture() -> dict[str, Any]: return load_json_object_fixture("fan_ge_12730_state.json", DOMAIN) +@pytest.fixture(name="enbrighten_58446_zwa4013_state", scope="package") +def enbrighten_58446_zwa4013_state_fixture() -> dict[str, Any]: + """Load the Enbrighten/GE 58446/zwa401 node state fixture data.""" + return load_json_object_fixture("enbrighten_58446_zwa4013_state.json", DOMAIN) + + @pytest.fixture(name="aeotec_radiator_thermostat_state", scope="package") def aeotec_radiator_thermostat_state_fixture() -> dict[str, Any]: """Load the Aeotec Radiator Thermostat node state fixture data.""" @@ -1078,6 +1084,14 @@ def ge_12730_fixture(client, ge_12730_state) -> Node: return node +@pytest.fixture(name="enbrighten_58446_zwa4013") +def enbrighten_58446_zwa4013_fixture(client, enbrighten_58446_zwa4013_state) -> Node: + """Mock a Enbrighten_58446/zwa4013 fan controller node.""" + node = Node(client, copy.deepcopy(enbrighten_58446_zwa4013_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="inovelli_lzw36") def inovelli_lzw36_fixture(client, inovelli_lzw36_state) -> Node: """Mock a Inovelli LZW36 fan controller node.""" diff --git a/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json new file mode 100644 index 00000000000..dd580a9b43b --- /dev/null +++ b/tests/components/zwave_js/fixtures/enbrighten_58446_zwa4013_state.json @@ -0,0 +1,1116 @@ +{ + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 99, + "productId": 13619, + "productType": 18756, + "firmwareVersion": "1.26.1", + "zwavePlusVersion": 2, + "name": "zwa4013_fan", + "deviceConfig": { + "manufacturer": "Enbrighten", + "manufacturerId": 99, + "label": "58446 / ZWA4013", + "description": "In-Wall Fan Speed Control, QFSW, 700S", + "devices": [ + { + "productType": 18756, + "productId": 13619 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "mapBasicSet": "event" + }, + "metadata": { + "inclusion": "1. Follow the instructions for your Z-Wave certified Controller to add a device to the Z-Wave network.\n2. Once the controller is ready to add your device, press the top of bottom of the wireless smart Fan controller", + "exclusion": "1. Follow the instructions for your Z-Wave certified controller to remove a device from the Z-wave network\n2. Once the controller is ready to remove your device, press the top or bottom of the wireless smart Fan controller", + "reset": "Pull the airgap switch. Press and hold the bottom button, push the airgap switch in and continue holding the bottom button for 10 seconds. The LED will flash once each of the 8 colors then stop" + } + }, + "label": "58446 / ZWA4013", + "interviewAttempts": 1, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + } + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0063:0x4944:0x3533:1.26.1", + "statistics": { + "commandsTX": 158, + "commandsRX": 154, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 30.1, + "lastSeen": "2025-07-05T19:10:23.100Z", + "lwr": { + "repeaters": [], + "protocolDataRate": 3 + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2025-07-05T19:10:23.100Z", + "protocol": 0, + "sdkVersion": "7.18.1", + "values": [ + { + "endpoint": 0, + "commandClass": 32, + "commandClassName": "Basic", + "property": "event", + "propertyName": "event", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Event value", + "min": 0, + "max": 255, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off", + "3": "Always on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Inverted Orientation", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "3-Way Setup", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "3-Way Setup", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Add-on", + "1": "Standard" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Alternate Exclusion", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Press MENU button once", + "label": "Alternate Exclusion", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "LED Indicator Color", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Color", + "default": 5, + "min": 1, + "max": 8, + "states": { + "1": "Red", + "2": "Orange", + "3": "Yellow", + "4": "Green", + "5": "Blue", + "6": "Pink", + "7": "Purple", + "8": "White" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "LED Indicator Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Guidelight Mode Intensity", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Guidelight Mode Intensity", + "default": 4, + "min": 0, + "max": 7, + "states": { + "0": "Off" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "State After Power Failure", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "State After Power Failure", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Always off", + "1": "Previous state" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Fan Speed Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Fan Speed Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Press and hold", + "1": "Single button presses" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 84, + "propertyName": "Reset to Factory Default", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Reset to Factory Default", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 13619 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 18756 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.26"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.26.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 273 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.18.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.18.1" + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "0x50 (Node Identify) - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 8 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "0x50 (Node Identify) - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "0x50 (Node Identify) - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 6 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 19, + "index": 0, + "installerIcon": 1024, + "userIcon": 1024, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing End Node" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + } + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index c8bfca2b35f..44133db03ac 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -98,6 +98,20 @@ async def test_ge_12730(hass: HomeAssistant, client, ge_12730, integration) -> N assert state +async def test_enbrighten_58446_zwa4013( + hass: HomeAssistant, client, enbrighten_58446_zwa4013, integration +) -> None: + """Test GE 12730 Fan Controller v2.0 multilevel switch is discovered as a fan.""" + node = enbrighten_58446_zwa4013 + assert node.device_class.specific.label == "Multilevel Power Switch" + + state = hass.states.get("light.zwa4013_fan") + assert not state + + state = hass.states.get("fan.zwa4013_fan") + assert state + + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration ) -> None: From 023dd9d523267ac1c686f297168ae1c79ebd46a7 Mon Sep 17 00:00:00 2001 From: Robert Meijers Date: Sun, 13 Jul 2025 16:56:31 +0200 Subject: [PATCH 1348/1664] Discover Heos players using Zeroconf (#144763) --- homeassistant/components/heos/config_flow.py | 92 ++++++------ homeassistant/components/heos/manifest.json | 3 +- homeassistant/generated/zeroconf.py | 5 + tests/components/heos/conftest.py | 32 +++++ tests/components/heos/test_config_flow.py | 139 +++++++++++++++++++ 5 files changed, 229 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index e2d3e2522dc..b6cda10dcb7 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, ENTRY_TITLE from .coordinator import HeosConfigEntry @@ -142,51 +143,16 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location - entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovery is ignored or host is part of the current system - if entry and ( - entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) - ): - return self.async_abort(reason="single_instance_allowed") + return await self._async_handle_discovered(hostname) - # Connect to discovered host and get system information - heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) - try: - await heos.connect() - system_info = await heos.get_system_info() - except HeosError as error: - _LOGGER.debug( - "Failed to retrieve system information from discovered HEOS device %s", - hostname, - exc_info=error, - ) - return self.async_abort(reason="cannot_connect") - finally: - await heos.disconnect() - - # Select the preferred host, if available - if system_info.preferred_hosts: - hostname = system_info.preferred_hosts[0].ip_address - - # Move to confirmation when not configured - if entry is None: - self._discovered_host = hostname - return await self.async_step_confirm_discovery() - - # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload - if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: - _LOGGER.debug( - "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname - ) - return self.async_update_reload_and_abort( - entry, - data_updates={CONF_HOST: hostname}, - reason="reconfigure_successful", - ) - return self.async_abort(reason="single_instance_allowed") + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + return await self._async_handle_discovered(discovery_info.host) async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -267,6 +233,50 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): ), ) + async def _async_handle_discovered(self, hostname: str) -> ConfigFlowResult: + entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): + return self.async_abort(reason="single_instance_allowed") + + # Connect to discovered host and get system information + heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) + try: + await heos.connect() + system_info = await heos.get_system_info() + except HeosError as error: + _LOGGER.debug( + "Failed to retrieve system information from discovered HEOS device %s", + hostname, + exc_info=error, + ) + return self.async_abort(reason="cannot_connect") + finally: + await heos.disconnect() + + # Select the preferred host, if available + if system_info.preferred_hosts and system_info.preferred_hosts[0].ip_address: + hostname = system_info.preferred_hosts[0].ip_address + + # Move to confirmation when not configured + if entry is None: + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: + _LOGGER.debug( + "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: hostname}, + reason="reconfigure_successful", + ) + return self.async_abort(reason="single_instance_allowed") + class HeosOptionsFlowHandler(OptionsFlow): """Define HEOS options flow.""" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 8a88913456d..99cedf56f1f 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -13,5 +13,6 @@ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" } - ] + ], + "zeroconf": ["_heos-audio._tcp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 274fafa51cf..47522a69c41 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -534,6 +534,11 @@ ZEROCONF = { "domain": "homekit_controller", }, ], + "_heos-audio._tcp.local.": [ + { + "domain": "heos", + }, + ], "_homeconnect._tcp.local.": [ { "domain": "home_connect", diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 835e4436398..e72c72c7334 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from ipaddress import ip_address from unittest.mock import Mock, patch from pyheos import ( @@ -39,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -284,6 +286,36 @@ def discovery_data_fixture_bedroom() -> SsdpServiceInfo: ) +@pytest.fixture(name="zeroconf_discovery_data") +def zeroconf_discovery_data_fixture() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.1" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenon._heos-audio._tcp.local.", + properties={}, + ) + + +@pytest.fixture(name="zeroconf_discovery_data_bedroom") +def zeroconf_discovery_data_fixture_bedroom() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.2" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenonBedroom._heos-audio._tcp.local.", + properties={}, + ) + + @pytest.fixture(name="quick_selects") def quick_selects_fixture() -> dict[int, str]: """Create a dict of quick selects for testing.""" diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69d9aa3a38e..4749dc48b01 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -18,12 +18,14 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_SSDP, SOURCE_USER, + SOURCE_ZEROCONF, ConfigEntryState, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -244,6 +246,143 @@ async def test_discovery_updates( assert config_entry.data[CONF_HOST] == "127.0.0.2" +async def test_zeroconf_discovery( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + + +async def test_zeroconf_discovery_flow_aborts_already_setup( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test discovery flow aborts when entry already setup and hosts didn't change.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_aborts_same_system( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_ignored_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_zeroconf_discovery_fails_to_connect_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + controller: MockHeos, +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + +async def test_zeroconf_discovery_updates( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: From f3ad6bd9b63cb81683a9394a2d02417c689784bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 17:55:24 +0200 Subject: [PATCH 1349/1664] Report correctly when no funds for OpenAI (#148677) --- .../components/openai_conversation/entity.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 97f3bd0ccfe..db14480ec5f 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -362,19 +362,26 @@ class OpenAIBaseLLMEntity(Entity): try: result = await client.responses.create(**model_args) + + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, result, messages) + ): + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) except openai.RateLimitError as err: LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: + if ( + isinstance(err, openai.APIError) + and err.type == "insufficient_quota" + ): + LOGGER.error("Insufficient funds for OpenAI: %s", err) + raise HomeAssistantError("Insufficient funds for OpenAI") from err + LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) - if not chat_log.unresponded_tool_results: break From 23a8442abec4468d2a5d9658031d8c9a9d6eeb5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 19:35:11 +0200 Subject: [PATCH 1350/1664] Make attachments native to chat log (#148693) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/ai_task/__init__.py | 3 +- homeassistant/components/ai_task/entity.py | 4 ++- homeassistant/components/ai_task/task.py | 33 +++++++------------ .../components/conversation/__init__.py | 2 ++ .../components/conversation/chat_log.py | 19 +++++++++++ .../ai_task.py | 2 +- .../entity.py | 11 ++----- .../ai_task/snapshots/test_task.ambr | 1 + tests/components/ai_task/test_init.py | 6 ++-- .../snapshots/test_conversation.ambr | 1 + .../test_ai_task.py | 2 +- .../snapshots/test_conversation.ambr | 2 ++ 12 files changed, 49 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index a472b0db131..a16e11c05d7 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -33,7 +33,7 @@ from .const import ( ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenDataTask, GenDataTaskResult, PlayMediaWithId, async_generate_data +from .task import GenDataTask, GenDataTaskResult, async_generate_data __all__ = [ "DOMAIN", @@ -41,7 +41,6 @@ __all__ = [ "AITaskEntityFeature", "GenDataTask", "GenDataTaskResult", - "PlayMediaWithId", "async_generate_data", "async_setup", "async_setup_entry", diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index cb6094cba4e..420777ce5c3 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -79,7 +79,9 @@ class AITaskEntity(RestoreEntity): user_llm_prompt=DEFAULT_SYSTEM_PROMPT, ) - chat_log.async_add_user_content(UserContent(task.instructions)) + chat_log.async_add_user_content( + UserContent(task.instructions, attachments=task.attachments) + ) yield chat_log diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 72d1018210c..bb57a89203e 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -2,30 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass from typing import Any import voluptuous as vol -from homeassistant.components import media_source +from homeassistant.components import conversation, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature -@dataclass(slots=True) -class PlayMediaWithId(media_source.PlayMedia): - """Play media with a media content ID.""" - - media_content_id: str - """Media source ID to play.""" - - def __str__(self) -> str: - """Return media source ID as a string.""" - return f"" - - async def async_generate_data( hass: HomeAssistant, *, @@ -52,7 +40,7 @@ async def async_generate_data( ) # Resolve attachments - resolved_attachments: list[PlayMediaWithId] | None = None + resolved_attachments: list[conversation.Attachment] | None = None if attachments: if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: @@ -66,13 +54,16 @@ async def async_generate_data( media = await media_source.async_resolve_media( hass, attachment["media_content_id"], None ) + if media.path is None: + raise HomeAssistantError( + "Only local attachments are currently supported" + ) resolved_attachments.append( - PlayMediaWithId( - **{ - field.name: getattr(media, field.name) - for field in fields(media) - }, + conversation.Attachment( media_content_id=attachment["media_content_id"], + url=media.url, + mime_type=media.mime_type, + path=media.path, ) ) @@ -99,7 +90,7 @@ class GenDataTask: structure: vol.Schema | None = None """Optional structure for the data to be generated.""" - attachments: list[PlayMediaWithId] | None = None + attachments: list[conversation.Attachment] | None = None """List of attachments to go along the instructions.""" def __str__(self) -> str: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 66a5735e6b6..ec866604205 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -34,6 +34,7 @@ from .agent_manager import ( from .chat_log import ( AssistantContent, AssistantContentDeltaDict, + Attachment, ChatLog, Content, ConverseError, @@ -66,6 +67,7 @@ __all__ = [ "HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", + "Attachment", "ChatLog", "Content", "ConversationEntity", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 6322bdb4435..e8ec66afa76 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,6 +8,7 @@ from contextlib import contextmanager from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging +from pathlib import Path from typing import Any, Literal, TypedDict import voluptuous as vol @@ -136,6 +137,24 @@ class UserContent: role: Literal["user"] = field(init=False, default="user") content: str + attachments: list[Attachment] | None = field(default=None) + + +@dataclass(frozen=True) +class Attachment: + """Attachment for a chat message.""" + + media_content_id: str + """Media content ID of the attachment.""" + + url: str + """URL of the attachment.""" + + mime_type: str + """MIME type of the attachment.""" + + path: Path + """Path to the attachment on disk.""" @dataclass(frozen=True) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index 80d5a1dfa06..4ffca835fed 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -48,7 +48,7 @@ class GoogleGenerativeAITaskEntity( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log, task.structure, task.attachments) + await self._async_handle_chat_log(chat_log, task.structure) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index fce1fdd40e7..8e967d84517 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -30,7 +30,7 @@ from google.genai.types import ( import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components import ai_task, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -338,7 +338,6 @@ class GoogleGenerativeAILLMBaseEntity(Entity): self, chat_log: conversation.ChatLog, structure: vol.Schema | None = None, - attachments: list[ai_task.PlayMediaWithId] | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -442,15 +441,11 @@ class GoogleGenerativeAILLMBaseEntity(Entity): user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) chat_request: str | list[Part] = user_message.content - if attachments: - if any(a.path is None for a in attachments): - raise HomeAssistantError( - "Only local attachments are currently supported" - ) + if user_message.attachments: files = await async_prepare_files_for_prompt( self.hass, self._genai_client, - [a.path for a in attachments], # type: ignore[misc] + [a.path for a in user_message.attachments], ) chat_request = [chat_request, *files] diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 3b40b0632a6..181fc383d64 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -9,6 +9,7 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Test prompt', 'role': 'user', }), diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 840285493ac..19f73045532 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -1,5 +1,6 @@ """Test initialization of the AI Task component.""" +from pathlib import Path from typing import Any from unittest.mock import patch @@ -89,6 +90,7 @@ async def test_generate_data_service( return_value=media_source.PlayMedia( url="http://example.com/media.mp4", mime_type="video/mp4", + path=Path("media.mp4"), ), ): result = await hass.services.async_call( @@ -118,9 +120,7 @@ async def test_generate_data_service( assert attachment.url == "http://example.com/media.mp4" assert attachment.mime_type == "video/mp4" assert attachment.media_content_id == msg_attachment["media_content_id"] - assert ( - str(attachment) == f"" - ) + assert attachment.path == Path("media.mp4") async def test_generate_data_service_structure_fields( diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 09618b135db..d97eaab41e4 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -12,6 +12,7 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index 653b41fcb6e..6326bd94ad9 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -185,7 +185,7 @@ async def test_generate_data( ) assert result.data == {"characters": ["Mario", "Luigi"]} - assert len(mock_chat_create.mock_calls) == 4 + assert len(mock_chat_create.mock_calls) == 3 config = mock_chat_create.mock_calls[-1][2]["config"] assert config.response_mime_type == "application/json" assert config.response_schema == { diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 48ad0878b2f..77c52ab97e6 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -2,6 +2,7 @@ # name: test_function_call list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), @@ -58,6 +59,7 @@ # name: test_function_call_without_reasoning list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), From 611f86cf8c1a89b33baeff902563476dbdea564a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 21:51:46 +0200 Subject: [PATCH 1351/1664] OpenAI: Add attachment support to AI task (#148676) --- .../components/openai_conversation/ai_task.py | 5 +- .../components/openai_conversation/entity.py | 20 +++++ .../openai_conversation/test_ai_task.py | 88 ++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py index ff8c6e62520..5fc700a73ad 100644 --- a/homeassistant/components/openai_conversation/ai_task.py +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -39,7 +39,10 @@ class OpenAITaskEntity( ): """OpenAI AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index db14480ec5f..7679bef83f1 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -345,6 +345,26 @@ class OpenAIBaseLLMEntity(Entity): for content in chat_log.content for m in _convert_content_to_param(content) ] + + last_content = chat_log.content[-1] + + # Handle attachments by adding them to the last user message + if last_content.role == "user" and last_content.attachments: + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + last_message = messages[-1] + assert ( + last_message["type"] == "message" + and last_message["role"] == "user" + and isinstance(last_message["content"], str) + ) + last_message["content"] = [ + {"type": "input_text", "text": last_message["content"]}, # type: ignore[list-item] + *files, # type: ignore[list-item] + ] + if structure and structure_name: model_args["text"] = { "format": { diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 4541e11f5f8..14e3056c0e2 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -1,11 +1,12 @@ """Test AI Task platform of OpenAI Conversation integration.""" -from unittest.mock import AsyncMock +from pathlib import Path +from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -122,3 +123,86 @@ async def test_generate_invalid_structured_data( }, ), ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachments( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with attachments.""" + entity_id = "ai_task.openai_ai_task" + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi there!", output_index=0) + ] + + # Test with attachments + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + # patch.object(hass.config, "is_allowed_path", return_value=True), + patch( + "homeassistant.components.openai_conversation.entity.guess_file_type", + return_value=("image/jpeg", None), + ), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + assert result.data == "Hi there!" + + # Verify that the create stream was called with the correct parameters + # The last call should have the user message with attachments + call_args = mock_create_stream.call_args + assert call_args is not None + + # Check that the input includes the attachments + input_messages = call_args[1]["input"] + assert len(input_messages) > 0 + + # Find the user message with attachments + user_message_with_attachments = input_messages[-2] + + assert user_message_with_attachments is not None + assert isinstance(user_message_with_attachments["content"], list) + assert len(user_message_with_attachments["content"]) == 3 # Text + attachments + assert user_message_with_attachments["content"] == [ + {"type": "input_text", "text": "Test prompt"}, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + ] From b2fe17c6d47a09d84ea21ebe048bdab63417feb0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:12:00 +0200 Subject: [PATCH 1352/1664] Update PyMicroBot to 0.0.23 (#148700) --- .../components/keymitt_ble/__init__.py | 32 ++----------------- .../components/keymitt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 5 files changed, 5 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 0f71519e420..01948006852 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,42 +2,14 @@ from __future__ import annotations -from collections.abc import Generator -from contextlib import contextmanager - -import bleak +from microbot import MicroBotApiClient from homeassistant.components import bluetooth from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady - -@contextmanager -def patch_unused_bleak_discover_import() -> Generator[None]: - """Patch bleak.discover import in microbot. It is unused and was removed in bleak 1.0.0.""" - - def getattr_bleak(name: str) -> object: - if name == "discover": - return None - raise AttributeError - - original_func = bleak.__dict__.get("__getattr__") - bleak.__dict__["__getattr__"] = getattr_bleak - try: - yield - finally: - if original_func is not None: - bleak.__dict__["__getattr__"] = original_func - - -with patch_unused_bleak_discover_import(): - from microbot import MicroBotApiClient - -from .coordinator import ( # noqa: E402 - MicroBotConfigEntry, - MicroBotDataUpdateCoordinator, -) +from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator PLATFORMS: list[str] = [Platform.SWITCH] diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 5abdfe5b4a7..249bb5eb121 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.17"] + "requirements": ["PyMicroBot==0.0.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72e86bc3324..5b9322b39ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a846910eb3..a079b52ce17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -67,7 +67,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/script/licenses.py b/script/licenses.py index 6d5f7e58f2f..d7819cba536 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -178,7 +178,6 @@ OSI_APPROVED_LICENSES = { } EXCEPTIONS = { - "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 "chacha20poly1305", # LGPL From 74288a3bc8a63061fa7a0c5ccbedd3e489052564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 13 Jul 2025 22:46:42 +0200 Subject: [PATCH 1353/1664] Re-enable Home Connect updates automatically (#148657) Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 46 ++++++------ .../components/home_connect/strings.json | 11 --- .../home_connect/test_coordinator.py | 74 +++++++++++-------- 3 files changed, 67 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index bb419f6bd7c..81f785b55ae 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -38,7 +38,7 @@ from propcache.api import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -626,39 +626,37 @@ class HomeConnectCoordinator( """Check if the appliance data hasn't been refreshed too often recently.""" now = self.hass.loop.time() - if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: - return True + + execution_tracker = self._execution_tracker[appliance_ha_id] + initial_len = len(execution_tracker) execution_tracker = self._execution_tracker[appliance_ha_id] = [ timestamp - for timestamp in self._execution_tracker[appliance_ha_id] + for timestamp in execution_tracker if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW ] execution_tracker.append(now) if len(execution_tracker) >= MAX_EXECUTIONS: - ir.async_create_issue( - self.hass, - DOMAIN, - f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.ERROR, - translation_key="home_connect_too_many_connected_paired_events", - data={ - "entry_id": self.config_entry.entry_id, - "appliance_ha_id": appliance_ha_id, - }, - translation_placeholders={ - "appliance_name": self.data[appliance_ha_id].info.name, - "times": str(MAX_EXECUTIONS), - "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), - "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299", - }, - ) + if initial_len < MAX_EXECUTIONS: + _LOGGER.warning( + 'Too many connected/paired events for appliance "%s" ' + "(%s times in less than %s minutes), updates have been disabled " + "and they will be enabled again whenever the connection stabilizes. " + "Consider trying to unplug the appliance " + "for a while to perform a soft reset", + self.data[appliance_ha_id].info.name, + MAX_EXECUTIONS, + MAX_EXECUTIONS_TIME_WINDOW // 60, + ) return True + if initial_len >= MAX_EXECUTIONS: + _LOGGER.info( + 'Connected/paired events from the appliance "%s" have stabilized,' + " updates have been re-enabled", + self.data[appliance_ha_id].info.name, + ) return False diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index e1c0b42ca0b..853d2bd2f8e 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -124,17 +124,6 @@ } }, "issues": { - "home_connect_too_many_connected_paired_events": { - "title": "{appliance_name} sent too many connected or paired events", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})." - } - } - } - }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index f9fed995b89..a368cfbef2d 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -53,16 +52,11 @@ from homeassistant.core import ( HomeAssistant, callback, ) -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -580,8 +574,7 @@ async def test_paired_disconnected_devices_not_fetching( async def test_coordinator_disabling_updates_for_appliance( hass: HomeAssistant, - hass_client: ClientSessionGenerator, - issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -592,7 +585,6 @@ async def test_coordinator_disabling_updates_for_appliance( When the user confirms the issue the updates should be enabled again. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -606,13 +598,26 @@ async def test_coordinator_disabling_updates_for_appliance( EventType.CONNECTED, data=ArrayOfEvents([]), ) - for _ in range(8) + for _ in range(6) ] ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue + freezer.tick(timedelta(minutes=10)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(2) + ] + ) + await hass.async_block_till_done() + + # At this point, the updates have been blocked because + # 6 + 2 connected events have been received in less than an hour get_settings_original_side_effect = client.get_settings.side_effect @@ -644,18 +649,36 @@ async def test_coordinator_disabling_updates_for_appliance( assert hass.states.is_state("switch.dishwasher_power", STATE_ON) - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, + # After 55 minutes, the updates should be enabled again + # because one hour has passed since the first connect events, + # so there are 2 connected events in the execution_tracker + freezer.tick(timedelta(minutes=55)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + # If more connect events are sent, it should be blocked again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) # 2 + 1 + 5 = 8 connect events in less than an hour + ] + ) + await hass.async_block_till_done() + client.get_settings = get_settings_original_side_effect await client.add_events( [ EventMessage( @@ -672,7 +695,6 @@ async def test_coordinator_disabling_updates_for_appliance( async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( hass: HomeAssistant, - issue_registry: ir.IssueRegistry, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -682,7 +704,6 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r The repair issue should also be deleted. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -701,14 +722,9 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED From cfc7cfcf372b7a73f49aba9b13ee08fd4ad84ce9 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sun, 13 Jul 2025 14:44:55 -0700 Subject: [PATCH 1354/1664] Bump screenlogicpy to 0.10.2 (#148703) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 434b8921bc2..2a91fcd6c8e 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.10.0"] + "requirements": ["screenlogicpy==0.10.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5b9322b39ab..35419a46e06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2716,7 +2716,7 @@ sanix==1.0.6 satel-integra==0.3.7 # homeassistant.components.screenlogic -screenlogicpy==0.10.0 +screenlogicpy==0.10.2 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a079b52ce17..69228f8e11c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2244,7 +2244,7 @@ samsungtvws[async,encrypted]==2.7.2 sanix==1.0.6 # homeassistant.components.screenlogic -screenlogicpy==0.10.0 +screenlogicpy==0.10.2 # homeassistant.components.backup securetar==2025.2.1 From 25ba2437ddaf5dc46a43d9fd8cac7b5bb7bf5a5b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 14 Jul 2025 01:15:50 +0300 Subject: [PATCH 1355/1664] Bump aioshelly to 13.7.2 (#148706) --- 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 1db8dbf55c6..08c9163bb3b 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.1"], + "requirements": ["aioshelly==13.7.2"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 35419a46e06..a5310b6e804 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -381,7 +381,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.1 +aioshelly==13.7.2 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69228f8e11c..7850fff15c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -363,7 +363,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.7.1 +aioshelly==13.7.2 # homeassistant.components.skybell aioskybell==22.7.0 From bc07030304581d7529f0fe49a7f5ac33d2e864d5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 14 Jul 2025 01:18:35 +0300 Subject: [PATCH 1356/1664] Bump aioamazondevices to 3.2.10 (#148709) --- 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 41154d91779..25ad75d0d00 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.2.8"] + "requirements": ["aioamazondevices==3.2.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index a5310b6e804..282f8770d48 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.8 +aioamazondevices==3.2.10 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7850fff15c8..845e9783f10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.12 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.8 +aioamazondevices==3.2.10 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 5e30e6cb916c951107bdc30fa1319d76b186c2da Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:02:43 +0200 Subject: [PATCH 1357/1664] Update python-mystrom to 2.4.0 (#148682) --- homeassistant/components/mystrom/manifest.json | 2 +- pyproject.toml | 2 -- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/hassfest/requirements.py | 5 ----- 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index eaf9eb6acdc..c5a981dbf46 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", "loggers": ["pymystrom"], - "requirements": ["python-mystrom==2.2.0"] + "requirements": ["python-mystrom==2.4.0"] } diff --git a/pyproject.toml b/pyproject.toml index 3ea2a9c9f1b..860b4af379d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -568,8 +568,6 @@ filterwarnings = [ "ignore:pkg_resources is deprecated as an API:UserWarning:pysiaalarm.data.data", # https://pypi.org/project/pybotvac/ - v0.0.28 - 2025-06-11 "ignore:pkg_resources is deprecated as an API:UserWarning:pybotvac.version", - # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21 - "ignore:pkg_resources is deprecated as an API:UserWarning:pymystrom", # - SyntaxWarning - is with literal # https://github.com/majuss/lupupy/pull/15 - >0.3.2 # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16 diff --git a/requirements_all.txt b/requirements_all.txt index 282f8770d48..f623b8ef114 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2474,7 +2474,7 @@ python-miio==0.5.12 python-mpd2==3.1.1 # homeassistant.components.mystrom -python-mystrom==2.2.0 +python-mystrom==2.4.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 845e9783f10..a6ea35dd6f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2047,7 +2047,7 @@ python-miio==0.5.12 python-mpd2==3.1.1 # homeassistant.components.mystrom -python-mystrom==2.2.0 +python-mystrom==2.4.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py index b334b75451e..9c3f60a827c 100644 --- a/script/hassfest/requirements.py +++ b/script/hassfest/requirements.py @@ -222,11 +222,6 @@ FORBIDDEN_PACKAGE_EXCEPTIONS: dict[str, dict[str, set[str]]] = { # pymonoprice > pyserial-asyncio "pymonoprice": {"pyserial-asyncio"} }, - "mystrom": { - # https://github.com/home-assistant-ecosystem/python-mystrom/issues/55 - # python-mystrom > setuptools - "python-mystrom": {"setuptools"} - }, "nibe_heatpump": {"nibe": {"async-timeout"}}, "norway_air": {"pymetno": {"async-timeout"}}, "nx584": { From e4359e74c68bb929cab9c0d445a159fe091596d6 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 14 Jul 2025 08:08:54 +0200 Subject: [PATCH 1358/1664] Bump PyViCare to 2.50.0 (#148679) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index fed777e6435..8e632e46efe 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.44.0"] + "requirements": ["PyViCare==2.50.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f623b8ef114..c1c783f3d8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6ea35dd6f7..fd21c1d1f63 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.8.0 # homeassistant.components.vicare -PyViCare==2.44.0 +PyViCare==2.50.0 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From 26d71fcdba1a36c68476d9e8256d87c688e426a8 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Sun, 13 Jul 2025 23:17:20 -0700 Subject: [PATCH 1359/1664] Fix derivative migration from 'none' unit_prefix (#147820) --- .../components/derivative/__init__.py | 38 +++++++++++ .../components/derivative/config_flow.py | 3 + homeassistant/components/derivative/sensor.py | 6 +- tests/components/derivative/test_init.py | 64 ++++++++++++++++++- 4 files changed, 105 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 0806a8f824d..8fb614a3de4 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SOURCE, Platform from homeassistant.core import HomeAssistant @@ -11,6 +13,8 @@ from homeassistant.helpers.device import ( ) from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" @@ -54,3 +58,37 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,)) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + new_options = {**config_entry.options} + + if new_options.get("unit_prefix") == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing + del new_options["unit_prefix"] + + hass.config_entries.async_update_entry( + config_entry, options=new_options, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index dc12e1bbfe2..c90631f3aeb 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -141,6 +141,9 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab09c17673c..bfba2f0023c 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -123,10 +123,6 @@ async def async_setup_entry( source_entity_id, ) - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": - # Before we had support for optional selectors, "none" was used for selecting nothing - unit_prefix = None - if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): max_sub_interval = cv.time_period(max_sub_interval_dict) else: @@ -139,7 +135,7 @@ async def async_setup_entry( time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), unique_id=config_entry.entry_id, unit_of_measurement=None, - unit_prefix=unit_prefix, + unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, max_sub_interval=max_sub_interval, diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 533f91c8a33..1f7d051d27e 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import derivative from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -421,3 +421,65 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.parametrize( + ("unit_prefix", "expect_prefix"), + [ + ({}, None), + ({"unit_prefix": "k"}, "k"), + ({"unit_prefix": "none"}, None), + ], +) +async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: + """Test migration from v1.1 deletes "none" unit_prefix.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + **unit_prefix, + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=1, + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options["unit_time"] == "min" + assert config_entry.options.get("unit_prefix") == expect_prefix + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.power", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From f761f7628add3bd543e6c0366e41ca17e4144ec4 Mon Sep 17 00:00:00 2001 From: MattMorgan <48740594+spycle@users.noreply.github.com> Date: Mon, 14 Jul 2025 07:50:25 +0100 Subject: [PATCH 1360/1664] Minor update to keymitt_ble manifest. (#148708) --- homeassistant/components/keymitt_ble/manifest.json | 4 ++-- homeassistant/generated/integrations.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 249bb5eb121..7b1e133bb6e 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -13,8 +13,8 @@ "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/keymitt_ble", - "integration_type": "hub", + "integration_type": "device", "iot_class": "assumed_state", - "loggers": ["keymitt_ble"], + "loggers": ["keymitt_ble", "microbot"], "requirements": ["PyMicroBot==0.0.23"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6bf63b260de..ec790549519 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3252,7 +3252,7 @@ }, "keymitt_ble": { "name": "Keymitt MicroBot Push", - "integration_type": "hub", + "integration_type": "device", "config_flow": true, "iot_class": "assumed_state" }, From 5e50c723a7bfcfa56dd4176dafdb98740b4464ac Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 14 Jul 2025 18:29:29 +1000 Subject: [PATCH 1361/1664] Fix Charge Cable binary sensor in Teslemetry (#148675) --- homeassistant/components/teslemetry/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 439df76c838..6905cefdc30 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -125,8 +125,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = ( key="charge_state_conn_charge_cable", polling=True, polling_value_fn=lambda x: x != "", - streaming_listener=lambda vehicle, callback: vehicle.listen_ChargingCableType( - lambda value: callback(value is not None and value != "Unknown") + streaming_listener=lambda vehicle, callback: vehicle.listen_DetailedChargeState( + lambda value: callback(None if value is None else value != "Disconnected") ), entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.CONNECTIVITY, From eae9f4f925b9493f57440b62b4bda092487cb6ba Mon Sep 17 00:00:00 2001 From: Hessel Date: Mon, 14 Jul 2025 10:30:48 +0200 Subject: [PATCH 1362/1664] Wallbox Integration - Add repair action for insufficient rights (#148610) Co-authored-by: Norbert Rittel --- .../components/wallbox/coordinator.py | 51 ++++++++++++++++--- homeassistant/components/wallbox/strings.json | 9 ++++ tests/components/wallbox/test_lock.py | 4 +- tests/components/wallbox/test_number.py | 6 +-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 23b028330d1..4e743b2106b 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,6 +14,7 @@ from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -197,7 +198,6 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.ECO_MODE elif eco_smart_mode == 1: data[CHARGER_ECO_SMART_KEY] = EcoSmartMode.FULL_SOLAR - return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: @@ -228,8 +228,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth( - translation_domain=DOMAIN, translation_key="invalid_auth" + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -256,8 +258,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth( - translation_domain=DOMAIN, translation_key="invalid_auth" + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -313,8 +317,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth( - translation_domain=DOMAIN, translation_key="invalid_auth" + raise InsufficientRights( + translation_domain=DOMAIN, + translation_key="insufficient_rights", + hass=self.hass, ) from wallbox_connection_error if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -379,3 +385,34 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" + + +class InsufficientRights(HomeAssistantError): + """Error to indicate there are insufficient right for the user.""" + + def __init__( + self, + *args: object, + translation_domain: str | None = None, + translation_key: str | None = None, + translation_placeholders: dict[str, str] | None = None, + hass: HomeAssistant, + ) -> None: + """Initialize exception.""" + super().__init__( + self, *args, translation_domain, translation_key, translation_placeholders + ) + self.hass = hass + self._create_insufficient_rights_issue() + + def _create_insufficient_rights_issue(self) -> None: + """Creates an issue for insufficient rights.""" + ir.create_issue( + self.hass, + DOMAIN, + "insufficient_rights", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + learn_more_url="https://www.home-assistant.io/integrations/wallbox/#troubleshooting", + translation_key="insufficient_rights", + ) diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 13f038d14b6..c59b5389658 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -114,6 +114,12 @@ } } }, + "issues": { + "insufficient_rights": { + "title": "The Wallbox account has insufficient rights.", + "description": "The Wallbox account has insufficient rights to lock/unlock and change the charging power. Please assign the user admin rights in the Wallbox portal." + } + }, "exceptions": { "api_failed": { "message": "Error communicating with Wallbox API" @@ -123,6 +129,9 @@ }, "invalid_auth": { "message": "Invalid authentication" + }, + "insufficient_rights": { + "message": "Insufficient rights for Wallbox user" } } } diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index e3c6048e928..3f856ed5dc2 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.wallbox.coordinator import InvalidAuth +from homeassistant.components.wallbox.coordinator import InsufficientRights from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -96,7 +96,7 @@ async def test_wallbox_lock_class_error_handling( with ( patch.object(mock_wallbox, "lockCharger", side_effect=http_403_error), patch.object(mock_wallbox, "unlockCharger", side_effect=http_403_error), - pytest.raises(InvalidAuth), + pytest.raises(InsufficientRights), ): await hass.services.async_call( "lock", diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index cb332d1cb1e..5c77189f264 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN -from homeassistant.components.wallbox.coordinator import InvalidAuth +from homeassistant.components.wallbox.coordinator import InsufficientRights from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -130,7 +130,7 @@ async def test_wallbox_number_power_class_error_handling( with ( patch.object(mock_wallbox, "setMaxChargingCurrent", side_effect=http_403_error), - pytest.raises(InvalidAuth), + pytest.raises(InsufficientRights), ): await hass.services.async_call( NUMBER_DOMAIN, @@ -202,7 +202,7 @@ async def test_wallbox_number_icp_power_class_error_handling( with ( patch.object(mock_wallbox, "setIcpMaxCurrent", side_effect=http_403_error), - pytest.raises(InvalidAuth), + pytest.raises(InsufficientRights), ): await hass.services.async_call( NUMBER_DOMAIN, From 9f3d890e91046ef85fa733e249b9114640355ee2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niccol=C3=B2=20Maggioni?= Date: Mon, 14 Jul 2025 10:46:13 +0200 Subject: [PATCH 1363/1664] Bump `pysnmp` to v7 and `brother` to v5 (#129761) Co-authored-by: Maciej Bieniek --- .../components/brother/manifest.json | 2 +- .../components/snmp/device_tracker.py | 60 ++++++++++++------- homeassistant/components/snmp/manifest.json | 2 +- homeassistant/components/snmp/sensor.py | 12 ++-- homeassistant/components/snmp/switch.py | 23 ++++--- homeassistant/components/snmp/util.py | 20 +++---- requirements_all.txt | 4 +- requirements_test_all.txt | 4 +- tests/components/snmp/test_float_sensor.py | 2 +- tests/components/snmp/test_init.py | 6 +- tests/components/snmp/test_integer_sensor.py | 2 +- tests/components/snmp/test_negative_sensor.py | 2 +- tests/components/snmp/test_string_sensor.py | 2 +- tests/components/snmp/test_switch.py | 6 +- 14 files changed, 84 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index fa70f3a5dc5..deae818e2b5 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["brother", "pyasn1", "pysmi", "pysnmp"], - "requirements": ["brother==4.3.1"], + "requirements": ["brother==5.0.0"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index f69c844f191..eb963ce6a42 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -7,13 +7,13 @@ import logging from typing import TYPE_CHECKING from pysnmp.error import PySnmpError -from pysnmp.hlapi.asyncio import ( +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, Udp6TransportTarget, UdpTransportTarget, UsmUserData, - bulkWalkCmd, - isEndOfMib, + bulk_walk_cmd, + is_end_of_mib, ) import voluptuous as vol @@ -59,7 +59,7 @@ async def async_get_scanner( hass: HomeAssistant, config: ConfigType ) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" - scanner = SnmpScanner(config[DEVICE_TRACKER_DOMAIN]) + scanner = await SnmpScanner.create(config[DEVICE_TRACKER_DOMAIN]) await scanner.async_init(hass) return scanner if scanner.success_init else None @@ -69,8 +69,8 @@ class SnmpScanner(DeviceScanner): """Queries any SNMP capable Access Point for connected devices.""" def __init__(self, config): - """Initialize the scanner and test the target device.""" - host = config[CONF_HOST] + """Initialize the scanner after testing the target device.""" + community = config[CONF_COMMUNITY] baseoid = config[CONF_BASEOID] authkey = config.get(CONF_AUTH_KEY) @@ -78,19 +78,6 @@ class SnmpScanner(DeviceScanner): privkey = config.get(CONF_PRIV_KEY) privproto = DEFAULT_PRIV_PROTOCOL - try: - # Try IPv4 first. - target = UdpTransportTarget((host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT) - except PySnmpError: - # Then try IPv6. - try: - target = Udp6TransportTarget( - (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT - ) - except PySnmpError as err: - _LOGGER.error("Invalid SNMP host: %s", err) - return - if authkey is not None or privkey is not None: if not authkey: authproto = "none" @@ -109,16 +96,43 @@ class SnmpScanner(DeviceScanner): community, mpModel=SNMP_VERSIONS[DEFAULT_VERSION] ) - self._target = target + self._target: UdpTransportTarget | Udp6TransportTarget self.request_args: RequestArgsType | None = None self.baseoid = baseoid self.last_results = [] self.success_init = False + @classmethod + async def create(cls, config): + """Asynchronously test the target device before fully initializing the scanner.""" + host = config[CONF_HOST] + + try: + # Try IPv4 first. + target = await UdpTransportTarget.create( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError: + # Then try IPv6. + try: + target = Udp6TransportTarget( + (host, DEFAULT_PORT), timeout=DEFAULT_TIMEOUT + ) + except PySnmpError as err: + _LOGGER.error("Invalid SNMP host: %s", err) + return None + instance = cls(config) + instance._target = target + + return instance + async def async_init(self, hass: HomeAssistant) -> None: """Make a one-off read to check if the target device is reachable and readable.""" self.request_args = await async_create_request_cmd_args( - hass, self._auth_data, self._target, self.baseoid + hass, + self._auth_data, + self._target, + self.baseoid, ) data = await self.async_get_snmp_data() self.success_init = data is not None @@ -154,7 +168,7 @@ class SnmpScanner(DeviceScanner): assert self.request_args is not None engine, auth_data, target, context_data, object_type = self.request_args - walker = bulkWalkCmd( + walker = bulk_walk_cmd( engine, auth_data, target, @@ -177,7 +191,7 @@ class SnmpScanner(DeviceScanner): return None for _oid, value in res: - if not isEndOfMib(res): + if not is_end_of_mib(res): try: mac = binascii.hexlify(value.asOctets()).decode("utf-8") except AttributeError: diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index a2a4405a1b5..ebe1bcc0262 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"], "quality_scale": "legacy", - "requirements": ["pysnmp==6.2.6"] + "requirements": ["pysnmp==7.1.21"] } diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index bd50e2050e0..3574affaccd 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -8,13 +8,13 @@ from struct import unpack from pyasn1.codec.ber import decoder from pysnmp.error import PySnmpError -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( +import pysnmp.hlapi.v3arch.asyncio as hlapi +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, Udp6TransportTarget, UdpTransportTarget, UsmUserData, - getCmd, + get_cmd, ) from pysnmp.proto.rfc1902 import Opaque from pysnmp.proto.rfc1905 import NoSuchObject @@ -134,7 +134,7 @@ async def async_setup_platform( try: # Try IPv4 first. - target = UdpTransportTarget((host, port), timeout=DEFAULT_TIMEOUT) + target = await UdpTransportTarget.create((host, port), timeout=DEFAULT_TIMEOUT) except PySnmpError: # Then try IPv6. try: @@ -159,7 +159,7 @@ async def async_setup_platform( auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) request_args = await async_create_request_cmd_args(hass, auth_data, target, baseoid) - get_result = await getCmd(*request_args) + get_result = await get_cmd(*request_args) errindication, _, _, _ = get_result if errindication and not accept_errors: @@ -235,7 +235,7 @@ class SnmpData: async def async_update(self): """Get the latest data from the remote SNMP capable host.""" - get_result = await getCmd(*self._request_args) + get_result = await get_cmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication and not self._accept_errors: diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index fd405567d60..26fb7d5e99d 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -5,15 +5,15 @@ from __future__ import annotations import logging from typing import Any -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( +import pysnmp.hlapi.v3arch.asyncio as hlapi +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, ObjectIdentity, ObjectType, UdpTransportTarget, UsmUserData, - getCmd, - setCmd, + get_cmd, + set_cmd, ) from pysnmp.proto.rfc1902 import ( Counter32, @@ -169,7 +169,7 @@ async def async_setup_platform( else: auth_data = CommunityData(community, mpModel=SNMP_VERSIONS[version]) - transport = UdpTransportTarget((host, port)) + transport = await UdpTransportTarget.create((host, port)) request_args = await async_create_request_cmd_args( hass, auth_data, transport, baseoid ) @@ -228,10 +228,17 @@ class SnmpSwitch(SwitchEntity): self._state: bool | None = None self._payload_on = payload_on self._payload_off = payload_off - self._target = UdpTransportTarget((host, port)) + self._host = host + self._port = port self._request_args = request_args self._command_args = command_args + async def async_added_to_hass(self) -> None: + """Run when this Entity has been added to HA.""" + # The transport creation is done once this entity is registered with HA + # (rather than in the __init__) + self._target = await UdpTransportTarget.create((self._host, self._port)) # pylint: disable=attribute-defined-outside-init + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" # If vartype set, use it - https://www.pysnmp.com/pysnmp/docs/api-reference.html#pysnmp.smi.rfc1902.ObjectType @@ -255,7 +262,7 @@ class SnmpSwitch(SwitchEntity): async def async_update(self) -> None: """Update the state.""" - get_result = await getCmd(*self._request_args) + get_result = await get_cmd(*self._request_args) errindication, errstatus, errindex, restable = get_result if errindication: @@ -291,6 +298,6 @@ class SnmpSwitch(SwitchEntity): async def _set(self, value: Any) -> None: """Set the state of the switch.""" - await setCmd( + await set_cmd( *self._command_args, ObjectType(ObjectIdentity(self._commandoid), value) ) diff --git a/homeassistant/components/snmp/util.py b/homeassistant/components/snmp/util.py index dd3e9a6b6d2..df0171b6610 100644 --- a/homeassistant/components/snmp/util.py +++ b/homeassistant/components/snmp/util.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from pysnmp.hlapi.asyncio import ( +from pysnmp.hlapi.v3arch.asyncio import ( CommunityData, ContextData, ObjectIdentity, @@ -14,8 +14,8 @@ from pysnmp.hlapi.asyncio import ( UdpTransportTarget, UsmUserData, ) -from pysnmp.hlapi.asyncio.cmdgen import lcd, vbProcessor -from pysnmp.smi.builder import MibBuilder +from pysnmp.hlapi.v3arch.asyncio.cmdgen import LCD +from pysnmp.smi import view from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -80,7 +80,7 @@ async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: @callback def _async_shutdown_listener(ev: Event) -> None: _LOGGER.debug("Unconfiguring SNMP engine") - lcd.unconfigure(engine, None) + LCD.unconfigure(engine, None) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_shutdown_listener) return engine @@ -89,10 +89,10 @@ async def async_get_snmp_engine(hass: HomeAssistant) -> SnmpEngine: def _get_snmp_engine() -> SnmpEngine: """Return a cached instance of SnmpEngine.""" engine = SnmpEngine() - mib_controller = vbProcessor.getMibViewController(engine) - # Actually load the MIBs from disk so we do - # not do it in the event loop - builder: MibBuilder = mib_controller.mibBuilder - if "PYSNMP-MIB" not in builder.mibSymbols: - builder.loadModules() + # Actually load the MIBs from disk so we do not do it in the event loop + mib_view_controller = view.MibViewController( + engine.message_dispatcher.mib_instrum_controller.get_mib_builder() + ) + engine.cache["mibViewController"] = mib_view_controller + mib_view_controller.mibBuilder.load_modules() return engine diff --git a/requirements_all.txt b/requirements_all.txt index c1c783f3d8c..a0f903370b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -677,7 +677,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.1 +brother==5.0.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -2363,7 +2363,7 @@ pysml==0.1.5 pysmlight==0.2.7 # homeassistant.components.snmp -pysnmp==6.2.6 +pysnmp==7.1.21 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd21c1d1f63..aee0dc556a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -604,7 +604,7 @@ bring-api==1.1.0 broadlink==0.19.0 # homeassistant.components.brother -brother==4.3.1 +brother==5.0.0 # homeassistant.components.brottsplatskartan brottsplatskartan==1.0.5 @@ -1966,7 +1966,7 @@ pysml==0.1.5 pysmlight==0.2.7 # homeassistant.components.snmp -pysnmp==6.2.6 +pysnmp==7.1.21 # homeassistant.components.snooz pysnooz==0.8.6 diff --git a/tests/components/snmp/test_float_sensor.py b/tests/components/snmp/test_float_sensor.py index a4f6e21dad7..032a89e8be8 100644 --- a/tests/components/snmp/test_float_sensor.py +++ b/tests/components/snmp/test_float_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Opaque(value=b"\x9fx\x04=\xa4\x00\x00") with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_init.py b/tests/components/snmp/test_init.py index 0aa97dcc475..37039444aa0 100644 --- a/tests/components/snmp/test_init.py +++ b/tests/components/snmp/test_init.py @@ -2,8 +2,8 @@ from unittest.mock import patch -from pysnmp.hlapi.asyncio import SnmpEngine -from pysnmp.hlapi.asyncio.cmdgen import lcd +from pysnmp.hlapi.v3arch.asyncio import SnmpEngine +from pysnmp.hlapi.v3arch.asyncio.cmdgen import LCD from homeassistant.components import snmp from homeassistant.const import EVENT_HOMEASSISTANT_STOP @@ -16,7 +16,7 @@ async def test_async_get_snmp_engine(hass: HomeAssistant) -> None: assert isinstance(engine, SnmpEngine) engine2 = await snmp.async_get_snmp_engine(hass) assert engine is engine2 - with patch.object(lcd, "unconfigure") as mock_unconfigure: + with patch.object(LCD, "unconfigure") as mock_unconfigure: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() assert mock_unconfigure.called diff --git a/tests/components/snmp/test_integer_sensor.py b/tests/components/snmp/test_integer_sensor.py index 8e7e0f166ef..8a7d3b91138 100644 --- a/tests/components/snmp/test_integer_sensor.py +++ b/tests/components/snmp/test_integer_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Integer32(13) with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_negative_sensor.py b/tests/components/snmp/test_negative_sensor.py index 66a111b68d0..512cd536df9 100644 --- a/tests/components/snmp/test_negative_sensor.py +++ b/tests/components/snmp/test_negative_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = Integer32(-13) with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_string_sensor.py b/tests/components/snmp/test_string_sensor.py index 5362e79c98d..b51fae0afe5 100644 --- a/tests/components/snmp/test_string_sensor.py +++ b/tests/components/snmp/test_string_sensor.py @@ -16,7 +16,7 @@ def hlapi_mock(): """Mock out 3rd party API.""" mock_data = OctetString("98F") with patch( - "homeassistant.components.snmp.sensor.getCmd", + "homeassistant.components.snmp.sensor.get_cmd", return_value=(None, None, None, [[mock_data]]), ): yield diff --git a/tests/components/snmp/test_switch.py b/tests/components/snmp/test_switch.py index fe1c3922ff0..a70428934ac 100644 --- a/tests/components/snmp/test_switch.py +++ b/tests/components/snmp/test_switch.py @@ -27,7 +27,7 @@ async def test_snmp_integer_switch_off(hass: HomeAssistant) -> None: mock_data = Integer32(0) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) @@ -41,7 +41,7 @@ async def test_snmp_integer_switch_on(hass: HomeAssistant) -> None: mock_data = Integer32(1) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) @@ -57,7 +57,7 @@ async def test_snmp_integer_switch_unknown( mock_data = Integer32(3) with patch( - "homeassistant.components.snmp.switch.getCmd", + "homeassistant.components.snmp.switch.get_cmd", return_value=(None, None, None, [[mock_data]]), ): assert await async_setup_component(hass, SWITCH_DOMAIN, config) From 334d5f09fb693343dff7094d018205ceb577dfdf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Jul 2025 11:24:00 +0200 Subject: [PATCH 1364/1664] Create Google Generative AI sub entries for an enabled entry (#148161) Co-authored-by: Erik Montnemery --- .../__init__.py | 121 ++++- .../config_flow.py | 2 +- .../test_init.py | 427 +++++++++++++++++- 3 files changed, 520 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index a3b87c05e5a..1ff9f355c06 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -195,11 +195,15 @@ async def async_update_options( async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -213,9 +217,14 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) if use_existing: @@ -228,25 +237,51 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: unique_id=None, ), ) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: - entity_registry.async_update_entity( - conversation_entity, - config_entry_id=parent_entry.entry_id, - config_subentry_id=subentry.subentry_id, - new_unique_id=subentry.subentry_id, - ) - device = device_registry.async_get_device( identifiers={(DOMAIN, entry.entry_id)} ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) + entity_registry.async_update_entity( + conversation_entity_id, + config_entry_id=parent_entry.entry_id, + config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, + new_unique_id=subentry.subentry_id, + ) + if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -266,12 +301,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_TITLE, options={}, version=2, - minor_version=2, + minor_version=4, ) @@ -315,19 +351,58 @@ async def async_migrate_entry( if entry.version == 2 and entry.minor_version == 2: # Add AI Task subentry with default options - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry( + hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry +) -> None: + """Add AI Task subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index a68ca09e76d..7d1429b110e 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -97,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 - MINOR_VERSION = 3 + MINOR_VERSION = 4 async def async_step_api( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 351293e7ac0..e154f9d33c9 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -1,5 +1,6 @@ """Tests for the Google Generative AI Conversation integration.""" +from typing import Any from unittest.mock import AsyncMock, Mock, mock_open, patch from google.genai.types import File, FileState @@ -17,11 +18,17 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) -from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData +from homeassistant.config_entries import ( + ConfigEntryDisabler, + ConfigEntryState, + ConfigSubentryData, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID @@ -479,7 +486,7 @@ async def test_migration_from_v1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 4 @@ -556,6 +563,223 @@ async def test_migration_from_v1( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.google_generative_ai_conversation", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.google_generative_ai_conversation_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "models/gemini-2.0-flash", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Google Generative AI 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="google_generative_ai_conversation", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="google_generative_ai_conversation_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == DEFAULT_TITLE + assert len(entry.subentries) == 4 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Google Generative AI" in subentry.title + tts_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "tts" + ] + assert len(tts_subentries) == 1 + assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS + assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -633,7 +857,7 @@ async def test_migration_from_v1_with_multiple_keys( for entry in entries: assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 3 @@ -736,7 +960,7 @@ async def test_migration_from_v1_with_same_keys( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 4 @@ -957,7 +1181,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE assert len(entry.subentries) == 4 @@ -1094,7 +1318,7 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: # Check version and subversion were updated assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 # Check we now have conversation, tts and ai_task_data subentries assert len(entry.subentries) == 3 @@ -1123,3 +1347,194 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert tts_subentry is not None assert tts_subentry.title == DEFAULT_TTS_NAME assert tts_subentry.data == RECOMMENDED_TTS_OPTIONS + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation and TTS subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "data": RECOMMENDED_TTS_OPTIONS, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Google", + model="Generative AI", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="google_generative_ai_conversation", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From ad4e5459b148918d2e2dd07ea7476a5e8a473751 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:25:22 +0200 Subject: [PATCH 1365/1664] Fix - only enable AlexaModeController if at least one mode is offered (#148614) --- homeassistant/components/alexa/entities.py | 23 ++- tests/components/alexa/test_entities.py | 165 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7088b624e21..5f789813869 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity): ): yield AlexaThermostatController(self.hass, self.entity) yield AlexaTemperatureSensor(self.hass, self.entity) - if self.entity.domain == water_heater.DOMAIN and ( - supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + if ( + self.entity.domain == water_heater.DOMAIN + and ( + supported_features + & water_heater.WaterHeaterEntityFeature.OPERATION_MODE + ) + and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST) ): yield AlexaModeController( self.entity, @@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity): self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}" ) force_range_controller = False - if supported & fan.FanEntityFeature.PRESET_MODE: + if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get( + fan.ATTR_PRESET_MODES + ): yield AlexaModeController( self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}" ) @@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity): yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or [] - if activities and supported & remote.RemoteEntityFeature.ACTIVITY: + if ( + activities + and (supported & remote.RemoteEntityFeature.ACTIVITY) + and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) + ): yield AlexaModeController( self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}" ) @@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity): """Yield the supported interfaces.""" yield AlexaPowerController(self.entity) supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if supported & humidifier.HumidifierEntityFeature.MODES: + if ( + supported & humidifier.HumidifierEntityFeature.MODES + ) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES): yield AlexaModeController( self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}" ) diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py index 6998b2acc97..4d8d0dca67f 100644 --- a/tests/components/alexa/test_entities.py +++ b/tests/components/alexa/test_entities.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components import fan, humidifier, remote, water_heater from homeassistant.components.alexa import smart_home from homeassistant.const import EntityCategory, UnitOfTemperature, __version__ from homeassistant.core import HomeAssistant @@ -200,3 +201,167 @@ async def test_serialize_discovery_recovers( "Error serializing Alexa.PowerController discovery" f" for {hass.states.get('switch.bla')}" ) in caplog.text + + +@pytest.mark.parametrize( + ("domain", "state", "state_attributes", "mode_controller_exists"), + [ + ("switch", "on", {}, False), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": "eco", + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco", "auto"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": ["eco"], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + True, + ), + ( + "fan", + "on", + { + "preset_modes": [], + "preset_mode": None, + "supported_features": fan.FanEntityFeature.PRESET_MODE.value, + }, + False, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto", "manual"], + "mode": "auto", + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": ["auto"], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + True, + ), + ( + "humidifier", + "on", + { + "available_modes": [], + "mode": None, + "supported_features": humidifier.HumidifierEntityFeature.MODES.value, + }, + False, + ), + ( + "remote", + "on", + { + "activity_list": ["tv", "dvd"], + "current_activity": "tv", + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": ["tv"], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + True, + ), + ( + "remote", + "on", + { + "activity_list": [], + "current_activity": None, + "supported_features": remote.RemoteEntityFeature.ACTIVITY.value, + }, + False, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on", "auto"], + "operation_mode": "auto", + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": ["on"], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + True, + ), + ( + "water_heater", + "on", + { + "operation_list": [], + "operation_mode": None, + "supported_features": water_heater.WaterHeaterEntityFeature.OPERATION_MODE.value, + }, + False, + ), + ], +) +async def test_mode_controller_is_omitted_if_no_modes_are_set( + hass: HomeAssistant, + domain: str, + state: str, + state_attributes: dict[str, Any], + mode_controller_exists: bool, +) -> None: + """Test we do not generate an invalid discovery with AlexaModeController during serialize discovery. + + AlexModeControllers need at least 2 modes. If one mode is set, an extra mode will be added for compatibility. + If no modes are offered, the mode controller should be omitted to prevent schema validations. + """ + request = get_new_request("Alexa.Discovery", "Discover") + + hass.states.async_set( + f"{domain}.bla", state, {"friendly_name": "Boop Woz"} | state_attributes + ) + + msg = await smart_home.async_handle_message(hass, get_default_config(hass), request) + msg = msg["event"] + + interfaces = { + ifc["interface"] for ifc in msg["payload"]["endpoints"][0]["capabilities"] + } + + assert ("Alexa.ModeController" in interfaces) is mode_controller_exists From 09104fca4d3647c6c69c6b93573096079d64b591 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 14 Jul 2025 11:26:37 +0200 Subject: [PATCH 1366/1664] Fix hide empty sections in mqtt subentry flows (#148692) --- homeassistant/components/mqtt/config_flow.py | 3 ++ tests/components/mqtt/test_config_flow.py | 51 +++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index ee451b5f81d..a3cf2d1d12f 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -2114,6 +2114,9 @@ def data_schema_from_fields( if schema_section is None: data_schema.update(data_schema_element) continue + if not data_schema_element: + # Do not show empty sections + continue collapsed = ( not any( (default := data_schema_fields[str(option)].default) is vol.UNDEFINED diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 9386f1da32c..77c74001939 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3220,7 +3220,7 @@ async def test_subentry_configflow( "url": learn_more_url(component["platform"]), } - # Process entity details setep + # Process entity details step assert result["step_id"] == "entity_platform_config" # First test validators if set of test @@ -4212,3 +4212,52 @@ async def test_subentry_reconfigure_availablity( "payload_available": "1", "payload_not_available": "0", } + + +async def test_subentry_configflow_section_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subentry ConfigFlow sections are hidden when they have no configurable options.""" + await mqtt_mock_entry() + config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + result = await hass.config_entries.subentries.async_init( + (config_entry.entry_id, "device"), + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "device" + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"name": "Bla", "mqtt_settings": {"qos": 1}}, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"platform": "fan"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["description_placeholders"] == { + "mqtt_device": "Bla", + "platform": "fan", + "entity": "Bla", + "url": learn_more_url("fan"), + } + + # Process entity details step + assert result["step_id"] == "entity_platform_config" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={"fan_feature_speed": True}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["step_id"] == "mqtt_platform_config" + + # Check mqtt platform config flow sections from data schema + data_schema = result["data_schema"].schema + assert "fan_speed_settings" in data_schema + assert "fan_preset_mode_settings" not in data_schema From 21b1122f83996bac4435834620f56930da90f1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20M=C3=A5rtensson?= Date: Mon, 14 Jul 2025 11:43:02 +0200 Subject: [PATCH 1367/1664] Add test fixture for Tuya cover (#148660) --- tests/components/tuya/__init__.py | 5 ++ .../am43_corded_motor_zigbee_cover.json | 61 +++++++++++++++++++ .../components/tuya/snapshots/test_cover.ambr | 51 ++++++++++++++++ .../tuya/snapshots/test_select.ambr | 57 +++++++++++++++++ tests/components/tuya/test_cover.py | 33 ++++++++++ 5 files changed, 207 insertions(+) create mode 100644 tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 80e21e84c2e..09606c7e116 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,6 +13,11 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { + "am43_corded_motor_zigbee_cover": [ + # https://github.com/home-assistant/core/issues/71242 + Platform.SELECT, + Platform.COVER, + ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 Platform.COVER, diff --git a/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json new file mode 100644 index 00000000000..14d1c39fc94 --- /dev/null +++ b/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json @@ -0,0 +1,61 @@ +{ + "id": "zah67ekd", + "name": "Kitchen Blinds", + "category": "cl", + "product_id": "zah67ekd", + "product_name": "AM43拉绳电机-Zigbee", + "online": true, + "function": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + } + }, + "status_range": { + "control": { + "type": "Enum", + "value": { "range": ["open", "stop", "close", "continue"] } + }, + "percent_control": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "percent_state": { + "type": "Integer", + "value": { "unit": "%", "min": 0, "max": 100, "scale": 0, "step": 1 } + }, + "control_back_mode": { + "type": "Enum", + "value": { "range": ["forward", "back"] } + }, + "work_state": { + "type": "Enum", + "value": { "range": ["opening", "closing"] } + }, + "situation_set": { + "type": "Enum", + "value": { "range": ["fully_open", "fully_close"] } + }, + "fault": { + "type": "Bitmap", + "value": { "label": ["motor_fault"] } + } + }, + "status": { + "control": "stop", + "percent_control": 100, + "percent_state": 52, + "control_back_mode": "forward", + "work_state": "closing", + "situation_set": "fully_open", + "fault": 0 + } +} diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 843ee2db6b0..1ab635919ca 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,4 +1,55 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.kitchen_blinds_curtain', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Curtain', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'curtain', + 'unique_id': 'tuya.zah67ekdcontrol', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 48, + 'device_class': 'curtain', + 'friendly_name': 'Kitchen Blinds Curtain', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.kitchen_blinds_curtain', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'open', + }) +# --- # name: test_platform_setup_and_discovery[clkg_curtain_switch][cover.tapparelle_studio_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 519ac33fb9f..e8337fb4fbf 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,4 +1,61 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'forward', + 'back', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + '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': 'Motor mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'curtain_motor_mode', + 'unique_id': 'tuya.zah67ekdcontrol_back_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Kitchen Blinds Motor mode', + 'options': list([ + 'forward', + 'back', + ]), + }), + 'context': , + 'entity_id': 'select.kitchen_blinds_motor_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'forward', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 6f94896c8c7..4550ed9d6f4 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -55,3 +55,36 @@ 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", + ["am43_corded_motor_zigbee_cover"], +) +@pytest.mark.parametrize( + ("percent_control", "percent_state"), + [ + (100, 52), + (0, 100), + (50, 25), + ], +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.COVER]) +async def test_percent_state_on_cover( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + percent_control: int, + percent_state: int, +) -> None: + """Test percent_state attribute on the cover entity.""" + mock_device.status["percent_control"] = percent_control + # 100 is closed and 0 is open for Tuya covers + mock_device.status["percent_state"] = 100 - percent_state + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + cover_state = hass.states.get("cover.kitchen_blinds_curtain") + assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" + assert cover_state.attributes["current_position"] == percent_state From 50047f0a4e67de5285ddc270276d58f4f1923225 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Jul 2025 11:46:17 +0200 Subject: [PATCH 1368/1664] Add new device class for absolute humidity (#148567) --- homeassistant/components/number/const.py | 10 ++++++++++ homeassistant/components/number/icons.json | 3 +++ homeassistant/components/number/strings.json | 3 +++ homeassistant/components/sensor/const.py | 14 ++++++++++++++ .../components/sensor/device_condition.py | 3 +++ homeassistant/components/sensor/device_trigger.py | 3 +++ homeassistant/components/sensor/icons.json | 3 +++ homeassistant/components/sensor/strings.json | 5 +++++ homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 7 +++++-- tests/components/sensor/common.py | 2 ++ tests/components/sensor/test_device_condition.py | 2 +- tests/components/sensor/test_device_trigger.py | 2 +- tests/util/test_unit_conversion.py | 8 ++++++++ 14 files changed, 62 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 1b41146cd2a..bfb74d621c3 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,7 @@ from typing import Final import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -78,6 +79,11 @@ class NumberDeviceClass(StrEnum): """Device class for numbers.""" # NumberDeviceClass should be aligned with SensorDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ APPARENT_POWER = "apparent_power" """Apparent power. @@ -452,6 +458,10 @@ class NumberDeviceClass(StrEnum): DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { + NumberDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, NumberDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), NumberDeviceClass.AQI: {None}, NumberDeviceClass.AREA: set(UnitOfArea), diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json index dcce09984bd..482b4bc6793 100644 --- a/homeassistant/components/number/icons.json +++ b/homeassistant/components/number/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:ray-vertex" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 998b9ffba38..1e4290f1d75 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -31,6 +31,9 @@ } } }, + "absolute_humidity": { + "name": "[%key:component::sensor::entity_component::absolute_humidity::name%]" + }, "apparent_power": { "name": "[%key:component::sensor::entity_component::apparent_power::name%]" }, diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 994c29b6bbf..5f9d5ec9ca0 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,7 @@ from typing import Final import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -107,6 +108,12 @@ class SensorDeviceClass(StrEnum): """ # Numerical device classes, these should be aligned with NumberDeviceClass + ABSOLUTE_HUMIDITY = "absolute_humidity" + """Absolute humidity. + + Unit of measurement: `g/m³`, `mg/m³` + """ + APPARENT_POWER = "apparent_power" """Apparent power. @@ -521,6 +528,7 @@ STATE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(SensorStateClass)) STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: MassVolumeConcentrationConverter, SensorDeviceClass.AREA: AreaConverter, SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.BLOOD_GLUCOSE_CONCENTRATION: BloodGlucoseConcentrationConverter, @@ -554,6 +562,10 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = } DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: { + CONCENTRATION_GRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + }, SensorDeviceClass.APPARENT_POWER: set(UnitOfApparentPower), SensorDeviceClass.AQI: {None}, SensorDeviceClass.AREA: set(UnitOfArea), @@ -651,6 +663,7 @@ DEFAULT_PRECISION_LIMIT = 2 # have 0 decimals, that one should be used and not mW, even though mW also should have # 0 decimals. Otherwise the smaller units will have more decimals than expected. UNITS_PRECISION = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: (CONCENTRATION_GRAMS_PER_CUBIC_METER, 1), SensorDeviceClass.APPARENT_POWER: (UnitOfApparentPower.VOLT_AMPERE, 0), SensorDeviceClass.AREA: (UnitOfArea.SQUARE_CENTIMETERS, 0), SensorDeviceClass.ATMOSPHERIC_PRESSURE: (UnitOfPressure.PA, 0), @@ -691,6 +704,7 @@ UNITS_PRECISION = { } DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.APPARENT_POWER: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AQI: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.AREA: set(SensorStateClass), diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 2b1eb350c3e..1ad5fe12e99 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -33,6 +33,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" +CONF_IS_ABSOLUTE_HUMIDITY = "is_absolute_humidity" CONF_IS_APPARENT_POWER = "is_apparent_power" CONF_IS_AQI = "is_aqi" CONF_IS_AREA = "is_area" @@ -88,6 +89,7 @@ CONF_IS_WIND_DIRECTION = "is_wind_direction" CONF_IS_WIND_SPEED = "is_wind_speed" ENTITY_CONDITIONS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_IS_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_IS_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_IS_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_IS_AREA}], @@ -159,6 +161,7 @@ CONDITION_SCHEMA = vol.All( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_IS_ABSOLUTE_HUMIDITY, CONF_IS_APPARENT_POWER, CONF_IS_AQI, CONF_IS_AREA, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index d44611a49db..ae2125962e8 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -32,6 +32,7 @@ from . import ATTR_STATE_CLASS, DOMAIN, SensorDeviceClass DEVICE_CLASS_NONE = "none" +CONF_ABSOLUTE_HUMIDITY = "absolute_humidity" CONF_APPARENT_POWER = "apparent_power" CONF_AQI = "aqi" CONF_AREA = "area" @@ -87,6 +88,7 @@ CONF_WIND_DIRECTION = "wind_direction" CONF_WIND_SPEED = "wind_speed" ENTITY_TRIGGERS = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: [{CONF_TYPE: CONF_ABSOLUTE_HUMIDITY}], SensorDeviceClass.APPARENT_POWER: [{CONF_TYPE: CONF_APPARENT_POWER}], SensorDeviceClass.AQI: [{CONF_TYPE: CONF_AQI}], SensorDeviceClass.AREA: [{CONF_TYPE: CONF_AREA}], @@ -159,6 +161,7 @@ TRIGGER_SCHEMA = vol.All( vol.Required(CONF_ENTITY_ID): cv.entity_id_or_uuid, vol.Required(CONF_TYPE): vol.In( [ + CONF_ABSOLUTE_HUMIDITY, CONF_APPARENT_POWER, CONF_AQI, CONF_AREA, diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json index 05311868fc6..cea955e061c 100644 --- a/homeassistant/components/sensor/icons.json +++ b/homeassistant/components/sensor/icons.json @@ -3,6 +3,9 @@ "_": { "default": "mdi:eye" }, + "absolute_humidity": { + "default": "mdi:water-opacity" + }, "apparent_power": { "default": "mdi:flash" }, diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index ecaeb2504d9..c69bf99eff0 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -2,6 +2,7 @@ "title": "Sensor", "device_automation": { "condition_type": { + "is_absolute_humidity": "Current {entity_name} absolute humidity", "is_apparent_power": "Current {entity_name} apparent power", "is_aqi": "Current {entity_name} air quality index", "is_area": "Current {entity_name} area", @@ -57,6 +58,7 @@ "is_wind_speed": "Current {entity_name} wind speed" }, "trigger_type": { + "absolute_humidity": "{entity_name} absolute humidity changes", "apparent_power": "{entity_name} apparent power changes", "aqi": "{entity_name} air quality index changes", "area": "{entity_name} area changes", @@ -148,6 +150,9 @@ "duration": { "name": "Duration" }, + "absolute_humidity": { + "name": "Absolute humidity" + }, "apparent_power": { "name": "Apparent power" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index e6da8ba4a69..6b4f16c316f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -910,6 +910,7 @@ class UnitOfPrecipitationDepth(StrEnum): # Concentration units +CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: Final = "µg/m³" CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: Final = "mg/m³" CONCENTRATION_MICROGRAMS_PER_CUBIC_FOOT: Final = "μg/ft³" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index d0830d1f8bb..5bde108dfc1 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -7,6 +7,7 @@ from functools import lru_cache from math import floor, log10 from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -693,12 +694,14 @@ class MassVolumeConcentrationConverter(BaseUnitConverter): UNIT_CLASS = "concentration" _UNIT_CONVERSION: dict[str | None, float] = { - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000.0, # 1000 µg/m³ = 1 mg/m³ - CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1.0, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER: 1000000.0, # 1000 µg/m³ = 1 mg/m³ + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER: 1000.0, # 1000 mg/m³ = 1 g/m³ + CONCENTRATION_GRAMS_PER_CUBIC_METER: 1.0, } VALID_UNITS = { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_GRAMS_PER_CUBIC_METER, } diff --git a/tests/components/sensor/common.py b/tests/components/sensor/common.py index 2df13b697da..1b9810a8250 100644 --- a/tests/components/sensor/common.py +++ b/tests/components/sensor/common.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.components.sensor.const import DEVICE_CLASS_STATE_CLASSES from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, DEGREE, @@ -44,6 +45,7 @@ from homeassistant.const import ( from tests.common import MockEntity UNITS_OF_MEASUREMENT = { + SensorDeviceClass.ABSOLUTE_HUMIDITY: CONCENTRATION_GRAMS_PER_CUBIC_METER, SensorDeviceClass.APPARENT_POWER: UnitOfApparentPower.VOLT_AMPERE, SensorDeviceClass.AQI: None, SensorDeviceClass.AREA: UnitOfArea.SQUARE_METERS, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 1c87845c2c7..da69610f4c5 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -125,7 +125,7 @@ async def test_get_conditions( conditions = await async_get_device_automations( hass, DeviceAutomationType.CONDITION, device_entry.id ) - assert len(conditions) == 54 + assert len(conditions) == 55 assert conditions == unordered(expected_conditions) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index bb57797e6dd..c39a5216f0f 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -126,7 +126,7 @@ async def test_get_triggers( triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, device_entry.id ) - assert len(triggers) == 54 + assert len(triggers) == 55 assert triggers == unordered(expected_triggers) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 7d0eb7226a0..537cfb33c31 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -8,6 +8,7 @@ from itertools import chain import pytest from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -762,6 +763,13 @@ _CONVERTED_VALUE: dict[ 2000, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), + # 3 g/m³ = 3000 mg/m³ + ( + 3, + CONCENTRATION_GRAMS_PER_CUBIC_METER, + 3000, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + ), ], VolumeConverter: [ (5, UnitOfVolume.LITERS, 1.32086, UnitOfVolume.GALLONS), From dcbdce4b2b0611439c82e2e8d8cd4743174a682f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 11:57:27 +0200 Subject: [PATCH 1369/1664] Improve docstrings of event helpers related to state changes (#148722) --- homeassistant/helpers/event.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3b959337b6d..f2dfb7250f7 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -316,6 +316,10 @@ def async_track_state_change_event( Unlike async_track_state_change, async_track_state_change_event passes the full event to the callback. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + In order to avoid having to iterate a long list of EVENT_STATE_CHANGED and fire and create a job for each one, we keep a dict of entity ids that @@ -866,6 +870,10 @@ def async_track_state_change_filtered( ) -> _TrackStateChangeFiltered: """Track state changes with a TrackStates filter that can be updated. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + Args: hass: Home assistant object. @@ -1348,9 +1356,13 @@ def async_track_template_result( then whenever the output from the template changes. The template will be reevaluated if any states referenced in the last run of the template change, or if manually triggered. If the result of the - evaluation is different from the previous run, the listener is passed + evaluation is different from the previous run, the action is passed the result. + The action will not be called immediately, but will be scheduled to run + in the next event loop iteration, even if the action is decorated with + @callback. + If the template results in an TemplateError, this will be returned to the listener the first time this happens but not for subsequent errors. Once the template returns to a non-error condition the result is sent From 25f64a2f3698b26023bd477efda5f223f7425306 Mon Sep 17 00:00:00 2001 From: ekutner <5628151+ekutner@users.noreply.github.com> Date: Mon, 14 Jul 2025 13:11:36 +0300 Subject: [PATCH 1370/1664] Do not specify the code_format when a code is not required (#148698) --- .../components/risco/alarm_control_panel.py | 10 +- .../risco/test_alarm_control_panel.py | 147 +++++++++++++++++- 2 files changed, 150 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index 2472baa932e..f485c923776 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -82,7 +82,6 @@ async def async_setup_entry( class RiscoAlarm(AlarmControlPanelEntity): """Representation of a Risco cloud partition.""" - _attr_code_format = CodeFormat.NUMBER _attr_has_entity_name = True _attr_name = None @@ -100,8 +99,13 @@ class RiscoAlarm(AlarmControlPanelEntity): self._partition_id = partition_id self._partition = partition self._code = code - self._attr_code_arm_required = options[CONF_CODE_ARM_REQUIRED] - self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] + arm_required = options[CONF_CODE_ARM_REQUIRED] + disarm_required = options[CONF_CODE_DISARM_REQUIRED] + self._attr_code_arm_required = arm_required + self._code_disarm_required = disarm_required + self._attr_code_format = ( + CodeFormat.NUMBER if arm_required or disarm_required else None + ) self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] for state in self._ha_to_risco: diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py index 8caef1fbfc4..d27d39071a0 100644 --- a/tests/components/risco/test_alarm_control_panel.py +++ b/tests/components/risco/test_alarm_control_panel.py @@ -35,6 +35,7 @@ FIRST_LOCAL_ENTITY_ID = "alarm_control_panel.name_0" SECOND_LOCAL_ENTITY_ID = "alarm_control_panel.name_1" CODES_REQUIRED_OPTIONS = {"code_arm_required": True, "code_disarm_required": True} +CODES_NOT_REQUIRED_OPTIONS = {"code_arm_required": False, "code_disarm_required": False} TEST_RISCO_TO_HA = { "arm": AlarmControlPanelState.ARMED_AWAY, "partial_arm": AlarmControlPanelState.ARMED_HOME, @@ -388,7 +389,8 @@ async def test_cloud_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_correct_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -452,7 +454,58 @@ async def test_cloud_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_cloud_sets_without_code( + hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud +) -> None: + """Test settings the various modes when code is not required.""" + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_DISARM, "disarm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_CLOUD_ENTITY_ID, 0 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_CLOUD_ENTITY_ID, 1 + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_CLOUD_ENTITY_ID, 0, "C" + ) + await _test_cloud_service_call( + hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_CLOUD_ENTITY_ID, 1, "C" + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_CLOUD_ENTITY_ID, + 0, + ) + with pytest.raises(HomeAssistantError): + await _test_cloud_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_CLOUD_ENTITY_ID, + 1, + ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_cloud_sets_with_incorrect_code( hass: HomeAssistant, two_part_cloud_alarm, setup_risco_cloud @@ -837,7 +890,8 @@ async def test_local_sets_full_custom_mapping( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_correct_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -931,7 +985,8 @@ async def test_local_sets_with_correct_code( @pytest.mark.parametrize( - "options", [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}] + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS}], ) async def test_local_sets_with_incorrect_code( hass: HomeAssistant, two_part_local_alarm, setup_risco_local @@ -1020,3 +1075,87 @@ async def test_local_sets_with_incorrect_code( two_part_local_alarm[1], **code, ) + + +@pytest.mark.parametrize( + "options", + [{**CUSTOM_MAPPING_OPTIONS, **CODES_NOT_REQUIRED_OPTIONS}], +) +async def test_local_sets_without_code( + hass: HomeAssistant, two_part_local_alarm, setup_risco_local +) -> None: + """Test settings the various modes when code is not required.""" + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_DISARM, + "disarm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_AWAY, + "arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_HOME, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + "C", + ) + await _test_local_service_call( + hass, + SERVICE_ALARM_ARM_NIGHT, + "group_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + "C", + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + FIRST_LOCAL_ENTITY_ID, + two_part_local_alarm[0], + ) + with pytest.raises(HomeAssistantError): + await _test_local_no_service_call( + hass, + SERVICE_ALARM_ARM_CUSTOM_BYPASS, + "partial_arm", + SECOND_LOCAL_ENTITY_ID, + two_part_local_alarm[1], + ) From 155fc134b6fabe1ed092d7a434ebd5c5b44af32e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 13:33:00 +0200 Subject: [PATCH 1371/1664] Do not add derivative config entry to source device (#148674) Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> --- .../components/derivative/__init__.py | 26 +++- .../components/derivative/config_flow.py | 2 +- homeassistant/components/derivative/sensor.py | 18 +-- homeassistant/helpers/device.py | 13 ++ homeassistant/helpers/helper_integration.py | 21 ++- tests/components/derivative/test_init.py | 143 ++++++++++++++++-- tests/helpers/test_device.py | 46 +++++- tests/helpers/test_helper_integration.py | 71 ++++++++- 8 files changed, 301 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/derivative/__init__.py b/homeassistant/components/derivative/__init__.py index 8fb614a3de4..8bdf448bfba 100644 --- a/homeassistant/components/derivative/__init__.py +++ b/homeassistant/components/derivative/__init__.py @@ -11,7 +11,10 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) _LOGGER = logging.getLogger(__name__) @@ -19,6 +22,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Derivative from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE] ) @@ -29,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE], - source_entity_removed=source_entity_removed, ) ) await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,)) @@ -85,6 +85,20 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, options=new_options, version=1, minor_version=2 ) + if config_entry.minor_version < 3: + # Remove the derivative config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_SOURCE] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=3 + ) + _LOGGER.debug( "Migration to configuration version %s.%s successful", config_entry.version, diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index c90631f3aeb..b5dee1deee3 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -142,7 +142,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): options_flow = OPTIONS_FLOW VERSION = 1 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index bfba2f0023c..ab4feabc4ee 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -34,8 +34,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -118,17 +117,13 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None): max_sub_interval = cv.time_period(max_sub_interval_dict) else: max_sub_interval = None derivative_sensor = DerivativeSensor( + hass, name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), source_entity=source_entity_id, @@ -137,7 +132,6 @@ async def async_setup_entry( unit_of_measurement=None, unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -152,6 +146,7 @@ async def async_setup_platform( ) -> None: """Set up the derivative sensor.""" derivative = DerivativeSensor( + hass, name=config.get(CONF_NAME), round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE], @@ -174,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): def __init__( self, + hass: HomeAssistant, *, name: str | None, round_digits: int, @@ -184,11 +180,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity): unit_time: UnitOfTime, max_sub_interval: timedelta | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the derivative sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._sensor_source_id = source_entity self._round_digits = round_digits self._attr_native_value = round(Decimal(0), round_digits) diff --git a/homeassistant/helpers/device.py b/homeassistant/helpers/device.py index f1404bb068b..bf0e2ab31be 100644 --- a/homeassistant/helpers/device.py +++ b/homeassistant/helpers/device.py @@ -21,6 +21,19 @@ def async_entity_id_to_device_id( return entity.device_id +@callback +def async_entity_id_to_device( + hass: HomeAssistant, + entity_id_or_uuid: str, +) -> dr.DeviceEntry | None: + """Resolve the device entry for the entity id or entity uuid.""" + + if (device_id := async_entity_id_to_device_id(hass, entity_id_or_uuid)) is None: + return None + + return dr.async_get(hass).async_get(device_id) + + @callback def async_device_info_to_link_from_entity( hass: HomeAssistant, diff --git a/homeassistant/helpers/helper_integration.py b/homeassistant/helpers/helper_integration.py index d43c1b22a25..04a1d2cca76 100644 --- a/homeassistant/helpers/helper_integration.py +++ b/homeassistant/helpers/helper_integration.py @@ -19,13 +19,15 @@ def async_handle_source_entity_changes( set_source_entity_id_or_uuid: Callable[[str], None], source_device_id: str | None, source_entity_id_or_uuid: str, - source_entity_removed: Callable[[], Coroutine[Any, Any, None]], + source_entity_removed: Callable[[], Coroutine[Any, Any, None]] | None = None, ) -> CALLBACK_TYPE: """Handle changes to a helper entity's source entity. The following changes are handled: - - Entity removal: If the source entity is removed, the helper config entry - is removed, and the helper entity is cleaned up. + - Entity removal: If the source entity is removed: + - If source_entity_removed is provided, it is called to handle the removal. + - If source_entity_removed is not provided, The helper entity is updated to + not link to any device. - Entity ID changed: If the source entity's entity ID changes and the source entity is identified by an entity ID, the set_source_entity_id_or_uuid is called. If the source entity is identified by a UUID, the helper config entry @@ -52,7 +54,18 @@ def async_handle_source_entity_changes( data = event.data if data["action"] == "remove": - await source_entity_removed() + if source_entity_removed: + await source_entity_removed() + else: + for ( + helper_entity_entry + ) in entity_registry.entities.get_entries_for_config_entry_id( + helper_config_entry_id + ): + # Update the helper entity to link to the new device (or no device) + entity_registry.async_update_entity( + helper_entity_entry.entity_id, device_id=None + ) if data["action"] != "update": return diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 1f7d051d27e..abe90e72b56 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import derivative from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -82,6 +82,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(derivative_config_entry.entry_id) @@ -229,7 +230,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( derivative_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -240,6 +241,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the derivative config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.derivative.async_unload_entry", + wraps=derivative.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the derivative config entry is not removed + assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + derivative_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the derivative config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -256,7 +305,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -273,7 +322,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -300,7 +353,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -315,7 +368,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is removed from the device + # Check that the entity is no longer linked to the source device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id is None + + # Check that the derivative config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries @@ -348,7 +405,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert derivative_config_entry.entry_id not in sensor_device_2.config_entries @@ -365,11 +422,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the derivative config entry is moved to the other device + # Check that the entity is linked to the other device + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert derivative_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert derivative_config_entry.entry_id in sensor_device_2.config_entries + assert derivative_config_entry.entry_id not in sensor_device_2.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -394,7 +455,7 @@ async def test_async_handle_source_entity_new_entity_id( assert derivative_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, derivative_entity_entry.entity_id) @@ -412,9 +473,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the derivative config entry is updated with the new entity ID assert derivative_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert derivative_config_entry.entry_id in sensor_device.config_entries + assert derivative_config_entry.entry_id not in sensor_device.config_entries # Check that the derivative config entry is not removed assert derivative_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -431,7 +492,7 @@ async def test_async_handle_source_entity_new_entity_id( ({"unit_prefix": "none"}, None), ], ) -async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: +async def test_migration_1_1(hass: HomeAssistant, unit_prefix, expect_prefix) -> None: """Test migration from v1.1 deletes "none" unit_prefix.""" config_entry = MockConfigEntry( @@ -457,6 +518,60 @@ async def test_migration(hass: HomeAssistant, unit_prefix, expect_prefix) -> Non assert config_entry.options["unit_time"] == "min" assert config_entry.options.get("unit_prefix") == expect_prefix + assert config_entry.version == 1 + assert config_entry.minor_version == 3 + + +async def test_migration_1_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.2 removes derivative config entry from device.""" + + derivative_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": "sensor.test_unique", + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=1, + minor_version=2, + ) + derivative_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=derivative_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + assert derivative_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert derivative_config_entry.entry_id not in sensor_device.config_entries + derivative_entity_entry = entity_registry.async_get("sensor.my_derivative") + assert derivative_entity_entry.device_id == sensor_entity_entry.device_id + + assert derivative_config_entry.version == 1 + assert derivative_config_entry.minor_version == 3 + async def test_migration_from_future_version( hass: HomeAssistant, diff --git a/tests/helpers/test_device.py b/tests/helpers/test_device.py index 266435ef05d..262e700c29e 100644 --- a/tests/helpers/test_device.py +++ b/tests/helpers/test_device.py @@ -8,6 +8,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device import ( async_device_info_to_link_from_device_id, async_device_info_to_link_from_entity, + async_entity_id_to_device, async_entity_id_to_device_id, async_remove_stale_devices_links_keep_current_device, async_remove_stale_devices_links_keep_entity_device, @@ -16,12 +17,12 @@ from homeassistant.helpers.device import ( from tests.common import MockConfigEntry -async def test_entity_id_to_device_id( +async def test_entity_id_to_device_device_id( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test returning an entity's device ID.""" + """Test returning an entity's device / device ID.""" config_entry = MockConfigEntry(domain="my") config_entry.add_to_hass(hass) @@ -48,6 +49,41 @@ async def test_entity_id_to_device_id( entity_id_or_uuid=entity.entity_id, ) assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.entity_id, + ) + == device + ) + + assert ( + async_entity_id_to_device_id( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown.entity_id", + ) + is None + ) + + device_id = async_entity_id_to_device_id( + hass, + entity_id_or_uuid=entity.id, + ) + assert device_id == device.id + assert ( + async_entity_id_to_device( + hass, + entity_id_or_uuid=entity.id, + ) + == device + ) with pytest.raises(vol.Invalid): async_entity_id_to_device_id( @@ -55,6 +91,12 @@ async def test_entity_id_to_device_id( entity_id_or_uuid="unknown_uuid", ) + with pytest.raises(vol.Invalid): + async_entity_id_to_device( + hass, + entity_id_or_uuid="unknown_uuid", + ) + async def test_device_info_to_link( hass: HomeAssistant, diff --git a/tests/helpers/test_helper_integration.py b/tests/helpers/test_helper_integration.py index 91932a51ac2..640b2ff011a 100644 --- a/tests/helpers/test_helper_integration.py +++ b/tests/helpers/test_helper_integration.py @@ -155,7 +155,7 @@ def mock_helper_integration( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, - source_entity_removed: AsyncMock, + source_entity_removed: AsyncMock | None, ) -> None: """Mock the helper integration.""" @@ -197,7 +197,9 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s return events -def listen_entity_registry_events(hass: HomeAssistant) -> list[str]: +def listen_entity_registry_events( + hass: HomeAssistant, +) -> list[er.EventEntityRegistryUpdatedData]: """Track entity registry actions for an entity.""" events: list[er.EventEntityRegistryUpdatedData] = [] @@ -211,6 +213,7 @@ def listen_entity_registry_events(hass: HomeAssistant) -> list[str]: return events +@pytest.mark.parametrize("source_entity_removed", [None]) @pytest.mark.parametrize("use_entity_registry_id", [True, False]) @pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +228,70 @@ async def test_async_handle_source_entity_changes_source_entity_removed( async_remove_entry: AsyncMock, async_unload_entry: AsyncMock, set_source_entity_id_or_uuid: Mock, +) -> None: + """Test the helper config entry is removed when the source entity is removed.""" + # Add the helper config entry to the source device + device_registry.async_update_device( + source_device.id, add_config_entry_id=helper_config_entry.entry_id + ) + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_device.id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Check preconditions + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id == source_entity_entry.device_id + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + events = track_entity_registry_actions(hass, helper_entity_entry.entity_id) + + # Remove the source entitys's config entry from the device, this removes the + # source entity + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Check that the helper entity is not linked to the source device anymore + helper_entity_entry = entity_registry.async_get(helper_entity_entry.entity_id) + assert helper_entity_entry.device_id is None + async_unload_entry.assert_not_called() + async_remove_entry.assert_not_called() + set_source_entity_id_or_uuid.assert_not_called() + + # Check that the helper config entry is not removed from the device + source_device = device_registry.async_get(source_device.id) + assert helper_config_entry.entry_id in source_device.config_entries + + # Check that the helper config entry is not removed + assert helper_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +@pytest.mark.parametrize("use_entity_registry_id", [True, False]) +@pytest.mark.usefixtures("mock_helper_flow", "mock_helper_integration") +async def test_async_handle_source_entity_changes_source_entity_removed_custom_handler( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + helper_config_entry: MockConfigEntry, + helper_entity_entry: er.RegistryEntry, + source_config_entry: ConfigEntry, + source_device: dr.DeviceEntry, + source_entity_entry: er.RegistryEntry, + async_remove_entry: AsyncMock, + async_unload_entry: AsyncMock, + set_source_entity_id_or_uuid: Mock, source_entity_removed: AsyncMock, ) -> None: """Test the helper config entry is removed when the source entity is removed.""" From 5e4ce46daea65301fa1e2cd6547d81d997222c8c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:38:33 +0200 Subject: [PATCH 1372/1664] Use absolute humidity device class in Airq (#148568) --- homeassistant/components/airq/const.py | 1 - homeassistant/components/airq/icons.json | 3 --- homeassistant/components/airq/sensor.py | 8 +++----- homeassistant/components/airq/strings.json | 3 --- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/airq/const.py b/homeassistant/components/airq/const.py index 7a5abe47a8d..3e5c736c8c5 100644 --- a/homeassistant/components/airq/const.py +++ b/homeassistant/components/airq/const.py @@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average" CONF_CLIP_NEGATIVE: Final = "clip_negatives" DOMAIN: Final = "airq" MANUFACTURER: Final = "CorantGmbH" -CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³" ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³" UPDATE_INTERVAL: float = 10.0 diff --git a/homeassistant/components/airq/icons.json b/homeassistant/components/airq/icons.json index fec6eb8dd86..09f262aeaaf 100644 --- a/homeassistant/components/airq/icons.json +++ b/homeassistant/components/airq/icons.json @@ -4,9 +4,6 @@ "health_index": { "default": "mdi:heart-pulse" }, - "absolute_humidity": { - "default": "mdi:water" - }, "oxygen": { "default": "mdi:leaf" }, diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py index 08a344ae9f4..516114840d3 100644 --- a/homeassistant/components/airq/sensor.py +++ b/homeassistant/components/airq/sensor.py @@ -14,6 +14,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_GRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import AirQConfigEntry, AirQCoordinator -from .const import ( - ACTIVITY_BECQUEREL_PER_CUBIC_METER, - CONCENTRATION_GRAMS_PER_CUBIC_METER, -) +from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER _LOGGER = logging.getLogger(__name__) @@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [ ), AirQEntityDescription( key="humidity_abs", - translation_key="absolute_humidity", + device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY, native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, value=lambda data: data.get("humidity_abs"), diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json index 9c16975a3ab..de8c7d86b09 100644 --- a/homeassistant/components/airq/strings.json +++ b/homeassistant/components/airq/strings.json @@ -93,9 +93,6 @@ "health_index": { "name": "Health index" }, - "absolute_humidity": { - "name": "Absolute humidity" - }, "hydrogen": { "name": "Hydrogen" }, From 14ff04200e051f6eb0a65e4ee9d9856eca7486e8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Jul 2025 16:24:44 +0200 Subject: [PATCH 1373/1664] Make AI Task instructions multiline (#148606) --- homeassistant/components/ai_task/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 4298ab62a07..194c0e07bc3 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -10,6 +10,7 @@ generate_data: required: true selector: text: + multiline: true entity_id: required: false selector: From 9e022ad75eb601e8ff86988736f4c6aa4f2f61df Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Mon, 14 Jul 2025 18:44:11 +0300 Subject: [PATCH 1374/1664] Quality fixes for Jewish Calendar (#148689) --- .../jewish_calendar/binary_sensor.py | 70 +++++-------------- .../components/jewish_calendar/entity.py | 61 ++++++++++++++++ .../components/jewish_calendar/sensor.py | 65 +++-------------- .../components/jewish_calendar/services.py | 1 - .../jewish_calendar/test_binary_sensor.py | 19 +---- .../jewish_calendar/test_config_flow.py | 7 +- .../components/jewish_calendar/test_sensor.py | 18 ----- .../jewish_calendar/test_service.py | 44 +++++++----- 8 files changed, 114 insertions(+), 171 deletions(-) diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 79b49050cc2..d5097df962f 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util @@ -23,36 +22,29 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity PARALLEL_UPDATES = 0 -@dataclass(frozen=True) -class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription): - """Binary Sensor description mixin class for Jewish Calendar.""" - - is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False - - -@dataclass(frozen=True) -class JewishCalendarBinarySensorEntityDescription( - JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription -): +@dataclass(frozen=True, kw_only=True) +class JewishCalendarBinarySensorEntityDescription(BinarySensorEntityDescription): """Binary Sensor Entity description for Jewish Calendar.""" + is_on: Callable[[Zmanim], Callable[[dt.datetime], bool]] + BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = ( JewishCalendarBinarySensorEntityDescription( key="issur_melacha_in_effect", translation_key="issur_melacha_in_effect", - is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)), + is_on=lambda state: state.issur_melacha_in_effect, ), JewishCalendarBinarySensorEntityDescription( key="erev_shabbat_hag", translation_key="erev_shabbat_hag", - is_on=lambda state, now: bool(state.erev_shabbat_chag(now)), + is_on=lambda state: state.erev_shabbat_chag, entity_registry_enabled_default=False, ), JewishCalendarBinarySensorEntityDescription( key="motzei_shabbat_hag", translation_key="motzei_shabbat_hag", - is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)), + is_on=lambda state: state.motzei_shabbat_chag, entity_registry_enabled_default=False, ), ) @@ -73,9 +65,7 @@ async def async_setup_entry( class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): """Representation of an Jewish Calendar binary sensor.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBinarySensorEntityDescription @@ -83,40 +73,12 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): def is_on(self) -> bool: """Return true if sensor is on.""" zmanim = self.make_zmanim(dt.date.today()) - return self.entity_description.is_on(zmanim, dt_util.now()) + return self.entity_description.is_on(zmanim)(dt_util.now()) - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - @callback - def _update(self, now: dt.datetime | None = None) -> None: - """Update the state of the sensor.""" - self._update_unsub = None - self._schedule_update() - self.async_write_ha_state() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(dt.date.today()) - update = zmanim.netz_hachama.local + dt.timedelta(days=1) - candle_lighting = zmanim.candle_lighting - if candle_lighting is not None and now < candle_lighting < update: - update = candle_lighting - havdalah = zmanim.havdalah - if havdalah is not None and now < havdalah < update: - update = havdalah - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update, update - ) + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + return [ + zmanim.netz_hachama.local + dt.timedelta(days=1), + zmanim.candle_lighting, + zmanim.havdalah, + ] diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index 9d713aad0eb..d5e41129075 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,17 +1,24 @@ """Entity representing a Jewish Calendar sensor.""" +from abc import abstractmethod from dataclasses import dataclass import datetime as dt +import logging from hdate import HDateInfo, Location, Zmanim from hdate.translator import Language, set_language from homeassistant.config_entries import ConfigEntry +from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription +from homeassistant.util import dt as dt_util from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] @@ -39,6 +46,8 @@ class JewishCalendarEntity(Entity): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True + _attr_should_poll = False + _update_unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -63,3 +72,55 @@ class JewishCalendarEntity(Entity): candle_lighting_offset=self.data.candle_lighting_offset, havdalah_offset=self.data.havdalah_offset, ) + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + self._schedule_update() + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + + @abstractmethod + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + + def _schedule_update(self) -> None: + """Schedule the next update of the sensor.""" + now = dt_util.now() + zmanim = self.make_zmanim(now.date()) + update = dt_util.start_of_local_day() + dt.timedelta(days=1) + + for update_time in self._update_times(zmanim): + if update_time is not None and now < update_time < update: + update = update_time + + if self._update_unsub: + self._update_unsub() + self._update_unsub = event.async_track_point_in_time( + self.hass, self._update, update + ) + + @callback + def _update(self, now: dt.datetime | None = None) -> None: + """Update the sensor data.""" + self._update_unsub = None + self._schedule_update() + self.create_results(now) + self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 6479a61c713..d9ad89237f5 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -17,16 +17,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import EntityCategory -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import dt as dt_util -from .entity import ( - JewishCalendarConfigEntry, - JewishCalendarDataResults, - JewishCalendarEntity, -) +from .entity import JewishCalendarConfigEntry, JewishCalendarEntity _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 @@ -217,7 +212,7 @@ async def async_setup_entry( config_entry: JewishCalendarConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: - """Set up the Jewish calendar sensors .""" + """Set up the Jewish calendar sensors.""" sensors: list[JewishCalendarBaseSensor] = [ JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS ] @@ -231,59 +226,15 @@ async def async_setup_entry( class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity): """Base class for Jewish calendar sensors.""" - _attr_should_poll = False _attr_entity_category = EntityCategory.DIAGNOSTIC - _update_unsub: CALLBACK_TYPE | None = None entity_description: JewishCalendarBaseSensorDescription - async def async_added_to_hass(self) -> None: - """Call when entity is added to hass.""" - await super().async_added_to_hass() - self._schedule_update() - - async def async_will_remove_from_hass(self) -> None: - """Run when entity will be removed from hass.""" - if self._update_unsub: - self._update_unsub() - self._update_unsub = None - return await super().async_will_remove_from_hass() - - def _schedule_update(self) -> None: - """Schedule the next update of the sensor.""" - now = dt_util.now() - zmanim = self.make_zmanim(now.date()) - update = None - if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(zmanim) - next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) - if update is None or now > update: - update = next_midnight - if self._update_unsub: - self._update_unsub() - self._update_unsub = event.async_track_point_in_time( - self.hass, self._update_data, update - ) - - @callback - def _update_data(self, now: dt.datetime | None = None) -> None: - """Update the sensor data.""" - self._update_unsub = None - self._schedule_update() - self.create_results(now) - self.async_write_ha_state() - - def create_results(self, now: dt.datetime | None = None) -> None: - """Create the results for the sensor.""" - if now is None: - now = dt_util.now() - - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - zmanim = self.make_zmanim(today) - dateinfo = HDateInfo(today, diaspora=self.data.diaspora) - self.data.results = JewishCalendarDataResults(dateinfo, zmanim) + def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: + """Return a list of times to update the sensor.""" + if self.entity_description.next_update_fn is None: + return [] + return [self.entity_description.next_update_fn(zmanim)] def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: """Get the next date info.""" diff --git a/homeassistant/components/jewish_calendar/services.py b/homeassistant/components/jewish_calendar/services.py index 6fdebe6f74d..f77f9be4e64 100644 --- a/homeassistant/components/jewish_calendar/services.py +++ b/homeassistant/components/jewish_calendar/services.py @@ -50,7 +50,6 @@ def async_setup_services(hass: HomeAssistant) -> None: today = now.date() event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today) if event_date is None: - _LOGGER.error("Can't get sunset event date for %s", today) raise HomeAssistantError( translation_domain=DOMAIN, translation_key="sunset_event" ) diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py index 46f5fdfcc7d..a4c9fd02be3 100644 --- a/tests/components/jewish_calendar/test_binary_sensor.py +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -6,11 +6,8 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from tests.common import async_fire_time_changed @@ -140,17 +137,3 @@ async def test_issur_melacha_sensor_update( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results[1] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert BINARY_SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - BINARY_SENSOR_DOMAIN, - {BINARY_SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert BINARY_SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 7a8b6b8df1e..a63d9abb9a7 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, @@ -28,19 +28,18 @@ from tests.common import MockConfigEntry async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test user config.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_DIASPORA: DEFAULT_DIASPORA, CONF_LANGUAGE: DEFAULT_LANGUAGE}, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 38a3dd12206..ab24d35f932 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -8,11 +8,7 @@ from hdate.holidays import HolidayDatabase from hdate.parasha import Parasha import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -569,17 +565,3 @@ async def test_sensor_does_not_update_on_time_change( async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get(sensor_id).state == results["new_state"] - - -async def test_no_discovery_info( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test setup without discovery info.""" - assert SENSOR_DOMAIN not in hass.config.components - assert await async_setup_component( - hass, - SENSOR_DOMAIN, - {SENSOR_DOMAIN: {CONF_PLATFORM: DOMAIN}}, - ) - await hass.async_block_till_done() - assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/jewish_calendar/test_service.py b/tests/components/jewish_calendar/test_service.py index 4b3f31d11d4..ce5ccf2af37 100644 --- a/tests/components/jewish_calendar/test_service.py +++ b/tests/components/jewish_calendar/test_service.py @@ -4,7 +4,13 @@ import datetime as dt import pytest -from homeassistant.components.jewish_calendar.const import DOMAIN +from homeassistant.components.jewish_calendar.const import ( + ATTR_AFTER_SUNSET, + ATTR_DATE, + ATTR_NUSACH, + DOMAIN, +) +from homeassistant.const import CONF_LANGUAGE from homeassistant.core import HomeAssistant @@ -14,10 +20,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 3, 20), - "nusach": "sfarad", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 3, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "", id="no_blessing", @@ -25,10 +31,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "ashkenaz", - "language": "he", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "ashkenaz", + CONF_LANGUAGE: "he", + ATTR_AFTER_SUNSET: False, }, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים בעומר", id="ahskenaz-hebrew", @@ -36,10 +42,10 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": True, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: True, }, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset", @@ -47,23 +53,23 @@ from homeassistant.core import HomeAssistant pytest.param( dt.datetime(2025, 3, 20, 21, 0), { - "date": dt.date(2025, 5, 20), - "nusach": "sfarad", - "language": "en", - "after_sunset": False, + ATTR_DATE: dt.date(2025, 5, 20), + ATTR_NUSACH: "sfarad", + CONF_LANGUAGE: "en", + ATTR_AFTER_SUNSET: False, }, "Today is the thirty-seventh day, which are five weeks and two days of the Omer", id="sefarad-english-before-sunset", ), pytest.param( dt.datetime(2025, 5, 20, 21, 0), - {"nusach": "sfarad", "language": "en"}, + {ATTR_NUSACH: "sfarad", CONF_LANGUAGE: "en"}, "Today is the thirty-eighth day, which are five weeks and three days of the Omer", id="sefarad-english-after-sunset-without-date", ), pytest.param( dt.datetime(2025, 5, 20, 6, 0), - {"nusach": "sfarad"}, + {ATTR_NUSACH: "sfarad"}, "היום שבעה ושלושים יום שהם חמישה שבועות ושני ימים לעומר", id="sefarad-english-before-sunset-without-date", ), From f08d1e547fa384c9c0b0221f6ef4082f590ee1d6 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 14 Jul 2025 19:04:00 +0200 Subject: [PATCH 1375/1664] Fix adding a work area in Husqvarna Automower (#148358) --- .../husqvarna_automower/coordinator.py | 51 +++++++++----- .../husqvarna_automower/test_init.py | 70 ++++++++++++++----- 2 files changed, 86 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 70af5219d04..342f6892b2e 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -60,15 +60,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._devices_last_update: set[str] = set() self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - - def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None: - """Add/remove devices and dynamic entities, when amount of devices changed.""" - self._async_add_remove_devices(data) - for mower_id in data: - if data[mower_id].capabilities.stay_out_zones: - self._async_add_remove_stay_out_zones(data) - if data[mower_id].capabilities.work_areas: - self._async_add_remove_work_areas(data) + self.async_add_listener(self._on_data_update) async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" @@ -82,14 +74,38 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): raise UpdateFailed(err) from err except AuthError as err: raise ConfigEntryAuthFailed(err) from err - self._async_add_remove_devices_and_entities(data) return data + @callback + def _on_data_update(self) -> None: + """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() + @callback def handle_websocket_updates(self, ws_data: MowerDictionary) -> None: """Process websocket callbacks and write them to the DataUpdateCoordinator.""" + self.hass.async_create_task(self._process_websocket_update(ws_data)) + + async def _process_websocket_update(self, ws_data: MowerDictionary) -> None: + """Handle incoming websocket update and update coordinator data.""" + for data in ws_data.values(): + existing_areas = data.work_areas or {} + for task in data.calendar.tasks: + work_area_id = task.work_area_id + if work_area_id is not None and work_area_id not in existing_areas: + _LOGGER.debug( + "New work area %s detected, refreshing data", work_area_id + ) + await self.async_request_refresh() + return + self.async_set_updated_data(ws_data) - self._async_add_remove_devices_and_entities(ws_data) @callback def async_set_updated_data(self, data: MowerDictionary) -> None: @@ -138,9 +154,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): "reconnect_task", ) - def _async_add_remove_devices(self, data: MowerDictionary) -> None: + def _async_add_remove_devices(self) -> None: """Add new device, remove non-existing device.""" - current_devices = set(data) + current_devices = set(self.data) # Skip update if no changes if current_devices == self._devices_last_update: @@ -155,7 +171,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): # Process new device new_devices = current_devices - self._devices_last_update if new_devices: - self.data = data _LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices))) self._add_new_devices(new_devices) @@ -179,11 +194,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): for mower_callback in self.new_devices_callbacks: mower_callback(new_devices) - def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None: + def _async_add_remove_stay_out_zones(self) -> None: """Add new stay-out zones, remove non-existing stay-out zones.""" current_zones = { mower_id: set(mower_data.stay_out_zones.zones) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.stay_out_zones and mower_data.stay_out_zones is not None } @@ -225,11 +240,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): return current_zones - def _async_add_remove_work_areas(self, data: MowerDictionary) -> None: + def _async_add_remove_work_areas(self) -> None: """Add new work areas, remove non-existing work areas.""" current_areas = { mower_id: set(mower_data.work_areas) - for mower_id, mower_data in data.items() + for mower_id, mower_data in self.data.items() if mower_data.capabilities.work_areas and mower_data.work_areas is not None } diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 9a45b2ad42d..f54250a3336 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -3,7 +3,7 @@ from asyncio import Event from collections.abc import Callable from copy import deepcopy -from datetime import datetime, timedelta +from datetime import datetime, time as dt_time, timedelta import http import time from unittest.mock import AsyncMock, patch @@ -14,7 +14,7 @@ from aioautomower.exceptions import ( HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import MowerAttributes, WorkArea +from aioautomower.model import Calendar, MowerAttributes, WorkArea from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -384,14 +384,45 @@ async def test_add_and_remove_work_area( values: dict[str, MowerAttributes], ) -> None: """Test adding a work area in runtime.""" + websocket_values = deepcopy(values) + callback_holder: dict[str, Callable] = {} + + @callback + def fake_register_websocket_response( + cb: Callable[[dict[str, MowerAttributes]], None], + ) -> None: + callback_holder["cb"] = cb + + mock_automower_client.register_data_callback.side_effect = ( + fake_register_websocket_response + ) await setup_integration(hass, mock_config_entry) entry = hass.config_entries.async_entries(DOMAIN)[0] current_entites_start = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) - values[TEST_MOWER_ID].work_area_names.append("new work area") - values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) - values[TEST_MOWER_ID].work_areas.update( + await hass.async_block_till_done() + + assert mock_automower_client.register_data_callback.called + assert "cb" in callback_holder + + new_task = Calendar( + start=dt_time(hour=11), + duration=timedelta(60), + monday=True, + tuesday=True, + wednesday=True, + thursday=True, + friday=True, + saturday=True, + sunday=True, + work_area_id=1, + ) + websocket_values[TEST_MOWER_ID].calendar.tasks.append(new_task) + poll_values = deepcopy(websocket_values) + poll_values[TEST_MOWER_ID].work_area_names.append("new work area") + poll_values[TEST_MOWER_ID].work_area_dict.update({1: "new work area"}) + poll_values[TEST_MOWER_ID].work_areas.update( { 1: WorkArea( name="new work area", @@ -404,10 +435,15 @@ async def test_add_and_remove_work_area( ) } ) - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) + mock_automower_client.get_status.return_value = poll_values + + callback_holder["cb"](websocket_values) await hass.async_block_till_done() + assert mock_automower_client.get_status.called + + state = hass.states.get("sensor.test_mower_1_new_work_area_progress") + assert state is not None + assert state.state == "12" current_entites_after_addition = len( er.async_entries_for_config_entry(entity_registry, entry.entry_id) ) @@ -419,15 +455,15 @@ async def test_add_and_remove_work_area( + ADDITIONAL_SWITCH_ENTITIES ) - values[TEST_MOWER_ID].work_area_names.remove("new work area") - del values[TEST_MOWER_ID].work_area_dict[1] - del values[TEST_MOWER_ID].work_areas[1] - values[TEST_MOWER_ID].work_area_names.remove("Front lawn") - del values[TEST_MOWER_ID].work_area_dict[123456] - del values[TEST_MOWER_ID].work_areas[123456] - del values[TEST_MOWER_ID].calendar.tasks[:2] - values[TEST_MOWER_ID].mower.work_area_id = 654321 - mock_automower_client.get_status.return_value = values + poll_values[TEST_MOWER_ID].work_area_names.remove("new work area") + del poll_values[TEST_MOWER_ID].work_area_dict[1] + del poll_values[TEST_MOWER_ID].work_areas[1] + poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") + del poll_values[TEST_MOWER_ID].work_area_dict[123456] + del poll_values[TEST_MOWER_ID].work_areas[123456] + del poll_values[TEST_MOWER_ID].calendar.tasks[:2] + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 + mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From f680e992ff3f9dae2f0a23feeb1a76072b60014e Mon Sep 17 00:00:00 2001 From: kanshurichard Date: Tue, 15 Jul 2025 01:07:50 +0800 Subject: [PATCH 1376/1664] Add support for Broadlink A2 air quality sensor (#142203) Co-authored-by: Joostlek --- homeassistant/components/broadlink/const.py | 1 + homeassistant/components/broadlink/sensor.py | 19 +++++++++++++++++++ homeassistant/components/broadlink/updater.py | 11 +++++++++++ 3 files changed, 31 insertions(+) diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py index c9b17128b79..602a3693b7b 100644 --- a/homeassistant/components/broadlink/const.py +++ b/homeassistant/components/broadlink/const.py @@ -11,6 +11,7 @@ DOMAINS_AND_TYPES = { Platform.SELECT: {"HYS"}, Platform.SENSOR: { "A1", + "A2", "MP1S", "RM4MINI", "RM4PRO", diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index e7d420f0c0e..5323a08d227 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, PERCENTAGE, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -34,6 +35,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="air_quality", device_class=SensorDeviceClass.AQI, ), + SensorEntityDescription( + key="pm10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM10, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm2_5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM25, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key="pm1", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + device_class=SensorDeviceClass.PM1, + state_class=SensorStateClass.MEASUREMENT, + ), SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index 8e0a521e182..7c1644fff54 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager """Return an update manager for a given Broadlink device.""" update_managers: dict[str, type[BroadlinkUpdateManager]] = { "A1": BroadlinkA1UpdateManager, + "A2": BroadlinkA2UpdateManager, "BG1": BroadlinkBG1UpdateManager, "HYS": BroadlinkThermostatUpdateManager, "LB1": BroadlinkLB1UpdateManager, @@ -118,6 +119,16 @@ class BroadlinkA1UpdateManager(BroadlinkUpdateManager[blk.a1]): return await self.device.async_request(self.device.api.check_sensors_raw) +class BroadlinkA2UpdateManager(BroadlinkUpdateManager[blk.a2]): + """Manages updates for Broadlink A2 devices.""" + + SCAN_INTERVAL = timedelta(seconds=10) + + async def async_fetch_data(self) -> dict[str, Any]: + """Fetch data from the device.""" + return await self.device.async_request(self.device.api.check_sensors_raw) + + class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]): """Manages updates for Broadlink MP1 devices.""" From 92bb1f255169d5363a8324d4f1ce50e99614a214 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:00:21 +0200 Subject: [PATCH 1377/1664] Do not add utility_meter config entry to source device (#148735) --- .../components/utility_meter/__init__.py | 42 +++- .../components/utility_meter/config_flow.py | 1 + .../components/utility_meter/select.py | 12 +- .../components/utility_meter/sensor.py | 19 +- .../snapshots/test_diagnostics.ambr | 2 +- .../utility_meter/test_config_flow.py | 48 +++- tests/components/utility_meter/test_init.py | 233 ++++++++++++++++-- tests/components/utility_meter/test_sensor.py | 2 + 8 files changed, 312 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 64fa3342c08..8a388058b19 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -21,7 +21,10 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType from .const import ( @@ -199,6 +202,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Utility Meter from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, entry.options[CONF_SOURCE_SENSOR] ) @@ -225,20 +229,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -286,13 +286,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old entry.""" - _LOGGER.debug("Migrating from version %s", config_entry.version) + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 2: + # This means the user has downgraded from a future version + return False if config_entry.version == 1: new = {**config_entry.options} new[CONF_METER_PERIODICALLY_RESETTING] = True hass.config_entries.async_update_entry(config_entry, options=new, version=2) - _LOGGER.info("Migration to version %s successful", config_entry.version) + if config_entry.version == 2: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the utility_meter config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, 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/utility_meter/config_flow.py b/homeassistant/components/utility_meter/config_flow.py index db7cea6ecf2..933a04accba 100644 --- a/homeassistant/components/utility_meter/config_flow.py +++ b/homeassistant/components/utility_meter/config_flow.py @@ -130,6 +130,7 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Utility Meter.""" VERSION = 2 + MINOR_VERSION = 2 config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py index 0c818525c8d..280a1fd7b1a 100644 --- a/homeassistant/components/utility_meter/select.py +++ b/homeassistant/components/utility_meter/select.py @@ -8,8 +8,8 @@ from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -33,7 +33,7 @@ async def async_setup_entry( unique_id = config_entry.entry_id - device_info = async_device_info_to_link_from_entity( + device = async_entity_id_to_device( hass, config_entry.options[CONF_SOURCE_SENSOR], ) @@ -42,7 +42,7 @@ async def async_setup_entry( name=name, tariffs=tariffs, unique_id=unique_id, - device_info=device_info, + device=device, ) async_add_entities([tariff_select]) @@ -91,14 +91,14 @@ class TariffSelect(SelectEntity, RestoreEntity): *, yaml_slug: str | None = None, unique_id: str | None = None, - device_info: DeviceInfo | None = None, + device: DeviceEntry | None = None, ) -> None: """Initialize a tariff selector.""" self._attr_name = name if yaml_slug: # Backwards compatibility with YAML configuration entries self.entity_id = f"select.{yaml_slug}" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = device self._current_tariff: str | None = None self._tariffs = tariffs self._attr_should_poll = False diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index d424692ac95..457b02c2b50 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -39,7 +39,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_platform, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -129,11 +129,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - cron_pattern = None delta_values = config_entry.options[CONF_METER_DELTA_VALUES] meter_offset = timedelta(days=config_entry.options[CONF_METER_OFFSET]) @@ -154,6 +149,7 @@ async def async_setup_entry( if not tariffs: # Add single sensor, not gated by a tariff selector meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -166,7 +162,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=None, unique_id=entry_id, - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -175,6 +170,7 @@ async def async_setup_entry( # Add sensors for each tariff for tariff in tariffs: meter_sensor = UtilityMeterSensor( + hass, cron_pattern=cron_pattern, delta_values=delta_values, meter_offset=meter_offset, @@ -187,7 +183,6 @@ async def async_setup_entry( tariff_entity=tariff_entity, tariff=tariff, unique_id=f"{entry_id}_{tariff}", - device_info=device_info, sensor_always_available=sensor_always_available, ) meters.append(meter_sensor) @@ -259,6 +254,7 @@ async def async_setup_platform( CONF_SENSOR_ALWAYS_AVAILABLE ] meter_sensor = UtilityMeterSensor( + hass, cron_pattern=conf_cron_pattern, delta_values=conf_meter_delta_values, meter_offset=conf_meter_offset, @@ -359,6 +355,7 @@ class UtilityMeterSensor(RestoreSensor): def __init__( self, + hass, *, cron_pattern, delta_values, @@ -374,11 +371,13 @@ class UtilityMeterSensor(RestoreSensor): unique_id, sensor_always_available, suggested_entity_id=None, - device_info=None, ): """Initialize the Utility Meter sensor.""" self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self.entity_id = suggested_entity_id self._parent_meter = parent_meter self._sensor_source_id = source_entity diff --git a/tests/components/utility_meter/snapshots/test_diagnostics.ambr b/tests/components/utility_meter/snapshots/test_diagnostics.ambr index ef235bba99d..024fd1aaa7b 100644 --- a/tests/components/utility_meter/snapshots/test_diagnostics.ambr +++ b/tests/components/utility_meter/snapshots/test_diagnostics.ambr @@ -8,7 +8,7 @@ 'discovery_keys': dict({ }), 'domain': 'utility_meter', - 'minor_version': 1, + 'minor_version': 2, 'options': dict({ 'cycle': 'monthly', 'delta_values': False, diff --git a/tests/components/utility_meter/test_config_flow.py b/tests/components/utility_meter/test_config_flow.py index 01fd80acc0e..0aa73d6d123 100644 --- a/tests/components/utility_meter/test_config_flow.py +++ b/tests/components/utility_meter/test_config_flow.py @@ -403,11 +403,19 @@ async def test_change_device_source( assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 1 (current) device registry + # Confirm that the configuration entry has not been added to the source entity 1 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_1.device_id # Change configuration options to use source entity 2 (with a linked device) and reload the integration previous_entity_source = source_entity_1 @@ -427,17 +435,25 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 1 (previous) device registry + # Confirm that the configuration entry is not in the source entity 1 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in to the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id # Change configuration options to use source entity 3 (without a device) and reload the integration previous_entity_source = source_entity_2 @@ -457,12 +473,20 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been removed from the source entity 2 (previous) device registry + # Confirm that the configuration entry has is not in the source entity 2 (previous) device registry previous_device = device_registry.async_get( device_id=previous_entity_source.device_id ) assert utility_meter_config_entry.entry_id not in previous_device.config_entries + # Check that the entities are no longer linked to a device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + # Confirm that there is no device with the helper configuration entry assert ( dr.async_entries_for_config_entry( @@ -489,8 +513,16 @@ async def test_change_device_source( assert result["type"] is FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - # Confirm that the configuration entry has been added to the source entity 2 (current) device registry + # Confirm that the configuration entry is not in the source entity 2 (current) device registry current_device = device_registry.async_get( device_id=current_entity_source.device_id ) - assert utility_meter_config_entry.entry_id in current_device.config_entries + assert utility_meter_config_entry.entry_id not in current_device.config_entries + + # Check that the entities are linked to the expected device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == source_entity_2.device_id diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py index ea4af741e19..ec7fdd1db87 100644 --- a/tests/components/utility_meter/test_init.py +++ b/tests/components/utility_meter/test_init.py @@ -20,7 +20,7 @@ from homeassistant.components.utility_meter import ( ) from homeassistant.components.utility_meter.config_flow import ConfigFlowHandler from homeassistant.components.utility_meter.const import DOMAIN, SERVICE_RESET -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, @@ -29,7 +29,7 @@ from homeassistant.const import ( Platform, UnitOfEnergy, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event from homeassistant.setup import async_setup_component @@ -108,6 +108,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -601,7 +602,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(utility_meter_config_entry.entry_id) @@ -616,7 +617,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( utility_meter_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 @pytest.mark.parametrize( @@ -642,6 +643,81 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, expected_entities: set[str], +) -> None: + """Test the utility_meter config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + events = {} + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + events[utility_meter_entity.entity_id] = track_entity_registry_actions( + hass, utility_meter_entity.entity_id + ) + assert set(events) == expected_entities + + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.utility_meter.async_unload_entry", + wraps=utility_meter.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the utility_meter config entry is not removed + assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + for entity_events in events.values(): + assert entity_events == ["update"] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + utility_meter_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, + expected_entities: set[str], ) -> None: """Test the utility_meter config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -667,7 +743,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor's config entry from the device, this removes the # source sensor @@ -682,7 +758,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -734,7 +818,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Remove the source sensor from the device with patch( @@ -747,7 +831,15 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is removed from the device + # Check that the entities are no longer linked to the source device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id is None + + # Check that the utility_meter config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries @@ -805,7 +897,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries @@ -820,11 +912,19 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the utility_meter config entry is moved to the other device + # Check that the entities are linked to the other device + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + assert utility_meter_entity.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert utility_meter_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert utility_meter_config_entry.entry_id in sensor_device_2.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device_2.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -874,7 +974,7 @@ async def test_async_handle_source_entity_new_entity_id( assert set(events) == expected_entities sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Change the source entity's entity ID with patch( @@ -890,9 +990,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the utility_meter config entry is updated with the new entity ID assert utility_meter_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert utility_meter_config_entry.entry_id in sensor_device.config_entries + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries # Check that the utility_meter config entry is not removed assert utility_meter_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -900,3 +1000,108 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events for entity_events in events.values(): assert entity_events == [] + + +@pytest.mark.parametrize( + ("tariffs", "expected_entities"), + [ + ([], {"sensor.my_utility_meter"}), + ( + ["peak", "offpeak"], + { + "select.my_utility_meter", + "sensor.my_utility_meter_offpeak", + "sensor.my_utility_meter_peak", + }, + ), + ], +) +async def test_migration_2_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, + tariffs: list[str], + expected_entities: set[str], +) -> None: + """Test migration from v2.1 removes utility_meter config entry from device.""" + + utility_meter_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": sensor_entity_entry.entity_id, + "tariffs": tariffs, + }, + title="My utility meter", + version=2, + minor_version=1, + ) + utility_meter_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=utility_meter_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(utility_meter_config_entry.entry_id) + await hass.async_block_till_done() + + assert utility_meter_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entities are linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert utility_meter_config_entry.entry_id not in sensor_device.config_entries + # Check that the entities are linked to the other device + entities = set() + for ( + utility_meter_entity + ) in entity_registry.entities.get_entries_for_config_entry_id( + utility_meter_config_entry.entry_id + ): + entities.add(utility_meter_entity.entity_id) + assert utility_meter_entity.device_id == sensor_entity_entry.device_id + assert entities == expected_entities + + assert utility_meter_config_entry.version == 2 + assert utility_meter_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "cycle": "monthly", + "delta_values": False, + "name": "My utility meter", + "net_consumption": False, + "offset": 0, + "periodically_resetting": True, + "source": "sensor.test", + "tariffs": [], + }, + title="My utility meter", + version=3, + minor_version=1, + ) + 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.MIGRATION_ERROR diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 2de2ee553b3..f684cdb16a0 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1888,10 +1888,12 @@ async def test_bad_offset(hass: HomeAssistant) -> None: def test_calculate_adjustment_invalid_new_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: """Test that calculate_adjustment method returns None if the new state is invalid.""" mock_sensor = UtilityMeterSensor( + hass, cron_pattern=None, delta_values=False, meter_offset=DEFAULT_OFFSET, From 57f89dd606fa22dadfccee7a37f951d7f8f8df84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:00:49 +0200 Subject: [PATCH 1378/1664] Do not add trend config entry to source device (#148733) --- homeassistant/components/trend/__init__.py | 45 ++++- .../components/trend/binary_sensor.py | 20 +-- homeassistant/components/trend/config_flow.py | 2 + tests/components/trend/test_init.py | 156 ++++++++++++++++-- 4 files changed, 199 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py index 086ac818c8e..332ec9455eb 100644 --- a/homeassistant/components/trend/__init__.py +++ b/homeassistant/components/trend/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) PLATFORMS = [Platform.BINARY_SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trend from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -37,6 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -53,6 +62,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the trend config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle an Trend options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 30058bb056c..5a7046c2125 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -33,8 +33,8 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, @@ -114,6 +114,7 @@ async def async_setup_platform( for sensor_name, sensor_config in config[CONF_SENSORS].items(): entities.append( SensorTrend( + hass, name=sensor_config.get(CONF_FRIENDLY_NAME, sensor_name), entity_id=sensor_config[CONF_ENTITY_ID], attribute=sensor_config.get(CONF_ATTRIBUTE), @@ -140,14 +141,10 @@ async def async_setup_entry( ) -> None: """Set up trend sensor from config entry.""" - device_info = async_device_info_to_link_from_entity( - hass, - entry.options[CONF_ENTITY_ID], - ) - async_add_entities( [ SensorTrend( + hass, name=entry.title, entity_id=entry.options[CONF_ENTITY_ID], attribute=entry.options.get(CONF_ATTRIBUTE), @@ -159,7 +156,6 @@ async def async_setup_entry( min_samples=entry.options.get(CONF_MIN_SAMPLES, DEFAULT_MIN_SAMPLES), max_samples=entry.options.get(CONF_MAX_SAMPLES, DEFAULT_MAX_SAMPLES), unique_id=entry.entry_id, - device_info=device_info, ) ] ) @@ -174,6 +170,8 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): def __init__( self, + hass: HomeAssistant, + *, name: str, entity_id: str, attribute: str | None, @@ -185,7 +183,6 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): unique_id: str | None = None, device_class: BinarySensorDeviceClass | None = None, sensor_entity_id: str | None = None, - device_info: dr.DeviceInfo | None = None, ) -> None: """Initialize the sensor.""" self._entity_id = entity_id @@ -199,7 +196,10 @@ class SensorTrend(BinarySensorEntity, RestoreEntity): self._attr_name = name self._attr_device_class = device_class self._attr_unique_id = unique_id - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) if sensor_entity_id: self.entity_id = sensor_entity_id diff --git a/homeassistant/components/trend/config_flow.py b/homeassistant/components/trend/config_flow.py index 756b9536d19..3bb06ae3042 100644 --- a/homeassistant/components/trend/config_flow.py +++ b/homeassistant/components/trend/config_flow.py @@ -101,6 +101,8 @@ CONFIG_SCHEMA = vol.Schema( class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Trend.""" + MINOR_VERSION = 2 + config_flow = { "user": SchemaFlowFormStep(schema=CONFIG_SCHEMA, next_step="settings"), "settings": SchemaFlowFormStep(get_base_options_schema), diff --git a/tests/components/trend/test_init.py b/tests/components/trend/test_init.py index 4ff6213d082..22700376b26 100644 --- a/tests/components/trend/test_init.py +++ b/tests/components/trend/test_init.py @@ -8,7 +8,7 @@ from homeassistant.components import trend from homeassistant.components.trend.config_flow import ConfigFlowHandler from homeassistant.components.trend.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +200,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(trend_config_entry.entry_id) @@ -214,7 +215,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( trend_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -225,6 +226,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the trend config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.trend.async_unload_entry", + wraps=trend.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the trend config entry is removed + assert trend_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + trend_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the trend config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -241,7 +289,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -258,7 +306,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("binary_sensor.my_trend") + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -285,7 +336,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -300,7 +351,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is removed from the device + # Check that the entity is no longer linked to the source device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id is None + + # Check that the trend config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries @@ -333,7 +388,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert trend_config_entry.entry_id not in sensor_device_2.config_entries @@ -350,11 +405,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the trend config entry is moved to the other device + # Check that the entity is linked to the other device + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_device_2.id + + # Check that the trend config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert trend_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert trend_config_entry.entry_id in sensor_device_2.config_entries + assert trend_config_entry.entry_id not in sensor_device_2.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -379,7 +438,7 @@ async def test_async_handle_source_entity_new_entity_id( assert trend_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, trend_entity_entry.entity_id) @@ -397,12 +456,83 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the trend config entry is updated with the new entity ID assert trend_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert trend_config_entry.entry_id in sensor_device.config_entries + assert trend_config_entry.entry_id not in sensor_device.config_entries # Check that the trend config entry is not removed assert trend_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes trend config entry from device.""" + + trend_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": sensor_entity_entry.entity_id, + "invert": False, + }, + title="My trend", + version=1, + minor_version=1, + ) + trend_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=trend_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(trend_config_entry.entry_id) + await hass.async_block_till_done() + + assert trend_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert trend_config_entry.entry_id not in sensor_device.config_entries + trend_entity_entry = entity_registry.async_get("binary_sensor.my_trend") + assert trend_entity_entry.device_id == sensor_entity_entry.device_id + + assert trend_config_entry.version == 1 + assert trend_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My trend", + "entity_id": "sensor.test", + "invert": False, + }, + title="My trend", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 7df0016fab91cd81e850856e25a4ce8a2b423bfb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:05:20 +0200 Subject: [PATCH 1379/1664] Do not add threshold config entry to source device (#148732) --- .../components/threshold/__init__.py | 50 ++++- .../components/threshold/binary_sensor.py | 42 +++-- .../components/threshold/config_flow.py | 17 +- tests/components/threshold/test_init.py | 177 ++++++++++++++++-- 4 files changed, 237 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py index 9460a50db80..56d51f4f1e0 100644 --- a/homeassistant/components/threshold/__init__.py +++ b/homeassistant/components/threshold/__init__.py @@ -1,5 +1,7 @@ """The threshold component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,12 +9,18 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Min/Max from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -25,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_ENTITY_ID: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_ENTITY_ID] ), source_entity_id_or_uuid=entry.options[CONF_ENTITY_ID], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the threshold config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3227f030812..88fd2784f96 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -31,8 +31,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -102,11 +101,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_ENTITY_ID] ) - device_info = async_device_info_to_link_from_entity( - hass, - entity_id, - ) - hysteresis = config_entry.options[CONF_HYSTERESIS] lower = config_entry.options[CONF_LOWER] name = config_entry.title @@ -116,14 +110,14 @@ async def async_setup_entry( async_add_entities( [ ThresholdSensor( - entity_id, - name, - lower, - upper, - hysteresis, - device_class, - unique_id, - device_info=device_info, + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=unique_id, ) ] ) @@ -146,7 +140,14 @@ async def async_setup_platform( async_add_entities( [ ThresholdSensor( - entity_id, name, lower, upper, hysteresis, device_class, None + hass, + entity_id=entity_id, + name=name, + lower=lower, + upper=upper, + hysteresis=hysteresis, + device_class=device_class, + unique_id=None, ) ], ) @@ -171,6 +172,8 @@ class ThresholdSensor(BinarySensorEntity): def __init__( self, + hass: HomeAssistant, + *, entity_id: str, name: str, lower: float | None, @@ -178,12 +181,15 @@ class ThresholdSensor(BinarySensorEntity): hysteresis: float, device_class: BinarySensorDeviceClass | None, unique_id: str | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the Threshold sensor.""" self._preview_callback: Callable[[str, Mapping[str, Any]], None] | None = None self._attr_unique_id = unique_id - self._attr_device_info = device_info + if entity_id: # Guard against empty entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + entity_id, + ) self._entity_id = entity_id self._attr_name = name if lower is not None: diff --git a/homeassistant/components/threshold/config_flow.py b/homeassistant/components/threshold/config_flow.py index 24f58333782..29f4a0986c1 100644 --- a/homeassistant/components/threshold/config_flow.py +++ b/homeassistant/components/threshold/config_flow.py @@ -80,6 +80,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Threshold.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -131,13 +133,14 @@ def ws_start_preview( ) preview_entity = ThresholdSensor( - entity_id, - name, - msg["user_input"].get(CONF_LOWER), - msg["user_input"].get(CONF_UPPER), - msg["user_input"].get(CONF_HYSTERESIS), - None, - None, + hass, + entity_id=entity_id, + name=name, + lower=msg["user_input"].get(CONF_LOWER), + upper=msg["user_input"].get(CONF_UPPER), + hysteresis=msg["user_input"].get(CONF_HYSTERESIS), + device_class=None, + unique_id=None, ) preview_entity.hass = hass diff --git a/tests/components/threshold/test_init.py b/tests/components/threshold/test_init.py index 599612ce0b7..fed35bc6502 100644 --- a/tests/components/threshold/test_init.py +++ b/tests/components/threshold/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import threshold from homeassistant.components.threshold.config_flow import ConfigFlowHandler from homeassistant.components.threshold.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -81,6 +81,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -174,6 +175,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Set up entities, with backing devices and config entries run1_entry = _create_mock_entity("sensor", "initial") run2_entry = _create_mock_entity("sensor", "changed") + assert run1_entry.device_id != run2_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -186,23 +188,27 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: "name": "My threshold", "upper": None, }, - title="My integration", + title="My threshold", ) config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(run1_entry) + assert config_entry.entry_id not in _get_device_config_entries(run1_entry) assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run1_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "entity_id": "sensor.changed"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(run1_entry) - assert config_entry.entry_id in _get_device_config_entries(run2_entry) + assert config_entry.entry_id not in _get_device_config_entries(run2_entry) + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == run2_entry.device_id async def test_device_cleaning( @@ -273,7 +279,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(threshold_config_entry.entry_id) @@ -288,7 +294,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( threshold_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -299,6 +305,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the threshold config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.threshold.async_unload_entry", + wraps=threshold.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the threshold config entry is not removed + assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + threshold_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the threshold config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -315,7 +369,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -332,7 +386,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -359,7 +417,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -374,7 +432,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is removed from the device + # Check that the entity is no longer linked to the source device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id is None + + # Check that the threshold config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries @@ -407,7 +469,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert threshold_config_entry.entry_id not in sensor_device_2.config_entries @@ -424,11 +486,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the threshold config entry is moved to the other device + # Check that the entity is linked to the other device + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert threshold_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert threshold_config_entry.entry_id in sensor_device_2.config_entries + assert threshold_config_entry.entry_id not in sensor_device_2.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -453,7 +519,7 @@ async def test_async_handle_source_entity_new_entity_id( assert threshold_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, threshold_entity_entry.entity_id) @@ -471,12 +537,87 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the threshold config entry is updated with the new entity ID assert threshold_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert threshold_config_entry.entry_id in sensor_device.config_entries + assert threshold_config_entry.entry_id not in sensor_device.config_entries # Check that the threshold config entry is not removed assert threshold_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes threshold config entry from device.""" + + threshold_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": sensor_entity_entry.entity_id, + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=1, + minor_version=1, + ) + threshold_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=threshold_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(threshold_config_entry.entry_id) + await hass.async_block_till_done() + + assert threshold_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert threshold_config_entry.entry_id not in sensor_device.config_entries + threshold_entity_entry = entity_registry.async_get("binary_sensor.my_threshold") + assert threshold_entity_entry.device_id == sensor_entity_entry.device_id + + assert threshold_config_entry.version == 1 + assert threshold_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "entity_id": "sensor.test", + "hysteresis": 0.0, + "lower": -2.0, + "name": "My threshold", + "upper": None, + }, + title="My threshold", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 254f76635787f3f14b792c9c8bb9552aad0fdc16 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:05:34 +0200 Subject: [PATCH 1380/1664] Do not add history_stats config entry to source device (#148729) --- .../components/history_stats/__init__.py | 44 ++++- .../components/history_stats/config_flow.py | 9 +- .../components/history_stats/sensor.py | 30 +++- tests/components/history_stats/test_init.py | 169 ++++++++++++++++-- 4 files changed, 228 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py index a3565f9ed77..efddabd180c 100644 --- a/homeassistant/components/history_stats/__init__.py +++ b/homeassistant/components/history_stats/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta +import logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, CONF_STATE @@ -11,7 +12,10 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.template import Template from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS @@ -20,6 +24,8 @@ from .data import HistoryStats type HistoryStatsConfigEntry = ConfigEntry[HistoryStatsUpdateCoordinator] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry @@ -47,6 +53,7 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -67,6 +74,7 @@ async def async_setup_entry( entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -83,6 +91,40 @@ async def async_setup_entry( return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the history_stats config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry( hass: HomeAssistant, entry: HistoryStatsConfigEntry ) -> bool: diff --git a/homeassistant/components/history_stats/config_flow.py b/homeassistant/components/history_stats/config_flow.py index 996c7ba0d0c..750180bf3f6 100644 --- a/homeassistant/components/history_stats/config_flow.py +++ b/homeassistant/components/history_stats/config_flow.py @@ -124,6 +124,8 @@ OPTIONS_FLOW = { class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for History stats.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -229,7 +231,12 @@ async def ws_start_preview( coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True) await coordinator.async_refresh() preview_entity = HistoryStatsSensor( - hass, coordinator, sensor_type, name, None, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=None, + source_entity_id=entity_id, ) preview_entity.hass = hass diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 780bff14eb1..0cfe82e09fb 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -27,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -113,7 +113,16 @@ async def async_setup_platform( if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception async_add_entities( - [HistoryStatsSensor(hass, coordinator, sensor_type, name, unique_id, entity_id)] + [ + HistoryStatsSensor( + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=name, + unique_id=unique_id, + source_entity_id=entity_id, + ) + ] ) @@ -130,7 +139,12 @@ async def async_setup_entry( async_add_entities( [ HistoryStatsSensor( - hass, coordinator, sensor_type, entry.title, entry.entry_id, entity_id + hass, + coordinator=coordinator, + sensor_type=sensor_type, + name=entry.title, + unique_id=entry.entry_id, + source_entity_id=entity_id, ) ] ) @@ -176,6 +190,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): def __init__( self, hass: HomeAssistant, + *, coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, @@ -190,10 +205,11 @@ class HistoryStatsSensor(HistoryStatsSensorBase): self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type self._attr_unique_id = unique_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION diff --git a/tests/components/history_stats/test_init.py b/tests/components/history_stats/test_init.py index cb3350f497f..7f81fe6625f 100644 --- a/tests/components/history_stats/test_init.py +++ b/tests/components/history_stats/test_init.py @@ -18,7 +18,7 @@ from homeassistant.components.history_stats.const import ( ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_STATE, CONF_TYPE -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -92,6 +92,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -181,7 +182,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(history_stats_config_entry.entry_id) @@ -196,9 +197,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( history_stats_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures("recorder_mock") @@ -210,6 +209,56 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the history_stats config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.history_stats.async_unload_entry", + wraps=history_stats.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the history_stats config entry is removed + assert ( + history_stats_config_entry.entry_id not in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == ["remove"] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + history_stats_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the history_stats config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -226,7 +275,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -243,7 +292,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_history_stats") + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -273,7 +325,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -288,7 +340,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is removed from the device + # Check that the entity is no longer linked to the source device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id is None + + # Check that the history_stats config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries @@ -322,7 +378,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries @@ -339,11 +395,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the history_stats config entry is moved to the other device + # Check that the entity is linked to the other device + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert history_stats_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert history_stats_config_entry.entry_id in sensor_device_2.config_entries + assert history_stats_config_entry.entry_id not in sensor_device_2.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -369,7 +429,7 @@ async def test_async_handle_source_entity_new_entity_id( assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, history_stats_entity_entry.entity_id) @@ -387,12 +447,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the history_stats config entry is updated with the new entity ID assert history_stats_config_entry.options[CONF_ENTITY_ID] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert history_stats_config_entry.entry_id in sensor_device.config_entries + assert history_stats_config_entry.entry_id not in sensor_device.config_entries # Check that the history_stats config entry is not removed assert history_stats_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes history_stats config entry from device.""" + + history_stats_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: sensor_entity_entry.entity_id, + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=1, + minor_version=1, + ) + history_stats_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=history_stats_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(history_stats_config_entry.entry_id) + await hass.async_block_till_done() + + assert history_stats_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert history_stats_config_entry.entry_id not in sensor_device.config_entries + history_stats_entity_entry = entity_registry.async_get("sensor.my_history_stats") + assert history_stats_entity_entry.device_id == sensor_entity_entry.device_id + + assert history_stats_config_entry.version == 1 + assert history_stats_config_entry.minor_version == 2 + + +@pytest.mark.usefixtures("recorder_mock") +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_ENTITY_ID: "sensor.test", + CONF_STATE: ["on"], + CONF_TYPE: "count", + CONF_START: "{{ as_timestamp(utcnow()) - 3600 }}", + CONF_END: "{{ utcnow() }}", + }, + title="My history stats", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 1a1e9e9f57c4e53dafbc212a8b62046abb5c4583 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:15:39 +0200 Subject: [PATCH 1381/1664] Add test for combining state change and state report listeners (#148721) --- tests/helpers/test_event.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 465d1b1778b..c875522b943 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4946,6 +4946,37 @@ async def test_async_track_state_report_event(hass: HomeAssistant) -> None: unsub() +async def test_async_track_state_report_change_event(hass: HomeAssistant) -> None: + """Test listen for both state change and state report events.""" + tracker_called: dict[str, list[str]] = {"light.bowl": [], "light.top": []} + + @ha.callback + def on_state_change(event: Event[EventStateChangedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + @ha.callback + def on_state_report(event: Event[EventStateReportedData]) -> None: + new_state = event.data["new_state"].state + tracker_called[event.data["entity_id"]].append(new_state) + + async_track_state_change_event(hass, ["light.bowl", "light.top"], on_state_change) + async_track_state_report_event(hass, ["light.bowl", "light.top"], on_state_report) + entity_ids = ["light.bowl", "light.top"] + state_sequence = ["on", "on", "off", "off"] + for state in state_sequence: + for entity_id in entity_ids: + hass.states.async_set(entity_id, state) + await hass.async_block_till_done() + + # The out-of-order is a result of state change listeners scheduled with + # loop.call_soon, whereas state report listeners are called immediately. + assert tracker_called == { + "light.bowl": ["on", "off", "on", "off"], + "light.top": ["on", "off", "on", "off"], + } + + async def test_async_track_template_no_hass_deprecated( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From e35f7b12f1fd6ff7da6a65e20dcd0e63955da724 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:18:11 +0200 Subject: [PATCH 1382/1664] Do not add generic_hygrostat config entry to source device (#148727) --- .../components/generic_hygrostat/__init__.py | 50 +++- .../generic_hygrostat/config_flow.py | 2 + .../generic_hygrostat/humidifier.py | 37 +-- .../components/generic_hygrostat/test_init.py | 264 +++++++++++++++--- 4 files changed, 289 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index a12994c1a75..d907f863988 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -1,5 +1,7 @@ """The generic_hygrostat component.""" +import logging + import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass @@ -16,7 +18,10 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "generic_hygrostat" @@ -70,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Generic Hygrostat component.""" @@ -89,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -101,23 +109,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_HUMIDIFIER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the humidifer, # but not the humidity sensor because the generic_hygrostat adds itself to the # humidifier's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HUMIDIFIER] ), source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER], - source_entity_removed=source_entity_removed, ) ) @@ -148,6 +152,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_hygrostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HUMIDIFIER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_hygrostat/config_flow.py b/homeassistant/components/generic_hygrostat/config_flow.py index 7c35b0e9317..449fa49b713 100644 --- a/homeassistant/components/generic_hygrostat/config_flow.py +++ b/homeassistant/components/generic_hygrostat/config_flow.py @@ -92,6 +92,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 6e699745279..7746346d010 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -42,7 +42,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -145,22 +145,22 @@ async def _async_setup_config( [ GenericHygrostat( hass, - name, - switch_entity_id, - sensor_entity_id, - min_humidity, - max_humidity, - target_humidity, - device_class, - min_cycle_duration, - dry_tolerance, - wet_tolerance, - keep_alive, - initial_state, - away_humidity, - away_fixed, - sensor_stale_duration, - unique_id, + name=name, + switch_entity_id=switch_entity_id, + sensor_entity_id=sensor_entity_id, + min_humidity=min_humidity, + max_humidity=max_humidity, + target_humidity=target_humidity, + device_class=device_class, + min_cycle_duration=min_cycle_duration, + dry_tolerance=dry_tolerance, + wet_tolerance=wet_tolerance, + keep_alive=keep_alive, + initial_state=initial_state, + away_humidity=away_humidity, + away_fixed=away_fixed, + sensor_stale_duration=sensor_stale_duration, + unique_id=unique_id, ) ] ) @@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, switch_entity_id: str, sensor_entity_id: str, @@ -195,7 +196,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): self._name = name self._switch_entity_id = switch_entity_id self._sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, switch_entity_id, ) diff --git a/tests/components/generic_hygrostat/test_init.py b/tests/components/generic_hygrostat/test_init.py index 254d4da5806..64db21eab8c 100644 --- a/tests/components/generic_hygrostat/test_init.py +++ b/tests/components/generic_hygrostat/test_init.py @@ -9,8 +9,8 @@ import pytest from homeassistant.components import generic_hygrostat from homeassistant.components.generic_hygrostat import DOMAIN from homeassistant.components.generic_hygrostat.config_flow import ConfigFlowHandler -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -119,10 +119,20 @@ def generic_hygrostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -201,7 +211,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -216,9 +226,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -229,8 +237,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -239,7 +251,83 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_hygrostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_hygrostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_hygrostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_hygrostat.async_unload_entry", + wraps=generic_hygrostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_hygrostat config entry is not removed + assert ( + generic_hygrostat_config_entry.entry_id in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_hygrostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_hygrostat config entry is removed when the source entity is removed.""" @@ -263,9 +351,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -284,6 +370,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -305,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -315,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -333,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -352,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_hygrostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +479,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +489,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_hygrostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +511,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries @@ -427,13 +530,18 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_hygrostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_hygrostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device_2.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -452,10 +560,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "humidifier"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "humidifier"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -466,7 +574,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -483,9 +590,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_hygrostat_entity_entry.entity_id @@ -505,11 +610,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_hygrostat config entry is updated with the new entity ID assert generic_hygrostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_hygrostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_hygrostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_hygrostat config entry is not removed assert ( @@ -518,3 +621,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_hygrostat config entry from device.""" + + generic_hygrostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": switch_entity_entry.entity_id, + "name": "My generic hygrostat", + "target_sensor": sensor_entity_entry.entity_id, + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=1, + minor_version=1, + ) + generic_hygrostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_hygrostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_hygrostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_hygrostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_hygrostat_config_entry.entry_id not in switch_device.config_entries + generic_hygrostat_entity_entry = entity_registry.async_get( + "humidifier.my_generic_hygrostat" + ) + assert generic_hygrostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_hygrostat_config_entry.version == 1 + assert generic_hygrostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "device_class": "humidifier", + "dry_tolerance": 2.0, + "humidifier": "switch.test", + "name": "My generic hygrostat", + "target_sensor": "sensor.test", + "wet_tolerance": 4.0, + }, + title="My generic hygrostat", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 3ae9ea3f19fe087982ed004eeef65acbd8bd3ddb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:18:21 +0200 Subject: [PATCH 1383/1664] Do not add generic_thermostat config entry to source device (#148728) --- .../components/generic_thermostat/__init__.py | 50 +++- .../components/generic_thermostat/climate.py | 39 +-- .../generic_thermostat/config_flow.py | 2 + .../generic_thermostat/test_init.py | 265 +++++++++++++++--- 4 files changed, 292 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py index 3e2af8598de..98cd9a02baa 100644 --- a/homeassistant/components/generic_thermostat/__init__.py +++ b/homeassistant/components/generic_thermostat/__init__.py @@ -1,5 +1,7 @@ """The generic_thermostat component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import entity_registry as er @@ -8,14 +10,20 @@ from homeassistant.helpers.device import ( async_remove_stale_devices_links_keep_entity_device, ) from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -28,23 +36,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_HEATER: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( # We use async_handle_source_entity_changes to track changes to the heater, but # not the temperature sensor because the generic_hygrostat adds itself to the # heater's device. async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_HEATER] ), source_entity_id_or_uuid=entry.options[CONF_HEATER], - source_entity_removed=source_entity_removed, ) ) @@ -75,6 +79,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the generic_thermostat config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_HEATER] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 185040f02c9..76fcc4acdde 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -48,7 +48,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import ConditionError from homeassistant.helpers import condition, config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -182,23 +182,23 @@ async def _async_setup_config( [ GenericThermostat( hass, - name, - heater_entity_id, - sensor_entity_id, - min_temp, - max_temp, - target_temp, - ac_mode, - min_cycle_duration, - cold_tolerance, - hot_tolerance, - keep_alive, - initial_hvac_mode, - presets, - precision, - target_temperature_step, - unit, - unique_id, + name=name, + heater_entity_id=heater_entity_id, + sensor_entity_id=sensor_entity_id, + min_temp=min_temp, + max_temp=max_temp, + target_temp=target_temp, + ac_mode=ac_mode, + min_cycle_duration=min_cycle_duration, + cold_tolerance=cold_tolerance, + hot_tolerance=hot_tolerance, + keep_alive=keep_alive, + initial_hvac_mode=initial_hvac_mode, + presets=presets, + precision=precision, + target_temperature_step=target_temperature_step, + unit=unit, + unique_id=unique_id, ) ] ) @@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): def __init__( self, hass: HomeAssistant, + *, name: str, heater_entity_id: str, sensor_entity_id: str, @@ -234,7 +235,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity): self._attr_name = name self.heater_entity_id = heater_entity_id self.sensor_entity_id = sensor_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, heater_entity_id, ) diff --git a/homeassistant/components/generic_thermostat/config_flow.py b/homeassistant/components/generic_thermostat/config_flow.py index 1fbeaefde6b..b69106597d1 100644 --- a/homeassistant/components/generic_thermostat/config_flow.py +++ b/homeassistant/components/generic_thermostat/config_flow.py @@ -100,6 +100,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/tests/components/generic_thermostat/test_init.py b/tests/components/generic_thermostat/test_init.py index 9131e3ffdd4..ceca7ecc444 100644 --- a/tests/components/generic_thermostat/test_init.py +++ b/tests/components/generic_thermostat/test_init.py @@ -9,8 +9,8 @@ import pytest from homeassistant.components import generic_thermostat from homeassistant.components.generic_thermostat.config_flow import ConfigFlowHandler from homeassistant.components.generic_thermostat.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -117,10 +117,20 @@ def generic_thermostat_config_entry( return config_entry +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + switch_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return switch_device.id if request.param == "switch_device_id" else None + + def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -199,7 +209,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(helper_config_entry.entry_id) @@ -214,9 +224,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( helper_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 @pytest.mark.usefixtures( @@ -227,8 +235,12 @@ async def test_device_cleaning( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "expected_events"), - [("switch.test_unique", True, ["update"]), ("sensor.test_unique", False, [])], + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed( hass: HomeAssistant, @@ -237,7 +249,84 @@ async def test_async_handle_source_entity_changes_source_entity_removed( generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the generic_thermostat config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup( + generic_thermostat_config_entry.entry_id + ) + await hass.async_block_till_done() + + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions( + hass, generic_thermostat_entity_entry.entity_id + ) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.generic_thermostat.async_unload_entry", + wraps=generic_thermostat.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the generic_thermostat config entry is not removed + assert ( + generic_thermostat_config_entry.entry_id + in hass.config_entries.async_entry_ids() + ) + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.usefixtures( + "sensor_config_entry", + "sensor_device", + "sensor_entity_entry", + "switch_config_entry", + "switch_device", +) +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("switch.test_unique", None, ["update"]), + ("sensor.test_unique", "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + generic_thermostat_config_entry: MockConfigEntry, + switch_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the generic_thermostat config entry is removed when the source entity is removed.""" @@ -261,9 +350,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -282,6 +369,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get("switch.test_unique") + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + # Check if the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -304,8 +398,17 @@ async def test_async_handle_source_entity_changes_source_entity_removed( "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("switch.test_unique", 1, None, ["update"]), + ("sensor.test_unique", 0, "switch_device_id", []), + ], + indirect=["expected_helper_device_id"], ) async def test_async_handle_source_entity_changes_source_entity_removed_from_device( hass: HomeAssistant, @@ -314,8 +417,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, + expected_helper_device_id: str | None, expected_events: list[str], ) -> None: """Test the source entity removed from the source device.""" @@ -332,9 +435,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -351,7 +452,13 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is removed from the device + # Check that the helper entity is linked to the expected source device + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == expected_helper_device_id + + # Check that the generic_thermostat config entry is not in the device source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries @@ -373,8 +480,8 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "helper_in_device", "unload_entry_calls", "expected_events"), - [("switch.test_unique", True, 1, ["update"]), ("sensor.test_unique", False, 0, [])], + ("source_entity_id", "unload_entry_calls", "expected_events"), + [("switch.test_unique", 1, ["update"]), ("sensor.test_unique", 0, [])], ) async def test_async_handle_source_entity_changes_source_entity_moved_other_device( hass: HomeAssistant, @@ -383,7 +490,6 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi generic_thermostat_config_entry: MockConfigEntry, switch_entity_entry: er.RegistryEntry, source_entity_id: str, - helper_in_device: bool, unload_entry_calls: int, expected_events: list[str], ) -> None: @@ -406,9 +512,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( generic_thermostat_config_entry.entry_id not in source_device_2.config_entries @@ -429,13 +533,20 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() assert len(mock_unload_entry.mock_calls) == unload_entry_calls - # Check that the generic_thermostat config entry is moved to the other device + # Check that the helper entity is linked to the expected source device + switch_entity_entry = entity_registry.async_get(switch_entity_entry.entity_id) + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + # Check that the generic_thermostat config entry is not in any of the devices source_device = device_registry.async_get(source_device.id) assert generic_thermostat_config_entry.entry_id not in source_device.config_entries source_device_2 = device_registry.async_get(source_device_2.id) assert ( - generic_thermostat_config_entry.entry_id in source_device_2.config_entries - ) == helper_in_device + generic_thermostat_config_entry.entry_id not in source_device_2.config_entries + ) # Check that the generic_thermostat config entry is not removed assert ( @@ -455,10 +566,10 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi "switch_device", ) @pytest.mark.parametrize( - ("source_entity_id", "new_entity_id", "helper_in_device", "config_key"), + ("source_entity_id", "new_entity_id", "config_key"), [ - ("switch.test_unique", "switch.new_entity_id", True, "heater"), - ("sensor.test_unique", "sensor.new_entity_id", False, "target_sensor"), + ("switch.test_unique", "switch.new_entity_id", "heater"), + ("sensor.test_unique", "sensor.new_entity_id", "target_sensor"), ], ) async def test_async_handle_source_entity_new_entity_id( @@ -469,7 +580,6 @@ async def test_async_handle_source_entity_new_entity_id( switch_entity_entry: er.RegistryEntry, source_entity_id: str, new_entity_id: str, - helper_in_device: bool, config_key: str, ) -> None: """Test the source entity's entity ID is changed.""" @@ -486,9 +596,7 @@ async def test_async_handle_source_entity_new_entity_id( assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id source_device = device_registry.async_get(source_entity_entry.device_id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries events = track_entity_registry_actions( hass, generic_thermostat_entity_entry.entity_id @@ -508,11 +616,9 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the generic_thermostat config entry is updated with the new entity ID assert generic_thermostat_config_entry.options[config_key] == new_entity_id - # Check that the helper config is still in the device + # Check that the helper config is not in the device source_device = device_registry.async_get(source_device.id) - assert ( - generic_thermostat_config_entry.entry_id in source_device.config_entries - ) == helper_in_device + assert generic_thermostat_config_entry.entry_id not in source_device.config_entries # Check that the generic_thermostat config entry is not removed assert ( @@ -522,3 +628,84 @@ async def test_async_handle_source_entity_new_entity_id( # Check we got the expected events assert events == [] + + +@pytest.mark.usefixtures("sensor_device") +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + switch_device: dr.DeviceEntry, + switch_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes generic_thermostat config entry from device.""" + + generic_thermostat_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": switch_entity_entry.entity_id, + "target_sensor": sensor_entity_entry.entity_id, + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=1, + minor_version=1, + ) + generic_thermostat_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + switch_device.id, add_config_entry_id=generic_thermostat_config_entry.entry_id + ) + + # Check preconditions + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(generic_thermostat_config_entry.entry_id) + await hass.async_block_till_done() + + assert generic_thermostat_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert generic_thermostat_config_entry.entry_id not in switch_device.config_entries + generic_thermostat_entity_entry = entity_registry.async_get( + "climate.my_generic_thermostat" + ) + assert generic_thermostat_entity_entry.device_id == switch_entity_entry.device_id + + assert generic_thermostat_config_entry.version == 1 + assert generic_thermostat_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My generic thermostat", + "heater": "switch.test", + "target_sensor": "sensor.test", + "ac_mode": False, + "cold_tolerance": 0.3, + "hot_tolerance": 0.3, + }, + title="My generic thermostat", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From c27a67db8248ff78b96cad237c77e77e0b7c3bb4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:18:41 +0200 Subject: [PATCH 1384/1664] Do not add integration config entry to source device (#148730) --- .../components/integration/__init__.py | 50 ++++- .../components/integration/config_flow.py | 2 + .../components/integration/sensor.py | 18 +- tests/components/integration/test_init.py | 179 ++++++++++++++++-- 4 files changed, 216 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py index 0a64ce7140f..82f44578aed 100644 --- a/homeassistant/components/integration/__init__.py +++ b/homeassistant/components/integration/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -9,14 +11,20 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) from .const import CONF_SOURCE_SENSOR +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Integration from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -29,20 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id}, ) - async def source_entity_removed() -> None: - # The source entity has been removed, we need to clean the device links. - async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None) - entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( hass, entry.options[CONF_SOURCE_SENSOR] ), source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR], - source_entity_removed=source_entity_removed, ) ) @@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the integration config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_SOURCE_SENSOR] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update listener, called when the config entry options are changed.""" # Remove device link for entry, the source device may have changed. diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 28cd280f7f8..329abdbea87 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -147,6 +147,8 @@ OPTIONS_FLOW = { class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config or options flow for Integration.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index df5342111a7..25181ac6149 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -40,8 +40,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.device import async_device_info_to_link_from_entity -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -246,11 +245,6 @@ async def async_setup_entry( registry, config_entry.options[CONF_SOURCE_SENSOR] ) - device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) - if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": # Before we had support for optional selectors, "none" was used for selecting nothing unit_prefix = None @@ -265,6 +259,7 @@ async def async_setup_entry( round_digits = int(round_digits) integral = IntegrationSensor( + hass, integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, round_digits=round_digits, @@ -272,7 +267,6 @@ async def async_setup_entry( unique_id=config_entry.entry_id, unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], - device_info=device_info, max_sub_interval=max_sub_interval, ) @@ -287,6 +281,7 @@ async def async_setup_platform( ) -> None: """Set up the integration sensor.""" integral = IntegrationSensor( + hass, integration_method=config[CONF_METHOD], name=config.get(CONF_NAME), round_digits=config.get(CONF_ROUND_DIGITS), @@ -308,6 +303,7 @@ class IntegrationSensor(RestoreSensor): def __init__( self, + hass: HomeAssistant, *, integration_method: str, name: str | None, @@ -317,7 +313,6 @@ class IntegrationSensor(RestoreSensor): unit_prefix: str | None, unit_time: UnitOfTime, max_sub_interval: timedelta | None, - device_info: DeviceInfo | None = None, ) -> None: """Initialize the integration sensor.""" self._attr_unique_id = unique_id @@ -335,7 +330,10 @@ class IntegrationSensor(RestoreSensor): self._attr_icon = "mdi:chart-histogram" self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None - self._attr_device_info = device_info + self.device_entry = async_entity_id_to_device( + hass, + source_entity, + ) self._max_sub_interval: timedelta | None = ( None # disable time based integration if max_sub_interval is None or max_sub_interval.total_seconds() == 0 diff --git a/tests/components/integration/test_init.py b/tests/components/integration/test_init.py index 0ce3297a2ff..50243551d37 100644 --- a/tests/components/integration/test_init.py +++ b/tests/components/integration/test_init.py @@ -7,8 +7,8 @@ import pytest from homeassistant.components import integration from homeassistant.components.integration.config_flow import ConfigFlowHandler from homeassistant.components.integration.const import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -83,6 +83,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -176,6 +177,7 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: # Set up entities, with backing devices and config entries input_entry = _create_mock_entity("sensor", "input") valid_entry = _create_mock_entity("sensor", "valid") + assert input_entry.device_id != valid_entry.device_id # Setup the config entry config_entry = MockConfigEntry( @@ -193,17 +195,21 @@ async def test_entry_changed(hass: HomeAssistant, platform) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.entry_id in _get_device_config_entries(input_entry) + assert config_entry.entry_id not in _get_device_config_entries(input_entry) assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == input_entry.device_id hass.config_entries.async_update_entry( config_entry, options={**config_entry.options, "source": "sensor.valid"} ) await hass.async_block_till_done() - # Check that the config entry association has updated + # Check that the device association has updated assert config_entry.entry_id not in _get_device_config_entries(input_entry) - assert config_entry.entry_id in _get_device_config_entries(valid_entry) + assert config_entry.entry_id not in _get_device_config_entries(valid_entry) + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == valid_entry.device_id async def test_device_cleaning( @@ -276,7 +282,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(integration_config_entry.entry_id) @@ -291,7 +297,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( integration_config_entry.entry_id ) - assert len(devices_after_reload) == 1 + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -302,6 +308,54 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the integration config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.integration.async_unload_entry", + wraps=integration.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the integration config entry is not removed + assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["update"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + integration_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the integration config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -318,7 +372,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -335,7 +389,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_not_called() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -362,7 +420,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -377,7 +435,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is removed from the device + # Check that the entity is no longer linked to the source device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id is None + + # Check that the integration config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries @@ -410,7 +472,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert integration_config_entry.entry_id not in sensor_device_2.config_entries @@ -427,11 +489,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the integration config entry is moved to the other device + # Check that the entity is linked to the other device + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_device_2.id + + # Check that the derivative config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert integration_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert integration_config_entry.entry_id in sensor_device_2.config_entries + assert integration_config_entry.entry_id not in sensor_device_2.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -456,7 +522,7 @@ async def test_async_handle_source_entity_new_entity_id( assert integration_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, integration_entity_entry.entity_id) @@ -474,12 +540,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the integration config entry is updated with the new entity ID assert integration_config_entry.options["source"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert integration_config_entry.entry_id in sensor_device.config_entries + assert integration_config_entry.entry_id not in sensor_device.config_entries # Check that the integration config entry is not removed assert integration_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes integration config entry from device.""" + + integration_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=1, + minor_version=1, + ) + integration_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=integration_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(integration_config_entry.entry_id) + await hass.async_block_till_done() + + assert integration_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert integration_config_entry.entry_id not in sensor_device.config_entries + integration_entity_entry = entity_registry.async_get("sensor.my_integration") + assert integration_entity_entry.device_id == sensor_entity_entry.device_id + + assert integration_config_entry.version == 1 + assert integration_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "method": "trapezoidal", + "name": "My integration", + "round": 1.0, + "source": "sensor.test", + "unit_prefix": "k", + "unit_time": "min", + "max_sub_interval": {"minutes": 1}, + }, + title="My integration", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 124931b2eebc0dde424962d70dea74afe81ac254 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 14 Jul 2025 20:23:43 +0200 Subject: [PATCH 1385/1664] TTS to always stream when available (#148695) Co-authored-by: Michael Hansen --- homeassistant/components/tts/__init__.py | 16 +++- .../snapshots/test_pipeline.ambr | 2 +- .../assist_pipeline/test_pipeline.py | 6 +- tests/components/tts/test_init.py | 2 +- .../wyoming/snapshots/test_tts.ambr | 80 +++++++++++++++++++ tests/components/wyoming/test_tts.py | 10 ++- 6 files changed, 107 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index c8e6e0f67fb..cf9099448df 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -382,7 +382,7 @@ async def _async_convert_audio( assert process.stderr stderr_data = await process.stderr.read() _LOGGER.error(stderr_data.decode()) - raise RuntimeError( + raise HomeAssistantError( f"Unexpected error while running ffmpeg with arguments: {command}. " "See log for details." ) @@ -976,7 +976,7 @@ class SpeechManager: if engine_instance.name is None or engine_instance.name is UNDEFINED: raise HomeAssistantError("TTS engine name is not set.") - if isinstance(engine_instance, Provider) or isinstance(message_or_stream, str): + if isinstance(engine_instance, Provider): if isinstance(message_or_stream, str): message = message_or_stream else: @@ -996,8 +996,18 @@ class SpeechManager: data_gen = make_data_generator(data) else: + if isinstance(message_or_stream, str): + + async def gen_stream() -> AsyncGenerator[str]: + yield message_or_stream + + stream = gen_stream() + + else: + stream = message_or_stream + tts_result = await engine_instance.internal_async_stream_tts_audio( - TTSAudioRequest(language, options, message_or_stream) + TTSAudioRequest(language, options, stream) ) extension = tts_result.extension data_gen = tts_result.data_gen diff --git a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr index 7f760d069e6..95415ddb902 100644 --- a/tests/components/assist_pipeline/snapshots/test_pipeline.ambr +++ b/tests/components/assist_pipeline/snapshots/test_pipeline.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_chat_log_tts_streaming[to_stream_deltas0-0-] +# name: test_chat_log_tts_streaming[to_stream_deltas0-1-hello, how are you?] list([ dict({ 'data': dict({ diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 3a4895440dc..5bc7b86c38c 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -1550,9 +1550,9 @@ async def test_pipeline_language_used_instead_of_conversation_language( "?", ], ), - # We are not streaming, so 0 chunks via streaming method - 0, - "", + # We always stream when possible, so 1 chunk via streaming method + 1, + "hello, how are you?", ), # Size above STREAM_RESPONSE_CHUNKS ( diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 22fb10209b0..db42da5de0e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -1835,7 +1835,7 @@ async def test_async_convert_audio_error(hass: HomeAssistant) -> None: async def bad_data_gen(): yield bytes(0) - with pytest.raises(RuntimeError): + with pytest.raises(HomeAssistantError): # Simulate a bad WAV file async for _chunk in tts._async_convert_audio( hass, "wav", bad_data_gen(), "mp3" diff --git a/tests/components/wyoming/snapshots/test_tts.ambr b/tests/components/wyoming/snapshots/test_tts.ambr index 53cc02eaacf..67c9b24160c 100644 --- a/tests/components/wyoming/snapshots/test_tts.ambr +++ b/tests/components/wyoming/snapshots/test_tts.ambr @@ -1,6 +1,19 @@ # serializer version: 1 # name: test_get_tts_audio list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -8,10 +21,29 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_different_formats list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -19,10 +51,29 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_different_formats.1 list([ + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -30,6 +81,12 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- # name: test_get_tts_audio_streaming @@ -71,6 +128,23 @@ # --- # name: test_voice_speaker list([ + dict({ + 'data': dict({ + 'voice': dict({ + 'name': 'voice1', + 'speaker': 'speaker1', + }), + }), + 'payload': None, + 'type': 'synthesize-start', + }), + dict({ + 'data': dict({ + 'text': 'Hello world', + }), + 'payload': None, + 'type': 'synthesize-chunk', + }), dict({ 'data': dict({ 'text': 'Hello world', @@ -82,5 +156,11 @@ 'payload': None, 'type': 'synthesize', }), + dict({ + 'data': dict({ + }), + 'payload': None, + 'type': 'synthesize-stop', + }), ]) # --- diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index 3374328f411..efcf464eebb 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -52,6 +52,7 @@ async def test_get_tts_audio( # Verify audio audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -77,7 +78,10 @@ async def test_get_tts_audio( assert wav_file.getframerate() == 16000 assert wav_file.getsampwidth() == 2 assert wav_file.getnchannels() == 1 - assert wav_file.readframes(wav_file.getnframes()) == audio + + # nframes = 0 due to streaming + assert len(data) == len(audio) + 44 # WAVE header is 44 bytes + assert data[44:] == audio assert mock_client.written == snapshot @@ -88,6 +92,7 @@ async def test_get_tts_audio_different_formats( """Test changing preferred audio format.""" audio = bytes(16000 * 2 * 1) # one second audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -123,6 +128,7 @@ async def test_get_tts_audio_different_formats( # MP3 is the default audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -167,6 +173,7 @@ async def test_get_tts_audio_audio_oserror( """Test get audio and error raising.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] @@ -197,6 +204,7 @@ async def test_voice_speaker( """Test using a different voice and speaker.""" audio = bytes(100) audio_events = [ + AudioStart(rate=16000, width=2, channels=1).event(), AudioChunk(audio=audio, rate=16000, width=2, channels=1).event(), AudioStop().event(), ] From 8421ca7802d16b4b3fed9ede6d2608230bde0b49 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 14 Jul 2025 14:28:27 -0400 Subject: [PATCH 1386/1664] Add assumed optimistic state to template select (#148513) --- homeassistant/components/template/select.py | 150 ++++++++++++-------- tests/components/template/test_select.py | 118 ++++++++++++--- 2 files changed, 190 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 8c05e8e2592..256955e70a8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -32,6 +32,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN +from .entity import AbstractTemplateEntity from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -45,7 +46,7 @@ DEFAULT_OPTIMISTIC = False SELECT_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_STATE): cv.template, vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, vol.Required(ATTR_OPTIONS): cv.template, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, @@ -116,49 +117,22 @@ async def async_setup_entry( async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) -class TemplateSelect(TemplateEntity, SelectEntity): - """Representation of a template select.""" +class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): + """Representation of a template select features.""" - _attr_should_poll = False + # 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) - def __init__( - self, - hass: HomeAssistant, - config: dict[str, Any], - unique_id: str | None, - ) -> None: - """Initialize the select.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None - self._value_template = config[CONF_STATE] - # Scripts can be an empty list, therefore we need to check for None - if (select_option := config.get(CONF_SELECT_OPTION)) is not None: - self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN) self._options_template = config[ATTR_OPTIONS] - self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False) + + 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 - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - - @callback - def _async_setup_templates(self) -> None: - """Set up templates.""" - self.add_template_attribute( - "_attr_current_option", - self._value_template, - validator=cv.string, - none_on_template_error=True, - ) - self.add_template_attribute( - "_attr_options", - self._options_template, - validator=vol.All(cv.ensure_list, [cv.string]), - none_on_template_error=True, - ) - super()._async_setup_templates() async def async_select_option(self, option: str) -> None: """Change the selected option.""" @@ -173,11 +147,56 @@ class TemplateSelect(TemplateEntity, SelectEntity): ) -class TriggerSelectEntity(TriggerEntity, SelectEntity): +class TemplateSelect(TemplateEntity, AbstractTemplateSelect): + """Representation of a template select.""" + + _attr_should_poll = False + + def __init__( + self, + hass: HomeAssistant, + config: dict[str, Any], + unique_id: str | None, + ) -> None: + """Initialize the select.""" + TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + AbstractTemplateSelect.__init__(self, config) + + name = self._attr_name + if TYPE_CHECKING: + assert name is not None + + if (select_option := config.get(CONF_SELECT_OPTION)) is not None: + self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) + + self._attr_device_info = async_device_info_to_link_from_device_id( + hass, + config.get(CONF_DEVICE_ID), + ) + + @callback + def _async_setup_templates(self) -> None: + """Set up templates.""" + if self._template is not None: + self.add_template_attribute( + "_attr_current_option", + self._template, + validator=cv.string, + none_on_template_error=True, + ) + self.add_template_attribute( + "_attr_options", + self._options_template, + validator=vol.All(cv.ensure_list, [cv.string]), + none_on_template_error=True, + ) + super()._async_setup_templates() + + +class TriggerSelectEntity(TriggerEntity, AbstractTemplateSelect): """Select entity based on trigger data.""" domain = SELECT_DOMAIN - extra_template_keys = (CONF_STATE,) extra_template_keys_complex = (ATTR_OPTIONS,) def __init__( @@ -187,7 +206,12 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): config: dict, ) -> None: """Initialize the entity.""" - super().__init__(hass, coordinator, config) + TriggerEntity.__init__(self, hass, coordinator, config) + AbstractTemplateSelect.__init__(self, config) + + if CONF_STATE in config: + self._to_render_simple.append(CONF_STATE) + # Scripts can be an empty list, therefore we need to check for None if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script( @@ -197,24 +221,26 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity): DOMAIN, ) - @property - def current_option(self) -> str | None: - """Return the currently selected option.""" - return self._rendered.get(CONF_STATE) + def _handle_coordinator_update(self): + """Handle updated data from the coordinator.""" + self._process_data() - @property - def options(self) -> list[str]: - """Return the list of available options.""" - return self._rendered.get(ATTR_OPTIONS, []) - - async def async_select_option(self, option: str) -> None: - """Change the selected option.""" - if self._config[CONF_OPTIMISTIC]: - self._attr_current_option = option + if not self.available: + self.async_write_ha_state() + return + + write_ha_state = False + if (options := self._rendered.get(ATTR_OPTIONS)) is not None: + self._attr_options = vol.All(cv.ensure_list, [cv.string])(options) + write_ha_state = True + + if (state := self._rendered.get(CONF_STATE)) is not None: + self._attr_current_option = cv.string(state) + write_ha_state = True + + if len(self._rendered) > 0: + # In case any non optimistic template + write_ha_state = True + + if write_ha_state: self.async_write_ha_state() - if select_option := self._action_scripts.get(CONF_SELECT_OPTION): - await self.async_run_script( - select_option, - run_variables={ATTR_OPTION: option}, - context=self._context, - ) diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 5e29993f0f6..6971d41750d 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ATTR_ICON, CONF_ENTITY_ID, CONF_ICON, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistant, ServiceCall @@ -43,11 +44,15 @@ _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" # Represent for select's current_option _OPTION_INPUT_SELECT = "input_select.option" TEST_STATE_ENTITY_ID = "select.test_state" - +TEST_AVAILABILITY_ENTITY_ID = "binary_sensor.test_availability" TEST_STATE_TRIGGER = { "trigger": { "trigger": "state", - "entity_id": [_OPTION_INPUT_SELECT, TEST_STATE_ENTITY_ID], + "entity_id": [ + _OPTION_INPUT_SELECT, + TEST_STATE_ENTITY_ID, + TEST_AVAILABILITY_ENTITY_ID, + ], }, "variables": {"triggering_entity": "{{ trigger.entity_id }}"}, "action": [ @@ -201,20 +206,6 @@ async def test_multiple_configs(hass: HomeAssistant) -> None: async def test_missing_required_keys(hass: HomeAssistant) -> None: """Test: missing required fields will fail.""" - with assert_setup_component(0, "template"): - assert await setup.async_setup_component( - hass, - "template", - { - "template": { - "select": { - "select_option": {"service": "script.select_option"}, - "options": "{{ ['a', 'b'] }}", - } - } - }, - ) - with assert_setup_component(0, "select"): assert await setup.async_setup_component( hass, @@ -559,3 +550,98 @@ async def test_empty_action_config(hass: HomeAssistant, setup_select) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "a" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + }, + ) + ], +) +@pytest.mark.parametrize( + "style", + [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER], +) +@pytest.mark.usefixtures("setup_select") +async def test_optimistic(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNKNOWN + + # 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( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "test"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + await hass.services.async_call( + select.DOMAIN, + select.SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: _TEST_SELECT, "option": "yes"}, + blocking=True, + ) + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" + + +@pytest.mark.parametrize( + ("count", "select_config"), + [ + ( + 1, + { + "options": "{{ ['test', 'yes', 'no'] }}", + "select_option": [], + "state": "{{ states('select.test_state') }}", + "availability": "{{ is_state('binary_sensor.test_availability', 'on') }}", + }, + ) + ], +) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.usefixtures("setup_select") +async def test_availability(hass: HomeAssistant) -> None: + """Test configuration with optimistic state.""" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + hass.states.async_set(TEST_STATE_ENTITY_ID, "test") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "test" + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "off") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_STATE_ENTITY_ID, "yes") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == STATE_UNAVAILABLE + + hass.states.async_set(TEST_AVAILABILITY_ENTITY_ID, "on") + await hass.async_block_till_done() + + state = hass.states.get(_TEST_SELECT) + assert state.state == "yes" From 1753baf1860eb1cc49111f56171727d55750e9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 14 Jul 2025 19:28:53 +0100 Subject: [PATCH 1387/1664] Add method to track entity state changes from target selectors (#148086) Co-authored-by: Erik Montnemery --- homeassistant/helpers/target.py | 115 ++++++++++++++++++- tests/helpers/test_target.py | 194 +++++++++++++++++++++++++++++++- 2 files changed, 303 insertions(+), 6 deletions(-) diff --git a/homeassistant/helpers/target.py b/homeassistant/helpers/target.py index c16819235b9..239d1e66336 100644 --- a/homeassistant/helpers/target.py +++ b/homeassistant/helpers/target.py @@ -2,9 +2,11 @@ from __future__ import annotations +from collections.abc import Callable import dataclasses +import logging from logging import Logger -from typing import TypeGuard +from typing import Any, TypeGuard from homeassistant.const import ( ATTR_AREA_ID, @@ -14,7 +16,14 @@ from homeassistant.const import ( ATTR_LABEL_ID, ENTITY_MATCH_NONE, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + CALLBACK_TYPE, + Event, + EventStateChangedData, + HomeAssistant, + callback, +) +from homeassistant.exceptions import HomeAssistantError from . import ( area_registry as ar, @@ -25,8 +34,11 @@ from . import ( group, label_registry as lr, ) +from .event import async_track_state_change_event from .typing import ConfigType +_LOGGER = logging.getLogger(__name__) + def _has_match(ids: str | list[str] | None) -> TypeGuard[str | list[str]]: """Check if ids can match anything.""" @@ -238,3 +250,102 @@ def async_extract_referenced_entity_ids( ) return selected + + +class TargetStateChangeTracker: + """Helper class to manage state change tracking for targets.""" + + def __init__( + self, + hass: HomeAssistant, + selector_data: TargetSelectorData, + action: Callable[[Event[EventStateChangedData]], Any], + ) -> None: + """Initialize the state change tracker.""" + self._hass = hass + self._selector_data = selector_data + self._action = action + + self._state_change_unsub: CALLBACK_TYPE | None = None + self._registry_unsubs: list[CALLBACK_TYPE] = [] + + def async_setup(self) -> Callable[[], None]: + """Set up the state change tracking.""" + self._setup_registry_listeners() + self._track_entities_state_change() + return self._unsubscribe + + def _track_entities_state_change(self) -> None: + """Set up state change tracking for currently selected entities.""" + selected = async_extract_referenced_entity_ids( + self._hass, self._selector_data, expand_group=False + ) + + @callback + def state_change_listener(event: Event[EventStateChangedData]) -> None: + """Handle state change events.""" + if ( + 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) + + _LOGGER.debug("Tracking state changes for entities: %s", tracked_entities) + self._state_change_unsub = async_track_state_change_event( + self._hass, tracked_entities, state_change_listener + ) + + def _setup_registry_listeners(self) -> None: + """Set up listeners for registry changes that require resubscription.""" + + @callback + def resubscribe_state_change_event(event: Event[Any] | None = None) -> None: + """Resubscribe to state change events when registry changes.""" + if self._state_change_unsub: + self._state_change_unsub() + self._track_entities_state_change() + + # Subscribe to registry updates that can change the entities to track: + # - Entity registry: entity added/removed; entity labels changed; entity area changed. + # - Device registry: device labels changed; device area changed. + # - Area registry: area floor changed. + # + # We don't track other registries (like floor or label registries) because their + # changes don't affect which entities are tracked. + self._registry_unsubs = [ + self._hass.bus.async_listen( + er.EVENT_ENTITY_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, resubscribe_state_change_event + ), + self._hass.bus.async_listen( + ar.EVENT_AREA_REGISTRY_UPDATED, resubscribe_state_change_event + ), + ] + + def _unsubscribe(self) -> None: + """Unsubscribe from all events.""" + for registry_unsub in self._registry_unsubs: + registry_unsub() + self._registry_unsubs.clear() + if self._state_change_unsub: + self._state_change_unsub() + self._state_change_unsub = None + + +def async_track_target_selector_state_change_event( + hass: HomeAssistant, + target_selector_config: ConfigType, + action: Callable[[Event[EventStateChangedData]], Any], +) -> CALLBACK_TYPE: + """Track state changes for entities referenced directly or indirectly in a target selector.""" + selector_data = TargetSelectorData(target_selector_config) + if not selector_data.has_any_selector: + raise HomeAssistantError( + f"Target selector {target_selector_config} does not have any selectors defined" + ) + tracker = TargetStateChangeTracker(hass, selector_data, action) + return tracker.async_setup() diff --git a/tests/helpers/test_target.py b/tests/helpers/test_target.py index ca38f316d89..c87a320e378 100644 --- a/tests/helpers/test_target.py +++ b/tests/helpers/test_target.py @@ -2,9 +2,6 @@ import pytest -# TODO(abmantis): is this import needed? -# To prevent circular import when running just this file -import homeassistant.components # noqa: F401 from homeassistant.components.group import Group from homeassistant.const import ( ATTR_AREA_ID, @@ -17,17 +14,21 @@ from homeassistant.const import ( STATE_ON, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( area_registry as ar, device_registry as dr, entity_registry as er, + floor_registry as fr, + label_registry as lr, target, ) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import ( + MockConfigEntry, RegistryEntryWithDefaults, mock_area_registry, mock_device_registry, @@ -457,3 +458,188 @@ async def test_extract_referenced_entity_ids( ) == expected_selected ) + + +async def test_async_track_target_selector_state_change_event_empty_selector( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test async_track_target_selector_state_change_event with empty selector.""" + + @callback + def state_change_callback(event): + """Handle state change events.""" + + with pytest.raises(HomeAssistantError) as excinfo: + target.async_track_target_selector_state_change_event( + hass, {}, state_change_callback + ) + assert str(excinfo.value) == ( + "Target selector {} does not have any selectors defined" + ) + + +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]] = [] + + @callback + def state_change_callback(event: Event[EventStateChangedData]): + """Handle state change events.""" + events.append(event) + + last_state = STATE_OFF + + async def set_states_and_check_events( + entities_to_set_state: list[str], entities_to_assert_change: list[str] + ) -> None: + """Toggle the state entities and check for events.""" + nonlocal last_state + last_state = STATE_ON if last_state == STATE_OFF else STATE_OFF + for entity_id in entities_to_set_state: + hass.states.async_set(entity_id, last_state) + await hass.async_block_till_done() + + 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 + assert entities_seen == set(entities_to_assert_change) + events.clear() + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + + device_reg = dr.async_get(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "device_1")}, + ) + + untargeted_device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("test", "area_device")}, + ) + + entity_reg = er.async_get(hass) + device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light", + device_id=device_entry.id, + ).entity_id + + untargeted_device_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="area_device_light", + device_id=untargeted_device_entry.id, + ).entity_id + + untargeted_entity = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="untargeted_light", + ).entity_id + + targeted_entity = "light.test_light" + + targeted_entities = [targeted_entity, device_entity] + await set_states_and_check_events(targeted_entities, []) + + label = lr.async_get(hass).async_create("Test Label").name + area = ar.async_get(hass).async_create("Test Area").id + floor = fr.async_get(hass).async_create("Test Floor").floor_id + + selector_config = { + ATTR_ENTITY_ID: targeted_entity, + ATTR_DEVICE_ID: device_entry.id, + ATTR_AREA_ID: area, + ATTR_FLOOR_ID: floor, + ATTR_LABEL_ID: label, + } + unsub = target.async_track_target_selector_state_change_event( + hass, selector_config, state_change_callback + ) + + # Test directly targeted entity and device + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Add new entity to the targeted device -> should trigger on state change + device_entity_2 = entity_reg.async_get_or_create( + domain="light", + platform="test", + unique_id="device_light_2", + device_id=device_entry.id, + ).entity_id + + targeted_entities = [targeted_entity, device_entity, device_entity_2] + await set_states_and_check_events(targeted_entities, targeted_entities) + + # Test untargeted entity -> should not trigger + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add label to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, labels={label}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove label from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, labels={}) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted entity -> should trigger now + entity_reg.async_update_entity(untargeted_entity, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], [*targeted_entities, untargeted_entity] + ) + + # Remove area from untargeted entity -> should not trigger anymore + entity_reg.async_update_entity(untargeted_entity, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Add area to untargeted device -> should trigger on state change + device_reg.async_update_device(untargeted_device_entry.id, area_id=area) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], + [*targeted_entities, untargeted_device_entity], + ) + + # Remove area from untargeted device -> should not trigger anymore + device_reg.async_update_device(untargeted_device_entry.id, area_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_device_entity], targeted_entities + ) + + # Set the untargeted area on the untargeted entity -> should not trigger + untracked_area = ar.async_get(hass).async_create("Untargeted Area").id + entity_reg.async_update_entity(untargeted_entity, area_id=untracked_area) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # Set targeted floor on the untargeted area -> should trigger now + ar.async_get(hass).async_update(untracked_area, floor_id=floor) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], + [*targeted_entities, untargeted_entity], + ) + + # Remove untargeted area from targeted floor -> should not trigger anymore + ar.async_get(hass).async_update(untracked_area, floor_id=None) + await set_states_and_check_events( + [*targeted_entities, untargeted_entity], targeted_entities + ) + + # After unsubscribing, changes should not trigger + unsub() + await set_states_and_check_events(targeted_entities, []) From c9356868f730a5cbb97aba7ef6c729a52168cace Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:29:57 +0200 Subject: [PATCH 1388/1664] Add add-on discovery flow to pyLoad integration (#148494) --- .../components/pyload/config_flow.py | 58 ++++++ homeassistant/components/pyload/strings.json | 12 ++ tests/components/pyload/conftest.py | 16 ++ tests/components/pyload/test_config_flow.py | 191 +++++++++++++++++- 4 files changed, 275 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py index 50d354d345d..1a1481f9c26 100644 --- a/homeassistant/components/pyload/config_flow.py +++ b/homeassistant/components/pyload/config_flow.py @@ -26,6 +26,7 @@ from homeassistant.helpers.selector import ( TextSelectorConfig, TextSelectorType, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .const import DEFAULT_NAME, DOMAIN @@ -97,6 +98,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 MINOR_VERSION = 1 + _hassio_discovery: HassioServiceInfo | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -211,3 +214,58 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN): description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]}, errors=errors, ) + + async def async_step_hassio( + self, discovery_info: HassioServiceInfo + ) -> ConfigFlowResult: + """Prepare configuration for pyLoad add-on. + + This flow is triggered by the discovery component. + """ + url = URL(discovery_info.config[CONF_URL]).human_repr() + self._async_abort_entries_match({CONF_URL: url}) + await self.async_set_unique_id(discovery_info.uuid) + self._abort_if_unique_id_configured(updates={CONF_URL: url}) + discovery_info.config[CONF_URL] = url + self._hassio_discovery = discovery_info + return await self.async_step_hassio_confirm() + + async def async_step_hassio_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm Supervisor discovery.""" + assert self._hassio_discovery + errors: dict[str, str] = {} + + data = {**self._hassio_discovery.config, CONF_VERIFY_SSL: False} + + if user_input is not None: + data.update(user_input) + + try: + await validate_input(self.hass, data) + except (CannotConnect, ParserError): + _LOGGER.debug("Cannot connect", exc_info=True) + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if user_input is None: + self._set_confirm_only() + return self.async_show_form( + step_id="hassio_confirm", + description_placeholders=self._hassio_discovery.config, + ) + return self.async_create_entry(title=self._hassio_discovery.slug, data=data) + + return self.async_show_form( + step_id="hassio_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=REAUTH_SCHEMA, suggested_values=data + ), + description_placeholders=self._hassio_discovery.config, + errors=errors if user_input is not None else None, + ) diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json index 9414f7f7bb8..66435fd2806 100644 --- a/homeassistant/components/pyload/strings.json +++ b/homeassistant/components/pyload/strings.json @@ -39,6 +39,18 @@ "username": "[%key:component::pyload::config::step::user::data_description::username%]", "password": "[%key:component::pyload::config::step::user::data_description::password%]" } + }, + "hassio_confirm": { + "title": "pyLoad via Home Assistant add-on", + "description": "Do you want to configure Home Assistant to connect to the pyLoad service provided by the add-on: {addon}?", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "[%key:component::pyload::config::step::user::data_description::username%]", + "password": "[%key:component::pyload::config::step::user::data_description::password%]" + } } }, "error": { diff --git a/tests/components/pyload/conftest.py b/tests/components/pyload/conftest.py index 9b410a5fdd6..72fabfa3de1 100644 --- a/tests/components/pyload/conftest.py +++ b/tests/components/pyload/conftest.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) +from homeassistant.helpers.service_info.hassio import HassioServiceInfo from tests.common import MockConfigEntry @@ -39,6 +40,21 @@ NEW_INPUT = { } +ADDON_DISCOVERY_INFO = { + "addon": "pyLoad-ng", + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", +} + +ADDON_SERVICE_INFO = HassioServiceInfo( + config=ADDON_DISCOVERY_INFO, + name="pyLoad-ng Addon", + slug="p539df76c_pyload-ng", + uuid="1234", +) + + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: """Override async_setup_entry.""" diff --git a/tests/components/pyload/test_config_flow.py b/tests/components/pyload/test_config_flow.py index 492e4a4b652..1eafbd2eb66 100644 --- a/tests/components/pyload/test_config_flow.py +++ b/tests/components/pyload/test_config_flow.py @@ -6,11 +6,18 @@ from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError import pytest from homeassistant.components.pyload.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_HASSIO, SOURCE_IGNORE, SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .conftest import NEW_INPUT, REAUTH_INPUT, USER_INPUT +from .conftest import ( + ADDON_DISCOVERY_INFO, + ADDON_SERVICE_INFO, + NEW_INPUT, + REAUTH_INPUT, + USER_INPUT, +) from tests.common import MockConfigEntry @@ -245,3 +252,183 @@ async def test_reconfigure_errors( assert result["reason"] == "reconfigure_successful" assert config_entry.data == USER_INPUT assert len(hass.config_entries.async_entries()) == 1 + + +async def test_hassio_discovery( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = InvalidAuth + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_confirm_only( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test flow started from Supervisor discovery. Abort with confirm only.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("side_effect", "error_text"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (IndexError, "unknown"), + ], +) +async def test_hassio_discovery_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pyloadapi: AsyncMock, + side_effect: Exception, + error_text: str, +) -> None: + """Test flow started from Supervisor discovery.""" + + mock_pyloadapi.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "hassio_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error_text} + + mock_pyloadapi.login.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "pyload", CONF_PASSWORD: "pyload"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "p539df76c_pyload-ng" + assert result["data"] == {**ADDON_DISCOVERY_INFO, CONF_VERIFY_SSL: False} + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_already_configured( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured.""" + + MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://539df76c-pyload-ng:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_data_update( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if already configured and we update entry from discovery data.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: "http://localhost:8000/", + CONF_USERNAME: "pyload", + CONF_PASSWORD: "pyload", + }, + unique_id="1234", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert entry.data[CONF_URL] == "http://539df76c-pyload-ng:8000/" + + +@pytest.mark.usefixtures("mock_pyloadapi") +async def test_hassio_discovery_ignored( + hass: HomeAssistant, +) -> None: + """Test we abort discovery flow if discovery was ignored.""" + + MockConfigEntry( + domain=DOMAIN, + source=SOURCE_IGNORE, + data={}, + unique_id="1234", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=ADDON_SERVICE_INFO, + context={"source": SOURCE_HASSIO}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" From 0729b3a2f1f9516281ac8659a7a52ce6ad441938 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Mon, 14 Jul 2025 20:53:53 +0200 Subject: [PATCH 1389/1664] Change hass.data storage to runtime.data for Squeezebox (#146482) --- homeassistant/components/squeezebox/__init__.py | 13 ++++--------- homeassistant/components/squeezebox/const.py | 2 -- homeassistant/components/squeezebox/media_player.py | 8 +++----- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 8bd0e2fca52..c6cb04b5ffb 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -1,7 +1,7 @@ """The Squeezebox integration.""" from asyncio import timeout -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from http import HTTPStatus import logging @@ -37,8 +37,6 @@ from .const import ( DISCOVERY_INTERVAL, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, SERVER_MANUFACTURER, SERVER_MODEL, SERVER_MODEL_ID, @@ -73,6 +71,7 @@ class SqueezeboxData: coordinator: LMSStatusDataUpdateCoordinator server: Server + known_player_ids: set[str] = field(default_factory=set) type SqueezeboxConfigEntry = ConfigEntry[SqueezeboxData] @@ -187,16 +186,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms) - # set up player discovery - known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {}) - known_players = known_servers.setdefault(lms.uuid, {}).setdefault(KNOWN_PLAYERS, []) - async def _player_discovery(now: datetime | None = None) -> None: """Discover squeezebox players by polling server.""" async def _discovered_player(player: Player) -> None: """Handle a (re)discovered player.""" - if player.player_id in known_players: + if player.player_id in entry.runtime_data.known_player_ids: await player.async_update() async_dispatcher_send( hass, SIGNAL_PLAYER_REDISCOVERED, player.player_id, player.connected @@ -207,7 +202,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - hass, entry, player, lms.uuid ) await player_coordinator.async_refresh() - known_players.append(player.player_id) + entry.runtime_data.known_player_ids.add(player.player_id) async_dispatcher_send( hass, SIGNAL_PLAYER_DISCOVERED, player_coordinator ) diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index 9d78605aee1..091ef4d1bbd 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -4,8 +4,6 @@ CONF_HTTPS = "https" DISCOVERY_TASK = "discovery_task" DOMAIN = "squeezebox" DEFAULT_PORT = 9000 -KNOWN_PLAYERS = "known_players" -KNOWN_SERVERS = "known_servers" PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub" SENSOR_UPDATE_INTERVAL = 60 SERVER_MANUFACTURER = "https://lyrion.org/" diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 8cf945cd7e9..f37faa4e115 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -60,8 +60,6 @@ from .const import ( DEFAULT_VOLUME_STEP, DISCOVERY_TASK, DOMAIN, - KNOWN_PLAYERS, - KNOWN_SERVERS, SERVER_MANUFACTURER, SERVER_MODEL, SERVER_MODEL_ID, @@ -316,9 +314,9 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): async def async_will_remove_from_hass(self) -> None: """Remove from list of known players when removed from hass.""" - known_servers = self.hass.data[DOMAIN][KNOWN_SERVERS] - known_players = known_servers[self.coordinator.server_uuid][KNOWN_PLAYERS] - known_players.remove(self.coordinator.player.player_id) + self.coordinator.config_entry.runtime_data.known_player_ids.remove( + self.coordinator.player.player_id + ) @property def volume_level(self) -> float | None: From ed4a23d104711e24adfe9133743ca990feaf6556 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 14 Jul 2025 20:57:00 +0200 Subject: [PATCH 1390/1664] Override connect method in RecorderPool (#148490) --- homeassistant/components/recorder/pool.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index d8d7ddb832a..2ee41ba2038 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.pool import ( ConnectionPoolEntry, NullPool, + PoolProxiedConnection, SingletonThreadPool, StaticPool, ) @@ -119,6 +120,12 @@ class RecorderPool(SingletonThreadPool, NullPool): ) return NullPool._create_connection(self) # noqa: SLF001 + def connect(self) -> PoolProxiedConnection: + """Return a connection from the pool.""" + if threading.get_ident() in self.recorder_and_worker_thread_ids: + return super().connect() + return NullPool.connect(self) + class MutexPool(StaticPool): """A pool which prevents concurrent accesses from multiple threads. From 1ef07544d57b2009204357791a1d95b5f5ec86db Mon Sep 17 00:00:00 2001 From: Stephan Traub Date: Mon, 14 Jul 2025 21:07:47 +0200 Subject: [PATCH 1391/1664] Fix for ignored devices issue #137114 (#146562) --- homeassistant/components/wiz/config_flow.py | 2 +- tests/components/wiz/test_config_flow.py | 43 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 92b25389450..a676c77688d 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -124,7 +124,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): data={CONF_HOST: device.ip_address}, ) - current_unique_ids = self._async_current_ids() + current_unique_ids = self._async_current_ids(include_ignore=False) current_hosts = { entry.data[CONF_HOST] for entry in self._async_current_entries(include_ignore=False) diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index ddf4a4f452a..946eb032f8e 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -572,3 +572,46 @@ async def test_discovered_during_onboarding(hass: HomeAssistant, source, data) - } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_flow_replace_ignored_device(hass: HomeAssistant) -> None: + """Test we can replace an ignored device via discovery.""" + # Add ignored entry to simulate previously ignored device + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + source=config_entries.SOURCE_IGNORE, + ) + entry.add_to_hass(hass) + # Patch discovery to find the same ignored device + with _patch_discovery(), _patch_wizlight(): + 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"], {}) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "pick_device" + # Proceed with selecting the device — previously ignored + with ( + _patch_wizlight(), + patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.wiz.async_setup", + return_value=True, + ) as mock_setup, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_DEVICE: FAKE_MAC} + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "WiZ Dimmable White ABCABC" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 9068a09620643b193c3b671b3170dc1a63da901c Mon Sep 17 00:00:00 2001 From: fwestenberg <47930023+fwestenberg@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:08:16 +0200 Subject: [PATCH 1392/1664] Add Stookwijzer forecast service (#138392) Co-authored-by: Joost Lekkerkerker --- .../components/stookwijzer/__init__.py | 16 +++- homeassistant/components/stookwijzer/const.py | 3 + .../components/stookwijzer/icons.json | 7 ++ .../components/stookwijzer/services.py | 76 +++++++++++++++++++ .../components/stookwijzer/services.yaml | 7 ++ .../components/stookwijzer/strings.json | 18 +++++ tests/components/stookwijzer/conftest.py | 10 +-- .../stookwijzer/snapshots/test_services.ambr | 27 +++++++ tests/components/stookwijzer/test_services.py | 72 ++++++++++++++++++ 9 files changed, 226 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/stookwijzer/icons.json create mode 100644 homeassistant/components/stookwijzer/services.py create mode 100644 homeassistant/components/stookwijzer/services.yaml create mode 100644 tests/components/stookwijzer/snapshots/test_services.ambr create mode 100644 tests/components/stookwijzer/test_services.py diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py index 9adfc09de0e..e51f3d76c7c 100644 --- a/homeassistant/components/stookwijzer/__init__.py +++ b/homeassistant/components/stookwijzer/__init__.py @@ -8,13 +8,27 @@ from stookwijzer import Stookwijzer from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers import ( + config_validation as cv, + entity_registry as er, + issue_registry as ir, +) +from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator +from .services import setup_services PLATFORMS = [Platform.SENSOR] +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Stookwijzer component.""" + setup_services(hass) + return True + async def async_setup_entry(hass: HomeAssistant, entry: StookwijzerConfigEntry) -> bool: """Set up Stookwijzer from a config entry.""" diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 1b0be86d375..7b4c28540fc 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -5,3 +5,6 @@ from typing import Final DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) + +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/icons.json b/homeassistant/components/stookwijzer/icons.json new file mode 100644 index 00000000000..19fda370796 --- /dev/null +++ b/homeassistant/components/stookwijzer/icons.json @@ -0,0 +1,7 @@ +{ + "services": { + "get_forecast": { + "service": "mdi:clock-plus-outline" + } + } +} diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py new file mode 100644 index 00000000000..e8c12717a21 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.py @@ -0,0 +1,76 @@ +"""Define services for the Stookwijzer integration.""" + +from typing import Required, TypedDict, cast + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError + +from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST +from .coordinator import StookwijzerConfigEntry + +SERVICE_GET_FORECAST_SCHEMA = vol.Schema( + { + vol.Required(ATTR_CONFIG_ENTRY_ID): str, + } +) + + +class Forecast(TypedDict): + """Typed Stookwijzer forecast dict.""" + + datetime: Required[str] + advice: str | None + final: bool | None + + +def async_get_entry( + hass: HomeAssistant, config_entry_id: str +) -> StookwijzerConfigEntry: + """Get the Overseerr config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": DOMAIN}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return cast(StookwijzerConfigEntry, entry) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Stookwijzer integration.""" + + async def async_get_forecast(call: ServiceCall) -> ServiceResponse | None: + """Get the forecast from API endpoint.""" + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + client = entry.runtime_data.client + + return cast( + ServiceResponse, + { + "forecast": cast( + list[Forecast], await client.async_get_forecast() or [] + ), + }, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECAST, + async_get_forecast, + schema=SERVICE_GET_FORECAST_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/stookwijzer/services.yaml b/homeassistant/components/stookwijzer/services.yaml new file mode 100644 index 00000000000..49e1f7b2927 --- /dev/null +++ b/homeassistant/components/stookwijzer/services.yaml @@ -0,0 +1,7 @@ +get_forecast: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: stookwijzer diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json index a028f1f19c5..160387ed8aa 100644 --- a/homeassistant/components/stookwijzer/strings.json +++ b/homeassistant/components/stookwijzer/strings.json @@ -27,6 +27,18 @@ } } }, + "services": { + "get_forecast": { + "name": "Get forecast", + "description": "Retrieves the advice forecast from Stookwijzer.", + "fields": { + "config_entry_id": { + "name": "Stookwijzer instance", + "description": "The Stookwijzer instance to get the forecast from." + } + } + } + }, "issues": { "location_migration_failed": { "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.", @@ -36,6 +48,12 @@ "exceptions": { "no_data_received": { "message": "No data received from Stookwijzer." + }, + "not_loaded": { + "message": "{target} is not loaded." + }, + "integration_not_found": { + "message": "Integration \"{target}\" not found in registry." } } } diff --git a/tests/components/stookwijzer/conftest.py b/tests/components/stookwijzer/conftest.py index dd7f2a7bbc3..0f127ba767a 100644 --- a/tests/components/stookwijzer/conftest.py +++ b/tests/components/stookwijzer/conftest.py @@ -1,26 +1,18 @@ """Fixtures for Stookwijzer integration tests.""" from collections.abc import Generator -from typing import Required, TypedDict from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.stookwijzer.const import DOMAIN +from homeassistant.components.stookwijzer.services import Forecast from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -class Forecast(TypedDict): - """Typed Stookwijzer forecast dict.""" - - datetime: Required[str] - advice: str | None - final: bool | None - - @pytest.fixture def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" diff --git a/tests/components/stookwijzer/snapshots/test_services.ambr b/tests/components/stookwijzer/snapshots/test_services.ambr new file mode 100644 index 00000000000..d5124219d32 --- /dev/null +++ b/tests/components/stookwijzer/snapshots/test_services.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_service_get_forecast + dict({ + 'forecast': tuple( + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T17:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_yellow', + 'datetime': '2025-02-12T23:00:00+01:00', + 'final': True, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T05:00:00+01:00', + 'final': False, + }), + dict({ + 'advice': 'code_orange', + 'datetime': '2025-02-13T11:00:00+01:00', + 'final': False, + }), + ), + }) +# --- diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py new file mode 100644 index 00000000000..f60730a290d --- /dev/null +++ b/tests/components/stookwijzer/test_services.py @@ -0,0 +1,72 @@ +"""Tests for the Stookwijzer services.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.stookwijzer.const import ( + ATTR_CONFIG_ENTRY_ID, + DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("init_integration") +async def test_service_get_forecast( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Stookwijzer forecast service.""" + + assert snapshot == await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_entry_not_loaded( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when entry is not loaded.""" + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id}, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_service_integration_not_found( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test error handling when integration not in registry.""" + with pytest.raises( + ServiceValidationError, match='Integration "stookwijzer" not found in registry' + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECAST, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id"}, + blocking=True, + return_response=True, + ) From d42d270fb233ee8f2af6fcbabe2b7bff1f10a1c3 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Mon, 14 Jul 2025 21:16:26 +0200 Subject: [PATCH 1393/1664] Bump Huum to version 0.8.0 (#148763) --- homeassistant/components/huum/climate.py | 12 ++---------- homeassistant/components/huum/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index 84173260d04..bbeb50a2b72 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -112,16 +112,8 @@ class HuumDevice(ClimateEntity): await self._turn_on(temperature) async def async_update(self) -> None: - """Get the latest status data. - - We get the latest status first from the status endpoints of the sauna. - If that data does not include the temperature, that means that the sauna - is off, we then call the off command which will in turn return the temperature. - This is a workaround for getting the temperature as the Huum API does not - return the target temperature of a sauna that is off, even if it can have - a target temperature at that time. - """ - self._status = await self._huum_handler.status_from_status_or_stop() + """Get the latest status data.""" + self._status = await self._huum_handler.status() if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: self._target_temperature = self._status.target_temperature diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 38562e1a072..82b863e4e42 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", - "requirements": ["huum==0.7.12"] + "requirements": ["huum==0.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a0f903370b4..0a5313d6978 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1186,7 +1186,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.0 # homeassistant.components.hyperion hyperion-py==0.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aee0dc556a1..332a6c61863 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1032,7 +1032,7 @@ httplib2==0.20.4 huawei-lte-api==1.11.0 # homeassistant.components.huum -huum==0.7.12 +huum==0.8.0 # homeassistant.components.hyperion hyperion-py==0.7.6 From c08c4024097d5208165b2900abf3a661638c853f Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:16:29 +0200 Subject: [PATCH 1394/1664] Add switches for HmIPW-DRI16, HmIPW-DRI32, HmIPW-DRS4, HmIPW-DRS8 (#148571) --- homeassistant/components/homematicip_cloud/switch.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index ca591adbf5e..5da2989f93f 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -18,6 +18,9 @@ from homematicip.device import ( PrintedCircuitBoardSwitch2, PrintedCircuitBoardSwitchBattery, SwitchMeasuring, + WiredInput32, + WiredInputSwitch6, + WiredSwitch4, WiredSwitch8, ) from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup @@ -51,6 +54,7 @@ async def async_setup_entry( elif isinstance( device, ( + WiredSwitch4, WiredSwitch8, OpenCollector8Module, BrandSwitch2, @@ -60,6 +64,8 @@ async def async_setup_entry( MotionDetectorSwitchOutdoor, DinRailSwitch, DinRailSwitch4, + WiredInput32, + WiredInputSwitch6, ), ): channel_indices = [ From 9e3a78b7efa954bcf1eac7d9ef6a77b1040f4237 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 14 Jul 2025 21:18:12 +0200 Subject: [PATCH 1395/1664] Bump pySmartThings to 3.2.8 (#148761) --- homeassistant/components/smartthings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json index 2c4974a6567..35354570f23 100644 --- a/homeassistant/components/smartthings/manifest.json +++ b/homeassistant/components/smartthings/manifest.json @@ -30,5 +30,5 @@ "iot_class": "cloud_push", "loggers": ["pysmartthings"], "quality_scale": "bronze", - "requirements": ["pysmartthings==3.2.7"] + "requirements": ["pysmartthings==3.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0a5313d6978..52b7555b6fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2348,7 +2348,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 332a6c61863..d8be5f73588 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1951,7 +1951,7 @@ pysmappee==0.2.29 pysmarlaapi==0.9.0 # homeassistant.components.smartthings -pysmartthings==3.2.7 +pysmartthings==3.2.8 # homeassistant.components.smarty pysmarty2==0.10.2 From 80eb4fb2f6a80eacd7a5c9c8dad31d07961a25f9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:24:32 +0200 Subject: [PATCH 1396/1664] Replace asyncio.iscoroutinefunction (#148738) --- homeassistant/components/knx/websocket.py | 4 ++-- homeassistant/core.py | 2 +- homeassistant/helpers/condition.py | 4 ++-- homeassistant/helpers/frame.py | 4 ++-- homeassistant/helpers/http.py | 4 ++-- homeassistant/helpers/service.py | 3 ++- homeassistant/helpers/singleton.py | 3 ++- homeassistant/helpers/trigger.py | 3 ++- homeassistant/util/__init__.py | 4 ++-- tests/components/music_assistant/common.py | 4 ++-- tests/util/test_logging.py | 5 +++-- 11 files changed, 22 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py index 31c5e8297e0..b40dc2246b8 100644 --- a/homeassistant/components/knx/websocket.py +++ b/homeassistant/components/knx/websocket.py @@ -2,9 +2,9 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from functools import wraps +import inspect from typing import TYPE_CHECKING, Any, Final, overload import knx_frontend as knx_panel @@ -116,7 +116,7 @@ def provide_knx( "KNX integration not loaded.", ) - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @wraps(func) async def with_knx( diff --git a/homeassistant/core.py b/homeassistant/core.py index 469acd5dae8..8ffabf56171 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -384,7 +384,7 @@ def get_hassjob_callable_job_type(target: Callable[..., Any]) -> HassJobType: while isinstance(check_target, functools.partial): check_target = check_target.func - if asyncio.iscoroutinefunction(check_target): + if inspect.iscoroutinefunction(check_target): return HassJobType.Coroutinefunction if is_callback(check_target): return HassJobType.Callback diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 37ff9b22ff7..3c6120f523f 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -3,12 +3,12 @@ from __future__ import annotations import abc -import asyncio from collections import deque from collections.abc import Callable, Container, Coroutine, Generator, Iterable from contextlib import contextmanager from datetime import datetime, time as dt_time, timedelta import functools as ft +import inspect import logging import re import sys @@ -359,7 +359,7 @@ async def async_from_config( while isinstance(check_factory, ft.partial): check_factory = check_factory.func - if asyncio.iscoroutinefunction(check_factory): + if inspect.iscoroutinefunction(check_factory): return cast(ConditionCheckerType, await factory(hass, config)) return cast(ConditionCheckerType, factory(config)) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 8f0741b5166..2d9b368254a 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -2,11 +2,11 @@ from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass import enum import functools +import inspect import linecache import logging import sys @@ -397,7 +397,7 @@ def _report_usage_no_integration( def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT: """Mock a function to warn when it was about to be used.""" - if asyncio.iscoroutinefunction(func): + if inspect.iscoroutinefunction(func): @functools.wraps(func) async def report_use(*args: Any, **kwargs: Any) -> None: diff --git a/homeassistant/helpers/http.py b/homeassistant/helpers/http.py index 68daf5c7939..e890a8ed087 100644 --- a/homeassistant/helpers/http.py +++ b/homeassistant/helpers/http.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Awaitable, Callable from contextvars import ContextVar from http import HTTPStatus +import inspect import logging from typing import Any, Final @@ -45,7 +45,7 @@ def request_handler_factory( hass: HomeAssistant, view: HomeAssistantView, handler: Callable ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" - is_coroutinefunction = asyncio.iscoroutinefunction(handler) + is_coroutinefunction = inspect.iscoroutinefunction(handler) assert is_coroutinefunction or is_callback(handler), ( "Handler should be a coroutine or a callback." ) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 1d4dac10c27..3186c211eaa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -7,6 +7,7 @@ from collections.abc import Callable, Coroutine, Iterable import dataclasses from enum import Enum from functools import cache, partial +import inspect import logging from types import ModuleType from typing import TYPE_CHECKING, Any, TypedDict, cast, override @@ -997,7 +998,7 @@ def verify_domain_control( service_handler: Callable[[ServiceCall], Any], ) -> Callable[[ServiceCall], Any]: """Decorate.""" - if not asyncio.iscoroutinefunction(service_handler): + if not inspect.iscoroutinefunction(service_handler): raise HomeAssistantError("Can only decorate async functions.") async def check_permissions(call: ServiceCall) -> Any: diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 075fc50b49a..dac2e5832f6 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine import functools +import inspect from typing import Any, Literal, assert_type, cast, overload from homeassistant.core import HomeAssistant @@ -47,7 +48,7 @@ def singleton[_S, _T, _U]( def wrapper(func: _FuncType[_Coro[_T] | _U]) -> _FuncType[_Coro[_T] | _U]: """Wrap a function with caching logic.""" - if not asyncio.iscoroutinefunction(func): + if not inspect.iscoroutinefunction(func): @functools.lru_cache(maxsize=1) @bind_hass diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 57ee6b99029..46b3d883865 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -8,6 +8,7 @@ from collections import defaultdict from collections.abc import Callable, Coroutine, Iterable from dataclasses import dataclass, field import functools +import inspect import logging from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast @@ -407,7 +408,7 @@ def _trigger_action_wrapper( check_func = check_func.func wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]] - if asyncio.iscoroutinefunction(check_func): + if inspect.iscoroutinefunction(check_func): async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action) @functools.wraps(async_action) diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index 19515fd7945..17a4a86f106 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -2,10 +2,10 @@ from __future__ import annotations -import asyncio from collections.abc import Callable, Coroutine, Iterable, KeysView, Mapping from datetime import datetime, timedelta from functools import wraps +import inspect import random import re import string @@ -125,7 +125,7 @@ class Throttle: def __call__(self, method: Callable) -> Callable: """Caller for the throttle.""" # Make sure we return a coroutine if the method is async. - if asyncio.iscoroutinefunction(method): + if inspect.iscoroutinefunction(method): async def throttled_value() -> None: """Stand-in function for when real func is being throttled.""" diff --git a/tests/components/music_assistant/common.py b/tests/components/music_assistant/common.py index a98ae82fbe1..072b1ece1a1 100644 --- a/tests/components/music_assistant/common.py +++ b/tests/components/music_assistant/common.py @@ -2,7 +2,7 @@ from __future__ import annotations -import asyncio +import inspect from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -191,7 +191,7 @@ async def trigger_subscription_callback( object_id=object_id, data=data, ) - if asyncio.iscoroutinefunction(cb_func): + if inspect.iscoroutinefunction(cb_func): await cb_func(event) else: cb_func(event) diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index ba473ee0c58..406952881bc 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -2,6 +2,7 @@ import asyncio from functools import partial +import inspect import logging import queue from unittest.mock import patch @@ -102,7 +103,7 @@ def test_catch_log_exception() -> None: async def async_meth(): pass - assert asyncio.iscoroutinefunction( + assert inspect.iscoroutinefunction( logging_util.catch_log_exception(partial(async_meth), lambda: None) ) @@ -120,7 +121,7 @@ def test_catch_log_exception() -> None: wrapped = logging_util.catch_log_exception(partial(sync_meth), lambda: None) assert not is_callback(wrapped) - assert not asyncio.iscoroutinefunction(wrapped) + assert not inspect.iscoroutinefunction(wrapped) @pytest.mark.no_fail_on_log_exception From 5ec9c4e6e31c46bb63af2a48992f134709383627 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:24:50 +0200 Subject: [PATCH 1397/1664] Add PS Vita support to PlayStation Network integration (#148186) --- .../playstation_network/__init__.py | 14 +- .../playstation_network/binary_sensor.py | 2 +- .../playstation_network/config_flow.py | 5 +- .../components/playstation_network/const.py | 5 +- .../playstation_network/coordinator.py | 80 +++++++-- .../playstation_network/diagnostics.py | 16 +- .../components/playstation_network/entity.py | 8 +- .../components/playstation_network/helpers.py | 52 +++++- .../playstation_network/media_player.py | 48 ++++-- .../components/playstation_network/sensor.py | 2 +- .../playstation_network/conftest.py | 43 ++++- .../snapshots/test_diagnostics.ambr | 9 + .../snapshots/test_media_player.ambr | 162 ++++++++++++++++++ .../playstation_network/test_init.py | 158 ++++++++++++++++- .../playstation_network/test_media_player.py | 70 ++++++++ 15 files changed, 614 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index feb598a646a..e5b98d00726 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -6,7 +6,12 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from .const import CONF_NPSSO -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkRuntimeData, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .helpers import PlaystationNetwork PLATFORMS: list[Platform] = [ @@ -23,9 +28,12 @@ async def async_setup_entry( psn = PlaystationNetwork(hass, entry.data[CONF_NPSSO]) - coordinator = PlaystationNetworkCoordinator(hass, psn, entry) + coordinator = PlaystationNetworkUserDataCoordinator(hass, psn, entry) await coordinator.async_config_entry_first_refresh() - entry.runtime_data = coordinator + + trophy_titles = PlaystationNetworkTrophyTitlesCoordinator(hass, psn, entry) + + entry.runtime_data = PlaystationNetworkRuntimeData(coordinator, trophy_titles) 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 fcecd1d1ee1..453cfb37347 100644 --- a/homeassistant/components/playstation_network/binary_sensor.py +++ b/homeassistant/components/playstation_network/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the binary sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS diff --git a/homeassistant/components/playstation_network/config_flow.py b/homeassistant/components/playstation_network/config_flow.py index b4a4a9374fa..0e69abf1080 100644 --- a/homeassistant/components/playstation_network/config_flow.py +++ b/homeassistant/components/playstation_network/config_flow.py @@ -10,7 +10,6 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPInvalidTokenError, PSNAWPNotFoundError, ) -from psnawp_api.models.user import User from psnawp_api.utils.misc import parse_npsso_token import voluptuous as vol @@ -42,7 +41,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): else: psn = PlaystationNetwork(self.hass, npsso) try: - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except PSNAWPNotFoundError: @@ -98,7 +97,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN): try: npsso = parse_npsso_token(user_input[CONF_NPSSO]) psn = PlaystationNetwork(self.hass, npsso) - user: User = await psn.get_user() + user = await psn.get_user() except PSNAWPAuthenticationError: errors["base"] = "invalid_auth" except (PSNAWPNotFoundError, PSNAWPInvalidTokenError): diff --git a/homeassistant/components/playstation_network/const.py b/homeassistant/components/playstation_network/const.py index 77b43af3b73..f4c5c7a3e5b 100644 --- a/homeassistant/components/playstation_network/const.py +++ b/homeassistant/components/playstation_network/const.py @@ -8,9 +8,10 @@ DOMAIN = "playstation_network" CONF_NPSSO: Final = "npsso" SUPPORTED_PLATFORMS = { - PlatformType.PS5, - PlatformType.PS4, + PlatformType.PS_VITA, PlatformType.PS3, + PlatformType.PS4, + PlatformType.PS5, PlatformType.PSPC, } diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 69cc95d1d49..a9f49f7f7bb 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -2,6 +2,8 @@ from __future__ import annotations +from abc import abstractmethod +from dataclasses import dataclass from datetime import timedelta import logging @@ -10,6 +12,7 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPClientError, PSNAWPServerError, ) +from psnawp_api.models.trophies import TrophyTitle from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,13 +24,22 @@ from .helpers import PlaystationNetwork, PlaystationNetworkData _LOGGER = logging.getLogger(__name__) -type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkCoordinator] +type PlaystationNetworkConfigEntry = ConfigEntry[PlaystationNetworkRuntimeData] -class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData]): - """Data update coordinator for PSN.""" +@dataclass +class PlaystationNetworkRuntimeData: + """Dataclass holding PSN runtime data.""" + + user_data: PlaystationNetworkUserDataCoordinator + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator + + +class PlayStationNetworkBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]): + """Base coordinator for PSN.""" config_entry: PlaystationNetworkConfigEntry + _update_inverval: timedelta def __init__( self, @@ -41,16 +53,43 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData name=DOMAIN, logger=_LOGGER, config_entry=config_entry, - update_interval=timedelta(seconds=30), + update_interval=self._update_interval, ) self.psn = psn + @abstractmethod + async def update_data(self) -> _DataT: + """Update coordinator data.""" + + async def _async_update_data(self) -> _DataT: + """Get the latest data from the PSN.""" + try: + return await self.update_data() + except PSNAWPAuthenticationError as error: + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="not_ready", + ) from error + except (PSNAWPServerError, PSNAWPClientError) as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error + + +class PlaystationNetworkUserDataCoordinator( + PlayStationNetworkBaseCoordinator[PlaystationNetworkData] +): + """Data update coordinator for PSN.""" + + _update_interval = timedelta(seconds=30) + async def _async_setup(self) -> None: """Set up the coordinator.""" try: - await self.psn.get_user() + await self.psn.async_setup() except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -62,17 +101,22 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData translation_key="update_failed", ) from error - async def _async_update_data(self) -> PlaystationNetworkData: + async def update_data(self) -> PlaystationNetworkData: """Get the latest data from the PSN.""" - try: - return await self.psn.get_data() - except PSNAWPAuthenticationError as error: - raise ConfigEntryAuthFailed( - translation_domain=DOMAIN, - translation_key="not_ready", - ) from error - except (PSNAWPServerError, PSNAWPClientError) as error: - raise UpdateFailed( - translation_domain=DOMAIN, - translation_key="update_failed", - ) from error + return await self.psn.get_data() + + +class PlaystationNetworkTrophyTitlesCoordinator( + PlayStationNetworkBaseCoordinator[list[TrophyTitle]] +): + """Trophy titles data update coordinator for PSN.""" + + _update_interval = timedelta(days=1) + + 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()) + ) + await self.config_entry.runtime_data.user_data.async_request_refresh() + return self.psn.trophy_titles diff --git a/homeassistant/components/playstation_network/diagnostics.py b/homeassistant/components/playstation_network/diagnostics.py index 8332572177d..7b5c762db12 100644 --- a/homeassistant/components/playstation_network/diagnostics.py +++ b/homeassistant/components/playstation_network/diagnostics.py @@ -10,7 +10,7 @@ from psnawp_api.models.trophies import PlatformType from homeassistant.components.diagnostics import async_redact_data from homeassistant.core import HomeAssistant -from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from .coordinator import PlaystationNetworkConfigEntry TO_REDACT = { "account_id", @@ -27,12 +27,12 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: PlaystationNetworkConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: PlaystationNetworkCoordinator = entry.runtime_data + coordinator = entry.runtime_data.user_data return { "data": async_redact_data( _serialize_platform_types(asdict(coordinator.data)), TO_REDACT - ), + ) } @@ -46,10 +46,12 @@ def _serialize_platform_types(data: Any) -> Any: for platform, record in data.items() } if isinstance(data, set): - return [ - record.value if isinstance(record, PlatformType) else record - for record in data - ] + return sorted( + [ + record.value if isinstance(record, PlatformType) else record + for record in data + ] + ) if isinstance(data, PlatformType): return data.value return data diff --git a/homeassistant/components/playstation_network/entity.py b/homeassistant/components/playstation_network/entity.py index 54f5fd5db70..660c77dc30f 100644 --- a/homeassistant/components/playstation_network/entity.py +++ b/homeassistant/components/playstation_network/entity.py @@ -7,17 +7,19 @@ from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import PlaystationNetworkCoordinator +from .coordinator import PlaystationNetworkUserDataCoordinator -class PlaystationNetworkServiceEntity(CoordinatorEntity[PlaystationNetworkCoordinator]): +class PlaystationNetworkServiceEntity( + CoordinatorEntity[PlaystationNetworkUserDataCoordinator] +): """Common entity class for PlayStationNetwork Service entities.""" _attr_has_entity_name = True def __init__( self, - coordinator: PlaystationNetworkCoordinator, + coordinator: PlaystationNetworkUserDataCoordinator, entity_description: EntityDescription, ) -> None: """Initialize PlayStation Network Service Entity.""" diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index 9c7dac29a81..debe7a338e2 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -8,7 +8,7 @@ from typing import Any from psnawp_api import PSNAWP from psnawp_api.models.client import Client -from psnawp_api.models.trophies import PlatformType, TrophySummary +from psnawp_api.models.trophies import PlatformType, TrophySummary, TrophyTitle from psnawp_api.models.user import User from pyrate_limiter import Duration, Rate @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from .const import SUPPORTED_PLATFORMS -LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4} +LEGACY_PLATFORMS = {PlatformType.PS3, PlatformType.PS4, PlatformType.PS_VITA} @dataclass @@ -52,10 +52,22 @@ class PlaystationNetwork: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) self.psn = PSNAWP(npsso, rate_limit=rate) - self.client: Client | None = None + self.client: Client self.hass = hass self.user: User self.legacy_profile: dict[str, Any] | None = None + self.trophy_titles: list[TrophyTitle] = [] + self._title_icon_urls: dict[str, str] = {} + + def _setup(self) -> None: + """Setup PSN.""" + self.user = self.psn.user(online_id="me") + self.client = self.psn.me() + self.trophy_titles = list(self.user.trophy_titles()) + + async def async_setup(self) -> None: + """Setup PSN.""" + await self.hass.async_add_executor_job(self._setup) async def get_user(self) -> User: """Get the user object from the PlayStation Network.""" @@ -68,9 +80,6 @@ class PlaystationNetwork: """Bundle api calls to retrieve data from the PlayStation Network.""" data = PlaystationNetworkData() - if not self.client: - self.client = self.psn.me() - data.registered_platforms = { PlatformType(device["deviceType"]) for device in self.client.get_account_devices() @@ -123,7 +132,7 @@ class PlaystationNetwork: presence = self.legacy_profile["profile"].get("presences", []) if (game_title_info := presence[0] if presence else {}) and game_title_info[ "onlineStatus" - ] == "online": + ] != "offline": platform = PlatformType(game_title_info["platform"]) if platform is PlatformType.PS4: @@ -135,6 +144,10 @@ class PlaystationNetwork: account_id="me", np_communication_id="", ).get_title_icon_url() + elif platform is PlatformType.PS_VITA and game_title_info.get( + "npTitleId" + ): + media_image_url = self.get_psvita_title_icon_url(game_title_info) else: media_image_url = None @@ -147,3 +160,28 @@ class PlaystationNetwork: status=game_title_info["onlineStatus"], ) return data + + def get_psvita_title_icon_url(self, game_title_info: dict[str, Any]) -> str | None: + """Look up title_icon_url from trophy titles data.""" + + if url := self._title_icon_urls.get(game_title_info["npTitleId"]): + return url + + url = next( + ( + title.title_icon_url + for title in self.trophy_titles + if game_title_info["titleName"] + == normalize_title(title.title_name or "") + and next(iter(title.title_platform)) == PlatformType.PS_VITA + ), + None, + ) + if url is not None: + self._title_icon_urls[game_title_info["npTitleId"]] = url + return url + + +def normalize_title(name: str) -> str: + """Normalize trophy title.""" + return name.removesuffix("Trophies").removesuffix("Trophy Set").strip() diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 3e55e565460..0a9b8fe6162 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -17,13 +17,18 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator +from . import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkTrophyTitlesCoordinator, + PlaystationNetworkUserDataCoordinator, +) from .const import DOMAIN, SUPPORTED_PLATFORMS _LOGGER = logging.getLogger(__name__) PLATFORM_MAP = { + PlatformType.PS_VITA: "PlayStation Vita", PlatformType.PS5: "PlayStation 5", PlatformType.PS4: "PlayStation 4", PlatformType.PS3: "PlayStation 3", @@ -38,7 +43,8 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Media Player Entity Setup.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data + trophy_titles = config_entry.runtime_data.trophy_titles devices_added: set[PlatformType] = set() device_reg = dr.async_get(hass) entities = [] @@ -50,10 +56,12 @@ async def async_setup_entry( if not SUPPORTED_PLATFORMS - devices_added: remove_listener() - new_platforms = set(coordinator.data.active_sessions.keys()) - devices_added + new_platforms = ( + set(coordinator.data.active_sessions.keys()) & SUPPORTED_PLATFORMS + ) - devices_added if new_platforms: async_add_entities( - PsnMediaPlayerEntity(coordinator, platform_type) + PsnMediaPlayerEntity(coordinator, platform_type, trophy_titles) for platform_type in new_platforms ) devices_added |= new_platforms @@ -64,7 +72,7 @@ async def async_setup_entry( (DOMAIN, f"{coordinator.config_entry.unique_id}_{platform.value}") } ): - entities.append(PsnMediaPlayerEntity(coordinator, platform)) + entities.append(PsnMediaPlayerEntity(coordinator, platform, trophy_titles)) devices_added.add(platform) if entities: async_add_entities(entities) @@ -74,7 +82,7 @@ async def async_setup_entry( class PsnMediaPlayerEntity( - CoordinatorEntity[PlaystationNetworkCoordinator], MediaPlayerEntity + CoordinatorEntity[PlaystationNetworkUserDataCoordinator], MediaPlayerEntity ): """Media player entity representing currently playing game.""" @@ -86,7 +94,10 @@ class PsnMediaPlayerEntity( _attr_name = None def __init__( - self, coordinator: PlaystationNetworkCoordinator, platform: PlatformType + self, + coordinator: PlaystationNetworkUserDataCoordinator, + platform: PlatformType, + trophy_titles: PlaystationNetworkTrophyTitlesCoordinator, ) -> None: """Initialize PSN MediaPlayer.""" super().__init__(coordinator) @@ -101,15 +112,21 @@ class PsnMediaPlayerEntity( model=PLATFORM_MAP[platform], via_device=(DOMAIN, coordinator.config_entry.unique_id), ) + self.trophy_titles = trophy_titles @property def state(self) -> MediaPlayerState: """Media Player state getter.""" session = self.coordinator.data.active_sessions.get(self.key) - if session and session.status == "online": - if session.title_id is not None: - return MediaPlayerState.PLAYING - return MediaPlayerState.ON + if session: + if session.status == "online": + return ( + MediaPlayerState.PLAYING + if session.title_id is not None + else MediaPlayerState.ON + ) + if session.status == "standby": + return MediaPlayerState.STANDBY return MediaPlayerState.OFF @property @@ -129,3 +146,12 @@ class PsnMediaPlayerEntity( """Media image url getter.""" session = self.coordinator.data.active_sessions.get(self.key) return session.media_image_url if session else None + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + + await super().async_added_to_hass() + if self.key is PlatformType.PS_VITA: + self.async_on_remove( + self.trophy_titles.async_add_listener(self._handle_coordinator_update) + ) diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index cfd81fe4033..b17b4c04ab7 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -131,7 +131,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the sensor platform.""" - coordinator = config_entry.runtime_data + coordinator = config_entry.runtime_data.user_data async_add_entities( PlaystationNetworkSensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 431a30ba7f7..5f6f3436699 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -1,9 +1,15 @@ """Common fixtures for the Playstation Network tests.""" from collections.abc import Generator +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch -from psnawp_api.models.trophies import TrophySet, TrophySummary +from psnawp_api.models.trophies import ( + PlatformType, + TrophySet, + TrophySummary, + TrophyTitle, +) import pytest from homeassistant.components.playstation_network.const import CONF_NPSSO, DOMAIN @@ -83,13 +89,14 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: client.user.return_value = mock_user client.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PSVITA"}, { "deviceId": "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234", "deviceType": "PS5", "activationType": "PRIMARY", "activationDate": "2021-01-14T18:00:00.000Z", "accountDeviceVector": "abcdefghijklmnopqrstuv", - } + }, ] client.me.return_value.trophy_summary.return_value = TrophySummary( PSN_ID, 1079, 19, 10, TrophySet(14450, 8722, 11754, 1398) @@ -118,7 +125,37 @@ def mock_psnawpapi(mock_user: MagicMock) -> Generator[MagicMock]: "isOfficiallyVerified": False, "isMe": True, } - + client.user.return_value.trophy_titles.return_value = [ + TrophyTitle( + np_service_name="trophy", + np_communication_id="NPWR03134_00", + trophy_set_version="01.03", + title_name="Assassin's Creed® III Liberation", + title_detail="Assassin's Creed® III Liberation", + title_icon_url="https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG", + title_platform=frozenset({PlatformType.PS_VITA}), + has_trophy_groups=False, + progress=28, + hidden_flag=False, + earned_trophies=TrophySet(bronze=4, silver=8, gold=0, platinum=0), + defined_trophies=TrophySet(bronze=22, silver=21, gold=1, platinum=1), + last_updated_datetime=datetime(2016, 10, 6, 18, 5, 8, tzinfo=UTC), + np_title_id=None, + ) + ] + client.me.return_value.get_profile_legacy.return_value = { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + } yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index f320eea4b7c..ebf8d9e927f 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -12,6 +12,14 @@ 'title_id': 'PPSA07784_00', 'title_name': 'STAR WARS Jedi: Survivor™', }), + 'PSVITA': dict({ + 'format': 'PSVITA', + 'media_image_url': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'platform': 'PSVITA', + 'status': 'online', + 'title_id': 'PCSB00074_00', + 'title_name': "Assassin's Creed® III Liberation", + }), }), 'availability': 'availableToPlay', 'presence': dict({ @@ -61,6 +69,7 @@ }), 'registered_platforms': list([ 'PS5', + 'PSVITA', ]), 'trophy_summary': dict({ 'account_id': '**REDACTED**', diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index a42522592e4..69024c2326f 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -1,4 +1,166 @@ # serializer version: 1 +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload0][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'standby', + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture': 'https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG', + 'entity_picture_local': '/api/media_player_proxy/media_player.playstation_vita?token=123456789&cache=c7c916a6e18aec3d', + 'friendly_name': 'PlayStation Vita', + 'media_content_id': 'PCSB00074_00', + 'media_content_type': , + 'media_title': "Assassin's Creed® III Liberation", + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'playing', + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.playstation_vita', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'playstation', + 'unique_id': 'my-psn-id_PSVITA', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_player_psvita[presence_payload2][media_player.playstation_vita-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'entity_picture_local': None, + 'friendly_name': 'PlayStation Vita', + 'media_content_type': , + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.playstation_vita', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform[PS4_idle][media_player.playstation_4-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py index 09fbe4b0de4..c1f2691d623 100644 --- a/tests/components/playstation_network/test_init.py +++ b/tests/components/playstation_network/test_init.py @@ -1,7 +1,9 @@ """Tests for PlayStation Network.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory from psnawp_api.core import ( PSNAWPAuthenticationError, PSNAWPClientError, @@ -11,10 +13,13 @@ from psnawp_api.core import ( import pytest from homeassistant.components.playstation_network.const import DOMAIN +from homeassistant.components.playstation_network.coordinator import ( + PlaystationNetworkRuntimeData, +) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.parametrize( @@ -107,3 +112,154 @@ async def test_coordinator_update_auth_failed( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == config_entry.entry_id + + +async def test_trophy_title_coordinator( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator updates when PS Vita is registered.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + +async def test_trophy_title_coordinator_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator starts reauth on authentication error.""" + + 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 + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = ( + PSNAWPAuthenticationError + ) + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_trophy_title_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator update failed.""" + + 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 + + mock_psnawpapi.user.return_value.trophy_titles.side_effect = exception + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data + assert runtime_data.trophy_titles.last_update_success is False + + +async def test_trophy_title_coordinator_doesnt_update( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test trophy title coordinator does not update if no PS Vita is registered.""" + + mock_psnawpapi.me.return_value.get_account_devices.return_value = [ + {"deviceType": "PS5"}, + {"deviceType": "PS3"}, + ] + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 1 + + +async def test_trophy_title_coordinator_play_new_game( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test we play a new game and get a title image on next trophy titles update.""" + + _tmp = mock_psnawpapi.user.return_value.trophy_titles.return_value + mock_psnawpapi.user.return_value.trophy_titles.return_value = [] + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("media_player.playstation_vita")) + assert state.attributes.get("entity_picture") is None + + mock_psnawpapi.user.return_value.trophy_titles.return_value = _tmp + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2 + + assert (state := hass.states.get("media_player.playstation_vita")) + assert ( + state.attributes["entity_picture"] + == "https://image.api.playstation.com/trophy/np/NPWR03134_00_0008206095F67FD3BB385E9E00A7C9CFE6F5A4AB96/5F87A6997DD23D1C4D4CC0D1F958ED79CB905331.PNG" + ) diff --git a/tests/components/playstation_network/test_media_player.py b/tests/components/playstation_network/test_media_player.py index f503a5ec297..53bf6436c73 100644 --- a/tests/components/playstation_network/test_media_player.py +++ b/tests/components/playstation_network/test_media_player.py @@ -114,6 +114,76 @@ async def test_platform( """Test setup of the PlayStation Network media_player platform.""" mock_psnawpapi.user().get_presence.return_value = presence_payload + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = { + "profile": {"presences": []} + } + 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) + + +@pytest.mark.parametrize( + "presence_payload", + [ + { + "profile": { + "presences": [ + { + "onlineStatus": "standby", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "npTitleId": "PCSB00074_00", + "titleName": "Assassin's Creed® III Liberation", + "hasBroadcastData": False, + } + ] + } + }, + { + "profile": { + "presences": [ + { + "onlineStatus": "online", + "platform": "PSVITA", + "hasBroadcastData": False, + } + ] + } + }, + ], +) +@pytest.mark.usefixtures("mock_psnawpapi", "mock_token") +async def test_media_player_psvita( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_psnawpapi: MagicMock, + presence_payload: dict[str, Any], +) -> None: + """Test setup of the PlayStation Network media_player for PlayStation Vita.""" + + mock_psnawpapi.user().get_presence.return_value = { + "basicPresence": { + "availability": "unavailable", + "primaryPlatformInfo": {"onlineStatus": "offline", "platform": ""}, + } + } + mock_psnawpapi.me.return_value.get_profile_legacy.return_value = presence_payload config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 37ae476c67cddd842c93493f8acc63ef45740e6b Mon Sep 17 00:00:00 2001 From: Willem-Jan van Rootselaar Date: Mon, 14 Jul 2025 21:26:03 +0200 Subject: [PATCH 1398/1664] Add Zeroconf support for bsblan integration (#146137) Co-authored-by: Joost Lekkerkerker --- .../components/bsblan/config_flow.py | 142 ++++- homeassistant/components/bsblan/manifest.json | 8 +- homeassistant/components/bsblan/sensor.py | 2 + homeassistant/components/bsblan/strings.json | 20 +- homeassistant/generated/zeroconf.py | 4 + tests/components/bsblan/test_config_flow.py | 539 ++++++++++++++++-- 6 files changed, 658 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/bsblan/config_flow.py b/homeassistant/components/bsblan/config_flow.py index a1d7d6d403a..6abfe57a4ae 100644 --- a/homeassistant/components/bsblan/config_flow.py +++ b/homeassistant/components/bsblan/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN @@ -21,12 +22,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - host: str - port: int - mac: str - passkey: str | None = None - username: str | None = None - password: str | None = None + def __init__(self) -> None: + """Initialize BSBLan flow.""" + self.host: str | None = None + self.port: int = DEFAULT_PORT + self.mac: str | None = None + self.passkey: str | None = None + self.username: str | None = None + self.password: str | None = None + self._auth_required = True async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -41,9 +45,111 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): self.username = user_input.get(CONF_USERNAME) self.password = user_input.get(CONF_PASSWORD) + return await self._validate_and_create() + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle Zeroconf discovery.""" + + self.host = str(discovery_info.ip_address) + self.port = discovery_info.port or DEFAULT_PORT + + # Get MAC from properties + self.mac = discovery_info.properties.get("mac") + + # If MAC was found in zeroconf, use it immediately + if self.mac: + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + else: + # MAC not available from zeroconf - check for existing host/port first + self._async_abort_entries_match( + {CONF_HOST: self.host, CONF_PORT: self.port} + ) + + # Try to get device info without authentication to minimize discovery popup + config = BSBLANConfig(host=self.host, port=self.port) + session = async_get_clientsession(self.hass) + bsblan = BSBLAN(config, session) + try: + device = await bsblan.device() + except BSBLANError: + # Device requires authentication - proceed to discovery confirm + self.mac = None + else: + self.mac = device.MAC + + # Got MAC without auth - set unique ID and check for existing device + await self.async_set_unique_id(format_mac(self.mac)) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.host, + CONF_PORT: self.port, + } + ) + # No auth needed, so we can proceed to a confirmation step without fields + self._auth_required = False + + # Proceed to get credentials + self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle getting credentials for discovered device.""" + if user_input is None: + data_schema = vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ) + if not self._auth_required: + data_schema = vol.Schema({}) + + return self.async_show_form( + step_id="discovery_confirm", + data_schema=data_schema, + description_placeholders={"host": str(self.host)}, + ) + + if not self._auth_required: + return self._async_create_entry() + + self.passkey = user_input.get(CONF_PASSKEY) + self.username = user_input.get(CONF_USERNAME) + self.password = user_input.get(CONF_PASSWORD) + + return await self._validate_and_create(is_discovery=True) + + async def _validate_and_create( + self, is_discovery: bool = False + ) -> ConfigFlowResult: + """Validate device connection and create entry.""" try: - await self._get_bsblan_info() + await self._get_bsblan_info(is_discovery=is_discovery) except BSBLANError: + if is_discovery: + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema( + { + vol.Optional(CONF_PASSKEY): str, + vol.Optional(CONF_USERNAME): str, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors={"base": "cannot_connect"}, + description_placeholders={"host": str(self.host)}, + ) return self._show_setup_form({"base": "cannot_connect"}) return self._async_create_entry() @@ -67,6 +173,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): @callback def _async_create_entry(self) -> ConfigFlowResult: + """Create the config entry.""" return self.async_create_entry( title=format_mac(self.mac), data={ @@ -78,8 +185,10 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None: - """Get device information from an BSBLAN device.""" + async def _get_bsblan_info( + self, raise_on_progress: bool = True, is_discovery: bool = False + ) -> None: + """Get device information from a BSBLAN device.""" config = BSBLANConfig( host=self.host, passkey=self.passkey, @@ -90,11 +199,18 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN): session = async_get_clientsession(self.hass) bsblan = BSBLAN(config, session) device = await bsblan.device() - self.mac = device.MAC + retrieved_mac = device.MAC - await self.async_set_unique_id( - format_mac(self.mac), raise_on_progress=raise_on_progress - ) + # Handle unique ID assignment based on whether MAC was available from zeroconf + if not self.mac: + # MAC wasn't available from zeroconf, now we have it from API + self.mac = retrieved_mac + await self.async_set_unique_id( + format_mac(self.mac), raise_on_progress=raise_on_progress + ) + + # Always allow updating host/port for both user and discovery flows + # This ensures connectivity is maintained when devices change IP addresses self._abort_if_unique_id_configured( updates={ CONF_HOST: self.host, diff --git a/homeassistant/components/bsblan/manifest.json b/homeassistant/components/bsblan/manifest.json index 8ea339f76c4..c5245524e28 100644 --- a/homeassistant/components/bsblan/manifest.json +++ b/homeassistant/components/bsblan/manifest.json @@ -7,5 +7,11 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["bsblan"], - "requirements": ["python-bsblan==2.1.0"] + "requirements": ["python-bsblan==2.1.0"], + "zeroconf": [ + { + "type": "_http._tcp.local.", + "name": "bsb-lan*" + } + ] } diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py index 6a6784a4542..7f3f7f48afc 100644 --- a/homeassistant/components/bsblan/sensor.py +++ b/homeassistant/components/bsblan/sensor.py @@ -20,6 +20,8 @@ from . import BSBLanConfigEntry, BSBLanData from .coordinator import BSBLanCoordinatorData from .entity import BSBLanEntity +PARALLEL_UPDATES = 1 + @dataclass(frozen=True, kw_only=True) class BSBLanSensorEntityDescription(SensorEntityDescription): diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json index 93562763999..cd4633dfb86 100644 --- a/homeassistant/components/bsblan/strings.json +++ b/homeassistant/components/bsblan/strings.json @@ -13,7 +13,25 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "host": "The hostname or IP address of your BSB-Lan device." + "host": "The hostname or IP address of your BSB-Lan device.", + "port": "The port number of your BSB-Lan device.", + "passkey": "The passkey for your BSB-Lan device.", + "username": "The username for your BSB-Lan device.", + "password": "The password for your BSB-Lan device." + } + }, + "discovery_confirm": { + "title": "BSB-Lan device discovered", + "description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.", + "data": { + "passkey": "[%key:component::bsblan::config::step::user::data::passkey%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]", + "username": "[%key:component::bsblan::config::step::user::data_description::username%]", + "password": "[%key:component::bsblan::config::step::user::data_description::password%]" } } }, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 47522a69c41..a3668acee8d 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -568,6 +568,10 @@ ZEROCONF = { "domain": "bosch_shc", "name": "bosch shc*", }, + { + "domain": "bsblan", + "name": "bsb-lan*", + }, { "domain": "eheimdigital", "name": "eheimdigital._http._tcp.local.", diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py index 91e4338d688..72360ece687 100644 --- a/tests/components/bsblan/test_config_flow.py +++ b/tests/components/bsblan/test_config_flow.py @@ -1,19 +1,124 @@ """Tests for the BSBLan device config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from bsblan import BSBLANConnectionError +import pytest -from homeassistant.components.bsblan import config_flow from homeassistant.components.bsblan.const import CONF_PASSKEY, DOMAIN -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from tests.common import MockConfigEntry +# ZeroconfServiceInfo fixtures for different discovery scenarios + + +@pytest.fixture +def zeroconf_discovery_info() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device with MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "00:80:41:19:69:90"}, + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_no_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info for a BSBLAN device without MAC address.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={}, # No MAC in properties + port=80, + hostname="BSB-LAN.local.", + ) + + +@pytest.fixture +def zeroconf_discovery_info_different_mac() -> ZeroconfServiceInfo: + """Return zeroconf discovery info with a different MAC than the device API returns.""" + return ZeroconfServiceInfo( + ip_address=ip_address("10.0.2.60"), + ip_addresses=[ip_address("10.0.2.60")], + name="BSB-LAN web service._http._tcp.local.", + type="_http._tcp.local.", + properties={"mac": "aa:bb:cc:dd:ee:ff"}, # Different MAC than in device.json + port=80, + hostname="BSB-LAN.local.", + ) + + +# Helper functions to reduce repetition + + +async def _init_user_flow(hass: HomeAssistant, user_input: dict | None = None): + """Initialize a user config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=user_input, + ) + + +async def _init_zeroconf_flow(hass: HomeAssistant, discovery_info): + """Initialize a zeroconf config flow.""" + return await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + +async def _configure_flow(hass: HomeAssistant, flow_id: str, user_input: dict): + """Configure a flow with user input.""" + return await hass.config_entries.flow.async_configure( + flow_id, + user_input=user_input, + ) + + +def _assert_create_entry_result( + result, expected_title: str, expected_data: dict, expected_unique_id: str +): + """Assert that result is a successful CREATE_ENTRY.""" + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == expected_title + assert result.get("data") == expected_data + assert "result" in result + assert result["result"].unique_id == expected_unique_id + + +def _assert_form_result( + result, expected_step_id: str, expected_errors: dict | None = None +): + """Assert that result is a FORM with correct step and optional errors.""" + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == expected_step_id + if expected_errors is None: + # Handle both None and {} as valid "no errors" states (like other integrations) + assert result.get("errors") in ({}, None) + else: + assert result.get("errors") == expected_errors + + +def _assert_abort_result(result, expected_reason: str): + """Assert that result is an ABORT with correct reason.""" + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -21,17 +126,13 @@ async def test_full_user_flow_implementation( mock_setup_entry: AsyncMock, ) -> None: """Test the full manual user flow from start to finish.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) + result = await _init_user_flow(hass) + _assert_form_result(result, "user") - assert result.get("type") is FlowResultType.FORM - assert result.get("step_id") == "user" - - result2 = await hass.config_entries.flow.async_configure( + result2 = await _configure_flow( + hass, result["flow_id"], - user_input={ + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -40,17 +141,18 @@ async def test_full_user_flow_implementation( }, ) - assert result2.get("type") is FlowResultType.CREATE_ENTRY - assert result2.get("title") == format_mac("00:80:41:19:69:90") - assert result2.get("data") == { - CONF_HOST: "127.0.0.1", - CONF_PORT: 80, - CONF_PASSKEY: "1234", - CONF_USERNAME: "admin", - CONF_PASSWORD: "admin1234", - } - assert "result" in result2 - assert result2["result"].unique_id == format_mac("00:80:41:19:69:90") + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) assert len(mock_setup_entry.mock_calls) == 1 assert len(mock_bsblan.device.mock_calls) == 1 @@ -58,13 +160,8 @@ async def test_full_user_flow_implementation( async def test_show_user_form(hass: HomeAssistant) -> None: """Test that the user set up form is served.""" - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": SOURCE_USER}, - ) - - assert result["step_id"] == "user" - assert result["type"] is FlowResultType.FORM + result = await _init_user_flow(hass) + _assert_form_result(result, "user") async def test_connection_error( @@ -74,10 +171,9 @@ async def test_connection_error( """Test we show user form on BSBLan connection error.""" mock_bsblan.device.side_effect = BSBLANConnectionError - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -86,9 +182,7 @@ async def test_connection_error( }, ) - assert result.get("type") is FlowResultType.FORM - assert result.get("errors") == {"base": "cannot_connect"} - assert result.get("step_id") == "user" + _assert_form_result(result, "user", {"base": "cannot_connect"}) async def test_user_device_exists_abort( @@ -98,10 +192,10 @@ async def test_user_device_exists_abort( ) -> None: """Test we abort flow if BSBLAN device already configured.""" mock_config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={ + + result = await _init_user_flow( + hass, + { CONF_HOST: "127.0.0.1", CONF_PORT: 80, CONF_PASSKEY: "1234", @@ -110,5 +204,366 @@ async def test_user_device_exists_abort( }, ) - assert result.get("type") is FlowResultType.ABORT - assert result.get("reason") == "already_configured" + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test the Zeroconf discovery flow.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_abort_if_existing_entry_for_zeroconf( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test we abort if the same host/port already exists during zeroconf discovery.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + +async def test_zeroconf_discovery_no_mac_requires_auth( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement and device requires auth.""" + # Make the first API call (without auth) fail, second call (with auth) succeed + mock_bsblan.device.side_effect = [ + BSBLANConnectionError, + mock_bsblan.device.return_value, + ] + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_form_result(result, "discovery_confirm") + + # Reset side_effect for the second call to succeed + mock_bsblan.device.side_effect = None + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + ) + + _assert_create_entry_result( + result2, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: "admin", + CONF_PASSWORD: "secret", + }, + "00:80:41:19:69:90", + ) + + # Should be called 3 times: once without auth (fails), twice with auth (in _validate_and_create) + assert len(mock_bsblan.device.mock_calls) == 3 + + +async def test_zeroconf_discovery_no_mac_no_auth_required( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery when no MAC in announcement but device accessible without auth.""" + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + + # Should now show the discovery_confirm form to the user + _assert_form_result(result, "discovery_confirm") + + # User confirms the discovery + result2 = await _configure_flow(hass, result["flow_id"], {}) + + _assert_create_entry_result( + result2, + "00:80:41:19:69:90", # MAC from fixture file + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: None, + CONF_USERNAME: None, + CONF_PASSWORD: None, + }, + "00:80:41:19:69:90", + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should be called once in zeroconf step, as _validate_and_create is skipped + assert len(mock_bsblan.device.mock_calls) == 1 + + +async def test_zeroconf_discovery_connection_error( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery shows the correct form.""" + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + + +async def test_zeroconf_discovery_updates_host_port_on_existing_entry( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test that discovered devices update host/port of existing entries.""" + # Create an existing entry with different host/port + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", # Different IP + CONF_PORT: 8080, # Different port + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port from discovery + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host from discovery + assert entry.data[CONF_PORT] == 80 # Updated port from discovery + + +async def test_user_flow_can_update_existing_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, +) -> None: + """Test that manual user configuration can update host/port of existing entries.""" + # Create an existing entry + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.100", + CONF_PORT: 8080, + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id="00:80:41:19:69:90", + ) + entry.add_to_hass(hass) + + # Try to configure the same device with different host/port via user flow + result = await _init_user_flow( + hass, + { + CONF_HOST: "10.0.2.60", # Different IP + CONF_PORT: 80, # Different port + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_abort_result(result, "already_configured") + + # Verify the existing entry WAS updated with new host/port (user flow behavior) + assert entry.data[CONF_HOST] == "10.0.2.60" # Updated host + assert entry.data[CONF_PORT] == 80 # Updated port + + +async def test_zeroconf_discovery_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, + zeroconf_discovery_info: ZeroconfServiceInfo, +) -> None: + """Test connection error during zeroconf discovery can be recovered from.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info) + _assert_form_result(result, "discovery_confirm") + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result2, "discovery_confirm", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result3 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result3, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "10.0.2.60", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_connection_error_recovery( + hass: HomeAssistant, + mock_bsblan: MagicMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test we can recover from BSBLan connection error in user flow.""" + # First attempt fails with connection error + mock_bsblan.device.side_effect = BSBLANConnectionError + + result = await _init_user_flow( + hass, + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_form_result(result, "user", {"base": "cannot_connect"}) + + # Second attempt succeeds (connection is fixed) + mock_bsblan.device.side_effect = None + + result2 = await _configure_flow( + hass, + result["flow_id"], + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + ) + + _assert_create_entry_result( + result2, + format_mac("00:80:41:19:69:90"), + { + CONF_HOST: "127.0.0.1", + CONF_PORT: 80, + CONF_PASSKEY: "1234", + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + format_mac("00:80:41:19:69:90"), + ) + + assert len(mock_setup_entry.mock_calls) == 1 + # Should have been called twice: first failed, second succeeded + assert len(mock_bsblan.device.mock_calls) == 2 + + +async def test_zeroconf_discovery_no_mac_duplicate_host_port( + hass: HomeAssistant, + mock_bsblan: MagicMock, + zeroconf_discovery_info_no_mac: ZeroconfServiceInfo, +) -> None: + """Test Zeroconf discovery aborts when no MAC and same host/port already configured.""" + # Create an existing entry with same host/port but no unique_id + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "10.0.2.60", # Same IP as discovery + CONF_PORT: 80, # Same port as discovery + CONF_USERNAME: "admin", + CONF_PASSWORD: "admin1234", + }, + unique_id=None, # Old entry without unique_id + ) + entry.add_to_hass(hass) + + result = await _init_zeroconf_flow(hass, zeroconf_discovery_info_no_mac) + _assert_abort_result(result, "already_configured") + + # Should not call device API since we abort early + assert len(mock_bsblan.device.mock_calls) == 0 From 66641356cc19dfa717cd480cb4f22bc2f33bdd55 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Mon, 14 Jul 2025 21:35:57 +0200 Subject: [PATCH 1399/1664] Add Uptime Kuma integration (#146393) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/uptime_kuma/__init__.py | 27 + .../components/uptime_kuma/config_flow.py | 79 ++ homeassistant/components/uptime_kuma/const.py | 26 + .../components/uptime_kuma/coordinator.py | 107 ++ .../components/uptime_kuma/icons.json | 32 + .../components/uptime_kuma/manifest.json | 11 + .../components/uptime_kuma/quality_scale.yaml | 78 ++ .../components/uptime_kuma/sensor.py | 178 ++++ .../components/uptime_kuma/strings.json | 94 ++ 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/uptime_kuma/__init__.py | 1 + tests/components/uptime_kuma/conftest.py | 101 ++ .../uptime_kuma/snapshots/test_sensor.ambr | 968 ++++++++++++++++++ .../uptime_kuma/test_config_flow.py | 122 +++ tests/components/uptime_kuma/test_init.py | 52 + tests/components/uptime_kuma/test_sensor.py | 97 ++ 22 files changed, 1999 insertions(+) create mode 100644 homeassistant/components/uptime_kuma/__init__.py create mode 100644 homeassistant/components/uptime_kuma/config_flow.py create mode 100644 homeassistant/components/uptime_kuma/const.py create mode 100644 homeassistant/components/uptime_kuma/coordinator.py create mode 100644 homeassistant/components/uptime_kuma/icons.json create mode 100644 homeassistant/components/uptime_kuma/manifest.json create mode 100644 homeassistant/components/uptime_kuma/quality_scale.yaml create mode 100644 homeassistant/components/uptime_kuma/sensor.py create mode 100644 homeassistant/components/uptime_kuma/strings.json create mode 100644 tests/components/uptime_kuma/__init__.py create mode 100644 tests/components/uptime_kuma/conftest.py create mode 100644 tests/components/uptime_kuma/snapshots/test_sensor.ambr create mode 100644 tests/components/uptime_kuma/test_config_flow.py create mode 100644 tests/components/uptime_kuma/test_init.py create mode 100644 tests/components/uptime_kuma/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 77e853262a1..626fc10a4c2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -535,6 +535,7 @@ homeassistant.components.unifiprotect.* homeassistant.components.upcloud.* homeassistant.components.update.* homeassistant.components.uptime.* +homeassistant.components.uptime_kuma.* homeassistant.components.uptimerobot.* homeassistant.components.usb.* homeassistant.components.uvc.* diff --git a/CODEOWNERS b/CODEOWNERS index 74c066a96c9..a6ab083e07d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1658,6 +1658,8 @@ build.json @home-assistant/supervisor /tests/components/upnp/ @StevenLooman /homeassistant/components/uptime/ @frenck /tests/components/uptime/ @frenck +/homeassistant/components/uptime_kuma/ @tr4nt0r +/tests/components/uptime_kuma/ @tr4nt0r /homeassistant/components/uptimerobot/ @ludeeus @chemelli74 /tests/components/uptimerobot/ @ludeeus @chemelli74 /homeassistant/components/usb/ @bdraco diff --git a/homeassistant/components/uptime_kuma/__init__.py b/homeassistant/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000..0215c83f0cc --- /dev/null +++ b/homeassistant/components/uptime_kuma/__init__.py @@ -0,0 +1,27 @@ +"""The Uptime Kuma integration.""" + +from __future__ import annotations + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +_PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Set up Uptime Kuma from a config entry.""" + + coordinator = UptimeKumaDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: UptimeKumaConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py new file mode 100644 index 00000000000..9866f08bef3 --- /dev/null +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -0,0 +1,79 @@ +"""Config flow for the Uptime Kuma integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, +) +import voluptuous as vol +from yarl import URL + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): TextSelector( + TextSelectorConfig( + type=TextSelectorType.URL, + autocomplete="url", + ), + ), + vol.Required(CONF_VERIFY_SSL, default=True): bool, + vol.Optional(CONF_API_KEY, default=""): str, + } +) + + +class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Uptime Kuma.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) + uptime_kuma = UptimeKuma(session, url, user_input[CONF_API_KEY]) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=url.host or "", + data={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/const.py b/homeassistant/components/uptime_kuma/const.py new file mode 100644 index 00000000000..2bd4b1f9165 --- /dev/null +++ b/homeassistant/components/uptime_kuma/const.py @@ -0,0 +1,26 @@ +"""Constants for the Uptime Kuma integration.""" + +from pythonkuma import MonitorType + +DOMAIN = "uptime_kuma" + +HAS_CERT = { + MonitorType.HTTP, + MonitorType.KEYWORD, + MonitorType.JSON_QUERY, +} +HAS_URL = HAS_CERT | {MonitorType.REAL_BROWSER} +HAS_PORT = { + MonitorType.PORT, + MonitorType.STEAM, + MonitorType.GAMEDIG, + MonitorType.MQTT, + MonitorType.RADIUS, + MonitorType.SNMP, + MonitorType.SMTP, +} +HAS_HOST = HAS_PORT | { + MonitorType.PING, + MonitorType.TAILSCALE_PING, + MonitorType.DNS, +} diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py new file mode 100644 index 00000000000..788d37cfb84 --- /dev/null +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -0,0 +1,107 @@ +"""Coordinator for the Uptime Kuma integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from pythonkuma import ( + UptimeKuma, + UptimeKumaAuthenticationException, + UptimeKumaException, + UptimeKumaMonitor, + UptimeKumaVersion, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type UptimeKumaConfigEntry = ConfigEntry[UptimeKumaDataUpdateCoordinator] + + +class UptimeKumaDataUpdateCoordinator( + DataUpdateCoordinator[dict[str | int, UptimeKumaMonitor]] +): + """Update coordinator for Uptime Kuma.""" + + config_entry: UptimeKumaConfigEntry + + def __init__( + self, hass: HomeAssistant, config_entry: UptimeKumaConfigEntry + ) -> None: + """Initialize the coordinator.""" + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + session = async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]) + self.api = UptimeKuma( + session, config_entry.data[CONF_URL], config_entry.data[CONF_API_KEY] + ) + self.version: UptimeKumaVersion | None = None + + async def _async_update_data(self) -> dict[str | int, UptimeKumaMonitor]: + """Fetch the latest data from Uptime Kuma.""" + + try: + metrics = await self.api.metrics() + except UptimeKumaAuthenticationException as e: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="auth_failed_exception", + ) from e + except UptimeKumaException as e: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="request_failed_exception", + ) from e + else: + async_migrate_entities_unique_ids(self.hass, self, metrics) + self.version = self.api.version + + return metrics + + +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, + coordinator: UptimeKumaDataUpdateCoordinator, + metrics: dict[str | int, UptimeKumaMonitor], +) -> None: + """Migrate unique_ids in the entity registry after updating Uptime Kuma.""" + + if ( + coordinator.version is coordinator.api.version + or int(coordinator.api.version.major) < 2 + ): + return + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, coordinator.config_entry.entry_id + ) + + for registry_entry in registry_entries: + name = registry_entry.unique_id.removeprefix( + f"{registry_entry.config_entry_id}_" + ).removesuffix(f"_{registry_entry.translation_key}") + if monitor := next( + (m for m in metrics.values() if m.monitor_name == name), None + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{registry_entry.config_entry_id}_{monitor.monitor_id!s}_{registry_entry.translation_key}", + ) diff --git a/homeassistant/components/uptime_kuma/icons.json b/homeassistant/components/uptime_kuma/icons.json new file mode 100644 index 00000000000..73f5fd63661 --- /dev/null +++ b/homeassistant/components/uptime_kuma/icons.json @@ -0,0 +1,32 @@ +{ + "entity": { + "sensor": { + "cert_days_remaining": { + "default": "mdi:certificate" + }, + "response_time": { + "default": "mdi:timeline-clock-outline" + }, + "status": { + "default": "mdi:lan-connect", + "state": { + "down": "mdi:lan-disconnect", + "pending": "mdi:lan-pending", + "maintenance": "mdi:account-hard-hat-outline" + } + }, + "type": { + "default": "mdi:protocol" + }, + "url": { + "default": "mdi:web" + }, + "hostname": { + "default": "mdi:ip-outline" + }, + "port": { + "default": "mdi:ip-outline" + } + } + } +} diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json new file mode 100644 index 00000000000..6f20d4ae20f --- /dev/null +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "uptime_kuma", + "name": "Uptime Kuma", + "codeowners": ["@tr4nt0r"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/uptime_kuma", + "iot_class": "cloud_polling", + "loggers": ["pythonkuma"], + "quality_scale": "bronze", + "requirements": ["pythonkuma==0.3.0"] +} diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml new file mode 100644 index 00000000000..145cbf58448 --- /dev/null +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -0,0 +1,78 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: integration has no 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: integration has no actions + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: integration has no 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: integration has no actions + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: integration has no options + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: done + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: is not locally discoverable + discovery: + status: exempt + comment: is not locally discoverable + docs-data-update: done + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: + status: exempt + comment: integration is a service + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: todo + 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: todo + repair-issues: + status: exempt + comment: has no repairs + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/uptime_kuma/sensor.py b/homeassistant/components/uptime_kuma/sensor.py new file mode 100644 index 00000000000..c76fbcae04c --- /dev/null +++ b/homeassistant/components/uptime_kuma/sensor.py @@ -0,0 +1,178 @@ +"""Sensor platform for the Uptime Kuma integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from pythonkuma import MonitorType, UptimeKumaMonitor +from pythonkuma.models import MonitorStatus + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_URL, EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, HAS_CERT, HAS_HOST, HAS_PORT, HAS_URL +from .coordinator import UptimeKumaConfigEntry, UptimeKumaDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +class UptimeKumaSensor(StrEnum): + """Uptime Kuma sensors.""" + + CERT_DAYS_REMAINING = "cert_days_remaining" + RESPONSE_TIME = "response_time" + STATUS = "status" + TYPE = "type" + URL = "url" + HOSTNAME = "hostname" + PORT = "port" + + +@dataclass(kw_only=True, frozen=True) +class UptimeKumaSensorEntityDescription(SensorEntityDescription): + """Uptime Kuma sensor description.""" + + value_fn: Callable[[UptimeKumaMonitor], StateType] + create_entity: Callable[[MonitorType], bool] + + +SENSOR_DESCRIPTIONS: tuple[UptimeKumaSensorEntityDescription, ...] = ( + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.CERT_DAYS_REMAINING, + translation_key=UptimeKumaSensor.CERT_DAYS_REMAINING, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, + value_fn=lambda m: m.monitor_cert_days_remaining, + create_entity=lambda t: t in HAS_CERT, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.RESPONSE_TIME, + translation_key=UptimeKumaSensor.RESPONSE_TIME, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + value_fn=( + lambda m: m.monitor_response_time if m.monitor_response_time > -1 else None + ), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.STATUS, + translation_key=UptimeKumaSensor.STATUS, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorStatus], + value_fn=lambda m: m.monitor_status.name.lower(), + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.TYPE, + translation_key=UptimeKumaSensor.TYPE, + device_class=SensorDeviceClass.ENUM, + options=[m.name.lower() for m in MonitorType], + value_fn=lambda m: m.monitor_type.name.lower(), + entity_category=EntityCategory.DIAGNOSTIC, + create_entity=lambda _: True, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.URL, + translation_key=UptimeKumaSensor.URL, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_url, + create_entity=lambda t: t in HAS_URL, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.HOSTNAME, + translation_key=UptimeKumaSensor.HOSTNAME, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_hostname, + create_entity=lambda t: t in HAS_HOST, + ), + UptimeKumaSensorEntityDescription( + key=UptimeKumaSensor.PORT, + translation_key=UptimeKumaSensor.PORT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda m: m.monitor_port, + create_entity=lambda t: t in HAS_PORT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: UptimeKumaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the sensor platform.""" + coordinator = config_entry.runtime_data + monitor_added: set[str | int] = set() + + @callback + def add_entities() -> None: + """Add sensor entities.""" + nonlocal monitor_added + + if new_monitor := set(coordinator.data.keys()) - monitor_added: + async_add_entities( + UptimeKumaSensorEntity(coordinator, monitor, description) + for description in SENSOR_DESCRIPTIONS + for monitor in new_monitor + if description.create_entity(coordinator.data[monitor].monitor_type) + ) + monitor_added |= new_monitor + + coordinator.async_add_listener(add_entities) + add_entities() + + +class UptimeKumaSensorEntity( + CoordinatorEntity[UptimeKumaDataUpdateCoordinator], SensorEntity +): + """An Uptime Kuma sensor entity.""" + + entity_description: UptimeKumaSensorEntityDescription + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: UptimeKumaDataUpdateCoordinator, + monitor: str | int, + entity_description: UptimeKumaSensorEntityDescription, + ) -> None: + """Initialize the entity.""" + + super().__init__(coordinator) + self.monitor = monitor + self.entity_description = entity_description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{monitor!s}_{entity_description.key}" + ) + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + name=coordinator.data[monitor].monitor_name, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{monitor!s}")}, + manufacturer="Uptime Kuma", + configuration_url=coordinator.config_entry.data[CONF_URL], + sw_version=coordinator.api.version.version, + ) + + @property + def native_value(self) -> StateType: + """Return the state of the sensor.""" + + return self.entity_description.value_fn(self.coordinator.data[self.monitor]) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.monitor in self.coordinator.data diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json new file mode 100644 index 00000000000..8cd361cccea --- /dev/null +++ b/homeassistant/components/uptime_kuma/strings.json @@ -0,0 +1,94 @@ +{ + "config": { + "step": { + "user": { + "description": "Set up **Uptime Kuma** monitoring service", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "Enter the full URL of your Uptime Kuma instance. Be sure to include the protocol (`http` or `https`), the hostname or IP address, the port number (if it is a non-default port), and any path prefix if applicable. Example: `https://uptime.example.com`", + "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", + "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "entity": { + "sensor": { + "cert_days_remaining": { + "name": "Certificate expiry" + }, + "response_time": { + "name": "Response time" + }, + "status": { + "name": "Status", + "state": { + "up": "Up", + "down": "Down", + "pending": "Pending", + "maintenance": "Maintenance" + } + }, + "type": { + "name": "Monitor type", + "state": { + "http": "HTTP(s)", + "port": "TCP port", + "ping": "Ping", + "keyword": "HTTP(s) - Keyword", + "dns": "DNS", + "push": "Push", + "steam": "Steam Game Server", + "mqtt": "MQTT", + "sqlserver": "Microsoft SQL Server", + "json_query": "HTTP(s) - JSON query", + "group": "Group", + "docker": "Docker", + "grpc_keyword": "gRPC(s) - Keyword", + "real_browser": "HTTP(s) - Browser engine", + "gamedig": "GameDig", + "kafka_producer": "Kafka Producer", + "postgres": "PostgreSQL", + "mysql": "MySQL/MariaDB", + "mongodb": "MongoDB", + "radius": "Radius", + "redis": "Redis", + "tailscale_ping": "Tailscale Ping", + "snmp": "SNMP", + "smtp": "SMTP", + "rabbit_mq": "RabbitMQ", + "manual": "Manual" + } + }, + "url": { + "name": "Monitored URL" + }, + "hostname": { + "name": "Monitored hostname" + }, + "port": { + "name": "Monitored port" + } + } + }, + "exceptions": { + "auth_failed_exception": { + "message": "Authentication with Uptime Kuma failed. Please check that your API key is correct and still valid" + }, + "request_failed_exception": { + "message": "Connection to Uptime Kuma failed" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 97e7929d317..92319af9617 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -680,6 +680,7 @@ FLOWS = { "upcloud", "upnp", "uptime", + "uptime_kuma", "uptimerobot", "v2c", "vallox", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ec790549519..277400bec02 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7080,6 +7080,12 @@ "iot_class": "local_push", "single_config_entry": true }, + "uptime_kuma": { + "name": "Uptime Kuma", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "uptimerobot": { "name": "UptimeRobot", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 48432118fa8..25039f7f386 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5109,6 +5109,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.uptime_kuma.*] +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.uptimerobot.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 52b7555b6fe..53bc939f588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2525,6 +2525,9 @@ python-vlc==3.0.18122 # homeassistant.components.egardia pythonegardia==1.0.52 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.0 + # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8be5f73588..a18908ffe97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2089,6 +2089,9 @@ python-technove==2.0.0 # homeassistant.components.telegram_bot python-telegram-bot[socks]==21.5 +# homeassistant.components.uptime_kuma +pythonkuma==0.3.0 + # homeassistant.components.tile pytile==2024.12.0 diff --git a/tests/components/uptime_kuma/__init__.py b/tests/components/uptime_kuma/__init__.py new file mode 100644 index 00000000000..ba8ab82dc46 --- /dev/null +++ b/tests/components/uptime_kuma/__init__.py @@ -0,0 +1 @@ +"""Tests for the Uptime Kuma integration.""" diff --git a/tests/components/uptime_kuma/conftest.py b/tests/components/uptime_kuma/conftest.py new file mode 100644 index 00000000000..4b7710a48b4 --- /dev/null +++ b/tests/components/uptime_kuma/conftest.py @@ -0,0 +1,101 @@ +"""Common fixtures for the Uptime Kuma tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from pythonkuma import MonitorType, UptimeKumaMonitor, UptimeKumaVersion +from pythonkuma.models import MonitorStatus + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.uptime_kuma.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Mock Uptime Kuma configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="uptime.example.org", + data={ + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + entry_id="123456789", + ) + + +@pytest.fixture +def mock_pythonkuma() -> Generator[AsyncMock]: + """Mock pythonkuma client.""" + + monitor_1 = UptimeKumaMonitor( + monitor_id=1, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 1", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.HTTP, + monitor_url="https://example.org", + ) + monitor_2 = UptimeKumaMonitor( + monitor_id=2, + monitor_cert_days_remaining=0, + monitor_cert_is_valid=0, + monitor_hostname=None, + monitor_name="Monitor 2", + monitor_port=None, + monitor_response_time=28, + monitor_status=MonitorStatus.UP, + monitor_type=MonitorType.PORT, + monitor_url=None, + ) + monitor_3 = UptimeKumaMonitor( + monitor_id=3, + monitor_cert_days_remaining=90, + monitor_cert_is_valid=1, + monitor_hostname=None, + monitor_name="Monitor 3", + monitor_port=None, + monitor_response_time=120, + monitor_status=MonitorStatus.DOWN, + monitor_type=MonitorType.JSON_QUERY, + monitor_url="https://down.example.org", + ) + + with ( + patch( + "homeassistant.components.uptime_kuma.config_flow.UptimeKuma", autospec=True + ) as mock_client, + patch( + "homeassistant.components.uptime_kuma.coordinator.UptimeKuma", + new=mock_client, + ), + ): + client = mock_client.return_value + + client.metrics.return_value = { + 1: monitor_1, + 2: monitor_2, + 3: monitor_3, + } + client.version = UptimeKumaVersion( + version="2.0.0", major="2", minor="0", patch="0" + ) + + yield client diff --git a/tests/components/uptime_kuma/snapshots/test_sensor.ambr b/tests/components/uptime_kuma/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..49a7d141c47 --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_sensor.ambr @@ -0,0 +1,968 @@ +# serializer version: 1 +# name: test_setup[sensor.monitor_1_certificate_expiry-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.monitor_1_certificate_expiry', + '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': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_1_monitor_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': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'http', + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-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.monitor_1_monitored_url', + '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': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 1 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_1_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://example.org', + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-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.monitor_1_response_time', + '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': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_1_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 1 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_1_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_1_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_1_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': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_1_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 1 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_1_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_2_monitor_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': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'port', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-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.monitor_2_monitored_hostname', + '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': 'Monitored hostname', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_hostname', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_hostname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored hostname', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_hostname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-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.monitor_2_monitored_port', + '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': 'Monitored port', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_port', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_monitored_port-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 2 Monitored port', + }), + 'context': , + 'entity_id': 'sensor.monitor_2_monitored_port', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-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.monitor_2_response_time', + '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': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_2_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 2 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_2_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '28', + }) +# --- +# name: test_setup[sensor.monitor_2_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_2_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': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_2_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_2_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 2 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_2_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'up', + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-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.monitor_3_certificate_expiry', + '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': 'Certificate expiry', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_cert_days_remaining', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_certificate_expiry-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Certificate expiry', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_certificate_expiry', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.monitor_3_monitor_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': 'Monitor type', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitor_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Monitor type', + 'options': list([ + 'http', + 'port', + 'ping', + 'keyword', + 'dns', + 'push', + 'steam', + 'mqtt', + 'sqlserver', + 'json_query', + 'group', + 'docker', + 'grpc_keyword', + 'real_browser', + 'gamedig', + 'kafka_producer', + 'postgres', + 'mysql', + 'mongodb', + 'radius', + 'redis', + 'tailscale_ping', + 'smtp', + 'snmp', + 'rabbit_mq', + 'manual', + 'unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitor_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'json_query', + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-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.monitor_3_monitored_url', + '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': 'Monitored URL', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_url', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_monitored_url-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Monitor 3 Monitored URL', + }), + 'context': , + 'entity_id': 'sensor.monitor_3_monitored_url', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'https://down.example.org', + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-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.monitor_3_response_time', + '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': 'Response time', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_response_time', + 'unit_of_measurement': , + }) +# --- +# name: test_setup[sensor.monitor_3_response_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Monitor 3 Response time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.monitor_3_response_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '120', + }) +# --- +# name: test_setup[sensor.monitor_3_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.monitor_3_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': 'Status', + 'platform': 'uptime_kuma', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '123456789_3_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_setup[sensor.monitor_3_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Monitor 3 Status', + 'options': list([ + 'down', + 'up', + 'pending', + 'maintenance', + ]), + }), + 'context': , + 'entity_id': 'sensor.monitor_3_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'down', + }) +# --- diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py new file mode 100644 index 00000000000..b70cb9d353c --- /dev/null +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -0,0 +1,122 @@ +"""Test the Uptime Kuma config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaConnectionException + +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test we handle errors and recover.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_pythonkuma.metrics.side_effect = raise_error + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "uptime.example.org" + assert result["data"] == { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_form_already_configured( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test we abort when entry is already configured.""" + + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org/", + CONF_VERIFY_SSL: True, + CONF_API_KEY: "apikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py new file mode 100644 index 00000000000..57390da60d5 --- /dev/null +++ b/tests/components/uptime_kuma/test_init.py @@ -0,0 +1,52 @@ +"""Tests for the Uptime Kuma integration.""" + +from unittest.mock import AsyncMock + +import pytest +from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_entry_setup_unload( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test integration setup and unload.""" + + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + + assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + ("exception", "state"), + [ + (UptimeKumaAuthenticationException, ConfigEntryState.SETUP_ERROR), + (UptimeKumaException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + exception: Exception, + state: ConfigEntryState, +) -> None: + """Test config entry not ready.""" + + mock_pythonkuma.metrics.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 state diff --git a/tests/components/uptime_kuma/test_sensor.py b/tests/components/uptime_kuma/test_sensor.py new file mode 100644 index 00000000000..25bd7650528 --- /dev/null +++ b/tests/components/uptime_kuma/test_sensor.py @@ -0,0 +1,97 @@ +"""Test for Uptime Kuma sensor platform.""" + +from collections.abc import Generator +from datetime import timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pythonkuma import MonitorStatus, UptimeKumaMonitor, UptimeKumaVersion +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture(autouse=True) +def sensor_only() -> Generator[None]: + """Enable only the sensor platform.""" + with patch( + "homeassistant.components.uptime_kuma._PLATFORMS", + [Platform.SENSOR], + ): + yield + + +@pytest.mark.usefixtures("mock_pythonkuma", "entity_registry_enabled_by_default") +async def test_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Snapshot test states of sensor 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) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_migrate_unique_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Snapshot test states of sensor platform.""" + mock_pythonkuma.metrics.return_value = { + "Monitor": UptimeKumaMonitor( + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="1.23.16", major="1", minor="23", patch="16" + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_Monitor_status" + + mock_pythonkuma.metrics.return_value = { + 1: UptimeKumaMonitor( + monitor_id=1, + monitor_name="Monitor", + monitor_hostname="null", + monitor_port="null", + monitor_status=MonitorStatus.UP, + monitor_url="test", + ) + } + mock_pythonkuma.version = UptimeKumaVersion( + version="2.0.0-beta.3", major="2", minor="0", patch="0-beta.3" + ) + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (entity := entity_registry.async_get("sensor.monitor_status")) + assert entity.unique_id == "123456789_1_status" From f65fa3842932ece090e62b508945f9e8d4eaf136 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Mon, 14 Jul 2025 21:49:52 +0200 Subject: [PATCH 1400/1664] Add reconfigure flow for KNX (#145067) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/knx/config_flow.py | 144 ++++--- .../components/knx/quality_scale.yaml | 2 +- homeassistant/components/knx/strings.json | 162 +------- tests/components/knx/conftest.py | 3 + tests/components/knx/test_config_flow.py | 381 +++++++++--------- 5 files changed, 290 insertions(+), 402 deletions(-) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 14a9016bcb9..796c4c60201 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations -from abc import ABC, abstractmethod from collections.abc import AsyncGenerator from typing import Any, Final, Literal @@ -20,8 +19,8 @@ from xknx.io.util import validate_ip as xknx_validate_ip from xknx.secure.keyring import Keyring, XMLInterface from homeassistant.config_entries import ( + SOURCE_RECONFIGURE, ConfigEntry, - ConfigEntryBaseFlow, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -103,12 +102,14 @@ _PORT_SELECTOR = vol.All( ) -class KNXCommonFlow(ABC, ConfigEntryBaseFlow): - """Base class for KNX flows.""" +class KNXConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a KNX config flow.""" - def __init__(self, initial_data: KNXConfigEntryData) -> None: - """Initialize KNXCommonFlow.""" - self.initial_data = initial_data + VERSION = 1 + + def __init__(self) -> None: + """Initialize KNX config flow.""" + self.initial_data = DEFAULT_ENTRY_DATA self.new_entry_data = KNXConfigEntryData() self.new_title: str | None = None @@ -121,19 +122,21 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self._gatewayscanner: GatewayScanner | None = None self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: + """Get the options flow for this handler.""" + return KNXOptionsFlow(config_entry) + @property def _xknx(self) -> XKNX: """Return XKNX instance.""" - if isinstance(self, OptionsFlow) and ( + if (self.source == SOURCE_RECONFIGURE) and ( knx_module := self.hass.data.get(KNX_MODULE_KEY) ): return knx_module.xknx return XKNX() - @abstractmethod - def finish_flow(self) -> ConfigFlowResult: - """Finish the flow.""" - @property def connection_type(self) -> str: """Return the configured connection type.""" @@ -150,6 +153,61 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA), ) + @callback + def finish_flow(self) -> ConfigFlowResult: + """Create or update the ConfigEntry.""" + if self.source == SOURCE_RECONFIGURE: + entry = self._get_reconfigure_entry() + _tunnel_endpoint_str = self.initial_data.get( + CONF_KNX_TUNNEL_ENDPOINT_IA, "Tunneling" + ) + if self.new_title and not entry.title.startswith( + # Overwrite standard titles, but not user defined ones + ( + f"KNX {self.initial_data[CONF_KNX_CONNECTION_TYPE]}", + CONF_KNX_AUTOMATIC.capitalize(), + "Tunneling @ ", + f"{_tunnel_endpoint_str} @", + "Tunneling UDP @ ", + "Tunneling TCP @ ", + "Secure Tunneling", + "Routing as ", + "Secure Routing as ", + ) + ): + self.new_title = None + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data_updates=self.new_entry_data, + title=self.new_title or UNDEFINED, + ) + + title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" + return self.async_create_entry( + title=title, + data=DEFAULT_ENTRY_DATA | self.new_entry_data, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + return await self.async_step_connection_type() + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of existing entry.""" + entry = self._get_reconfigure_entry() + self.initial_data = dict(entry.data) # type: ignore[assignment] + return self.async_show_menu( + step_id="reconfigure", + menu_options=[ + "connection_type", + "secure_knxkeys", + ], + ) + async def async_step_connection_type( self, user_input: dict | None = None ) -> ConfigFlowResult: @@ -441,7 +499,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) ip_address: str | None if ( # initial attempt on ConfigFlow or coming from automatic / routing - (isinstance(self, ConfigFlow) or not _reconfiguring_existing_tunnel) + not _reconfiguring_existing_tunnel and not user_input and self._selected_tunnel is not None ): # default to first found tunnel @@ -841,52 +899,20 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow): ) -class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): - """Handle a KNX config flow.""" - - VERSION = 1 - - def __init__(self) -> None: - """Initialize KNX options flow.""" - super().__init__(initial_data=DEFAULT_ENTRY_DATA) - - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow: - """Get the options flow for this handler.""" - return KNXOptionsFlow(config_entry) - - @callback - def finish_flow(self) -> ConfigFlowResult: - """Create the ConfigEntry.""" - title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}" - return self.async_create_entry( - title=title, - data=DEFAULT_ENTRY_DATA | self.new_entry_data, - ) - - async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult: - """Handle a flow initialized by the user.""" - return await self.async_step_connection_type() - - -class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): +class KNXOptionsFlow(OptionsFlow): """Handle KNX options.""" - general_settings: dict - def __init__(self, config_entry: ConfigEntry) -> None: """Initialize KNX options flow.""" - super().__init__(initial_data=config_entry.data) # type: ignore[arg-type] + self.initial_data = dict(config_entry.data) @callback - def finish_flow(self) -> ConfigFlowResult: + def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult: """Update the ConfigEntry and finish the flow.""" - new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data + new_data = self.initial_data | new_entry_data self.hass.config_entries.async_update_entry( self.config_entry, data=new_data, - title=self.new_title or UNDEFINED, ) return self.async_create_entry(title="", data={}) @@ -894,26 +920,20 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX options.""" - return self.async_show_menu( - step_id="init", - menu_options=[ - "connection_type", - "communication_settings", - "secure_knxkeys", - ], - ) + return await self.async_step_communication_settings() async def async_step_communication_settings( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage KNX communication settings.""" if user_input is not None: - self.new_entry_data = KNXConfigEntryData( - state_updater=user_input[CONF_KNX_STATE_UPDATER], - rate_limit=user_input[CONF_KNX_RATE_LIMIT], - telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + return self.finish_flow( + KNXConfigEntryData( + state_updater=user_input[CONF_KNX_STATE_UPDATER], + rate_limit=user_input[CONF_KNX_RATE_LIMIT], + telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], + ) ) - return self.finish_flow() data_schema = { vol.Required( diff --git a/homeassistant/components/knx/quality_scale.yaml b/homeassistant/components/knx/quality_scale.yaml index b4b36213c43..9e24cc1ce5b 100644 --- a/homeassistant/components/knx/quality_scale.yaml +++ b/homeassistant/components/knx/quality_scale.yaml @@ -104,7 +104,7 @@ rules: Since all entities are configured manually, names are user-defined. exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: todo stale-devices: status: exempt diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index dc4d7de42ff..921fc2c5288 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reconfigure": { + "title": "KNX connection settings", + "menu_options": { + "connection_type": "Reconfigure KNX connection", + "secure_knxkeys": "Import KNX keyring file" + } + }, "connection_type": { "title": "KNX connection", "description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.", @@ -65,7 +72,7 @@ }, "secure_knxkeys": { "title": "Import KNX Keyring", - "description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.", + "description": "The keyring is used to encrypt and decrypt KNX IP Secure communication. You can import a new keyring file or re-import to update existing keys if your configuration has changed.", "data": { "knxkeys_file": "Keyring file", "knxkeys_password": "Keyring password" @@ -129,6 +136,9 @@ } } }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.", @@ -159,16 +169,8 @@ }, "options": { "step": { - "init": { - "title": "KNX Settings", - "menu_options": { - "connection_type": "Configure KNX interface", - "communication_settings": "Communication settings", - "secure_knxkeys": "Import a `.knxkeys` file" - } - }, "communication_settings": { - "title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]", + "title": "Communication settings", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -179,147 +181,7 @@ "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`", "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } - }, - "connection_type": { - "title": "[%key:component::knx::config::step::connection_type::title%]", - "description": "[%key:component::knx::config::step::connection_type::description%]", - "data": { - "connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]" - }, - "data_description": { - "connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]" - } - }, - "tunnel": { - "title": "[%key:component::knx::config::step::tunnel::title%]", - "data": { - "gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]" - }, - "data_description": { - "gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]" - } - }, - "tcp_tunnel_endpoint": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "manual_tunnel": { - "title": "[%key:component::knx::config::step::manual_tunnel::title%]", - "description": "[%key:component::knx::config::step::manual_tunnel::description%]", - "data": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data::tunneling_type%]", - "port": "[%key:common::config_flow::data::port%]", - "host": "[%key:common::config_flow::data::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]", - "port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]", - "host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]", - "route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } - }, - "secure_key_source_menu_tunnel": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]" - } - }, - "secure_key_source_menu_routing": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", - "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", - "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" - } - }, - "secure_knxkeys": { - "title": "[%key:component::knx::config::step::secure_knxkeys::title%]", - "description": "[%key:component::knx::config::step::secure_knxkeys::description%]", - "data": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]" - }, - "data_description": { - "knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]", - "knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]" - } - }, - "knxkeys_tunnel_select": { - "title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]", - "data": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]" - }, - "data_description": { - "tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]" - } - }, - "secure_tunnel_manual": { - "title": "[%key:component::knx::config::step::secure_tunnel_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data::device_authentication%]" - }, - "data_description": { - "user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_id%]", - "user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_password%]", - "device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::device_authentication%]" - } - }, - "secure_routing_manual": { - "title": "[%key:component::knx::config::step::secure_routing_manual::title%]", - "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", - "data": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]" - }, - "data_description": { - "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data_description::backbone_key%]", - "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data_description::sync_latency_tolerance%]" - } - }, - "routing": { - "title": "[%key:component::knx::config::step::routing::title%]", - "description": "[%key:component::knx::config::step::routing::description%]", - "data": { - "individual_address": "[%key:component::knx::config::step::routing::data::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" - }, - "data_description": { - "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", - "routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]", - "multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]", - "multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" - } } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_backbone_key": "[%key:component::knx::config::error::invalid_backbone_key%]", - "invalid_individual_address": "[%key:component::knx::config::error::invalid_individual_address%]", - "invalid_ip_address": "[%key:component::knx::config::error::invalid_ip_address%]", - "keyfile_no_backbone_key": "[%key:component::knx::config::error::keyfile_no_backbone_key%]", - "keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]", - "keyfile_no_tunnel_for_host": "[%key:component::knx::config::error::keyfile_no_tunnel_for_host%]", - "keyfile_not_found": "[%key:component::knx::config::error::keyfile_not_found%]", - "no_router_discovered": "[%key:component::knx::config::error::no_router_discovered%]", - "no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]", - "unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]" } }, "entity": { diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 26683ced66e..32f7745a6e0 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -309,6 +309,9 @@ def mock_config_entry() -> MockConfigEntry: title="KNX", domain=DOMAIN, data={ + # homeassistant.components.knx.config_flow.DEFAULT_ENTRY_DATA has additional keys + # there are installations out there without these keys so we test with legacy data + # to ensure backwards compatibility (local_ip, telegram_log_size) CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_RATE_LIMIT: CONF_KNX_DEFAULT_RATE_LIMIT, CONF_KNX_STATE_UPDATER: CONF_KNX_DEFAULT_STATE_UPDATER, diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 6ebe8192f69..6457d099eb2 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -48,7 +48,7 @@ from homeassistant.components.knx.const import ( ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, get_fixture_path @@ -174,27 +174,27 @@ async def test_routing_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "1.1.110", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -227,19 +227,19 @@ async def test_routing_setup_advanced( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} # invalid user input result_invalid_input = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_MCAST_GRP: "10.1.2.3", # no valid multicast group CONF_KNX_MCAST_PORT: 3675, @@ -257,8 +257,8 @@ async def test_routing_setup_advanced( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3675, @@ -266,9 +266,9 @@ async def test_routing_setup_advanced( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Routing as 1.1.110" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Routing as 1.1.110" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, @@ -297,18 +297,18 @@ async def test_routing_secure_manual_setup( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -316,19 +316,19 @@ async def test_routing_secure_manual_setup( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_routing_manual"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_routing_manual" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_routing_manual" + assert not result["errors"] result_invalid_key1 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "xxaacc44bbaacc44bbaacc44bbaaccyy", # invalid hex string CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -339,7 +339,7 @@ async def test_routing_secure_manual_setup( assert result_invalid_key1["errors"] == {"backbone_key": "invalid_backbone_key"} result_invalid_key2 = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KNX_ROUTING_BACKBONE_KEY: "bbaacc44bbaacc44", # invalid length CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: 2000, @@ -386,18 +386,18 @@ async def test_routing_secure_keyfile( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {"base": "no_router_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {"base": "no_router_discovered"} - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_MCAST_PORT: 3671, @@ -405,20 +405,20 @@ async def test_routing_secure_keyfile( CONF_KNX_ROUTING_SECURE: True, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_routing" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_routing" - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): routing_secure_knxkeys = await hass.config_entries.flow.async_configure( - result4["flow_id"], + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", @@ -532,15 +532,15 @@ async def test_tunneling_setup_manual( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} with patch( "homeassistant.components.knx.config_flow.request_description", @@ -552,13 +552,13 @@ async def test_tunneling_setup_manual( ), ), ): - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == title - assert result3["data"] == config_entry_data + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["data"] == config_entry_data knx_setup.assert_called_once() @@ -724,19 +724,19 @@ async def test_tunneling_setup_for_local_ip( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "manual_tunnel" - assert result2["errors"] == {"base": "no_tunnel_discovered"} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual_tunnel" + assert result["errors"] == {"base": "no_tunnel_discovered"} # invalid host ip address result_invalid_host = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: DEFAULT_MCAST_GRP, # multicast addresses are invalid @@ -752,7 +752,7 @@ async def test_tunneling_setup_for_local_ip( } # invalid local ip address result_invalid_local = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -768,8 +768,8 @@ async def test_tunneling_setup_for_local_ip( } # valid user input - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -777,9 +777,9 @@ async def test_tunneling_setup_for_local_ip( CONF_KNX_LOCAL_IP: "192.168.1.112", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "Tunneling UDP @ 192.168.0.2" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Tunneling UDP @ 192.168.0.2" + assert result["data"] == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_HOST: "192.168.0.2", @@ -1008,15 +1008,15 @@ async def test_form_with_automatic_connection_handling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() - assert result2["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == CONF_KNX_AUTOMATIC.capitalize() + assert result["data"] == { # don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", @@ -1032,7 +1032,9 @@ async def test_form_with_automatic_connection_handling( knx_setup.assert_called_once() -async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: +async def _get_menu_step_secure_tunnel( + hass: HomeAssistant, +) -> config_entries.ConfigFlowResult: """Return flow in secure_tunnel menu step.""" gateway = _gateway_descriptor( "192.168.0.1", @@ -1050,23 +1052,23 @@ async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" - return result3 + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" + return result @patch( @@ -1099,24 +1101,24 @@ async def test_get_secure_menu_step_manual_tunnelling( assert result["type"] is FlowResultType.FORM assert not result["errors"] - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] manual_tunnel_flow = await hass.config_entries.flow.async_configure( - result2["flow_id"], + result["flow_id"], { CONF_KNX_GATEWAY: OPTION_MANUAL_TUNNEL, }, ) - result3 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( manual_tunnel_flow["flow_id"], { CONF_KNX_TUNNELING_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1124,8 +1126,8 @@ async def test_get_secure_menu_step_manual_tunnelling( CONF_PORT: 3675, }, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: @@ -1269,52 +1271,51 @@ async def test_configure_secure_knxkeys_no_tunnel_for_host(hass: HomeAssistant) assert secure_knxkeys["errors"] == {"base": "keyfile_no_tunnel_for_host"} -async def test_options_flow_connection_type( +async def test_reconfigure_flow_connection_type( hass: HomeAssistant, knx, mock_config_entry: MockConfigEntry ) -> None: - """Test options flow changing interface.""" - # run one option flow test with a set up integration (knx fixture) + """Test reconfigure flow changing interface.""" + # run one flow test with a set up integration (knx fixture) # instead of mocking async_setup_entry (knx_setup fixture) to test # usage of the already running XKNX instance for gateway scanner gateway = _gateway_descriptor("192.168.0.1", 3675) await knx.setup_integration() - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + menu_step = await knx.mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ CONF_KNX_GATEWAY: str(gateway), }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3["data"] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_HOST: "192.168.0.1", CONF_PORT: 3675, - CONF_KNX_LOCAL_IP: None, CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, CONF_KNX_RATE_LIMIT: 0, @@ -1324,14 +1325,13 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, - CONF_KNX_TELEGRAM_LOG_SIZE: 1000, } -async def test_options_flow_secure_manual_to_keyfile( +async def test_reconfigure_flow_secure_manual_to_keyfile( hass: HomeAssistant, knx_setup ) -> None: - """Test options flow changing secure credential source.""" + """Test reconfigure flow changing secure credential source.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1359,46 +1359,47 @@ async def test_options_flow_secure_manual_to_keyfile( mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "tunnel" - assert not result2["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "tunnel" + assert not result["errors"] - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_KNX_GATEWAY: str(gateway)}, ) - assert result3["type"] is FlowResultType.MENU - assert result3["step_id"] == "secure_key_source_menu_tunnel" + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "secure_key_source_menu_tunnel" - result4 = await hass.config_entries.options.async_configure( - result3["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "secure_knxkeys"}, ) - assert result4["type"] is FlowResultType.FORM - assert result4["step_id"] == "secure_knxkeys" - assert not result4["errors"] + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "secure_knxkeys" + assert not result["errors"] with patch_file_upload(): - secure_knxkeys = await hass.config_entries.options.async_configure( - result4["flow_id"], + secure_knxkeys = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "test", @@ -1407,12 +1408,13 @@ async def test_options_flow_secure_manual_to_keyfile( assert result["type"] is FlowResultType.FORM assert secure_knxkeys["step_id"] == "knxkeys_tunnel_select" assert not result["errors"] - secure_knxkeys = await hass.config_entries.options.async_configure( + secure_knxkeys = await hass.config_entries.flow.async_configure( secure_knxkeys["flow_id"], {CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1"}, ) - assert secure_knxkeys["type"] is FlowResultType.CREATE_ENTRY + assert secure_knxkeys["type"] is FlowResultType.ABORT + assert secure_knxkeys["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1433,8 +1435,8 @@ async def test_options_flow_secure_manual_to_keyfile( knx_setup.assert_called_once() -async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: - """Test options flow changing routing settings.""" +async def test_reconfigure_flow_routing(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow changing routing settings.""" mock_config_entry = MockConfigEntry( title="KNX", domain="knx", @@ -1446,36 +1448,38 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: gateway = _gateway_descriptor("192.168.0.1", 3676) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) with patch( "homeassistant.components.knx.config_flow.GatewayScanner" ) as gateway_scanner_mock: gateway_scanner_mock.return_value = GatewayScannerMock([gateway]) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "connection_type"}, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "connection_type" - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "routing" - assert result2["errors"] == {} + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "routing" + assert result["errors"] == {} - result3 = await hass.config_entries.options.async_configure( - result2["flow_id"], + result = await hass.config_entries.flow.async_configure( + result["flow_id"], { CONF_KNX_INDIVIDUAL_ADDRESS: "2.0.4", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_ROUTING, @@ -1491,43 +1495,8 @@ async def test_options_flow_routing(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_communication_settings( - hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry -) -> None: - """Test options flow changing communication settings.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) - - result = await hass.config_entries.options.async_configure( - menu_step["flow_id"], - {"next_step_id": "communication_settings"}, - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "communication_settings" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") - assert mock_config_entry.data == { - **DEFAULT_ENTRY_DATA, - CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, - CONF_KNX_STATE_UPDATER: False, - CONF_KNX_RATE_LIMIT: 40, - CONF_KNX_TELEGRAM_LOG_SIZE: 3000, - } - knx_setup.assert_called_once() - - -async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: - """Test options flow updating keyfile when tunnel endpoint is already configured.""" +async def test_reconfigure_update_keyfile(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow updating keyfile when tunnel endpoint is already configured.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP_SECURE, @@ -1549,9 +1518,10 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1559,15 +1529,15 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, CONF_KNX_KNXKEY_PASSWORD: "password", }, ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert not result2.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1578,8 +1548,8 @@ async def test_options_update_keyfile(hass: HomeAssistant, knx_setup) -> None: knx_setup.assert_called_once() -async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: - """Test options flow uploading a keyfile for the first time.""" +async def test_reconfigure_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: + """Test reconfigure flow uploading a keyfile for the first time.""" start_data = { **DEFAULT_ENTRY_DATA, CONF_KNX_CONNECTION_TYPE: CONF_KNX_TUNNELING_TCP, @@ -1596,9 +1566,10 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: ) mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) - menu_step = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + knx_setup.reset_mock() + menu_step = await mock_config_entry.start_reconfigure_flow(hass) - result = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( menu_step["flow_id"], {"next_step_id": "secure_knxkeys"}, ) @@ -1606,7 +1577,7 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: assert result["step_id"] == "secure_knxkeys" with patch_file_upload(): - result2 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_KEYRING_FILE: FIXTURE_UPLOAD_UUID, @@ -1614,17 +1585,17 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: }, ) - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "knxkeys_tunnel_select" + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "knxkeys_tunnel_select" - result3 = await hass.config_entries.options.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ CONF_KNX_TUNNEL_ENDPOINT_IA: "1.0.1", }, ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert not result3.get("data") + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" assert mock_config_entry.data == { **start_data, CONF_KNX_KNXKEY_FILENAME: "knx/keyring.knxkeys", @@ -1637,3 +1608,35 @@ async def test_options_keyfile_upload(hass: HomeAssistant, knx_setup) -> None: CONF_KNX_ROUTING_SYNC_LATENCY_TOLERANCE: None, } knx_setup.assert_called_once() + + +async def test_options_communication_settings( + hass: HomeAssistant, knx_setup, mock_config_entry: MockConfigEntry +) -> None: + """Test options flow changing communication settings.""" + initial_data = dict(mock_config_entry.data) + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "communication_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert not result.get("data") + assert initial_data != dict(mock_config_entry.data) + assert mock_config_entry.data == { + **initial_data, + CONF_KNX_STATE_UPDATER: False, + CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, + } + knx_setup.assert_called_once() From c476500c494882bf2a63fd72c4a79b1a467f43b9 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 14 Jul 2025 22:40:46 +0200 Subject: [PATCH 1401/1664] Fix Shelly `n_current` sensor removal condition (#148740) --- homeassistant/components/shelly/sensor.py | 4 +- tests/components/shelly/fixtures/pro_3em.json | 2 +- .../shelly/snapshots/test_devices.ambr | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index 3a6f5f221c5..cefcbb86a98 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -868,8 +868,8 @@ RPC_SENSORS: Final = { native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, - available=lambda status: (status and status["n_current"]) is not None, - removal_condition=lambda _config, status, _key: "n_current" not in status, + removal_condition=lambda _config, status, key: status[key].get("n_current") + is None, entity_registry_enabled_default=False, ), "total_current": RpcSensorDescription( diff --git a/tests/components/shelly/fixtures/pro_3em.json b/tests/components/shelly/fixtures/pro_3em.json index 93351e9bc65..4895766cc49 100644 --- a/tests/components/shelly/fixtures/pro_3em.json +++ b/tests/components/shelly/fixtures/pro_3em.json @@ -151,7 +151,7 @@ "c_pf": 0.72, "c_voltage": 230.2, "id": 0, - "n_current": null, + "n_current": 3.124, "total_act_power": 2413.825, "total_aprt_power": 2525.779, "total_current": 11.116, diff --git a/tests/components/shelly/snapshots/test_devices.ambr b/tests/components/shelly/snapshots/test_devices.ambr index 0b8ec71771b..9dcda321057 100644 --- a/tests/components/shelly/snapshots/test_devices.ambr +++ b/tests/components/shelly/snapshots/test_devices.ambr @@ -4303,6 +4303,62 @@ 'state': '230.2', }) # --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-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.test_name_phase_n_current', + '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': 'Phase N current', + 'platform': 'shelly', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '123456789ABC-em:0-n_current', + 'unit_of_measurement': , + }) +# --- +# name: test_shelly_pro_3em[sensor.test_name_phase_n_current-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'current', + 'friendly_name': 'Test name Phase N current', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_name_phase_n_current', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.124', + }) +# --- # name: test_shelly_pro_3em[sensor.test_name_rssi-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a81e83cb2893d24cde1ae1a6d2789a7f4c78eaf8 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Tue, 15 Jul 2025 07:38:01 +1000 Subject: [PATCH 1402/1664] Manually register powerview hub (#146709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Abílio Costa --- .../hunterdouglas_powerview/__init__.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 3e9ff8727ce..89624a0efbc 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -11,9 +11,9 @@ from aiopvapi.shades import Shades from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import DOMAIN, HUB_EXCEPTIONS +from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER from .coordinator import PowerviewShadeUpdateCoordinator from .model import PowerviewConfigEntry, PowerviewEntryData from .shade_data import PowerviewShadeData @@ -64,6 +64,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerviewConfigEntry) -> ) return False + # manual registration of the hub + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, hub.mac_address)}, + identifiers={(DOMAIN, hub.serial_number)}, + manufacturer=MANUFACTURER, + name=hub.name, + model=hub.model, + sw_version=hub.firmware, + hw_version=hub.main_processor_version.name, + ) + try: rooms = Rooms(pv_request) room_data: PowerviewData = await rooms.get_rooms() From 816977dd75a6145420877a64707593582f8aada1 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 02:26:34 -0400 Subject: [PATCH 1403/1664] Refactor async_setup_platform for template platforms (#147379) --- .../template/alarm_control_panel.py | 91 +---- .../components/template/binary_sensor.py | 107 +----- homeassistant/components/template/button.py | 50 +-- homeassistant/components/template/config.py | 14 +- homeassistant/components/template/cover.py | 84 +---- homeassistant/components/template/fan.py | 84 +---- homeassistant/components/template/helpers.py | 174 ++++++++- homeassistant/components/template/image.py | 47 +-- homeassistant/components/template/light.py | 77 +--- homeassistant/components/template/lock.py | 62 +--- homeassistant/components/template/number.py | 50 +-- homeassistant/components/template/select.py | 47 +-- homeassistant/components/template/sensor.py | 95 +---- homeassistant/components/template/switch.py | 92 +---- .../components/template/template_entity.py | 38 -- homeassistant/components/template/vacuum.py | 86 +---- homeassistant/components/template/weather.py | 76 +--- .../components/template/test_binary_sensor.py | 2 +- tests/components/template/test_helpers.py | 344 ++++++++++++++++++ tests/components/template/test_light.py | 123 ------- tests/components/template/test_switch.py | 33 -- 21 files changed, 711 insertions(+), 1065 deletions(-) create mode 100644 tests/components/template/test_helpers.py diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index bac3f03afb8..a308d55e443 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -45,12 +45,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -88,7 +84,7 @@ class TemplateCodeFormat(Enum): text = CodeFormat.TEXT -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -161,54 +157,6 @@ ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy alarm control panel configuration definitions to modern ones.""" - alarm_control_panels = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - alarm_control_panels.append(entity_conf) - - return alarm_control_panels - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template alarm control panels.""" - alarm_control_panels = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - alarm_control_panels.append( - AlarmControlPanelTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(alarm_control_panels) - - def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -231,7 +179,7 @@ async def async_setup_entry( validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) async_add_entities( [ - AlarmControlPanelTemplate( + StateAlarmControlPanelEntity( hass, validated_config, config_entry.entry_id, @@ -247,27 +195,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_ALARM_CONTROL_PANELS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + ALARM_CONTROL_PANEL_DOMAIN, + config, + StateAlarmControlPanelEntity, + TriggerAlarmControlPanelEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_ALARM_CONTROL_PANELS, ) @@ -414,7 +351,7 @@ class AbstractTemplateAlarmControlPanel( ) -class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPanel): +class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlPanel): """Representation of a templated Alarm Control Panel.""" _attr_should_poll = False diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index b3bbf37712f..6d41a5804b6 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -24,9 +24,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, - CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, CONF_SENSORS, @@ -53,18 +51,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import ( - CONF_ATTRIBUTES, - CONF_AVAILABILITY, - CONF_AVAILABILITY_TEMPLATE, - CONF_OBJECT_ID, - CONF_PICTURE, -) -from .template_entity import ( - TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, - rewrite_common_legacy_to_modern_conf, -) +from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .helpers import async_setup_template_platform +from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -73,12 +62,7 @@ CONF_AUTO_OFF = "auto_off" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -121,27 +105,6 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy binary sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( @@ -151,33 +114,6 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( ) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template binary sensors.""" - sensors = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - sensors.append( - BinarySensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -185,27 +121,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template binary sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerBinarySensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + BINARY_SENSOR_DOMAIN, + config, + StateBinarySensorEntity, + TriggerBinarySensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -219,20 +144,20 @@ async def async_setup_entry( _options.pop("template_type") validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) async_add_entities( - [BinarySensorTemplate(hass, validated_config, config_entry.entry_id)] + [StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)] ) @callback def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> BinarySensorTemplate: +) -> StateBinarySensorEntity: """Create a preview sensor.""" validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return BinarySensorTemplate(hass, validated_config, None) + return StateBinarySensorEntity(hass, validated_config, None) -class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): +class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 07aa41b3811..c52e2dae5a0 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,20 +3,17 @@ from __future__ import annotations import logging -from typing import Any import voluptuous as vol -from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_CLASS, - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, +from homeassistant.components.button import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BUTTON_DOMAIN, + ButtonEntity, ) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( @@ -26,6 +23,7 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema _LOGGER = logging.getLogger(__name__) @@ -50,19 +48,6 @@ CONFIG_BUTTON_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateButtonEntity]: - """Create the Template button.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateButtonEntity(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -70,15 +55,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template button.""" - if not discovery_info or "coordinator" in discovery_info: - raise PlatformNotReady( - "The template button platform doesn't support trigger entities" - ) - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + BUTTON_DOMAIN, + config, + StateButtonEntity, + None, + async_add_entities, + discovery_info, ) @@ -92,11 +76,11 @@ async def async_setup_entry( _options.pop("template_type") validated_config = CONFIG_BUTTON_SCHEMA(_options) async_add_entities( - [TemplateButtonEntity(hass, validated_config, config_entry.entry_id)] + [StateButtonEntity(hass, validated_config, config_entry.entry_id)] ) -class TemplateButtonEntity(TemplateEntity, ButtonEntity): +class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 86769a0d22a..1b3e9986d36 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -65,7 +65,7 @@ from . import ( weather as weather_platform, ) from .const import DOMAIN, PLATFORMS, TemplateConfig -from .helpers import async_get_blueprints +from .helpers import async_get_blueprints, rewrite_legacy_to_modern_configs PACKAGE_MERGE_HINT = "list" @@ -249,16 +249,16 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf legacy_warn_printed = False - for old_key, new_key, transform in ( + for old_key, new_key, legacy_fields in ( ( CONF_SENSORS, DOMAIN_SENSOR, - sensor_platform.rewrite_legacy_to_modern_conf, + sensor_platform.LEGACY_FIELDS, ), ( CONF_BINARY_SENSORS, DOMAIN_BINARY_SENSOR, - binary_sensor_platform.rewrite_legacy_to_modern_conf, + binary_sensor_platform.LEGACY_FIELDS, ), ): if old_key not in template_config: @@ -276,7 +276,11 @@ async def async_validate_config(hass: HomeAssistant, config: ConfigType) -> Conf definitions = ( list(template_config[new_key]) if new_key in template_config else [] ) - definitions.extend(transform(hass, template_config[old_key])) + definitions.extend( + rewrite_legacy_to_modern_configs( + hass, template_config[old_key], legacy_fields + ) + ) template_config = TemplateConfig({**template_config, new_key: definitions}) config_sections.append(template_config) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 68645c718b2..9d6391d80c9 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -39,12 +39,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -85,7 +84,7 @@ TILT_FEATURES = ( | CoverEntityFeature.SET_TILT_POSITION ) -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, CONF_POSITION_TEMPLATE: CONF_POSITION, CONF_TILT_TEMPLATE: CONF_TILT, @@ -140,54 +139,6 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - covers = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - covers.append(entity_conf) - - return covers - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - covers = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - covers.append( - CoverTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(covers) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -195,27 +146,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_COVERS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerCoverEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + COVER_DOMAIN, + config, + StateCoverEntity, + TriggerCoverEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_COVERS, ) @@ -445,7 +385,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): self.async_write_ha_state() -class CoverTemplate(TemplateEntity, AbstractTemplateCover): +class StateCoverEntity(TemplateEntity, AbstractTemplateCover): """Representation of a Template cover.""" _attr_should_poll = False diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index f7b0b57cf27..95086375f4b 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -41,12 +41,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -73,7 +72,7 @@ CONF_OSCILLATING = "oscillating" CONF_PERCENTAGE = "percentage" CONF_PRESET_MODE = "preset_mode" -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_DIRECTION_TEMPLATE: CONF_DIRECTION, CONF_OSCILLATING_TEMPLATE: CONF_OSCILLATING, CONF_PERCENTAGE_TEMPLATE: CONF_PERCENTAGE, @@ -132,54 +131,6 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy fan configuration definitions to modern ones.""" - fans = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - fans.append(entity_conf) - - return fans - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - fans.append( - TemplateFan( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -187,27 +138,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_FANS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerFanEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + FAN_DOMAIN, + config, + StateFanEntity, + TriggerFanEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_FANS, ) @@ -484,7 +424,7 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): ) -class TemplateFan(TemplateEntity, AbstractTemplateFan): +class StateFanEntity(TemplateEntity, AbstractTemplateFan): """A template fan component.""" _attr_should_poll = False diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 2cd587de5a1..514255f417a 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -1,19 +1,60 @@ """Helpers for template integration.""" +from collections.abc import Callable +import itertools import logging +from typing import Any from homeassistant.components import blueprint -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import ( + CONF_ENTITY_PICTURE_TEMPLATE, + CONF_FRIENDLY_NAME, + CONF_ICON, + CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_UNIQUE_ID, + SERVICE_RELOAD, +) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_platform import ( + AddEntitiesCallback, + async_get_platforms, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN +from .const import ( + CONF_ATTRIBUTE_TEMPLATES, + CONF_ATTRIBUTES, + CONF_AVAILABILITY, + CONF_AVAILABILITY_TEMPLATE, + CONF_OBJECT_ID, + CONF_PICTURE, + DOMAIN, +) from .entity import AbstractTemplateEntity +from .template_entity import TemplateEntity +from .trigger_entity import TriggerEntity DATA_BLUEPRINTS = "template_blueprints" -LOGGER = logging.getLogger(__name__) +LEGACY_FIELDS = { + CONF_ICON_TEMPLATE: CONF_ICON, + CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, + CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, + CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, + CONF_FRIENDLY_NAME: CONF_NAME, +} + +_LOGGER = logging.getLogger(__name__) + +type CreateTemplateEntitiesCallback = Callable[ + [type[TemplateEntity], AddEntitiesCallback, HomeAssistant, list[dict], str | None], + None, +] @callback @@ -59,8 +100,131 @@ def async_get_blueprints(hass: HomeAssistant) -> blueprint.DomainBlueprints: return blueprint.DomainBlueprints( hass, DOMAIN, - LOGGER, + _LOGGER, _blueprint_in_use, _reload_blueprint_templates, TEMPLATE_BLUEPRINT_SCHEMA, ) + + +def rewrite_legacy_to_modern_config( + hass: HomeAssistant, + entity_cfg: dict[str, Any], + extra_legacy_fields: dict[str, str], +) -> dict[str, Any]: + """Rewrite legacy config.""" + entity_cfg = {**entity_cfg} + + for from_key, to_key in itertools.chain( + LEGACY_FIELDS.items(), extra_legacy_fields.items() + ): + if from_key not in entity_cfg or to_key in entity_cfg: + continue + + val = entity_cfg.pop(from_key) + if isinstance(val, str): + val = template.Template(val, hass) + entity_cfg[to_key] = val + + if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): + entity_cfg[CONF_NAME] = template.Template(entity_cfg[CONF_NAME], hass) + + return entity_cfg + + +def rewrite_legacy_to_modern_configs( + hass: HomeAssistant, + entity_cfg: dict[str, dict], + extra_legacy_fields: dict[str, str], +) -> list[dict]: + """Rewrite legacy configuration definitions to modern ones.""" + entities = [] + for object_id, entity_conf in entity_cfg.items(): + entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} + + entity_conf = rewrite_legacy_to_modern_config( + hass, entity_conf, extra_legacy_fields + ) + + if CONF_NAME not in entity_conf: + entity_conf[CONF_NAME] = template.Template(object_id, hass) + + entities.append(entity_conf) + + return entities + + +@callback +def async_create_template_tracking_entities( + entity_cls: type[Entity], + async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + definitions: list[dict], + unique_id_prefix: str | None, +) -> None: + """Create the template tracking entities.""" + entities: list[Entity] = [] + for definition in definitions: + unique_id = definition.get(CONF_UNIQUE_ID) + if unique_id and unique_id_prefix: + unique_id = f"{unique_id_prefix}-{unique_id}" + entities.append(entity_cls(hass, definition, unique_id)) # type: ignore[call-arg] + async_add_entities(entities) + + +async def async_setup_template_platform( + hass: HomeAssistant, + domain: str, + config: ConfigType, + state_entity_cls: type[TemplateEntity], + trigger_entity_cls: type[TriggerEntity] | None, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None, + legacy_fields: dict[str, str] | None = None, + legacy_key: str | None = None, +) -> None: + """Set up the Template platform.""" + if discovery_info is None: + # Legacy Configuration + if legacy_fields is not None: + if legacy_key: + configs = rewrite_legacy_to_modern_configs( + hass, config[legacy_key], legacy_fields + ) + else: + configs = [rewrite_legacy_to_modern_config(hass, config, legacy_fields)] + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + configs, + None, + ) + else: + _LOGGER.warning( + "Template %s entities can only be configured under template:", domain + ) + return + + # Trigger Configuration + if "coordinator" in discovery_info: + if trigger_entity_cls: + entities = [ + trigger_entity_cls(hass, discovery_info["coordinator"], config) + for config in discovery_info["entities"] + ] + async_add_entities(entities) + else: + raise PlatformNotReady( + f"The template {domain} platform doesn't support trigger entities" + ) + return + + # Modern Configuration + async_create_template_tracking_entities( + state_entity_cls, + async_add_entities, + hass, + discovery_info["entities"], + discovery_info["unique_id"], + ) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index d286a2f6b4d..5f7f06faf4f 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -9,13 +9,7 @@ import voluptuous as vol from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_UNIQUE_ID, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector @@ -29,6 +23,7 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE +from .helpers import async_setup_template_platform from .template_entity import ( TemplateEntity, make_template_entity_common_modern_attributes_schema, @@ -59,19 +54,6 @@ IMAGE_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[StateImageEntity]: - """Create the template image.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(StateImageEntity(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -79,23 +61,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template image.""" - if discovery_info is None: - _LOGGER.warning( - "Template image entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerImageEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + IMAGE_DOMAIN, + config, + StateImageEntity, + TriggerImageEntity, + async_add_entities, + discovery_info, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 10870462bc9..438c295ecd5 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -51,12 +51,11 @@ from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -103,7 +102,7 @@ CONF_WHITE_VALUE_TEMPLATE = "white_value_template" DEFAULT_MIN_MIREDS = 153 DEFAULT_MAX_MIREDS = 500 -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_COLOR_ACTION: CONF_HS_ACTION, CONF_COLOR_TEMPLATE: CONF_HS, CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST, @@ -193,47 +192,6 @@ PLATFORM_SCHEMA = vol.All( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - lights = [] - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - lights.append(entity_conf) - - return lights - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the Template Lights.""" - lights = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - lights.append(LightTemplate(hass, entity_conf, unique_id)) - - async_add_entities(lights) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -241,27 +199,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template lights.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLightEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LIGHT_DOMAIN, + config, + StateLightEntity, + TriggerLightEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_LIGHTS, ) @@ -934,7 +881,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): self._attr_supported_features |= LightEntityFeature.TRANSITION -class LightTemplate(TemplateEntity, AbstractTemplateLight): +class StateLightEntity(TemplateEntity, AbstractTemplateLight): """Representation of a templated Light, including dimmable.""" _attr_should_poll = False diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 4e3f3ed8ccc..20bc098d130 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -31,12 +31,11 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PICTURE, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -49,7 +48,7 @@ CONF_OPEN = "open" DEFAULT_NAME = "Template Lock" DEFAULT_OPTIMISTIC = False -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_CODE_FORMAT_TEMPLATE: CONF_CODE_FORMAT, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -83,33 +82,6 @@ PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template fans.""" - fans = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - fans.append( - TemplateLock( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(fans) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -117,27 +89,15 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template fans.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - [rewrite_common_legacy_to_modern_conf(hass, config, LEGACY_FIELDS)], - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerLockEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + LOCK_DOMAIN, + config, + StateLockEntity, + TriggerLockEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, ) @@ -311,7 +271,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): ) -class TemplateLock(TemplateEntity, AbstractTemplateLock): +class StateLockEntity(TemplateEntity, AbstractTemplateLock): """Representation of a template lock.""" _attr_should_poll = False diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 4d9eaff0b2d..fa1e2790a9d 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -21,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, - CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback @@ -35,6 +34,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -70,19 +70,6 @@ NUMBER_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateNumber]: - """Create the Template number.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateNumber(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -90,23 +77,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template number.""" - if discovery_info is None: - _LOGGER.warning( - "Template number entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerNumberEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + NUMBER_DOMAIN, + config, + StateNumberEntity, + TriggerNumberEntity, + async_add_entities, + discovery_info, ) @@ -119,19 +97,21 @@ async def async_setup_entry( _options = dict(config_entry.options) _options.pop("template_type") validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities([TemplateNumber(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateNumberEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> TemplateNumber: +) -> StateNumberEntity: """Create a preview number.""" validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return TemplateNumber(hass, validated_config, None) + return StateNumberEntity(hass, validated_config, None) -class TemplateNumber(TemplateEntity, NumberEntity): +class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 256955e70a8..55b5c7375f8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -14,13 +14,7 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_NAME, - CONF_OPTIMISTIC, - CONF_STATE, - CONF_UNIQUE_ID, -) +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.device import async_device_info_to_link_from_device_id @@ -33,6 +27,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity @@ -65,19 +60,6 @@ SELECT_CONFIG_SCHEMA = vol.Schema( ) -async def _async_create_entities( - hass: HomeAssistant, definitions: list[dict[str, Any]], unique_id_prefix: str | None -) -> list[TemplateSelect]: - """Create the Template select.""" - entities = [] - for definition in definitions: - unique_id = definition.get(CONF_UNIQUE_ID) - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - entities.append(TemplateSelect(hass, definition, unique_id)) - return entities - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -85,23 +67,14 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template select.""" - if discovery_info is None: - _LOGGER.warning( - "Template select entities can only be configured under template:" - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSelectEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - async_add_entities( - await _async_create_entities( - hass, discovery_info["entities"], discovery_info["unique_id"] - ) + await async_setup_template_platform( + hass, + SELECT_DOMAIN, + config, + TemplateSelect, + TriggerSelectEntity, + async_add_entities, + discovery_info, ) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index c25a2a0e3cb..11fe279fdfb 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -56,16 +56,12 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID -from .template_entity import ( - TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity LEGACY_FIELDS = { CONF_FRIENDLY_NAME_TEMPLATE: CONF_NAME, - CONF_FRIENDLY_NAME: CONF_NAME, CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -142,27 +138,6 @@ def extra_validation_checks(val): return val -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, cfg: dict[str, dict] -) -> list[dict]: - """Rewrite legacy sensor definitions to modern ones.""" - sensors = [] - - for object_id, entity_cfg in cfg.items(): - entity_cfg = {**entity_cfg, CONF_OBJECT_ID: object_id} - - entity_cfg = rewrite_common_legacy_to_modern_conf( - hass, entity_cfg, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_cfg: - entity_cfg[CONF_NAME] = template.Template(object_id, hass) - - sensors.append(entity_cfg) - - return sensors - - PLATFORM_SCHEMA = vol.All( SENSOR_PLATFORM_SCHEMA.extend( { @@ -177,33 +152,6 @@ PLATFORM_SCHEMA = vol.All( _LOGGER = logging.getLogger(__name__) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template sensors.""" - sensors = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - sensors.append( - SensorTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(sensors) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -211,27 +159,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template sensors.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SENSORS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSensorEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SENSOR_DOMAIN, + config, + StateSensorEntity, + TriggerSensorEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SENSORS, ) @@ -244,19 +181,21 @@ async def async_setup_entry( _options = dict(config_entry.options) _options.pop("template_type") validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities([SensorTemplate(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateSensorEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SensorTemplate: +) -> StateSensorEntity: """Create a preview sensor.""" validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return SensorTemplate(hass, validated_config, None) + return StateSensorEntity(hass, validated_config, None) -class SensorTemplate(TemplateEntity, SensorEntity): +class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 677686ea8d8..e2ccb5a8a82 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -41,18 +41,17 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } @@ -96,27 +95,6 @@ SWITCH_CONFIG_SCHEMA = vol.Schema( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - switches = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - switches.append(entity_conf) - - return switches - - def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]: """Rewrite option configuration to modern configuration.""" option_config = {**option_config} @@ -127,33 +105,6 @@ def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, return option_config -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - switches = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - switches.append( - SwitchTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(switches) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -161,27 +112,16 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the template switches.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerSwitchEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + SWITCH_DOMAIN, + config, + StateSwitchEntity, + TriggerSwitchEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_SWITCHES, ) @@ -195,20 +135,22 @@ async def async_setup_entry( _options.pop("template_type") _options = rewrite_options_to_modern_conf(_options) validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)]) + async_add_entities( + [StateSwitchEntity(hass, validated_config, config_entry.entry_id)] + ) @callback def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] -) -> SwitchTemplate: +) -> StateSwitchEntity: """Create a preview switch.""" updated_config = rewrite_options_to_modern_conf(config) validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return SwitchTemplate(hass, validated_config, None) + return StateSwitchEntity(hass, validated_config, None) -class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): +class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 3157a60347e..e404821e651 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable, Mapping import contextlib -import itertools import logging from typing import Any, cast @@ -14,7 +13,6 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, - CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, @@ -137,42 +135,6 @@ TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY = vol.Schema( ).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY.schema) -LEGACY_FIELDS = { - CONF_ICON_TEMPLATE: CONF_ICON, - CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, - CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, - CONF_ATTRIBUTE_TEMPLATES: CONF_ATTRIBUTES, - CONF_FRIENDLY_NAME: CONF_NAME, -} - - -def rewrite_common_legacy_to_modern_conf( - hass: HomeAssistant, - entity_cfg: dict[str, Any], - extra_legacy_fields: dict[str, str] | None = None, -) -> dict[str, Any]: - """Rewrite legacy config.""" - entity_cfg = {**entity_cfg} - if extra_legacy_fields is None: - extra_legacy_fields = {} - - for from_key, to_key in itertools.chain( - LEGACY_FIELDS.items(), extra_legacy_fields.items() - ): - if from_key not in entity_cfg or to_key in entity_cfg: - continue - - val = entity_cfg.pop(from_key) - if isinstance(val, str): - val = Template(val, hass) - entity_cfg[to_key] = val - - if CONF_NAME in entity_cfg and isinstance(entity_cfg[CONF_NAME], str): - entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME], hass) - - return entity_cfg - - class _TemplateAttribute: """Attribute value linked to template result.""" diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 1fb5b89ead2..d9c416f4863 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -41,13 +41,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_OBJECT_ID, DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity +from .helpers import async_setup_template_platform from .template_entity import ( - LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS, TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY, TEMPLATE_ENTITY_AVAILABILITY_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_attributes_schema, - rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -72,7 +71,7 @@ _VALID_STATES = [ VacuumActivity.ERROR, ] -LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | { +LEGACY_FIELDS = { CONF_BATTERY_LEVEL_TEMPLATE: CONF_BATTERY_LEVEL, CONF_FAN_SPEED_TEMPLATE: CONF_FAN_SPEED, CONF_VALUE_TEMPLATE: CONF_STATE, @@ -125,82 +124,23 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( ) -def rewrite_legacy_to_modern_conf( - hass: HomeAssistant, config: dict[str, dict] -) -> list[dict]: - """Rewrite legacy switch configuration definitions to modern ones.""" - vacuums = [] - - for object_id, entity_conf in config.items(): - entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id} - - entity_conf = rewrite_common_legacy_to_modern_conf( - hass, entity_conf, LEGACY_FIELDS - ) - - if CONF_NAME not in entity_conf: - entity_conf[CONF_NAME] = template.Template(object_id, hass) - - vacuums.append(entity_conf) - - return vacuums - - -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the template switches.""" - vacuums = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - vacuums.append( - TemplateVacuum( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(vacuums) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Template cover.""" - if discovery_info is None: - _async_create_template_tracking_entities( - async_add_entities, - hass, - rewrite_legacy_to_modern_conf(hass, config[CONF_VACUUMS]), - None, - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerVacuumEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + """Set up the Template vacuum.""" + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + VACUUM_DOMAIN, + config, + TemplateStateVacuumEntity, + TriggerVacuumEntity, + async_add_entities, + discovery_info, + LEGACY_FIELDS, + legacy_key=CONF_VACUUMS, ) @@ -350,7 +290,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): self._attr_fan_speed = None -class TemplateVacuum(TemplateEntity, AbstractTemplateVacuum): +class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): """A template vacuum component.""" _attr_should_poll = False diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index ee834e757a3..66ead388d5d 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -31,12 +31,7 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import ( - CONF_TEMPERATURE_UNIT, - CONF_UNIQUE_ID, - STATE_UNAVAILABLE, - STATE_UNKNOWN, -) +from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template @@ -52,11 +47,8 @@ from homeassistant.util.unit_conversion import ( ) from .coordinator import TriggerUpdateCoordinator -from .template_entity import ( - TemplateEntity, - make_template_entity_common_modern_schema, - rewrite_common_legacy_to_modern_conf, -) +from .helpers import async_setup_template_platform +from .template_entity import TemplateEntity, make_template_entity_common_modern_schema from .trigger_entity import TriggerEntity CHECK_FORECAST_KEYS = ( @@ -138,33 +130,6 @@ WEATHER_SCHEMA = vol.Schema( PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) -@callback -def _async_create_template_tracking_entities( - async_add_entities: AddEntitiesCallback, - hass: HomeAssistant, - definitions: list[dict], - unique_id_prefix: str | None, -) -> None: - """Create the weather entities.""" - entities = [] - - for entity_conf in definitions: - unique_id = entity_conf.get(CONF_UNIQUE_ID) - - if unique_id and unique_id_prefix: - unique_id = f"{unique_id_prefix}-{unique_id}" - - entities.append( - WeatherTemplate( - hass, - entity_conf, - unique_id, - ) - ) - - async_add_entities(entities) - - async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -172,36 +137,19 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Template weather.""" - if discovery_info is None: - config = rewrite_common_legacy_to_modern_conf(hass, config) - unique_id = config.get(CONF_UNIQUE_ID) - async_add_entities( - [ - WeatherTemplate( - hass, - config, - unique_id, - ) - ] - ) - return - - if "coordinator" in discovery_info: - async_add_entities( - TriggerWeatherEntity(hass, discovery_info["coordinator"], config) - for config in discovery_info["entities"] - ) - return - - _async_create_template_tracking_entities( - async_add_entities, + await async_setup_template_platform( hass, - discovery_info["entities"], - discovery_info["unique_id"], + WEATHER_DOMAIN, + config, + StateWeatherEntity, + TriggerWeatherEntity, + async_add_entities, + discovery_info, + {}, ) -class WeatherTemplate(TemplateEntity, WeatherEntity): +class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index 75a9e2c9689..b30051a52d2 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -559,7 +559,7 @@ def setup_mock() -> Generator[Mock]: """Do setup of sensor mock.""" with patch( "homeassistant.components.template.binary_sensor." - "BinarySensorTemplate._update_state" + "StateBinarySensorEntity._update_state" ) as _update_state: yield _update_state diff --git a/tests/components/template/test_helpers.py b/tests/components/template/test_helpers.py new file mode 100644 index 00000000000..574c764ba28 --- /dev/null +++ b/tests/components/template/test_helpers.py @@ -0,0 +1,344 @@ +"""The tests for template helpers.""" + +import pytest + +from homeassistant.components.template.alarm_control_panel import ( + LEGACY_FIELDS as ALARM_CONTROL_PANEL_LEGACY_FIELDS, +) +from homeassistant.components.template.binary_sensor import ( + LEGACY_FIELDS as BINARY_SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.button import StateButtonEntity +from homeassistant.components.template.cover import LEGACY_FIELDS as COVER_LEGACY_FIELDS +from homeassistant.components.template.fan import LEGACY_FIELDS as FAN_LEGACY_FIELDS +from homeassistant.components.template.helpers import ( + async_setup_template_platform, + rewrite_legacy_to_modern_config, + rewrite_legacy_to_modern_configs, +) +from homeassistant.components.template.light import LEGACY_FIELDS as LIGHT_LEGACY_FIELDS +from homeassistant.components.template.lock import LEGACY_FIELDS as LOCK_LEGACY_FIELDS +from homeassistant.components.template.sensor import ( + LEGACY_FIELDS as SENSOR_LEGACY_FIELDS, +) +from homeassistant.components.template.switch import ( + LEGACY_FIELDS as SWITCH_LEGACY_FIELDS, +) +from homeassistant.components.template.vacuum import ( + LEGACY_FIELDS as VACUUM_LEGACY_FIELDS, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.template import Template + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + LOCK_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LOCK_LEGACY_FIELDS, + "code_format_template", + "code_format", + "{{ 'some format' }}", + ), + ], +) +async def test_legacy_to_modern_config( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of single legacy template to modern template.""" + config = { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + altered_configs = rewrite_legacy_to_modern_config(hass, config, legacy_fields) + + assert { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } == altered_configs + + +@pytest.mark.parametrize( + ("legacy_fields", "old_attr", "new_attr", "attr_template"), + [ + ( + ALARM_CONTROL_PANEL_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + BINARY_SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + COVER_LEGACY_FIELDS, + "position_template", + "position", + "{{ 100 }}", + ), + ( + COVER_LEGACY_FIELDS, + "tilt_template", + "tilt", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "direction_template", + "direction", + "{{ 1 == 1 }}", + ), + ( + FAN_LEGACY_FIELDS, + "oscillating_template", + "oscillating", + "{{ True }}", + ), + ( + FAN_LEGACY_FIELDS, + "percentage_template", + "percentage", + "{{ 100 }}", + ), + ( + FAN_LEGACY_FIELDS, + "preset_mode_template", + "preset_mode", + "{{ 'foo' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgb_template", + "rgb", + "{{ (255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbw_template", + "rgbw", + "{{ (255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "rgbww_template", + "rgbww", + "{{ (255,255,255,255,255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_list_template", + "effect_list", + "{{ ['a', 'b'] }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "effect_template", + "effect", + "{{ 'a' }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "level_template", + "level", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "max_mireds_template", + "max_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "min_mireds_template", + "min_mireds", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "supports_transition_template", + "supports_transition", + "{{ True }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "temperature_template", + "temperature", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "white_value_template", + "white_value", + "{{ 255 }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "hs_template", + "hs", + "{{ (255, 255) }}", + ), + ( + LIGHT_LEGACY_FIELDS, + "color_template", + "hs", + "{{ (255, 255) }}", + ), + ( + SENSOR_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + SWITCH_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "value_template", + "state", + "{{ 1 == 1 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "battery_level_template", + "battery_level", + "{{ 100 }}", + ), + ( + VACUUM_LEGACY_FIELDS, + "fan_speed_template", + "fan_speed", + "{{ 7 }}", + ), + ], +) +async def test_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, + old_attr: str, + new_attr: str, + attr_template: str, +) -> None: + """Test the conversion of legacy template to modern template.""" + config = { + "foo": { + "friendly_name": "foo bar", + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + old_attr: attr_template, + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "name": Template("foo bar", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + new_attr: Template(attr_template, hass), + } + ] == altered_configs + + +@pytest.mark.parametrize( + "legacy_fields", + [ + BINARY_SENSOR_LEGACY_FIELDS, + SENSOR_LEGACY_FIELDS, + ], +) +async def test_friendly_name_template_legacy_to_modern_configs( + hass: HomeAssistant, + legacy_fields, +) -> None: + """Test the conversion of friendly_name_tempalte in legacy template to modern template.""" + config = { + "foo": { + "unique_id": "foo-bar-entity", + "icon_template": "{{ 'mdi.abc' }}", + "entity_picture_template": "{{ 'mypicture.jpg' }}", + "availability_template": "{{ 1 == 1 }}", + "friendly_name_template": "{{ 'foo bar' }}", + } + } + altered_configs = rewrite_legacy_to_modern_configs(hass, config, legacy_fields) + + assert len(altered_configs) == 1 + + assert [ + { + "availability": Template("{{ 1 == 1 }}", hass), + "icon": Template("{{ 'mdi.abc' }}", hass), + "object_id": "foo", + "picture": Template("{{ 'mypicture.jpg' }}", hass), + "unique_id": "foo-bar-entity", + "name": Template("{{ 'foo bar' }}", hass), + } + ] == altered_configs + + +async def test_platform_not_ready( + hass: HomeAssistant, +) -> None: + """Test async_setup_template_platform raises PlatformNotReady when trigger object is None.""" + with pytest.raises(PlatformNotReady): + await async_setup_template_platform( + hass, + "button", + {}, + StateButtonEntity, + None, + None, + {"coordinator": None, "entities": []}, + ) diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index eaa1708aea7..bfffd0911a9 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -17,7 +17,6 @@ from homeassistant.components.light import ( ColorMode, LightEntityFeature, ) -from homeassistant.components.template.light import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -29,7 +28,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from .conftest import ConfigurationStyle @@ -289,127 +287,6 @@ TEST_UNIQUE_ID_CONFIG = { } -@pytest.mark.parametrize( - ("old_attr", "new_attr", "attr_template"), - [ - ( - "value_template", - "state", - "{{ 1 == 1 }}", - ), - ( - "rgb_template", - "rgb", - "{{ (255,255,255) }}", - ), - ( - "rgbw_template", - "rgbw", - "{{ (255,255,255,255) }}", - ), - ( - "rgbww_template", - "rgbww", - "{{ (255,255,255,255,255) }}", - ), - ( - "effect_list_template", - "effect_list", - "{{ ['a', 'b'] }}", - ), - ( - "effect_template", - "effect", - "{{ 'a' }}", - ), - ( - "level_template", - "level", - "{{ 255 }}", - ), - ( - "max_mireds_template", - "max_mireds", - "{{ 255 }}", - ), - ( - "min_mireds_template", - "min_mireds", - "{{ 255 }}", - ), - ( - "supports_transition_template", - "supports_transition", - "{{ True }}", - ), - ( - "temperature_template", - "temperature", - "{{ 255 }}", - ), - ( - "white_value_template", - "white_value", - "{{ 255 }}", - ), - ( - "hs_template", - "hs", - "{{ (255, 255) }}", - ), - ( - "color_template", - "hs", - "{{ (255, 255) }}", - ), - ], -) -async def test_legacy_to_modern_config( - hass: HomeAssistant, old_attr: str, new_attr: str, attr_template: str -) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "unique_id": "foo-bar-light", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - old_attr: attr_template, - **OPTIMISTIC_ON_OFF_LIGHT_CONFIG, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": { - "data_template": { - "action": "turn_off", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "turn_on": { - "data_template": { - "action": "turn_on", - "caller": "{{ this.entity_id }}", - }, - "service": "test.automation", - }, - "unique_id": "foo-bar-light", - new_attr: Template(attr_template, hass), - } - ] == altered_configs - - async def async_setup_legacy_format( hass: HomeAssistant, count: int, light_config: dict[str, Any] ) -> None: diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py index c6ed303af7b..2e2fb5e8093 100644 --- a/tests/components/template/test_switch.py +++ b/tests/components/template/test_switch.py @@ -7,7 +7,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import switch, template from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN -from homeassistant.components.template.switch import rewrite_legacy_to_modern_conf from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -18,7 +17,6 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from .conftest import ConfigurationStyle, async_get_flow_preview_state @@ -306,37 +304,6 @@ async def setup_single_attribute_optimistic_switch( ) -async def test_legacy_to_modern_config(hass: HomeAssistant) -> None: - """Test the conversion of legacy template to modern template.""" - config = { - "foo": { - "friendly_name": "foo bar", - "value_template": "{{ 1 == 1 }}", - "unique_id": "foo-bar-switch", - "icon_template": "{{ 'mdi.abc' }}", - "entity_picture_template": "{{ 'mypicture.jpg' }}", - "availability_template": "{{ 1 == 1 }}", - **SWITCH_ACTIONS, - } - } - altered_configs = rewrite_legacy_to_modern_conf(hass, config) - - assert len(altered_configs) == 1 - assert [ - { - "availability": Template("{{ 1 == 1 }}", hass), - "icon": Template("{{ 'mdi.abc' }}", hass), - "name": Template("foo bar", hass), - "object_id": "foo", - "picture": Template("{{ 'mypicture.jpg' }}", hass), - "turn_off": SWITCH_TURN_OFF, - "turn_on": SWITCH_TURN_ON, - "unique_id": "foo-bar-switch", - "state": Template("{{ 1 == 1 }}", hass), - } - ] == altered_configs - - @pytest.mark.parametrize(("count", "state_template"), [(1, "{{ True }}")]) @pytest.mark.parametrize( "style", From e2cc51f21def72ef5dbf9872119298147d7e0f41 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 15 Jul 2025 08:51:08 +0200 Subject: [PATCH 1404/1664] Allow AI Task to handle camera attachments (#148753) --- homeassistant/components/ai_task/entity.py | 7 +- .../components/ai_task/manifest.json | 1 + homeassistant/components/ai_task/task.py | 95 +++++++++++++++---- .../components/conversation/chat_log.py | 3 - tests/components/ai_task/test_init.py | 1 - tests/components/ai_task/test_task.py | 88 +++++++++++++++++ 6 files changed, 167 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 420777ce5c3..4c5cd186943 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -13,7 +13,7 @@ from homeassistant.components.conversation import ( ) from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.helpers import llm -from homeassistant.helpers.chat_session import async_get_chat_session +from homeassistant.helpers.chat_session import ChatSession from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import dt as dt_util @@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity): @contextlib.asynccontextmanager async def _async_get_ai_task_chat_log( self, + session: ChatSession, task: GenDataTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup with ( - async_get_chat_session(self.hass) as session, async_get_chat_log( self.hass, session, @@ -88,12 +88,13 @@ class AITaskEntity(RestoreEntity): @final async def internal_async_generate_data( self, + session: ChatSession, task: GenDataTask, ) -> GenDataTaskResult: """Run a gen data task.""" self.__last_activity = dt_util.utcnow().isoformat() self.async_write_ha_state() - async with self._async_get_ai_task_chat_log(task) as chat_log: + async with self._async_get_ai_task_chat_log(session, task) as chat_log: return await self._async_generate_data(task, chat_log) async def _async_generate_data( diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index c3e33e7d411..ea377ffa671 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -1,6 +1,7 @@ { "domain": "ai_task", "name": "AI Task", + "after_dependencies": ["camera"], "codeowners": ["@home-assistant/core"], "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index bb57a89203e..3cc43f8c07a 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,17 +3,32 @@ from __future__ import annotations from dataclasses import dataclass +import mimetypes +from pathlib import Path +import tempfile from typing import Any import voluptuous as vol -from homeassistant.components import conversation, media_source -from homeassistant.core import HomeAssistant +from homeassistant.components import camera, conversation, media_source +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.chat_session import async_get_chat_session from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature +def _save_camera_snapshot(image: camera.Image) -> Path: + """Save camera snapshot to temp file.""" + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=mimetypes.guess_extension(image.content_type, False), + delete=False, + ) as temp_file: + temp_file.write(image.content) + return Path(temp_file.name) + + async def async_generate_data( hass: HomeAssistant, *, @@ -40,41 +55,79 @@ async def async_generate_data( ) # Resolve attachments - resolved_attachments: list[conversation.Attachment] | None = None + resolved_attachments: list[conversation.Attachment] = [] + created_files: list[Path] = [] - if attachments: - if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: - raise HomeAssistantError( - f"AI Task entity {entity_id} does not support attachments" + if ( + attachments + and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features + ): + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + for attachment in attachments or []: + media_content_id = attachment["media_content_id"] + + # Special case for camera media sources + if media_content_id.startswith("media-source://camera/"): + # Extract entity_id from the media content ID + entity_id = media_content_id.removeprefix("media-source://camera/") + + # Get snapshot from camera + image = await camera.async_get_image(hass, entity_id) + + temp_filename = await hass.async_add_executor_job( + _save_camera_snapshot, image ) + created_files.append(temp_filename) - resolved_attachments = [] - - for attachment in attachments: - media = await media_source.async_resolve_media( - hass, attachment["media_content_id"], None + resolved_attachments.append( + conversation.Attachment( + media_content_id=media_content_id, + mime_type=image.content_type, + path=temp_filename, + ) ) + else: + # Handle regular media sources + media = await media_source.async_resolve_media(hass, media_content_id, None) if media.path is None: raise HomeAssistantError( "Only local attachments are currently supported" ) resolved_attachments.append( conversation.Attachment( - media_content_id=attachment["media_content_id"], - url=media.url, + media_content_id=media_content_id, mime_type=media.mime_type, path=media.path, ) ) - return await entity.internal_async_generate_data( - GenDataTask( - name=task_name, - instructions=instructions, - structure=structure, - attachments=resolved_attachments, + with async_get_chat_session(hass) as session: + if created_files: + + def cleanup_files() -> None: + """Cleanup temporary files.""" + for file in created_files: + file.unlink(missing_ok=True) + + @callback + def cleanup_files_callback() -> None: + """Cleanup temporary files.""" + hass.async_add_executor_job(cleanup_files) + + session.async_on_cleanup(cleanup_files_callback) + + return await entity.internal_async_generate_data( + session, + GenDataTask( + name=task_name, + instructions=instructions, + structure=structure, + attachments=resolved_attachments or None, + ), ) - ) @dataclass(slots=True) diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index e8ec66afa76..8d739b6267d 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -147,9 +147,6 @@ class Attachment: media_content_id: str """Media content ID of the attachment.""" - url: str - """URL of the attachment.""" - mime_type: str """MIME type of the attachment.""" diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 19f73045532..09ee926c187 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -117,7 +117,6 @@ async def test_generate_data_service( for msg_attachment, attachment in zip( msg_attachments, task.attachments or [], strict=False ): - assert attachment.url == "http://example.com/media.mp4" assert attachment.mime_type == "video/mp4" assert attachment.media_content_id == msg_attachment["media_content_id"] assert attachment.path == Path("media.mp4") diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index b11d96823cc..7eb75b62bb0 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -1,18 +1,26 @@ """Test tasks for the AI Task integration.""" +from datetime import timedelta +from pathlib import Path +from unittest.mock import patch + from freezegun import freeze_time import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components import media_source from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data +from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import chat_session +from homeassistant.util import dt as dt_util from .conftest import TEST_ENTITY_ID, MockAITaskEntity +from tests.common import async_fire_time_changed from tests.typing import WebSocketGenerator @@ -154,3 +162,83 @@ async def test_generate_data_attachments_not_supported( } ], ) + + +async def test_generate_data_mixed_attachments( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating data with both camera and regular media source attachments.""" + with ( + patch( + "homeassistant.components.camera.async_get_image", + return_value=Image(content_type="image/jpeg", content=b"fake_camera_jpeg"), + ) as mock_get_image, + patch( + "homeassistant.components.media_source.async_resolve_media", + return_value=media_source.PlayMedia( + url="http://example.com/test.mp4", + mime_type="video/mp4", + path=Path("/media/test.mp4"), + ), + ) as mock_resolve_media, + ): + await async_generate_data( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Analyze these files", + attachments=[ + { + "media_content_id": "media-source://camera/camera.front_door", + "media_content_type": "image/jpeg", + }, + { + "media_content_id": "media-source://media_player/video.mp4", + "media_content_type": "video/mp4", + }, + ], + ) + + # Verify both methods were called + mock_get_image.assert_called_once_with(hass, "camera.front_door") + mock_resolve_media.assert_called_once_with( + hass, "media-source://media_player/video.mp4", None + ) + + # Check attachments + assert len(mock_ai_task_entity.mock_generate_data_tasks) == 1 + task = mock_ai_task_entity.mock_generate_data_tasks[0] + assert task.attachments is not None + assert len(task.attachments) == 2 + + # Check camera attachment + camera_attachment = task.attachments[0] + assert ( + camera_attachment.media_content_id == "media-source://camera/camera.front_door" + ) + assert camera_attachment.mime_type == "image/jpeg" + assert isinstance(camera_attachment.path, Path) + assert camera_attachment.path.suffix == ".jpg" + + # Verify camera snapshot content + assert camera_attachment.path.exists() + content = await hass.async_add_executor_job(camera_attachment.path.read_bytes) + assert content == b"fake_camera_jpeg" + + # Trigger clean up + async_fire_time_changed( + hass, + dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), + ) + await hass.async_block_till_done() + + # Verify the temporary file cleaned up + assert not camera_attachment.path.exists() + + # Check regular media attachment + media_attachment = task.attachments[1] + assert media_attachment.media_content_id == "media-source://media_player/video.mp4" + assert media_attachment.mime_type == "video/mp4" + assert media_attachment.path == Path("/media/test.mp4") From 5e883cfb129859f06b54fb282756abbdadd50557 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 14 Jul 2025 21:03:49 -1000 Subject: [PATCH 1405/1664] Fix flaky nuki tests by preventing teardown race condition (#148795) --- tests/components/nuki/__init__.py | 51 +++++++++++---------- tests/components/nuki/conftest.py | 13 ++++++ tests/components/nuki/test_binary_sensor.py | 4 +- tests/components/nuki/test_lock.py | 4 +- tests/components/nuki/test_sensor.py | 4 +- 5 files changed, 50 insertions(+), 26 deletions(-) create mode 100644 tests/components/nuki/conftest.py diff --git a/tests/components/nuki/__init__.py b/tests/components/nuki/__init__.py index 4f5728003fc..307ff080d71 100644 --- a/tests/components/nuki/__init__.py +++ b/tests/components/nuki/__init__.py @@ -14,28 +14,33 @@ from tests.common import ( ) -async def init_integration(hass: HomeAssistant) -> MockConfigEntry: +async def init_integration( + hass: HomeAssistant, mock_nuki_requests: requests_mock.Mocker +) -> MockConfigEntry: """Mock integration setup.""" - with requests_mock.Mocker() as mock: - # Mocking authentication endpoint - mock.get("http://1.1.1.1:8080/info", json=MOCK_INFO) - mock.get( - "http://1.1.1.1:8080/list", - json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), - ) - mock.get( - "http://1.1.1.1:8080/callback/list", - json=await async_load_json_object_fixture( - hass, "callback_list.json", DOMAIN - ), - ) - mock.get( - "http://1.1.1.1:8080/callback/add", - json=await async_load_json_object_fixture( - hass, "callback_add.json", DOMAIN - ), - ) - entry = await setup_nuki_integration(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + # Mocking authentication endpoint + mock_nuki_requests.get("http://1.1.1.1:8080/info", json=MOCK_INFO) + mock_nuki_requests.get( + "http://1.1.1.1:8080/list", + json=await async_load_json_array_fixture(hass, "list.json", DOMAIN), + ) + callback_list_data = await async_load_json_object_fixture( + hass, "callback_list.json", DOMAIN + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/list", + json=callback_list_data, + ) + mock_nuki_requests.get( + "http://1.1.1.1:8080/callback/add", + json=await async_load_json_object_fixture(hass, "callback_add.json", DOMAIN), + ) + # Mock the callback remove endpoint for teardown + mock_nuki_requests.delete( + requests_mock.ANY, + json={"success": True}, + ) + entry = await setup_nuki_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() return entry diff --git a/tests/components/nuki/conftest.py b/tests/components/nuki/conftest.py new file mode 100644 index 00000000000..624a5cafb9e --- /dev/null +++ b/tests/components/nuki/conftest.py @@ -0,0 +1,13 @@ +"""Fixtures for nuki tests.""" + +from collections.abc import Generator + +import pytest +import requests_mock + + +@pytest.fixture +def mock_nuki_requests() -> Generator[requests_mock.Mocker]: + """Mock nuki HTTP requests.""" + with requests_mock.Mocker() as mock: + yield mock diff --git a/tests/components/nuki/test_binary_sensor.py b/tests/components/nuki/test_binary_sensor.py index 11507100aae..20551a66307 100644 --- a/tests/components/nuki/test_binary_sensor.py +++ b/tests/components/nuki/test_binary_sensor.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -19,9 +20,10 @@ async def test_binary_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test binary sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.BINARY_SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_lock.py b/tests/components/nuki/test_lock.py index fc2d9d1cba8..6d8c3cc43fc 100644 --- a/tests/components/nuki/test_lock.py +++ b/tests/components/nuki/test_lock.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_locks( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test locks.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.LOCK]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) diff --git a/tests/components/nuki/test_sensor.py b/tests/components/nuki/test_sensor.py index 69a0aec56f7..d03fe7f0da6 100644 --- a/tests/components/nuki/test_sensor.py +++ b/tests/components/nuki/test_sensor.py @@ -2,6 +2,7 @@ from unittest.mock import patch +import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.const import Platform @@ -17,9 +18,10 @@ async def test_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion, + mock_nuki_requests: requests_mock.Mocker, ) -> None: """Test sensors.""" with patch("homeassistant.components.nuki.PLATFORMS", [Platform.SENSOR]): - entry = await init_integration(hass) + entry = await init_integration(hass, mock_nuki_requests) await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) From 7d7767c93a35c580ff145d5a50f62855b94264e8 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 15 Jul 2025 17:21:00 +1000 Subject: [PATCH 1406/1664] Bump Tesla Fleet API to 1.2.2 (#148776) --- 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 4c92e0bd222..cf86fbeb4f9 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.0"] + "requirements": ["tesla-fleet-api==1.2.2"] } diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json index f58783e04a4..d12cf278d59 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.0", "teslemetry-stream==0.7.9"] + "requirements": ["tesla-fleet-api==1.2.2", "teslemetry-stream==0.7.9"] } diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json index c0cbc2ea431..26f26990d58 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.0"] + "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 53bc939f588..ee5e5b1e5df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2907,7 +2907,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a18908ffe97..f7d07254799 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2393,7 +2393,7 @@ temperusb==1.6.1 # homeassistant.components.tesla_fleet # homeassistant.components.teslemetry # homeassistant.components.tessie -tesla-fleet-api==1.2.0 +tesla-fleet-api==1.2.2 # homeassistant.components.powerwall tesla-powerwall==0.5.2 From f6aa4aa788165bfad08c792cb1fd9c927d44c134 Mon Sep 17 00:00:00 2001 From: Max Velitchko Date: Tue, 15 Jul 2025 01:14:26 -0700 Subject: [PATCH 1407/1664] Bump amcrest to 1.9.9 (#148769) --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 7d8f8f9e6c8..85e37b0df64 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["amcrest"], "quality_scale": "legacy", - "requirements": ["amcrest==1.9.8"] + "requirements": ["amcrest==1.9.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee5e5b1e5df..10abfedaad0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -471,7 +471,7 @@ altruistclient==0.1.1 amberelectric==2.0.12 # homeassistant.components.amcrest -amcrest==1.9.8 +amcrest==1.9.9 # homeassistant.components.androidtv androidtv[async]==0.0.75 From 41e261096aa30160ff7348045ed3984da4530910 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:34:16 +0200 Subject: [PATCH 1408/1664] Use suggested unit of measurement in Tuya (#148599) --- homeassistant/components/tuya/const.py | 11 ----- homeassistant/components/tuya/number.py | 8 ++-- homeassistant/components/tuya/sensor.py | 47 +++++++++++++++---- .../tuya/snapshots/test_sensor.ambr | 12 +++++ 4 files changed, 53 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index f9377114e47..61da1239554 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass, field from enum import StrEnum import logging @@ -417,8 +416,6 @@ class UnitOfMeasurement: device_classes: set[str] aliases: set[str] = field(default_factory=set) - conversion_unit: str | None = None - conversion_fn: Callable[[float], float] | None = None # A tuple of available units of measurements we can work with. @@ -458,8 +455,6 @@ UNITS = ( SensorDeviceClass.CO, SensorDeviceClass.CO2, }, - conversion_unit=CONCENTRATION_PARTS_PER_MILLION, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfElectricCurrent.AMPERE, @@ -470,8 +465,6 @@ UNITS = ( unit=UnitOfElectricCurrent.MILLIAMPERE, aliases={"ma", "milliampere"}, device_classes={SensorDeviceClass.CURRENT}, - conversion_unit=UnitOfElectricCurrent.AMPERE, - conversion_fn=lambda x: x / 1000, ), UnitOfMeasurement( unit=UnitOfEnergy.WATT_HOUR, @@ -527,8 +520,6 @@ UNITS = ( SensorDeviceClass.SULPHUR_DIOXIDE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, }, - conversion_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - conversion_fn=lambda x: x * 1000, ), UnitOfMeasurement( unit=UnitOfPower.WATT, @@ -596,8 +587,6 @@ UNITS = ( unit=UnitOfElectricPotential.MILLIVOLT, aliases={"mv", "millivolt"}, device_classes={SensorDeviceClass.VOLTAGE}, - conversion_unit=UnitOfElectricPotential.VOLT, - conversion_fn=lambda x: x / 1000, ), ) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index b5b8437ea8b..cb248d42739 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -382,20 +382,18 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> float | None: diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index b45b8214bff..9caf642d403 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -14,6 +14,8 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, EntityCategory, UnitOfElectricCurrent, @@ -98,6 +100,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -112,6 +115,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -164,6 +168,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -181,6 +186,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -192,6 +198,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -278,18 +285,21 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CO_VALUE, translation_key="carbon_monoxide", device_class=SensorDeviceClass.CO, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CO2_VALUE, translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -418,6 +428,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -432,6 +443,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -472,6 +484,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -489,12 +502,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -506,6 +521,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.VOC_VALUE, @@ -518,6 +534,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.VA_HUMIDITY, @@ -583,6 +600,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -597,6 +615,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -613,6 +632,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.TEMP, @@ -637,6 +657,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="concentration_carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.TOTAL_TIME, @@ -685,6 +706,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), *BATTERY_SENSORS, ), @@ -724,6 +746,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -747,6 +770,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.HUMIDITY_VALUE, @@ -759,12 +783,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="pm1", device_class=SensorDeviceClass.PM1, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.PM10, translation_key="pm10", device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), *BATTERY_SENSORS, ), @@ -945,6 +971,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -959,6 +986,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -1004,12 +1032,14 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="carbon_dioxide", device_class=SensorDeviceClass.CO2, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ), TuyaSensorEntityDescription( key=DPCode.PM25_VALUE, translation_key="pm25", device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ), TuyaSensorEntityDescription( key=DPCode.CH2O_VALUE, @@ -1057,6 +1087,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_registry_enabled_default=False, ), TuyaSensorEntityDescription( @@ -1071,6 +1102,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), ), @@ -1097,6 +1129,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="current", device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1113,6 +1146,7 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { translation_key="voltage", device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, + suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), @@ -1415,20 +1449,18 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): return uoms = DEVICE_CLASS_UNITS[self.device_class] - self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + uom = uoms.get(self.native_unit_of_measurement) or uoms.get( self.native_unit_of_measurement.lower() ) # Unknown unit of measurement, device class should not be used. - if self._uom is None: + if uom is None: self._attr_device_class = None return # Found unit of measurement, use the standardized Unit # Use the target conversion unit (if set) - self._attr_native_unit_of_measurement = ( - self._uom.conversion_unit or self._uom.unit - ) + self._attr_native_unit_of_measurement = uom.unit @property def native_value(self) -> StateType: @@ -1450,10 +1482,7 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): # Scale integer/float value if isinstance(self._type_data, IntegerTypeData): - scaled_value = self._type_data.scale_value(value) - if self._uom and self._uom.conversion_fn is not None: - return self._uom.conversion_fn(scaled_value) - return scaled_value + return self._type_data.scale_value(value) # Unexpected enum value if ( diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 3704aa4f067..f63c75567ef 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -387,6 +387,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -499,6 +502,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -555,6 +561,9 @@ 'sensor': dict({ 'suggested_display_precision': 2, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, @@ -667,6 +676,9 @@ 'sensor': dict({ 'suggested_display_precision': 0, }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), }), 'original_device_class': , 'original_icon': None, From e1f15dac3950ddbf50ea794ed9df33e58a1bf436 Mon Sep 17 00:00:00 2001 From: nasWebio <140073814+nasWebio@users.noreply.github.com> Date: Tue, 15 Jul 2025 10:39:53 +0200 Subject: [PATCH 1409/1664] Add Sensor platform to NASweb integration (#133063) Co-authored-by: Erik Montnemery --- homeassistant/components/nasweb/__init__.py | 2 +- homeassistant/components/nasweb/const.py | 1 + .../components/nasweb/coordinator.py | 20 +- homeassistant/components/nasweb/icons.json | 15 ++ homeassistant/components/nasweb/sensor.py | 189 ++++++++++++++++++ homeassistant/components/nasweb/strings.json | 12 ++ 6 files changed, 233 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/nasweb/icons.json create mode 100644 homeassistant/components/nasweb/sensor.py diff --git a/homeassistant/components/nasweb/__init__.py b/homeassistant/components/nasweb/__init__.py index 1992cc41c75..43998ef43b3 100644 --- a/homeassistant/components/nasweb/__init__.py +++ b/homeassistant/components/nasweb/__init__.py @@ -19,7 +19,7 @@ from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL from .coordinator import NASwebCoordinator from .nasweb_data import NASwebData -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH] NASWEB_CONFIG_URL = "https://{host}/page" diff --git a/homeassistant/components/nasweb/const.py b/homeassistant/components/nasweb/const.py index ec750c90c8c..9150785d3bb 100644 --- a/homeassistant/components/nasweb/const.py +++ b/homeassistant/components/nasweb/const.py @@ -1,6 +1,7 @@ """Constants for the NASweb integration.""" DOMAIN = "nasweb" +KEY_TEMP_SENSOR = "temp_sensor" MANUFACTURER = "chomtech.pl" STATUS_UPDATE_MAX_TIME_INTERVAL = 60 SUPPORT_EMAIL = "support@chomtech.eu" diff --git a/homeassistant/components/nasweb/coordinator.py b/homeassistant/components/nasweb/coordinator.py index 90dca0f3022..2865bffe9a5 100644 --- a/homeassistant/components/nasweb/coordinator.py +++ b/homeassistant/components/nasweb/coordinator.py @@ -11,16 +11,19 @@ from typing import Any from aiohttp.web import Request, Response from webio_api import WebioAPI -from webio_api.const import KEY_DEVICE_SERIAL, KEY_OUTPUTS, KEY_TYPE, TYPE_STATUS_UPDATE +from webio_api.const import KEY_DEVICE_SERIAL, KEY_TYPE, TYPE_STATUS_UPDATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import event from homeassistant.helpers.update_coordinator import BaseDataUpdateCoordinatorProtocol -from .const import STATUS_UPDATE_MAX_TIME_INTERVAL +from .const import KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL _LOGGER = logging.getLogger(__name__) +KEY_INPUTS = "inputs" +KEY_OUTPUTS = "outputs" + class NotificationCoordinator: """Coordinator redirecting push notifications for this integration to appropriate NASwebCoordinator.""" @@ -96,8 +99,11 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): self._job = HassJob(self._handle_max_update_interval, job_name) self._unsub_last_update_check: CALLBACK_TYPE | None = None self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} - data: dict[str, Any] = {} - data[KEY_OUTPUTS] = self.webio_api.outputs + data: dict[str, Any] = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(data) def is_connection_confirmed(self) -> bool: @@ -187,5 +193,9 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol): async def process_status_update(self, new_status: dict) -> None: """Process status update from NASweb.""" self.webio_api.update_device_status(new_status) - new_data = {KEY_OUTPUTS: self.webio_api.outputs} + new_data = { + KEY_OUTPUTS: self.webio_api.outputs, + KEY_INPUTS: self.webio_api.inputs, + KEY_TEMP_SENSOR: self.webio_api.temp_sensor, + } self.async_set_updated_data(new_data) diff --git a/homeassistant/components/nasweb/icons.json b/homeassistant/components/nasweb/icons.json new file mode 100644 index 00000000000..0055bf2296a --- /dev/null +++ b/homeassistant/components/nasweb/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "sensor_input": { + "default": "mdi:help-circle-outline", + "state": { + "tamper": "mdi:lock-alert", + "active": "mdi:alert", + "normal": "mdi:shield-check-outline", + "problem": "mdi:alert-circle" + } + } + } + } +} diff --git a/homeassistant/components/nasweb/sensor.py b/homeassistant/components/nasweb/sensor.py new file mode 100644 index 00000000000..eb342d7ce92 --- /dev/null +++ b/homeassistant/components/nasweb/sensor.py @@ -0,0 +1,189 @@ +"""Platform for NASweb sensors.""" + +from __future__ import annotations + +import logging +import time + +from webio_api import Input as NASwebInput, TempSensor + +from homeassistant.components.sensor import ( + DOMAIN as DOMAIN_SENSOR, + SensorDeviceClass, + SensorEntity, + SensorStateClass, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +import homeassistant.helpers.entity_registry as er +from homeassistant.helpers.typing import DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + BaseCoordinatorEntity, + BaseDataUpdateCoordinatorProtocol, +) + +from . import NASwebConfigEntry +from .const import DOMAIN, KEY_TEMP_SENSOR, STATUS_UPDATE_MAX_TIME_INTERVAL + +SENSOR_INPUT_TRANSLATION_KEY = "sensor_input" +STATE_UNDEFINED = "undefined" +STATE_TAMPER = "tamper" +STATE_ACTIVE = "active" +STATE_NORMAL = "normal" +STATE_PROBLEM = "problem" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config: NASwebConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up Sensor platform.""" + coordinator = config.runtime_data + current_inputs: set[int] = set() + + @callback + def _check_entities() -> None: + received_inputs: dict[int, NASwebInput] = { + entry.index: entry for entry in coordinator.webio_api.inputs + } + added = {i for i in received_inputs if i not in current_inputs} + removed = {i for i in current_inputs if i not in received_inputs} + entities_to_add: list[InputStateSensor] = [] + for index in added: + webio_input = received_inputs[index] + if not isinstance(webio_input, NASwebInput): + _LOGGER.error("Cannot create InputStateSensor without NASwebInput") + continue + new_input = InputStateSensor(coordinator, webio_input) + entities_to_add.append(new_input) + current_inputs.add(index) + async_add_entities(entities_to_add) + entity_registry = er.async_get(hass) + for index in removed: + unique_id = f"{DOMAIN}.{config.unique_id}.input.{index}" + if entity_id := entity_registry.async_get_entity_id( + DOMAIN_SENSOR, DOMAIN, unique_id + ): + entity_registry.async_remove(entity_id) + current_inputs.remove(index) + else: + _LOGGER.warning("Failed to remove old input: no entity_id") + + coordinator.async_add_listener(_check_entities) + _check_entities() + + nasweb_temp_sensor = coordinator.data[KEY_TEMP_SENSOR] + temp_sensor = TemperatureSensor(coordinator, nasweb_temp_sensor) + async_add_entities([temp_sensor]) + + +class BaseSensorEntity(SensorEntity, BaseCoordinatorEntity): + """Base class providing common functionality.""" + + def __init__(self, coordinator: BaseDataUpdateCoordinatorProtocol) -> None: + """Initialize base sensor.""" + super().__init__(coordinator) + self._attr_available = False + self._attr_has_entity_name = True + self._attr_should_poll = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() + + def _set_attr_available( + self, entity_last_update: float, available: bool | None + ) -> None: + if ( + self.coordinator.last_update is None + or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL + ): + self._attr_available = False + else: + self._attr_available = available if available is not None else False + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + Scheduling updates is not necessary, the coordinator takes care of updates via push notifications. + """ + + +class InputStateSensor(BaseSensorEntity): + """Entity representing NASweb input.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_options: list[str] = [ + STATE_UNDEFINED, + STATE_TAMPER, + STATE_ACTIVE, + STATE_NORMAL, + STATE_PROBLEM, + ] + _attr_translation_key = SENSOR_INPUT_TRANSLATION_KEY + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_input: NASwebInput, + ) -> None: + """Initialize InputStateSensor entity.""" + super().__init__(coordinator) + self._input = nasweb_input + self._attr_native_value: str | None = None + self._attr_translation_placeholders = {"index": f"{nasweb_input.index:2d}"} + self._attr_unique_id = ( + f"{DOMAIN}.{self._input.webio_serial}.input.{self._input.index}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._input.webio_serial)}, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self._input.state is None or self._input.state in self._attr_options: + self._attr_native_value = self._input.state + else: + _LOGGER.warning("Received unrecognized input state: %s", self._input.state) + self._attr_native_value = None + self._set_attr_available(self._input.last_update, self._input.available) + self.async_write_ha_state() + + +class TemperatureSensor(BaseSensorEntity): + """Entity representing NASweb temperature sensor.""" + + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS + + def __init__( + self, + coordinator: BaseDataUpdateCoordinatorProtocol, + nasweb_temp_sensor: TempSensor, + ) -> None: + """Initialize TemperatureSensor entity.""" + super().__init__(coordinator) + self._temp_sensor = nasweb_temp_sensor + self._attr_unique_id = f"{DOMAIN}.{self._temp_sensor.webio_serial}.temp_sensor" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._temp_sensor.webio_serial)} + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self._temp_sensor.value + self._set_attr_available( + self._temp_sensor.last_update, self._temp_sensor.available + ) + self.async_write_ha_state() diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 8b93ea10d79..2e1ea55ffcb 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -45,6 +45,18 @@ "switch_output": { "name": "Relay Switch {index}" } + }, + "sensor": { + "sensor_input": { + "name": "Input {index}", + "state": { + "undefined": "Undefined", + "tamper": "Tamper", + "active": "Active", + "normal": "Normal", + "problem": "Problem" + } + } } } } From 4f938d032d02265a5a464ddf6b3b16de89b6a4d6 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 15 Jul 2025 02:45:55 -0600 Subject: [PATCH 1410/1664] Bump elevenlabs to 2.3.0 (#147224) --- .../components/elevenlabs/__init__.py | 3 +- .../components/elevenlabs/config_flow.py | 20 ++-- homeassistant/components/elevenlabs/const.py | 2 - .../components/elevenlabs/manifest.json | 2 +- .../components/elevenlabs/strings.json | 5 +- homeassistant/components/elevenlabs/tts.py | 15 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/elevenlabs/conftest.py | 57 +++++++++++- .../components/elevenlabs/test_config_flow.py | 91 ++++++++++++++++++- tests/components/elevenlabs/test_tts.py | 87 ++++++++++-------- 11 files changed, 209 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/elevenlabs/__init__.py b/homeassistant/components/elevenlabs/__init__.py index e5807fec67c..a930dea43ed 100644 --- a/homeassistant/components/elevenlabs/__init__.py +++ b/homeassistant/components/elevenlabs/__init__.py @@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS] async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None: """Get ElevenLabs model from their API by the model_id.""" - models = await client.models.get_all() + models = await client.models.list() + for maybe_model in models: if maybe_model.model_id == model_id: return maybe_model diff --git a/homeassistant/components/elevenlabs/config_flow.py b/homeassistant/components/elevenlabs/config_flow.py index 227749bf82c..fc248235834 100644 --- a/homeassistant/components/elevenlabs/config_flow.py +++ b/homeassistant/components/elevenlabs/config_flow.py @@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry from .const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -51,7 +49,8 @@ async def get_voices_models( httpx_client = get_async_client(hass) client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client) voices = (await client.voices.get_all()).voices - models = await client.models.get_all() + models = await client.models.list() + voices_dict = { voice.voice_id: voice.name for voice in sorted(voices, key=lambda v: v.name or "") @@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY]) - except ApiError: - errors["base"] = "invalid_api_key" + except ApiError as exc: + errors["base"] = "unknown" + details = getattr(exc, "body", {}).get("detail", {}) + if details: + status = details.get("status") + if status == "invalid_api_key": + errors["base"] = "invalid_api_key" else: return self.async_create_entry( title="ElevenLabs", @@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow): vol.Coerce(float), vol.Range(min=0, max=1), ), - vol.Optional( - CONF_OPTIMIZE_LATENCY, - default=self.config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), - ): vol.All(int, vol.Range(min=0, max=4)), vol.Optional( CONF_STYLE, default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE), diff --git a/homeassistant/components/elevenlabs/const.py b/homeassistant/components/elevenlabs/const.py index 1de92f95e43..2629e62d2fc 100644 --- a/homeassistant/components/elevenlabs/const.py +++ b/homeassistant/components/elevenlabs/const.py @@ -7,7 +7,6 @@ CONF_MODEL = "model" CONF_CONFIGURE_VOICE = "configure_voice" CONF_STABILITY = "stability" CONF_SIMILARITY = "similarity" -CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency" CONF_STYLE = "style" CONF_USE_SPEAKER_BOOST = "use_speaker_boost" DOMAIN = "elevenlabs" @@ -15,6 +14,5 @@ DOMAIN = "elevenlabs" DEFAULT_MODEL = "eleven_multilingual_v2" DEFAULT_STABILITY = 0.5 DEFAULT_SIMILARITY = 0.75 -DEFAULT_OPTIMIZE_LATENCY = 0 DEFAULT_STYLE = 0 DEFAULT_USE_SPEAKER_BOOST = True diff --git a/homeassistant/components/elevenlabs/manifest.json b/homeassistant/components/elevenlabs/manifest.json index eb6df09149a..f36a2383576 100644 --- a/homeassistant/components/elevenlabs/manifest.json +++ b/homeassistant/components/elevenlabs/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["elevenlabs"], - "requirements": ["elevenlabs==1.9.0"] + "requirements": ["elevenlabs==2.3.0"] } diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json index 8b0205a9e9a..eb497f1a7a6 100644 --- a/homeassistant/components/elevenlabs/strings.json +++ b/homeassistant/components/elevenlabs/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } }, "options": { @@ -32,14 +33,12 @@ "data": { "stability": "Stability", "similarity": "Similarity", - "optimize_streaming_latency": "Latency", "style": "Style", "use_speaker_boost": "Speaker boost" }, "data_description": { "stability": "Stability of the generated audio. Higher values lead to less emotional audio.", "similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.", - "optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.", "style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.", "use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice." } diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py index 61850837075..fc1a950d4b9 100644 --- a/homeassistant/components/elevenlabs/tts.py +++ b/homeassistant/components/elevenlabs/tts.py @@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import ElevenLabsConfigEntry from .const import ( ATTR_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -75,9 +73,6 @@ async def async_setup_entry( config_entry.entry_id, config_entry.title, voice_settings, - config_entry.options.get( - CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY - ), ) ] ) @@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): entry_id: str, title: str, voice_settings: VoiceSettings, - latency: int = 0, ) -> None: """Init ElevenLabs TTS service.""" self._client = client @@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): if voice_indices: self._voices.insert(0, self._voices.pop(voice_indices[0])) self._voice_settings = voice_settings - self._latency = latency # Entity attributes self._attr_unique_id = entry_id @@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity): voice_id = options.get(ATTR_VOICE, self._default_voice_id) model = options.get(ATTR_MODEL, self._model.model_id) try: - audio = await self._client.generate( + audio = self._client.text_to_speech.convert( text=message, - voice=voice_id, - optimize_streaming_latency=self._latency, + voice_id=voice_id, voice_settings=self._voice_settings, - model=model, + model_id=model, ) bytes_combined = b"".join([byte_seg async for byte_seg in audio]) + except ApiError as exc: _LOGGER.warning( "Error during processing of TTS request %s", exc, exc_info=True diff --git a/requirements_all.txt b/requirements_all.txt index 10abfedaad0..140932f5f52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -845,7 +845,7 @@ eheimdigital==1.3.0 electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7d07254799..da9d5047723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -736,7 +736,7 @@ eheimdigital==1.3.0 electrickiwi-api==0.9.14 # homeassistant.components.elevenlabs -elevenlabs==1.9.0 +elevenlabs==2.3.0 # homeassistant.components.elgato elgato==5.1.2 diff --git a/tests/components/elevenlabs/conftest.py b/tests/components/elevenlabs/conftest.py index 1c261e2947a..c47017b88e9 100644 --- a/tests/components/elevenlabs/conftest.py +++ b/tests/components/elevenlabs/conftest.py @@ -28,7 +28,8 @@ def mock_setup_entry() -> Generator[AsyncMock]: def _client_mock(): client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS + return client_mock @@ -44,6 +45,10 @@ def mock_async_client() -> Generator[AsyncMock]: "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", new=mock_async_client, ), + patch( + "homeassistant.components.elevenlabs.tts.AsyncElevenLabs", + new=mock_async_client, + ), ): yield mock_async_client @@ -52,8 +57,12 @@ def mock_async_client() -> Generator[AsyncMock]: def mock_async_client_api_error() -> Generator[AsyncMock]: """Override async ElevenLabs client with ApiError side effect.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ApiError - client_mock.voices.get_all.side_effect = ApiError + api_error = ApiError() + api_error.body = { + "detail": {"status": "invalid_api_key", "message": "API key is invalid"} + } + client_mock.models.list.side_effect = api_error + client_mock.voices.get_all.side_effect = api_error with ( patch( @@ -68,11 +77,51 @@ def mock_async_client_api_error() -> Generator[AsyncMock]: yield mock_async_client +@pytest.fixture +def mock_async_client_voices_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "voices_unauthorized", + "message": "API is unauthorized for voices", + } + } + client_mock.voices.get_all.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + +@pytest.fixture +def mock_async_client_models_error() -> Generator[AsyncMock]: + """Override async ElevenLabs client with ApiError side effect.""" + client_mock = _client_mock() + api_error = ApiError() + api_error.body = { + "detail": { + "status": "models_unauthorized", + "message": "API is unauthorized for models", + } + } + client_mock.models.list.side_effect = api_error + + with patch( + "homeassistant.components.elevenlabs.config_flow.AsyncElevenLabs", + return_value=client_mock, + ) as mock_async_client: + yield mock_async_client + + @pytest.fixture def mock_async_client_connect_error() -> Generator[AsyncMock]: """Override async ElevenLabs client.""" client_mock = _client_mock() - client_mock.models.get_all.side_effect = ConnectError("Unknown") + client_mock.models.list.side_effect = ConnectError("Unknown") client_mock.voices.get_all.side_effect = ConnectError("Unknown") with ( patch( diff --git a/tests/components/elevenlabs/test_config_flow.py b/tests/components/elevenlabs/test_config_flow.py index 7eeb0a6eb46..eccd5d49d92 100644 --- a/tests/components/elevenlabs/test_config_flow.py +++ b/tests/components/elevenlabs/test_config_flow.py @@ -7,14 +7,12 @@ import pytest from homeassistant.components.elevenlabs.const import ( CONF_CONFIGURE_VOICE, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, DEFAULT_MODEL, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -101,6 +99,94 @@ async def test_invalid_api_key( mock_setup_entry.assert_called_once() +async def test_voices_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_voices_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + +async def test_models_error( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_async_client_models_error: AsyncMock, + request: pytest.FixtureRequest, +) -> None: + """Test user step with invalid api key.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "unknown"} + + mock_setup_entry.assert_not_called() + + # Use a working client + request.getfixturevalue("mock_async_client") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "api_key", + }, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "ElevenLabs" + assert result["data"] == { + "api_key": "api_key", + } + assert result["options"] == {CONF_MODEL: DEFAULT_MODEL, CONF_VOICE: "voice1"} + + mock_setup_entry.assert_called_once() + + async def test_options_flow_init( hass: HomeAssistant, mock_setup_entry: AsyncMock, @@ -166,7 +252,6 @@ async def test_options_flow_voice_settings_default( assert mock_entry.options == { CONF_MODEL: "model1", CONF_VOICE: "voice1", - CONF_OPTIMIZE_LATENCY: DEFAULT_OPTIMIZE_LATENCY, CONF_SIMILARITY: DEFAULT_SIMILARITY, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, diff --git a/tests/components/elevenlabs/test_tts.py b/tests/components/elevenlabs/test_tts.py index a63672cc85d..f25a03f2824 100644 --- a/tests/components/elevenlabs/test_tts.py +++ b/tests/components/elevenlabs/test_tts.py @@ -15,13 +15,11 @@ from homeassistant.components import tts from homeassistant.components.elevenlabs.const import ( ATTR_MODEL, CONF_MODEL, - CONF_OPTIMIZE_LATENCY, CONF_SIMILARITY, CONF_STABILITY, CONF_STYLE, CONF_USE_SPEAKER_BOOST, CONF_VOICE, - DEFAULT_OPTIMIZE_LATENCY, DEFAULT_SIMILARITY, DEFAULT_STABILITY, DEFAULT_STYLE, @@ -44,6 +42,19 @@ from tests.components.tts.common import retrieve_media from tests.typing import ClientSessionGenerator +class FakeAudioGenerator: + """Mock audio generator for ElevenLabs TTS.""" + + def __aiter__(self): + """Mock async iterator for audio parts.""" + + async def _gen(): + yield b"audio-part-1" + yield b"audio-part-2" + + return _gen() + + @pytest.fixture(autouse=True) def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock: MagicMock) -> None: """Mock writing tags.""" @@ -74,12 +85,6 @@ def mock_similarity(): return DEFAULT_SIMILARITY / 2 -@pytest.fixture -def mock_latency(): - """Mock latency.""" - return (DEFAULT_OPTIMIZE_LATENCY + 1) % 5 # 0, 1, 2, 3, 4 - - @pytest.fixture(name="setup") async def setup_fixture( hass: HomeAssistant, @@ -98,6 +103,7 @@ async def setup_fixture( raise RuntimeError("Invalid setup fixture") await hass.async_block_till_done() + return mock_async_client @@ -114,10 +120,9 @@ def config_options_fixture() -> dict[str, Any]: @pytest.fixture(name="config_options_voice") -def config_options_voice_fixture(mock_similarity, mock_latency) -> dict[str, Any]: +def config_options_voice_fixture(mock_similarity) -> dict[str, Any]: """Return config options.""" return { - CONF_OPTIMIZE_LATENCY: mock_latency, CONF_SIMILARITY: mock_similarity, CONF_STABILITY: DEFAULT_STABILITY, CONF_STYLE: DEFAULT_STYLE, @@ -144,7 +149,7 @@ async def mock_config_entry_setup( config_entry.add_to_hass(hass) client_mock = AsyncMock() client_mock.voices.get_all.return_value = GetVoicesResponse(voices=MOCK_VOICES) - client_mock.models.get_all.return_value = MOCK_MODELS + client_mock.models.list.return_value = MOCK_MODELS with patch( "homeassistant.components.elevenlabs.AsyncElevenLabs", return_value=client_mock ): @@ -217,7 +222,10 @@ async def test_tts_service_speak( ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=DEFAULT_SIMILARITY, @@ -240,12 +248,11 @@ async def test_tts_service_speak( voice_id = service_data[tts.ATTR_OPTIONS].get(tts.ATTR_VOICE, "voice1") model_id = service_data[tts.ATTR_OPTIONS].get(ATTR_MODEL, "model1") - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice=voice_id, - model=model_id, + voice_id=voice_id, + model_id=model_id, voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -287,7 +294,9 @@ async def test_tts_service_speak_lang_config( ) -> None: """Test service call say with other langcodes in the config.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -302,12 +311,11 @@ async def test_tts_service_speak_lang_config( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -337,8 +345,10 @@ async def test_tts_service_speak_error( ) -> None: """Test service call say with http response 400.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() - tts_entity._client.generate.side_effect = ApiError + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) + tts_entity._client.text_to_speech.convert.side_effect = ApiError await hass.services.async_call( tts.DOMAIN, @@ -353,12 +363,11 @@ async def test_tts_service_speak_error( == HTTPStatus.INTERNAL_SERVER_ERROR ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - model="model1", + voice_id="voice1", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -396,18 +405,18 @@ async def test_tts_service_speak_voice_settings( tts_service: str, service_data: dict[str, Any], mock_similarity: float, - mock_latency: int, ) -> None: """Test tts service.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) assert tts_entity._voice_settings == VoiceSettings( stability=DEFAULT_STABILITY, similarity_boost=mock_similarity, style=DEFAULT_STYLE, use_speaker_boost=DEFAULT_USE_SPEAKER_BOOST, ) - assert tts_entity._latency == mock_latency await hass.services.async_call( tts.DOMAIN, @@ -422,12 +431,11 @@ async def test_tts_service_speak_voice_settings( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice2", - model="model1", + voice_id="voice2", + model_id="model1", voice_settings=tts_entity._voice_settings, - optimize_streaming_latency=tts_entity._latency, ) @@ -457,7 +465,9 @@ async def test_tts_service_speak_without_options( ) -> None: """Test service call say with http response 200.""" tts_entity = hass.data[tts.DOMAIN].get_entity(service_data[ATTR_ENTITY_ID]) - tts_entity._client.generate.reset_mock() + tts_entity._client.text_to_speech.convert = MagicMock( + return_value=FakeAudioGenerator() + ) await hass.services.async_call( tts.DOMAIN, @@ -472,12 +482,11 @@ async def test_tts_service_speak_without_options( == HTTPStatus.OK ) - tts_entity._client.generate.assert_called_once_with( + tts_entity._client.text_to_speech.convert.assert_called_once_with( text="There is a person at the front door.", - voice="voice1", - optimize_streaming_latency=0, + voice_id="voice1", voice_settings=VoiceSettings( stability=0.5, similarity_boost=0.75, style=0.0, use_speaker_boost=True ), - model="model1", + model_id="model1", ) From db45f46c8a4f9473f15334bd1553aa0dd159902e Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:14:47 +0200 Subject: [PATCH 1411/1664] Fan support in WiZ (#146440) --- CODEOWNERS | 4 +- homeassistant/components/wiz/__init__.py | 1 + homeassistant/components/wiz/fan.py | 139 +++++++++++ homeassistant/components/wiz/manifest.json | 2 +- tests/components/wiz/__init__.py | 26 +++ tests/components/wiz/snapshots/test_fan.ambr | 61 +++++ tests/components/wiz/test_fan.py | 232 +++++++++++++++++++ 7 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/wiz/fan.py create mode 100644 tests/components/wiz/snapshots/test_fan.ambr create mode 100644 tests/components/wiz/test_fan.py diff --git a/CODEOWNERS b/CODEOWNERS index a6ab083e07d..c0bed7f100a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1758,8 +1758,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wirelesstag/ @sergeymaysak /homeassistant/components/withings/ @joostlek /tests/components/withings/ @joostlek -/homeassistant/components/wiz/ @sbidy -/tests/components/wiz/ @sbidy +/homeassistant/components/wiz/ @sbidy @arturpragacz +/tests/components/wiz/ @sbidy @arturpragacz /homeassistant/components/wled/ @frenck /tests/components/wled/ @frenck /homeassistant/components/wmspro/ @mback2k diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 43a9b863d20..39be4d9a387 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -37,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.FAN, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/wiz/fan.py b/homeassistant/components/wiz/fan.py new file mode 100644 index 00000000000..f826ee80b8b --- /dev/null +++ b/homeassistant/components/wiz/fan.py @@ -0,0 +1,139 @@ +"""WiZ integration fan platform.""" + +from __future__ import annotations + +import math +from typing import Any, ClassVar + +from pywizlight.bulblibrary import BulbType, Features + +from homeassistant.components.fan import ( + DIRECTION_FORWARD, + DIRECTION_REVERSE, + FanEntity, + FanEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.percentage import ( + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import WizConfigEntry +from .entity import WizEntity +from .models import WizData + +PRESET_MODE_BREEZE = "breeze" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WizConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the WiZ Platform from config_flow.""" + if entry.runtime_data.bulb.bulbtype.features.fan: + async_add_entities([WizFanEntity(entry.runtime_data, entry.title)]) + + +class WizFanEntity(WizEntity, FanEntity): + """Representation of WiZ Light bulb.""" + + _attr_name = None + + # We want the implementation of is_on to be the same as in ToggleEntity, + # but it is being overridden in FanEntity, so we need to restore it here. + is_on: ClassVar = ToggleEntity.is_on + + def __init__(self, wiz_data: WizData, name: str) -> None: + """Initialize a WiZ fan.""" + super().__init__(wiz_data, name) + bulb_type: BulbType = self._device.bulbtype + features: Features = bulb_type.features + + supported_features = ( + FanEntityFeature.TURN_ON + | FanEntityFeature.TURN_OFF + | FanEntityFeature.SET_SPEED + ) + if features.fan_reverse: + supported_features |= FanEntityFeature.DIRECTION + if features.fan_breeze_mode: + supported_features |= FanEntityFeature.PRESET_MODE + self._attr_preset_modes = [PRESET_MODE_BREEZE] + + self._attr_supported_features = supported_features + self._attr_speed_count = bulb_type.fan_speed_range + + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + state = self._device.state + + self._attr_is_on = state.get_fan_state() > 0 + self._attr_percentage = ranged_value_to_percentage( + (1, self.speed_count), state.get_fan_speed() + ) + if FanEntityFeature.PRESET_MODE in self.supported_features: + fan_mode = state.get_fan_mode() + self._attr_preset_mode = PRESET_MODE_BREEZE if fan_mode == 2 else None + if FanEntityFeature.DIRECTION in self.supported_features: + fan_reverse = state.get_fan_reverse() + self._attr_current_direction = None + if fan_reverse == 0: + self._attr_current_direction = DIRECTION_FORWARD + elif fan_reverse == 1: + self._attr_current_direction = DIRECTION_REVERSE + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode of the fan.""" + # preset_mode == PRESET_MODE_BREEZE + await self._device.fan_set_state(mode=2) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed percentage of the fan.""" + if percentage == 0: + await self.async_turn_off() + return + + speed = math.ceil(percentage_to_ranged_value((1, self.speed_count), percentage)) + await self._device.fan_set_state(mode=1, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + mode: int | None = None + speed: int | None = None + if preset_mode is not None: + self._valid_preset_mode_or_raise(preset_mode) + if preset_mode == PRESET_MODE_BREEZE: + mode = 2 + if percentage is not None: + speed = math.ceil( + percentage_to_ranged_value((1, self.speed_count), percentage) + ) + if mode is None: + mode = 1 + await self._device.fan_turn_on(mode=mode, speed=speed) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self._device.fan_turn_off(**kwargs) + await self.coordinator.async_request_refresh() + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + reverse = 1 if direction == DIRECTION_REVERSE else 0 + await self._device.fan_set_state(reverse=reverse) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index 2ae78a8af92..57671ecd007 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -1,7 +1,7 @@ { "domain": "wiz", "name": "WiZ", - "codeowners": ["@sbidy"], + "codeowners": ["@sbidy", "@arturpragacz"], "config_flow": true, "dependencies": ["network"], "dhcp": [ diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index d84074e37d3..037b6a1dfbd 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -33,6 +33,10 @@ FAKE_STATE = PilotParser( "c": 0, "w": 0, "dimming": 100, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, } ) FAKE_IP = "1.1.1.1" @@ -173,6 +177,25 @@ FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType( white_channels=1, white_to_color_ratio=80, ) +FAKE_DIMMABLE_FAN = BulbType( + bulb_type=BulbClass.FANDIM, + name="ESP03_FANDIMS_31", + features=Features( + color=False, + color_tmp=False, + effect=True, + brightness=True, + dual_head=False, + fan=True, + fan_breeze_mode=True, + fan_reverse=True, + ), + kelvin_range=KelvinRange(max=2700, min=2700), + fw_version="1.31.32", + white_channels=1, + white_to_color_ratio=20, + fan_speed_range=6, +) async def setup_integration(hass: HomeAssistant) -> MockConfigEntry: @@ -220,6 +243,9 @@ def _mocked_wizlight( bulb.async_close = AsyncMock() bulb.set_speed = AsyncMock() bulb.set_ratio = AsyncMock() + bulb.fan_set_state = AsyncMock() + bulb.fan_turn_on = AsyncMock() + bulb.fan_turn_off = AsyncMock() bulb.diagnostics = { "mocked": "mocked", "roomId": 123, diff --git a/tests/components/wiz/snapshots/test_fan.ambr b/tests/components/wiz/snapshots/test_fan.ambr new file mode 100644 index 00000000000..2c6b235e78b --- /dev/null +++ b/tests/components/wiz/snapshots/test_fan.ambr @@ -0,0 +1,61 @@ +# serializer version: 1 +# name: test_entity[fan.mock_title-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'breeze', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.mock_title', + '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': 'wiz', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'abcabcabcabc', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[fan.mock_title-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'direction': 'forward', + 'friendly_name': 'Mock Title', + 'percentage': 16, + 'percentage_step': 16.666666666666668, + 'preset_mode': None, + 'preset_modes': list([ + 'breeze', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.mock_title', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/wiz/test_fan.py b/tests/components/wiz/test_fan.py new file mode 100644 index 00000000000..d15f083d431 --- /dev/null +++ b/tests/components/wiz/test_fan.py @@ -0,0 +1,232 @@ +"""Tests for fan platform.""" + +from typing import Any +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, +) +from homeassistant.components.wiz.fan import PRESET_MODE_BREEZE +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 FAKE_DIMMABLE_FAN, FAKE_MAC, async_push_update, async_setup_integration + +from tests.common import snapshot_platform + +ENTITY_ID = "fan.mock_title" + +INITIAL_PARAMS = { + "mac": FAKE_MAC, + "fanState": 0, + "fanMode": 1, + "fanSpeed": 1, + "fanRevrs": 0, +} + + +@patch("homeassistant.components.wiz.PLATFORMS", [Platform.FAN]) +async def test_entity( + hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry +) -> None: + """Test the fan entity.""" + entry = (await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN))[1] + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +def _update_params( + params: dict[str, Any], + state: int | None = None, + mode: int | None = None, + speed: int | None = None, + reverse: int | None = None, +) -> dict[str, Any]: + """Get the parameters for the update.""" + if state is not None: + params["fanState"] = state + if mode is not None: + params["fanMode"] = mode + if speed is not None: + params["fanSpeed"] = speed + if reverse is not None: + params["fanRevrs"] = reverse + return params + + +async def test_turn_on_off(hass: HomeAssistant) -> None: + """Test turning the fan on and off.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": None, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2, "speed": None} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_turn_on.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_turn_on.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_PRESET_MODE] is None + + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, blocking=True + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_turn_off.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + + +async def test_fan_set_preset_mode(hass: HomeAssistant) -> None: + """Test setting the fan preset mode.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_MODE_BREEZE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 2} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PRESET_MODE] == PRESET_MODE_BREEZE + + +async def test_fan_set_percentage(hass: HomeAssistant) -> None: + """Test setting the fan percentage.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 50}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"mode": 1, "speed": 3} + await async_push_update(hass, device, _update_params(params, state=1, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_ON + assert state.attributes[ATTR_PERCENTAGE] == 50 + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + calls = device.fan_turn_off.mock_calls + assert len(calls) == 1 + await async_push_update(hass, device, _update_params(params, state=0)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_PERCENTAGE] == 50 + + +async def test_fan_set_direction(hass: HomeAssistant) -> None: + """Test setting the fan direction.""" + device, _ = await async_setup_integration(hass, bulb_type=FAKE_DIMMABLE_FAN) + + params = INITIAL_PARAMS.copy() + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_REVERSE}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 1} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_DIRECTION: DIRECTION_FORWARD}, + blocking=True, + ) + calls = device.fan_set_state.mock_calls + assert len(calls) == 1 + args = calls[0][2] + assert args == {"reverse": 0} + await async_push_update(hass, device, _update_params(params, **args)) + device.fan_set_state.reset_mock() + state = hass.states.get(ENTITY_ID) + assert state.state == STATE_OFF + assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD From 3d74d0270423c961a78a5e23aad2a3dd510fdc4f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:15:06 +0200 Subject: [PATCH 1412/1664] Update pytouchlinesl to 0.4.0 (#148801) --- homeassistant/components/touchline_sl/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/touchline_sl/manifest.json b/homeassistant/components/touchline_sl/manifest.json index ab07ae770fd..5140584f7ff 100644 --- a/homeassistant/components/touchline_sl/manifest.json +++ b/homeassistant/components/touchline_sl/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/touchline_sl", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["pytouchlinesl==0.3.0"] + "requirements": ["pytouchlinesl==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 140932f5f52..0b3e1361e81 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2538,7 +2538,7 @@ pytomorrowio==0.3.6 pytouchline_extended==0.4.5 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da9d5047723..b822277d8c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2099,7 +2099,7 @@ pytile==2024.12.0 pytomorrowio==0.3.6 # homeassistant.components.touchline_sl -pytouchlinesl==0.3.0 +pytouchlinesl==0.4.0 # homeassistant.components.traccar # homeassistant.components.traccar_server From a6e1d968526e7ad1f6d5e0a4f77e98a58efdabc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 15 Jul 2025 11:21:54 +0200 Subject: [PATCH 1413/1664] Update aioairzone-cloud to v0.6.13 (#148798) 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 ecc9634f36a..e185ed89106 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.12"] + "requirements": ["aioairzone-cloud==0.6.13"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0b3e1361e81..742deadc2f8 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.12 +aioairzone-cloud==0.6.13 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b822277d8c6..1a3d5730ec5 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.12 +aioairzone-cloud==0.6.13 # homeassistant.components.airzone aioairzone==1.0.0 From b522bd5ef20746c5a516e1ecac75ff4ed0a3d848 Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Tue, 15 Jul 2025 12:07:57 +0200 Subject: [PATCH 1414/1664] Get media player features elsewhere for jellyfin (#148805) --- homeassistant/components/jellyfin/media_player.py | 12 ++++++++++-- tests/components/jellyfin/fixtures/sessions.json | 2 +- .../jellyfin/snapshots/test_diagnostics.ambr | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index e0fcc8a559b..b71c0bf93c9 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from homeassistant.components.media_player import ( @@ -21,6 +22,8 @@ from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator from .entity import JellyfinClientEntity +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -177,10 +180,15 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): def supported_features(self) -> MediaPlayerEntityFeature: """Flag media player features that are supported.""" commands: list[str] = self.capabilities.get("SupportedCommands", []) - controllable = self.capabilities.get("SupportsMediaControl", False) + _LOGGER.debug( + "Supported commands for device %s, client %s, %s", + self.device_name, + self.client_name, + commands, + ) features = MediaPlayerEntityFeature(0) - if controllable: + if "PlayMediaSource" in commands: features |= ( MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA diff --git a/tests/components/jellyfin/fixtures/sessions.json b/tests/components/jellyfin/fixtures/sessions.json index db2b691dff0..9a8f93dc5bd 100644 --- a/tests/components/jellyfin/fixtures/sessions.json +++ b/tests/components/jellyfin/fixtures/sessions.json @@ -21,7 +21,7 @@ ], "Capabilities": { "PlayableMediaTypes": ["Video"], - "SupportedCommands": ["VolumeSet", "Mute"], + "SupportedCommands": ["VolumeSet", "Mute", "PlayMediaSource"], "SupportsMediaControl": true, "SupportsContentUploading": true, "MessageCallbackUrl": "string", diff --git a/tests/components/jellyfin/snapshots/test_diagnostics.ambr b/tests/components/jellyfin/snapshots/test_diagnostics.ambr index 9d73ee6397c..0100c7618b7 100644 --- a/tests/components/jellyfin/snapshots/test_diagnostics.ambr +++ b/tests/components/jellyfin/snapshots/test_diagnostics.ambr @@ -182,6 +182,7 @@ 'SupportedCommands': list([ 'VolumeSet', 'Mute', + 'PlayMediaSource', ]), 'SupportsContentUploading': True, 'SupportsMediaControl': True, From 1cb278966c9b05a5588d784031d185287b0da80b Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:15:19 +0200 Subject: [PATCH 1415/1664] Handle connection issues after websocket reconnected in homematicip_cloud (#147731) --- .../components/homematicip_cloud/hap.py | 63 ++++++++++++------- .../homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../homematicip_cloud/test_device.py | 11 +++- .../components/homematicip_cloud/test_hap.py | 61 ++++++++++++++++-- 6 files changed, 107 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index c42ebff200d..d66594da390 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -113,9 +113,7 @@ class HomematicipHAP: self._ws_close_requested = False self._ws_connection_closed = asyncio.Event() - self._retry_task: asyncio.Task | None = None - self._tries = 0 - self._accesspoint_connected = True + self._get_state_task: asyncio.Task | None = None self.hmip_device_by_entity_id: dict[str, Any] = {} self.reset_connection_listener: Callable | None = None @@ -161,17 +159,8 @@ class HomematicipHAP: """ if not self.home.connected: _LOGGER.error("HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False + self._ws_connection_closed.set() self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Now the HOME_CHANGED event has fired indicating the access - # point has reconnected to the cloud again. - # Explicitly getting an update as entity states might have - # changed during access point disconnect.""" - - job = self.hass.async_create_task(self.get_state()) - job.add_done_callback(self.get_state_finished) - self._accesspoint_connected = True @callback def async_create_entity(self, *args, **kwargs) -> None: @@ -185,20 +174,43 @@ class HomematicipHAP: await asyncio.sleep(30) await self.hass.config_entries.async_reload(self.config_entry.entry_id) + async def _try_get_state(self) -> None: + """Call get_state in a loop until no error occurs, using exponential backoff on error.""" + + # Wait until WebSocket connection is established. + while not self.home.websocket_is_connected(): + await asyncio.sleep(2) + + delay = 8 + max_delay = 1500 + while True: + try: + await self.get_state() + break + except HmipConnectionError as err: + _LOGGER.warning( + "Get_state failed, retrying in %s seconds: %s", delay, err + ) + await asyncio.sleep(delay) + delay = min(delay * 2, max_delay) + async def get_state(self) -> None: """Update HMIP state and tell Home Assistant.""" await self.home.get_current_state_async() self.update_all() def get_state_finished(self, future) -> None: - """Execute when get_state coroutine has finished.""" + """Execute when try_get_state coroutine has finished.""" try: future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error("Updating state after HMIP access point reconnect failed") - self.hass.async_create_task(self.home.disable_events()) + except Exception as err: # noqa: BLE001 + _LOGGER.error( + "Error updating state after HMIP access point reconnect: %s", err + ) + else: + _LOGGER.info( + "Updating state after HMIP access point reconnect finished successfully", + ) def set_all_to_unavailable(self) -> None: """Set all devices to unavailable and tell Home Assistant.""" @@ -222,8 +234,8 @@ class HomematicipHAP: async def async_reset(self) -> bool: """Close the websocket connection.""" self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() + if self._get_state_task is not None: + self._get_state_task.cancel() await self.home.disable_events_async() _LOGGER.debug("Closed connection to HomematicIP cloud server") await self.hass.config_entries.async_unload_platforms( @@ -247,7 +259,9 @@ class HomematicipHAP: """Handle websocket connected.""" _LOGGER.info("Websocket connection to HomematicIP Cloud established") if self._ws_connection_closed.is_set(): - await self.get_state() + self._get_state_task = self.hass.async_create_task(self._try_get_state()) + self._get_state_task.add_done_callback(self.get_state_finished) + self._ws_connection_closed.clear() async def ws_disconnected_handler(self) -> None: @@ -256,11 +270,12 @@ class HomematicipHAP: self._ws_connection_closed.set() async def ws_reconnected_handler(self, reason: str) -> None: - """Handle websocket reconnection.""" + """Handle websocket reconnection. Is called when Websocket tries to reconnect.""" _LOGGER.info( - "Websocket connection to HomematicIP Cloud re-established due to reason: %s", + "Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s", reason, ) + self._ws_connection_closed.set() async def get_hap( diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index d5af2859873..036ffa286a3 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.6"] + "requirements": ["homematicip==2.0.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 742deadc2f8..8fe43a3198c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a3d5730ec5..d7e3da48a19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.6 +homematicip==2.0.7 # homeassistant.components.remember_the_milk httplib2==0.20.4 diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index aff698cd3d9..9dd537848fe 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -195,9 +195,14 @@ async def test_hap_reconnected( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_UNAVAILABLE - mock_hap._accesspoint_connected = False - await async_manipulate_test_data(hass, mock_hap.home, "connected", True) - await hass.async_block_till_done() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await async_manipulate_test_data(hass, mock_hap.home, "connected", True) + await mock_hap.ws_connected_handler() + await hass.async_block_till_done() + ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_ON diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index ae094f7dded..69078beafaf 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -1,6 +1,6 @@ """Test HomematicIP Cloud accesspoint.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch from homematicip.auth import Auth from homematicip.connection.connection_context import ConnectionContext @@ -242,7 +242,14 @@ async def test_get_state_after_disconnect( hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch.object(hap, "get_state") as mock_get_state: + simple_mock_home = AsyncMock(spec=AsyncHome, autospec=True) + hap.home = simple_mock_home + hap.home.websocket_is_connected = Mock(side_effect=[False, True]) + + with ( + patch("asyncio.sleep", new=AsyncMock()) as mock_sleep, + patch.object(hap, "get_state") as mock_get_state, + ): assert not hap._ws_connection_closed.is_set() await hap.ws_connected_handler() @@ -250,8 +257,54 @@ async def test_get_state_after_disconnect( await hap.ws_disconnected_handler() assert hap._ws_connection_closed.is_set() - await hap.ws_connected_handler() - mock_get_state.assert_called_once() + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.websocket_is_connected", + return_value=True, + ): + await hap.ws_connected_handler() + mock_get_state.assert_called_once() + + assert not hap._ws_connection_closed.is_set() + hap.home.websocket_is_connected.assert_called() + mock_sleep.assert_awaited_with(2) + + +async def test_try_get_state_exponential_backoff() -> None: + """Test _try_get_state waits for websocket connection.""" + + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + hap.home.websocket_is_connected = Mock(return_value=True) + + hap.get_state = AsyncMock( + side_effect=[HmipConnectionError, HmipConnectionError, True] + ) + + with patch("asyncio.sleep", new=AsyncMock()) as mock_sleep: + await hap._try_get_state() + + assert mock_sleep.mock_calls[0].args[0] == 8 + assert mock_sleep.mock_calls[1].args[0] == 16 + assert hap.get_state.call_count == 3 + + +async def test_try_get_state_handle_exception() -> None: + """Test _try_get_state handles exceptions.""" + # Arrange: Create instance and mock home + hap = HomematicipHAP(MagicMock(), MagicMock()) + hap.home = MagicMock() + + expected_exception = Exception("Connection error") + future = AsyncMock() + future.result = Mock(side_effect=expected_exception) + + with patch("homeassistant.components.homematicip_cloud.hap._LOGGER") as mock_logger: + hap.get_state_finished(future) + + mock_logger.error.assert_called_once_with( + "Error updating state after HMIP access point reconnect: %s", expected_exception + ) async def test_async_connect( From ab187f39c2b63e434013a587e37517186bdef4fb Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 15 Jul 2025 12:16:07 +0200 Subject: [PATCH 1416/1664] Add support for HmIP-RGBW and HmIP-LSC in homematicip_cloud integration (#148639) --- .../components/homematicip_cloud/light.py | 77 +++- .../fixtures/homematicip_cloud.json | 370 ++++++++++++++++++ .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_light.py | 76 ++++ 4 files changed, 523 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index d5175e6e647..1e602cd09c2 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -2,13 +2,20 @@ from __future__ import annotations +import logging from typing import Any -from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState +from homematicip.base.enums import ( + DeviceType, + FunctionalChannelType, + OpticalSignalBehaviour, + RGBColorState, +) from homematicip.base.functionalChannels import NotificationLightChannel from homematicip.device import ( BrandDimmer, BrandSwitchNotificationLight, + Device, Dimmer, DinRailDimmer3, FullFlushDimmer, @@ -34,6 +41,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import HomematicipGenericEntity from .hap import HomematicIPConfigEntry, HomematicipHAP +_logger = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, @@ -43,6 +52,14 @@ async def async_setup_entry( """Set up the HomematicIP Cloud lights from a config entry.""" hap = config_entry.runtime_data entities: list[HomematicipGenericEntity] = [] + + entities.extend( + HomematicipLightHS(hap, d, ch.index) + for d in hap.home.devices + for ch in d.functionalChannels + if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL + ) + for device in hap.home.devices: if ( isinstance(device, SwitchMeasuring) @@ -104,6 +121,64 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity): await self._device.turn_off_async() +class HomematicipLightHS(HomematicipGenericEntity, LightEntity): + """Representation of the HomematicIP light with HS color mode.""" + + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + + def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None: + """Initialize the light entity.""" + super().__init__(hap, device, channel=channel_index, is_multi_channel=True) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return self.functional_channel.on + + @property + def brightness(self) -> int | None: + """Return the current brightness.""" + return int(self.functional_channel.dimLevel * 255.0) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hue and saturation color value [float, float].""" + if ( + self.functional_channel.hue is None + or self.functional_channel.saturationLevel is None + ): + return None + return ( + self.functional_channel.hue, + self.functional_channel.saturationLevel * 100.0, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + + hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0)) + hue = hs_color[0] % 360.0 + saturation = hs_color[1] / 100.0 + dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2) + + if ATTR_HS_COLOR not in kwargs: + hue = self.functional_channel.hue + saturation = self.functional_channel.saturationLevel + + if ATTR_BRIGHTNESS not in kwargs: + # If no brightness is set, use the current brightness + dim_level = self.functional_channel.dimLevel or 1.0 + + await self.functional_channel.set_hue_saturation_dim_level_async( + hue=hue, saturation_level=saturation, dim_level=dim_level + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.functional_channel.set_switch_state_async(on=False) + + class HomematicipLightMeasuring(HomematicipLight): """Representation of the HomematicIP measuring light.""" diff --git a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json index c378190d00c..c9eab0cf4f5 100644 --- a/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json +++ b/tests/components/homematicip_cloud/fixtures/homematicip_cloud.json @@ -8566,6 +8566,376 @@ "serializedGlobalTradeItemNumber": "3014F71100000000000SVCTH", "type": "TEMPERATURE_HUMIDITY_SENSOR_COMPACT", "updateState": "UP_TO_DATE" + }, + "3014F71100000000000RGBW2": { + "availableFirmwareVersion": "1.0.62", + "connectionType": "HMIP_RF", + "deviceArchetype": "HMIP", + "fastColorChangeSupported": true, + "firmwareVersion": "1.0.62", + "firmwareVersionInteger": 65598, + "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": "3014F71100000000000RGBW2", + "deviceOperationMode": "UNIVERSAL_LIGHT_1_RGB", + "deviceOverheated": false, + "deviceOverloaded": false, + "devicePowerFailureDetected": false, + "deviceUndervoltage": false, + "displayContrast": null, + "displayMode": null, + "displayMountingOrientation": null, + "dutyCycle": false, + "frostProtectionError": null, + "frostProtectionErrorAcknowledged": null, + "functionalChannelType": "DEVICE_BASE", + "groupIndex": 0, + "groups": ["00000000-0000-0000-0000-000000000056"], + "index": 0, + "inputLayoutMode": null, + "invertedDisplayColors": null, + "label": "", + "lockJammed": null, + "lowBat": null, + "mountingModuleError": null, + "mountingOrientation": null, + "multicastRoutingEnabled": false, + "noDataFromLinkyError": null, + "operationDays": null, + "particulateMatterSensorCommunicationError": null, + "particulateMatterSensorError": null, + "powerShortCircuit": null, + "profilePeriodLimitReached": null, + "routerModuleEnabled": false, + "routerModuleSupported": false, + "rssiDeviceValue": -50, + "rssiPeerValue": null, + "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": false, + "IFeatureMulticastRouter": false, + "IFeatureNoDataFromLinkyError": false, + "IFeaturePowerShortCircuit": false, + "IFeatureProfilePeriodLimit": false, + "IFeatureRssiValue": true, + "IFeatureShortCircuitDataLine": false, + "IFeatureTicVersionError": false, + "IOptionalFeatureAltitude": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDefaultLinkedGroup": false, + "IOptionalFeatureDeviceAliveSignalEnabled": false, + "IOptionalFeatureDeviceErrorLockJammed": false, + "IOptionalFeatureDeviceFrostProtectionError": false, + "IOptionalFeatureDeviceInputLayoutMode": false, + "IOptionalFeatureDeviceOperationMode": true, + "IOptionalFeatureDeviceSwitchChannelMode": false, + "IOptionalFeatureDeviceValveError": false, + "IOptionalFeatureDeviceWaterError": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureDisplayContrast": false, + "IOptionalFeatureDisplayMode": false, + "IOptionalFeatureDutyCycle": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureInvertedDisplayColors": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureLowBat": false, + "IOptionalFeatureMountingOrientation": false, + "IOptionalFeatureOperationDays": false, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": false + }, + "switchChannelMode": null, + "temperatureHumiditySensorCommunicationError": null, + "temperatureHumiditySensorError": null, + "temperatureOutOfRange": false, + "temperatureSensorError": null, + "ticVersionError": null, + "unreach": false, + "valveFlowError": null, + "valveWaterError": null + }, + "1": { + "channelActive": true, + "channelRole": "UNIVERSAL_LIGHT_ACTUATOR", + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": false, + "dimLevel": 0.68, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 1, + "groups": ["00000000-0000-0000-0000-000000000061"], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": 120, + "humanCentricLightActive": false, + "index": 1, + "label": "", + "lampFailure": null, + "lightSceneId": 1, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": true, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": 0.8, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLightGroupActuatorChannel": true, + "IFeatureLightProfileActuatorChannel": true, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": true, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": true, + "IOptionalFeatureLightScene": true, + "IOptionalFeatureLightSceneWithShortTimes": true, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": true, + "IOptionalFeaturePowerUpHueSaturationValue": true, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "2": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 2, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "3": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 3, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + }, + "4": { + "channelActive": false, + "channelRole": null, + "colorTemperature": null, + "connectedDeviceUnreach": null, + "controlGearFailure": null, + "deviceId": "3014F71100000000000RGBW2", + "dim2WarmActive": null, + "dimLevel": null, + "functionalChannelType": "UNIVERSAL_LIGHT_CHANNEL", + "groupIndex": 0, + "groups": [], + "hardwareColorTemperatureColdWhite": 6500, + "hardwareColorTemperatureWarmWhite": 2000, + "hue": null, + "humanCentricLightActive": null, + "index": 4, + "label": "", + "lampFailure": null, + "lightSceneId": null, + "limitFailure": null, + "maximumColorTemperature": 6500, + "minimalColorTemperature": 2000, + "on": null, + "onMinLevel": 0.05, + "powerUpColorTemperature": 10100, + "powerUpDimLevel": 1.0, + "powerUpHue": 361, + "powerUpSaturationLevel": 1.01, + "powerUpSwitchState": "PERMANENT_OFF", + "profileMode": "AUTOMATIC", + "rampTime": 0.5, + "saturationLevel": null, + "supportedOptionalFeatures": { + "IFeatureConnectedDeviceUnreach": false, + "IFeatureControlGearFailure": false, + "IFeatureLampFailure": false, + "IFeatureLimitFailure": false, + "IOptionalFeatureChannelActive": false, + "IOptionalFeatureColorTemperature": false, + "IOptionalFeatureColorTemperatureDim2Warm": false, + "IOptionalFeatureColorTemperatureDynamicDaylight": false, + "IOptionalFeatureDimmerState": false, + "IOptionalFeatureHardwareColorTemperature": false, + "IOptionalFeatureHueSaturationValue": false, + "IOptionalFeatureLightScene": false, + "IOptionalFeatureLightSceneWithShortTimes": false, + "IOptionalFeatureOnMinLevel": true, + "IOptionalFeaturePowerUpColorTemperature": false, + "IOptionalFeaturePowerUpDimmerState": false, + "IOptionalFeaturePowerUpHueSaturationValue": false, + "IOptionalFeaturePowerUpSwitchState": true + }, + "userDesiredProfileMode": "AUTOMATIC" + } + }, + "homeId": "00000000-0000-0000-0000-000000000001", + "id": "3014F71100000000000RGBW2", + "label": "RGBW Controller", + "lastStatusUpdate": 1749973334235, + "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED", + "manuallyUpdateForced": false, + "manufacturerCode": 1, + "measuredAttributes": {}, + "modelId": 462, + "modelType": "HmIP-RGBW", + "oem": "eQ-3", + "permanentlyReachable": true, + "serializedGlobalTradeItemNumber": "3014F71100000000000RGBW2", + "type": "RGBW_DIMMER", + "updateState": "UP_TO_DATE" } }, "groups": { diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 9dd537848fe..4fb9f9eede8 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) == 331 + assert len(mock_hap.hmip_device_by_entity_id) == 335 async def test_hmip_remove_device( diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index b929bd337cc..85106f2d987 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -600,3 +600,79 @@ async def test_hmip_din_rail_dimmer_3_channel3( ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF assert not ha_state.attributes.get(ATTR_BRIGHTNESS) + + +async def test_hmip_light_hs( + hass: HomeAssistant, default_mock_hap_factory: HomeFactory +) -> None: + """Test HomematicipLight with HS color mode.""" + entity_id = "light.rgbw_controller_channel1" + entity_name = "RGBW Controller Channel1" + device_model = "HmIP-RGBW" + mock_hap = await default_mock_hap_factory.async_get_mock_hap( + test_devices=["RGBW Controller"] + ) + + ha_state, hmip_device = get_and_check_entity_basics( + hass, mock_hap, entity_id, entity_name, device_model + ) + + assert ha_state.state == STATE_ON + assert ha_state.attributes[ATTR_COLOR_MODE] == ColorMode.HS + assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] + + service_call_counter = len(hmip_device.functionalChannels[1].mock_calls) + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [240.0, 100.0]}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 1 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 240.0, + "saturation_level": 1.0, + "dim_level": 0.68, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_HS_COLOR: [220.0, 80.0], ATTR_BRIGHTNESS: 123}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 2 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": 220.0, + "saturation_level": 0.8, + "dim_level": 0.48, + } + + # Test turning on with HS color + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, ATTR_BRIGHTNESS: 40}, + blocking=True, + ) + assert len(hmip_device.functionalChannels[1].mock_calls) == service_call_counter + 3 + assert ( + hmip_device.functionalChannels[1].mock_calls[-1][0] + == "set_hue_saturation_dim_level_async" + ) + assert hmip_device.functionalChannels[1].mock_calls[-1][2] == { + "hue": hmip_device.functionalChannels[1].hue, + "saturation_level": hmip_device.functionalChannels[1].saturationLevel, + "dim_level": 0.16, + } From 8256401f7f91a52d4d92c512267fb769eed75dc9 Mon Sep 17 00:00:00 2001 From: wuede Date: Tue, 15 Jul 2025 12:16:59 +0200 Subject: [PATCH 1417/1664] expose schedule id as an extra state attribute in Netatmo (#147076) --- homeassistant/components/netatmo/climate.py | 21 +++++++++++++------ homeassistant/components/netatmo/const.py | 1 + .../netatmo/snapshots/test_climate.ambr | 4 ++++ tests/components/netatmo/test_climate.py | 13 ++++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f8f89ffd06b..a74ed630a4b 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -38,6 +38,7 @@ from .const import ( ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, + ATTR_SELECTED_SCHEDULE_ID, ATTR_TARGET_TEMPERATURE, ATTR_TIME_PERIOD, DATA_SCHEDULES, @@ -251,16 +252,22 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): if data["event_type"] == EVENT_TYPE_SCHEDULE: # handle schedule change if "schedule_id" in data: + selected_schedule = self.hass.data[DOMAIN][DATA_SCHEDULES][ + self.home.entity_id + ].get(data["schedule_id"]) self._selected_schedule = getattr( - self.hass.data[DOMAIN][DATA_SCHEDULES][self.home.entity_id].get( - data["schedule_id"] - ), + selected_schedule, "name", None, ) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) + self.async_write_ha_state() self.data_handler.async_force_update(self._signal_name) # ignore other schedule events @@ -420,12 +427,14 @@ class NetatmoThermostat(NetatmoRoomEntity, ClimateEntity): self._attr_hvac_mode = HVAC_MAP_NETATMO[self._attr_preset_mode] self._away = self._attr_hvac_mode == HVAC_MAP_NETATMO[STATE_NETATMO_AWAY] - self._selected_schedule = getattr( - self.home.get_selected_schedule(), "name", None - ) + selected_schedule = self.home.get_selected_schedule() + self._selected_schedule = getattr(selected_schedule, "name", None) self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE] = ( self._selected_schedule ) + self._attr_extra_state_attributes[ATTR_SELECTED_SCHEDULE_ID] = getattr( + selected_schedule, "entity_id", None + ) if self.device_type == NA_VALVE: self._attr_extra_state_attributes[ATTR_HEATING_POWER_REQUEST] = ( diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index d69a62f37f9..d8ecc72ada7 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -95,6 +95,7 @@ ATTR_PSEUDO = "pseudo" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" ATTR_SELECTED_SCHEDULE = "selected_schedule" +ATTR_SELECTED_SCHEDULE_ID = "selected_schedule_id" ATTR_TARGET_TEMPERATURE = "target_temperature" ATTR_TIME_PERIOD = "time_period" diff --git a/tests/components/netatmo/snapshots/test_climate.ambr b/tests/components/netatmo/snapshots/test_climate.ambr index 22a50213306..e5d5f477d34 100644 --- a/tests/components/netatmo/snapshots/test_climate.ambr +++ b/tests/components/netatmo/snapshots/test_climate.ambr @@ -147,6 +147,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -229,6 +230,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 22, @@ -312,6 +314,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 7, @@ -396,6 +399,7 @@ 'schedule', ]), 'selected_schedule': 'Default', + 'selected_schedule_id': '591b54a2764ff4d50d8b5795', 'supported_features': , 'target_temp_step': 0.5, 'temperature': 12, diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index f38e21021dc..0344ec8a7c1 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -681,6 +681,13 @@ async def test_service_schedule_thermostats( webhook_id = config_entry.data[CONF_WEBHOOK_ID] climate_entity_livingroom = "climate.livingroom" + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "591b54a2764ff4d50d8b5795" + ) + # Test setting a valid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_schedule: await hass.services.async_call( @@ -707,6 +714,12 @@ async def test_service_schedule_thermostats( hass.states.get(climate_entity_livingroom).attributes["selected_schedule"] == "Winter" ) + assert ( + hass.states.get(climate_entity_livingroom).attributes.get( + "selected_schedule_id" + ) + == "b1b54a2f45795764f59d50d8" + ) # Test setting an invalid schedule with patch("pyatmo.home.Home.async_switch_schedule") as mock_switch_home_schedule: From c7aadcdd20544aa3842091b5b5c032bd8fa553b2 Mon Sep 17 00:00:00 2001 From: Alex Leversen <91166616+leversonic@users.noreply.github.com> Date: Tue, 15 Jul 2025 06:35:20 -0400 Subject: [PATCH 1418/1664] Add file name/size sensors to OctoPrint integration (#148636) --- homeassistant/components/octoprint/sensor.py | 62 +++++++++++++- tests/components/octoprint/__init__.py | 16 +++- tests/components/octoprint/test_sensor.py | 82 ++++++++++++++----- .../{test_servics.py => test_services.py} | 0 4 files changed, 137 insertions(+), 23 deletions(-) rename tests/components/octoprint/{test_servics.py => test_services.py} (100%) diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 71db1d804c5..5594de48ff5 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature +from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -84,6 +84,8 @@ async def async_setup_entry( OctoPrintJobPercentageSensor(coordinator, device_id), OctoPrintEstimatedFinishTimeSensor(coordinator, device_id), OctoPrintStartTimeSensor(coordinator, device_id), + OctoPrintFileNameSensor(coordinator, device_id), + OctoPrintFileSizeSensor(coordinator, device_id), ] async_add_entities(entities) @@ -262,3 +264,61 @@ class OctoPrintTemperatureSensor(OctoPrintSensorBase): def available(self) -> bool: """Return if entity is available.""" return self.coordinator.last_update_success and self.coordinator.data["printer"] + + +class OctoPrintFileNameSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File", device_id) + + @property + def native_value(self) -> str | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.name or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.name + + +class OctoPrintFileSizeSensor(OctoPrintSensorBase): + """Representation of an OctoPrint sensor.""" + + _attr_device_class = SensorDeviceClass.DATA_SIZE + _attr_native_unit_of_measurement = UnitOfInformation.BYTES + _attr_suggested_unit_of_measurement = UnitOfInformation.MEGABYTES + + def __init__( + self, + coordinator: OctoprintDataUpdateCoordinator, + device_id: str, + ) -> None: + """Initialize a new OctoPrint sensor.""" + super().__init__(coordinator, "Current File Size", device_id) + + @property + def native_value(self) -> int | None: + """Return sensor state.""" + job: OctoprintJobInfo = self.coordinator.data["job"] + + return job.job.file.size or None + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + job: OctoprintJobInfo = self.coordinator.data["job"] + return job and job.job.file.size diff --git a/tests/components/octoprint/__init__.py b/tests/components/octoprint/__init__.py index 3ddae7de587..3755b84a6f9 100644 --- a/tests/components/octoprint/__init__.py +++ b/tests/components/octoprint/__init__.py @@ -21,7 +21,21 @@ from homeassistant.helpers.typing import UNDEFINED, UndefinedType from tests.common import MockConfigEntry DEFAULT_JOB = { - "job": {"file": {}}, + "job": { + "averagePrintTime": None, + "estimatedPrintTime": None, + "filament": None, + "file": { + "date": None, + "display": None, + "name": None, + "origin": None, + "path": None, + "size": None, + }, + "lastPrintTime": None, + "user": None, + }, "progress": {"completion": 50}, } diff --git a/tests/components/octoprint/test_sensor.py b/tests/components/octoprint/test_sensor.py index 87485e46807..3b0ed2ded0b 100644 --- a/tests/components/octoprint/test_sensor.py +++ b/tests/components/octoprint/test_sensor.py @@ -4,6 +4,7 @@ from datetime import UTC, datetime from freezegun.api import FrozenDateTimeFactory +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfInformation from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,11 +24,7 @@ async def test_sensors( }, "temperature": {"tool1": {"actual": 18.83136, "target": 37.83136}}, } - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Printing", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 13, 543, tzinfo=UTC)) await init_integration(hass, "sensor", printer=printer, job=job) @@ -80,6 +77,21 @@ async def test_sensors( entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == "Test_File_Name.gcode" + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == "123.456789" + assert state.attributes.get("unit_of_measurement") == UnitOfInformation.MEGABYTES + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_no_target_temp( hass: HomeAssistant, @@ -106,11 +118,25 @@ async def test_sensors_no_target_temp( state = hass.states.get("sensor.octoprint_target_tool1_temp") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint target tool1 temp" entry = entity_registry.async_get("sensor.octoprint_target_tool1_temp") assert entry.unique_id == "target tool1 temp-uuid" + state = hass.states.get("sensor.octoprint_current_file") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File" + entry = entity_registry.async_get("sensor.octoprint_current_file") + assert entry.unique_id == "Current File-uuid" + + state = hass.states.get("sensor.octoprint_current_file_size") + assert state is not None + assert state.state == STATE_UNAVAILABLE + assert state.name == "OctoPrint Current File Size" + entry = entity_registry.async_get("sensor.octoprint_current_file_size") + assert entry.unique_id == "Current File Size-uuid" + async def test_sensors_paused( hass: HomeAssistant, @@ -125,24 +151,20 @@ async def test_sensors_paused( }, "temperature": {"tool1": {"actual": 18.83136, "target": None}}, } - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=printer, job=job) state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" @@ -154,11 +176,7 @@ async def test_sensors_printer_disconnected( entity_registry: er.EntityRegistry, ) -> None: """Test the underlying sensors.""" - job = { - "job": {"file": {}}, - "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, - "state": "Paused", - } + job = __standard_job() freezer.move_to(datetime(2020, 2, 20, 9, 10, 0)) await init_integration(hass, "sensor", printer=None, job=job) @@ -171,21 +189,43 @@ async def test_sensors_printer_disconnected( state = hass.states.get("sensor.octoprint_current_state") assert state is not None - assert state.state == "unavailable" + assert state.state == STATE_UNAVAILABLE assert state.name == "OctoPrint Current State" entry = entity_registry.async_get("sensor.octoprint_current_state") assert entry.unique_id == "Current State-uuid" state = hass.states.get("sensor.octoprint_start_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Start Time" entry = entity_registry.async_get("sensor.octoprint_start_time") assert entry.unique_id == "Start Time-uuid" state = hass.states.get("sensor.octoprint_estimated_finish_time") assert state is not None - assert state.state == "unknown" + assert state.state == STATE_UNKNOWN assert state.name == "OctoPrint Estimated Finish Time" entry = entity_registry.async_get("sensor.octoprint_estimated_finish_time") assert entry.unique_id == "Estimated Finish Time-uuid" + + +def __standard_job(): + return { + "job": { + "averagePrintTime": 6500, + "estimatedPrintTime": 6000, + "filament": {"tool0": {"length": 3000, "volume": 7}}, + "file": { + "date": 1577836800, + "display": "Test File Name", + "name": "Test_File_Name.gcode", + "origin": "local", + "path": "Folder1/Folder2/Test_File_Name.gcode", + "size": 123456789, + }, + "lastPrintTime": 12345.678, + "user": "testUser", + }, + "progress": {"completion": 50, "printTime": 600, "printTimeLeft": 6000}, + "state": "Printing", + } diff --git a/tests/components/octoprint/test_servics.py b/tests/components/octoprint/test_services.py similarity index 100% rename from tests/components/octoprint/test_servics.py rename to tests/components/octoprint/test_services.py From ee4325a927426f8208210502f88c09c40c356819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=85ke=20Strandberg?= Date: Tue, 15 Jul 2025 12:40:48 +0200 Subject: [PATCH 1419/1664] Replace deprecated battery property on Miele vacuum with sensor (#148765) --- homeassistant/components/miele/sensor.py | 10 + homeassistant/components/miele/vacuum.py | 6 - .../miele/snapshots/test_sensor.ambr | 375 ++++++++++++++++++ .../miele/snapshots/test_vacuum.ambr | 12 +- tests/components/miele/test_sensor.py | 15 + 5 files changed, 404 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index ff72b791735..a0daf462c7b 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -539,6 +539,16 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( options=sorted(StateDryingStep.keys()), ), ), + MieleSensorDefinition( + types=(MieleAppliance.ROBOT_VACUUM_CLEANER,), + description=MieleSensorDescription( + key="state_battery", + value_fn=lambda value: value.state_battery_level, + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.BATTERY, + ), + ), ) diff --git a/homeassistant/components/miele/vacuum.py b/homeassistant/components/miele/vacuum.py index 29a89e39bdb..999ceac5cce 100644 --- a/homeassistant/components/miele/vacuum.py +++ b/homeassistant/components/miele/vacuum.py @@ -87,7 +87,6 @@ class MieleVacuumStateCode(MieleEnum): SUPPORTED_FEATURES = ( VacuumEntityFeature.STATE - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.FAN_SPEED | VacuumEntityFeature.START | VacuumEntityFeature.STOP @@ -174,11 +173,6 @@ class MieleVacuum(MieleEntity, StateVacuumEntity): MieleVacuumStateCode(self.device.state_program_phase).value ) - @property - def battery_level(self) -> int | None: - """Return the battery level.""" - return self.device.state_battery_level - @property def fan_speed(self) -> str | None: """Return the fan speed.""" diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index dfc12a52c08..e37af02bf26 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -2920,3 +2920,378 @@ 'state': '0.0', }) # --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:robot-vacuum', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'Dummy_Vacuum_1-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner', + 'icon': 'mdi:robot-vacuum', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_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': , + 'entity_id': 'sensor.robot_vacuum_cleaner_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Dummy_Vacuum_1-state_battery', + 'unit_of_measurement': '%', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Robot vacuum cleaner Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '65', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-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.robot_vacuum_cleaner_elapsed_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': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'Dummy_Vacuum_1-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'Dummy_Vacuum_1-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program', + 'options': list([ + 'auto', + 'no_program', + 'silent', + 'spot', + 'turbo', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'auto', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_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': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'Dummy_Vacuum_1-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Robot vacuum cleaner Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal_operation_mode', + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-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.robot_vacuum_cleaner_remaining_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': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'Dummy_Vacuum_1-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_vacuum_sensor_states[platforms0-vacuum_device.json][sensor.robot_vacuum_cleaner_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Robot vacuum cleaner Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.robot_vacuum_cleaner_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- diff --git a/tests/components/miele/snapshots/test_vacuum.ambr b/tests/components/miele/snapshots/test_vacuum.ambr index 9f96db7b05a..3b808ad9cd2 100644 --- a/tests/components/miele/snapshots/test_vacuum.ambr +++ b/tests/components/miele/snapshots/test_vacuum.ambr @@ -34,7 +34,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -43,8 +43,6 @@ # name: test_sensor_states[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -52,7 +50,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', @@ -97,7 +95,7 @@ 'platform': 'miele', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'vacuum', 'unique_id': 'Dummy_Vacuum_1-vacuum', 'unit_of_measurement': None, @@ -106,8 +104,6 @@ # name: test_vacuum_states_api_push[platforms0-vacuum_device.json][vacuum.robot_vacuum_cleaner-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'battery_icon': 'mdi:battery-60', - 'battery_level': 65, 'fan_speed': 'normal', 'fan_speed_list': list([ 'normal', @@ -115,7 +111,7 @@ 'silent', ]), 'friendly_name': 'Robot vacuum cleaner', - 'supported_features': , + 'supported_features': , }), 'context': , 'entity_id': 'vacuum.robot_vacuum_cleaner', diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 47e101c6636..3f66f36f556 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -54,3 +54,18 @@ async def test_hob_sensor_states( """Test sensor state.""" await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_vacuum_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test robot vacuum cleaner sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) From 7d06aec8dabac85f999aa3f51b5d922e665054da Mon Sep 17 00:00:00 2001 From: Andrea Turri Date: Tue, 15 Jul 2025 12:50:28 +0200 Subject: [PATCH 1420/1664] Discovery of Miele temperature sensors (#144585) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/miele/entity.py | 7 +- homeassistant/components/miele/sensor.py | 204 +- .../components/miele/fixtures/4_actions.json | 15 + .../components/miele/fixtures/4_devices.json | 124 + .../miele/fixtures/fridge_freezer.json | 109 + tests/components/miele/fixtures/oven.json | 142 ++ .../miele/snapshots/test_binary_sensor.ambr | 582 +++++ .../miele/snapshots/test_button.ambr | 192 ++ .../miele/snapshots/test_diagnostics.ambr | 168 ++ .../miele/snapshots/test_light.ambr | 114 + .../miele/snapshots/test_sensor.ambr | 2145 +++++++++++++++++ .../miele/snapshots/test_switch.ambr | 96 + tests/components/miele/test_init.py | 8 +- tests/components/miele/test_sensor.py | 189 +- 14 files changed, 4015 insertions(+), 80 deletions(-) create mode 100644 tests/components/miele/fixtures/fridge_freezer.json create mode 100644 tests/components/miele/fixtures/oven.json diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py index f9ed4f0bf48..4c6e61f6ea5 100644 --- a/homeassistant/components/miele/entity.py +++ b/homeassistant/components/miele/entity.py @@ -16,6 +16,11 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): _attr_has_entity_name = True + @staticmethod + def get_unique_id(device_id: str, description: EntityDescription) -> str: + """Generate a unique ID for the entity.""" + return f"{device_id}-{description.key}" + def __init__( self, coordinator: MieleDataUpdateCoordinator, @@ -26,7 +31,7 @@ class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]): super().__init__(coordinator) self._device_id = device_id self.entity_description = description - self._attr_unique_id = f"{device_id}-{description.key}" + self._attr_unique_id = MieleEntity.get_unique_id(device_id, description) device = self.device appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type)) diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py index a0daf462c7b..216b91ca68e 100644 --- a/homeassistant/components/miele/sensor.py +++ b/homeassistant/components/miele/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass import logging from typing import Final, cast -from pymiele import MieleDevice +from pymiele import MieleDevice, MieleTemperature from homeassistant.components.sensor import ( SensorDeviceClass, @@ -25,10 +25,13 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from .const import ( + DISABLED_TEMP_ENTITIES, + DOMAIN, STATE_PROGRAM_ID, STATE_PROGRAM_PHASE, STATE_STATUS_TAGS, @@ -45,8 +48,6 @@ PARALLEL_UPDATES = 0 _LOGGER = logging.getLogger(__name__) -DISABLED_TEMPERATURE = -32768 - DEFAULT_PLATE_COUNT = 4 PLATE_COUNT = { @@ -75,12 +76,25 @@ def _convert_duration(value_list: list[int]) -> int | None: return value_list[0] * 60 + value_list[1] if value_list else None +def _convert_temperature( + value_list: list[MieleTemperature], index: int +) -> float | None: + """Convert temperature object to readable value.""" + if index >= len(value_list): + return None + raw_value = cast(int, value_list[index].temperature) / 100.0 + if raw_value in DISABLED_TEMP_ENTITIES: + return None + return raw_value + + @dataclass(frozen=True, kw_only=True) class MieleSensorDescription(SensorEntityDescription): """Class describing Miele sensor entities.""" value_fn: Callable[[MieleDevice], StateType] - zone: int = 1 + zone: int | None = None + unique_id_fn: Callable[[str, MieleSensorDescription], str] | None = None @dataclass @@ -404,32 +418,20 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( ), description=MieleSensorDescription( key="state_temperature_1", + zone=1, device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: cast(int, value.state_temperatures[0].temperature) - / 100.0, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 0), ), ), MieleSensorDefinition( types=( - MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL, - MieleAppliance.OVEN, - MieleAppliance.OVEN_MICROWAVE, - MieleAppliance.DISH_WARMER, - MieleAppliance.STEAM_OVEN, - MieleAppliance.MICROWAVE, - MieleAppliance.FRIDGE, - MieleAppliance.FREEZER, MieleAppliance.FRIDGE_FREEZER, - MieleAppliance.STEAM_OVEN_COMBI, MieleAppliance.WINE_CABINET, MieleAppliance.WINE_CONDITIONING_UNIT, MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, - MieleAppliance.STEAM_OVEN_MICRO, - MieleAppliance.DIALOG_OVEN, MieleAppliance.WINE_CABINET_FREEZER, - MieleAppliance.STEAM_OVEN_MK2, ), description=MieleSensorDescription( key="state_temperature_2", @@ -438,7 +440,24 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( translation_key="temperature_zone_2", native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda value: value.state_temperatures[1].temperature / 100.0, # type: ignore [operator] + value_fn=lambda value: _convert_temperature(value.state_temperatures, 1), + ), + ), + MieleSensorDefinition( + types=( + MieleAppliance.WINE_CABINET, + MieleAppliance.WINE_CONDITIONING_UNIT, + MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT, + MieleAppliance.WINE_CABINET_FREEZER, + ), + description=MieleSensorDescription( + key="state_temperature_3", + zone=3, + device_class=SensorDeviceClass.TEMPERATURE, + translation_key="temperature_zone_3", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda value: _convert_temperature(value.state_temperatures, 2), ), ), MieleSensorDefinition( @@ -454,11 +473,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast( - int, value.state_core_target_temperature[0].temperature - ) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_target_temperature, 0 ), ), ), @@ -479,9 +495,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_target_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_target_temperature, 0 ), ), ), @@ -497,9 +512,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, - value_fn=( - lambda value: cast(int, value.state_core_temperature[0].temperature) - / 100.0 + value_fn=lambda value: _convert_temperature( + value.state_core_temperature, 0 ), ), ), @@ -518,6 +532,8 @@ SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = ( device_class=SensorDeviceClass.ENUM, options=sorted(PlatePowerStep.keys()), value_fn=lambda value: None, + unique_id_fn=lambda device_id, + description: f"{device_id}-{description.key}-{description.zone}", ), ) for i in range(1, 7) @@ -559,10 +575,52 @@ async def async_setup_entry( ) -> None: """Set up the sensor platform.""" coordinator = config_entry.runtime_data - added_devices: set[str] = set() + added_devices: set[str] = set() # device_id + added_entities: set[str] = set() # unique_id - def _async_add_new_devices() -> None: - nonlocal added_devices + def _get_entity_class(definition: MieleSensorDefinition) -> type[MieleSensor]: + """Get the entity class for the sensor.""" + return { + "state_status": MieleStatusSensor, + "state_program_id": MieleProgramIdSensor, + "state_program_phase": MielePhaseSensor, + "state_plate_step": MielePlateSensor, + }.get(definition.description.key, MieleSensor) + + def _is_entity_registered(unique_id: str) -> bool: + """Check if the entity is already registered.""" + entity_registry = er.async_get(hass) + return any( + entry.platform == DOMAIN and entry.unique_id == unique_id + for entry in entity_registry.entities.values() + ) + + def _is_sensor_enabled( + definition: MieleSensorDefinition, + device: MieleDevice, + unique_id: str, + ) -> bool: + """Check if the sensor is enabled.""" + if ( + definition.description.device_class == SensorDeviceClass.TEMPERATURE + and definition.description.value_fn(device) is None + and definition.description.zone != 1 + ): + # all appliances supporting temperature have at least zone 1, for other zones + # don't create entity if API signals that datapoint is disabled, unless the sensor + # already appeared in the past (= it provided a valid value) + return _is_entity_registered(unique_id) + if ( + definition.description.key == "state_plate_step" + and definition.description.zone is not None + and definition.description.zone > _get_plate_count(device.tech_type) + ): + # don't create plate entity if not expected by the appliance tech type + return False + return True + + def _async_add_devices() -> None: + nonlocal added_devices, added_entities entities: list = [] entity_class: type[MieleSensor] new_devices_set, current_devices = coordinator.async_add_devices(added_devices) @@ -570,40 +628,35 @@ async def async_setup_entry( for device_id, device in coordinator.data.devices.items(): for definition in SENSOR_TYPES: - if ( - device_id in new_devices_set - and device.device_type in definition.types - ): - match definition.description.key: - case "state_status": - entity_class = MieleStatusSensor - case "state_program_id": - entity_class = MieleProgramIdSensor - case "state_program_phase": - entity_class = MielePhaseSensor - case "state_plate_step": - entity_class = MielePlateSensor - case _: - entity_class = MieleSensor - if ( - definition.description.device_class - == SensorDeviceClass.TEMPERATURE - and definition.description.value_fn(device) - == DISABLED_TEMPERATURE / 100 - ) or ( - definition.description.key == "state_plate_step" - and definition.description.zone - > _get_plate_count(device.tech_type) - ): - # Don't create entity if API signals that datapoint is disabled - continue - entities.append( - entity_class(coordinator, device_id, definition.description) + # device is not supported, skip + if device.device_type not in definition.types: + continue + + entity_class = _get_entity_class(definition) + unique_id = ( + definition.description.unique_id_fn( + device_id, definition.description ) + if definition.description.unique_id_fn is not None + else MieleEntity.get_unique_id(device_id, definition.description) + ) + + # entity was already added, skip + if device_id not in new_devices_set and unique_id in added_entities: + continue + + # sensors is not enabled, skip + if not _is_sensor_enabled(definition, device, unique_id): + continue + + added_entities.add(unique_id) + entities.append( + entity_class(coordinator, device_id, definition.description) + ) async_add_entities(entities) - config_entry.async_on_unload(coordinator.async_add_listener(_async_add_new_devices)) - _async_add_new_devices() + config_entry.async_on_unload(coordinator.async_add_listener(_async_add_devices)) + _async_add_devices() APPLIANCE_ICONS = { @@ -641,6 +694,17 @@ class MieleSensor(MieleEntity, SensorEntity): entity_description: MieleSensorDescription + def __init__( + self, + coordinator: MieleDataUpdateCoordinator, + device_id: str, + description: MieleSensorDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, device_id, description) + if description.unique_id_fn is not None: + self._attr_unique_id = description.unique_id_fn(device_id, description) + @property def native_value(self) -> StateType: """Return the state of the sensor.""" @@ -652,16 +716,6 @@ class MielePlateSensor(MieleSensor): entity_description: MieleSensorDescription - def __init__( - self, - coordinator: MieleDataUpdateCoordinator, - device_id: str, - description: MieleSensorDescription, - ) -> None: - """Initialize the plate sensor.""" - super().__init__(coordinator, device_id, description) - self._attr_unique_id = f"{device_id}-{description.key}-{description.zone}" - @property def native_value(self) -> StateType: """Return the state of the plate sensor.""" @@ -672,7 +726,7 @@ class MielePlateSensor(MieleSensor): cast( int, self.device.state_plate_step[ - self.entity_description.zone - 1 + cast(int, self.entity_description.zone) - 1 ].value_raw, ) ).name diff --git a/tests/components/miele/fixtures/4_actions.json b/tests/components/miele/fixtures/4_actions.json index 6a89fb4604a..903a075df3c 100644 --- a/tests/components/miele/fixtures/4_actions.json +++ b/tests/components/miele/fixtures/4_actions.json @@ -82,5 +82,20 @@ "colors": [], "modes": [], "runOnTime": [] + }, + "DummyAppliance_12": { + "processAction": [], + "light": [2], + "ambientLight": [], + "startTime": [], + "ventilationStep": [], + "programId": [], + "targetTemperature": [], + "deviceName": true, + "powerOn": false, + "powerOff": true, + "colors": [], + "modes": [], + "runOnTime": [] } } diff --git a/tests/components/miele/fixtures/4_devices.json b/tests/components/miele/fixtures/4_devices.json index b63c60ff4d3..7d6ee9a7173 100644 --- a/tests/components/miele/fixtures/4_devices.json +++ b/tests/components/miele/fixtures/4_devices.json @@ -466,5 +466,129 @@ "ecoFeedback": null, "batteryLevel": null } + }, + "DummyAppliance_12": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 356, + "value_localized": "Defrost", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 1, + "value_localized": "Program", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 3073, + "value_localized": "Heating-up phase", + "key_localized": "Program phase" + }, + "remainingTime": [0, 5], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 2500, + "value_localized": 25.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": 1954, + "value_localized": 19.54, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": 2200, + "value_localized": 22.0, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": true + }, + "ambientLight": null, + "light": 1, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } } } diff --git a/tests/components/miele/fixtures/fridge_freezer.json b/tests/components/miele/fixtures/fridge_freezer.json new file mode 100644 index 00000000000..5d091b9c74e --- /dev/null +++ b/tests/components/miele/fixtures/fridge_freezer.json @@ -0,0 +1,109 @@ +{ + "DummyAppliance_Fridge_Freezer": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 21, + "value_localized": "Fridge freezer" + }, + "deviceName": "", + "protocolVersion": 203, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "00", + "techType": "KFN 7734 C", + "matNumber": "12336150", + "swids": [] + }, + "xkmIdentLabel": { + "techType": "EK037LHBM", + "releaseVersion": "32.33" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 5, + "value_localized": "In use", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [], + "temperature": [ + { + "value_raw": 400, + "value_localized": 4.0, + "unit": "Celsius" + }, + { + "value_raw": -1800, + "value_localized": -18.0, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/fixtures/oven.json b/tests/components/miele/fixtures/oven.json new file mode 100644 index 00000000000..dbf14d4546c --- /dev/null +++ b/tests/components/miele/fixtures/oven.json @@ -0,0 +1,142 @@ +{ + "DummyOven": { + "ident": { + "type": { + "key_localized": "Device type", + "value_raw": 12, + "value_localized": "Oven" + }, + "deviceName": "", + "protocolVersion": 4, + "deviceIdentLabel": { + "fabNumber": "**REDACTED**", + "fabIndex": "16", + "techType": "H7660BP", + "matNumber": "11120960", + "swids": [ + "6166", + "25211", + "25210", + "4860", + "25245", + "6153", + "6050", + "25300", + "25307", + "25247", + "20570", + "25223", + "5640", + "20366", + "20462" + ] + }, + "xkmIdentLabel": { + "techType": "EK057", + "releaseVersion": "08.32" + } + }, + "state": { + "ProgramID": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program name" + }, + "status": { + "value_raw": 1, + "value_localized": "Off", + "key_localized": "status" + }, + "programType": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program type" + }, + "programPhase": { + "value_raw": 0, + "value_localized": "", + "key_localized": "Program phase" + }, + "remainingTime": [0, 0], + "startTime": [0, 0], + "targetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTargetTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "temperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + }, + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "coreTemperature": [ + { + "value_raw": -32768, + "value_localized": null, + "unit": "Celsius" + } + ], + "signalInfo": false, + "signalFailure": false, + "signalDoor": false, + "remoteEnable": { + "fullRemoteControl": true, + "smartGrid": false, + "mobileStart": false + }, + "ambientLight": null, + "light": null, + "elapsedTime": [0, 0], + "spinningSpeed": { + "unit": "rpm", + "value_raw": null, + "value_localized": null, + "key_localized": "Spin speed" + }, + "dryingStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Drying level" + }, + "ventilationStep": { + "value_raw": null, + "value_localized": "", + "key_localized": "Fan level" + }, + "plateStep": [], + "ecoFeedback": null, + "batteryLevel": null + } + } +} diff --git a/tests/components/miele/snapshots/test_binary_sensor.ambr b/tests/components/miele/snapshots/test_binary_sensor.ambr index f102c925c98..9a3de2ddd49 100644 --- a/tests/components/miele/snapshots/test_binary_sensor.ambr +++ b/tests/components/miele/snapshots/test_binary_sensor.ambr @@ -532,6 +532,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-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.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-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': , + 'entity_id': 'binary_sensor.oven_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-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': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-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': , + 'entity_id': 'binary_sensor.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-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': , + 'entity_id': 'binary_sensor.oven_remote_control', + '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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-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': , + 'entity_id': 'binary_sensor.oven_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1647,6 +1938,297 @@ 'state': 'off', }) # --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-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.oven_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Oven Door', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-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': , + 'entity_id': 'binary_sensor.oven_mobile_start', + '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': 'Mobile start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'mobile_start', + 'unique_id': 'DummyAppliance_12-state_mobile_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_mobile_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Mobile start', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_mobile_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-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': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Notification active', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'notification_active', + 'unique_id': 'DummyAppliance_12-state_signal_info', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_notification_active-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Notification active', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_notification_active', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-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': , + 'entity_id': 'binary_sensor.oven_problem', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Problem', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_signal_failure', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_problem-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Oven Problem', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_problem', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-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': , + 'entity_id': 'binary_sensor.oven_remote_control', + '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': 'Remote control', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remote_control', + 'unique_id': 'DummyAppliance_12-state_full_remote_control', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_remote_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Remote control', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_remote_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-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': , + 'entity_id': 'binary_sensor.oven_smart_grid', + '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': 'Smart grid', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_grid', + 'unique_id': 'DummyAppliance_12-state_smart_grid', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_states_api_push[platforms0][binary_sensor.oven_smart_grid-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Smart grid', + }), + 'context': , + 'entity_id': 'binary_sensor.oven_smart_grid', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_binary_sensor_states_api_push[platforms0][binary_sensor.refrigerator_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_button.ambr b/tests/components/miele/snapshots/test_button.ambr index 6e6f3cbb72d..e4eb80587c9 100644 --- a/tests/components/miele/snapshots/test_button.ambr +++ b/tests/components/miele/snapshots/test_button.ambr @@ -47,6 +47,102 @@ 'state': 'unknown', }) # --- +# name: test_button_states[platforms0][button.oven_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_start', + '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': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button_states[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -239,6 +335,102 @@ 'state': 'unavailable', }) # --- +# name: test_button_states_api_push[platforms0][button.oven_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_start', + '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': 'Start', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'DummyAppliance_12-start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Start', + }), + 'context': , + 'entity_id': 'button.oven_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.oven_stop', + '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': 'Stop', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'stop', + 'unique_id': 'DummyAppliance_12-stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button_states_api_push[platforms0][button.oven_stop-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Stop', + }), + 'context': , + 'entity_id': 'button.oven_stop', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_button_states_api_push[platforms0][button.washing_machine_pause-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_diagnostics.ambr b/tests/components/miele/snapshots/test_diagnostics.ambr index 8fa40755888..54f6083a74c 100644 --- a/tests/components/miele/snapshots/test_diagnostics.ambr +++ b/tests/components/miele/snapshots/test_diagnostics.ambr @@ -144,6 +144,39 @@ 'ventilationStep': list([ ]), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ambientLight': list([ + ]), + 'colors': list([ + ]), + 'deviceName': True, + 'light': list([ + ]), + 'modes': list([ + ]), + 'powerOff': False, + 'powerOn': True, + 'processAction': list([ + 1, + 2, + 3, + ]), + 'programId': list([ + ]), + 'runOnTime': list([ + ]), + 'startTime': list([ + ]), + 'targetTemperature': list([ + dict({ + 'max': 28, + 'min': -28, + 'zone': 1, + }), + ]), + 'ventilationStep': list([ + ]), + }), }), 'devices': dict({ '**REDACTED_019aa577ad1c330d': dict({ @@ -661,6 +694,141 @@ }), }), }), + '**REDACTED_e7bc6793e305bf53': dict({ + 'ident': dict({ + 'deviceIdentLabel': dict({ + 'fabIndex': '16', + 'fabNumber': '**REDACTED**', + 'matNumber': '11120960', + 'swids': list([ + ]), + 'techType': 'H7660BP', + }), + 'deviceName': '', + 'protocolVersion': 4, + 'type': dict({ + 'key_localized': 'Device type', + 'value_localized': 'Oven', + 'value_raw': 12, + }), + 'xkmIdentLabel': dict({ + 'releaseVersion': '08.32', + 'techType': 'EK057', + }), + }), + 'state': dict({ + 'ProgramID': dict({ + 'key_localized': 'Program name', + 'value_localized': 'Defrost', + 'value_raw': 356, + }), + 'ambientLight': None, + 'batteryLevel': None, + 'coreTargetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'coreTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 22.0, + 'value_raw': 2200, + }), + ]), + 'dryingStep': dict({ + 'key_localized': 'Drying level', + 'value_localized': '', + 'value_raw': None, + }), + 'ecoFeedback': None, + 'elapsedTime': list([ + 0, + 0, + ]), + 'light': 1, + 'plateStep': list([ + ]), + 'programPhase': dict({ + 'key_localized': 'Program phase', + 'value_localized': 'Heating-up phase', + 'value_raw': 3073, + }), + 'programType': dict({ + 'key_localized': 'Program type', + 'value_localized': 'Program', + 'value_raw': 1, + }), + 'remainingTime': list([ + 0, + 5, + ]), + 'remoteEnable': dict({ + 'fullRemoteControl': True, + 'mobileStart': True, + 'smartGrid': False, + }), + 'signalDoor': False, + 'signalFailure': False, + 'signalInfo': False, + 'spinningSpeed': dict({ + 'key_localized': 'Spin speed', + 'unit': 'rpm', + 'value_localized': None, + 'value_raw': None, + }), + 'startTime': list([ + 0, + 0, + ]), + 'status': dict({ + 'key_localized': 'status', + 'value_localized': 'In use', + 'value_raw': 5, + }), + 'targetTemperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 25.0, + 'value_raw': 2500, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'temperature': list([ + dict({ + 'unit': 'Celsius', + 'value_localized': 19.54, + 'value_raw': 1954, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + dict({ + 'unit': 'Celsius', + 'value_localized': None, + 'value_raw': -32768, + }), + ]), + 'ventilationStep': dict({ + 'key_localized': 'Fan level', + 'value_localized': '', + 'value_raw': None, + }), + }), + }), }), 'missing_code_warnings': list([ 'None', diff --git a/tests/components/miele/snapshots/test_light.ambr b/tests/components/miele/snapshots/test_light.ambr index 8c4a4f4bff9..243536fc997 100644 --- a/tests/components/miele/snapshots/test_light.ambr +++ b/tests/components/miele/snapshots/test_light.ambr @@ -113,6 +113,63 @@ 'state': 'on', }) # --- +# name: test_light_states[platforms0][light.oven_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.oven_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_light_states_api_push[platforms0][light.hood_ambient_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -227,3 +284,60 @@ 'state': 'on', }) # --- +# name: test_light_states_api_push[platforms0][light.oven_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.oven_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'light', + 'unique_id': 'DummyAppliance_12-light', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_states_api_push[platforms0][light.oven_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Oven Light', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.oven_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/miele/snapshots/test_sensor.ambr b/tests/components/miele/snapshots/test_sensor.ambr index e37af02bf26..915eda4d361 100644 --- a/tests/components/miele/snapshots/test_sensor.ambr +++ b/tests/components/miele/snapshots/test_sensor.ambr @@ -1,4 +1,207 @@ # serializer version: 1 +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.fridge_freezer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fridge-outline', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Fridge freezer', + 'icon': 'mdi:fridge-outline', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_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.fridge_freezer_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.0', + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-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.fridge_freezer_temperature_zone_2', + '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 zone 2', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_zone_2', + 'unique_id': 'DummyAppliance_Fridge_Freezer-state_temperature_2', + 'unit_of_measurement': , + }) +# --- +# name: test_fridge_freezer_sensor_states[platforms0-fridge_freezer.json][sensor.fridge_freezer_temperature_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Fridge freezer Temperature zone 2', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.fridge_freezer_temperature_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-18.0', + }) +# --- # name: test_hob_sensor_states[platforms0-hob.json][sensor.hob_with_extraction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -808,6 +1011,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_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.oven_core_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': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-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.oven_elapsed_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': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_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': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-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.oven_remaining_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': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-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.oven_start_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_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.oven_target_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': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_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.oven_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1640,6 +2758,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_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.washing_machine_target_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': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1983,6 +3157,921 @@ 'state': 'off', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:chef-hat', + 'original_name': None, + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'DummyAppliance_12-state_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven', + 'icon': 'mdi:chef-hat', + 'options': list([ + 'autocleaning', + 'failure', + 'idle', + 'in_use', + 'not_connected', + 'off', + 'on', + 'pause', + 'program_ended', + 'program_interrupted', + 'programmed', + 'rinse_hold', + 'service', + 'supercooling', + 'supercooling_superfreezing', + 'superfreezing', + 'superheating', + 'waiting_to_start', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'in_use', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_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.oven_core_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': 'Core temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'core_temperature', + 'unique_id': 'DummyAppliance_12-state_core_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_core_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Core temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_core_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-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.oven_elapsed_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': 'Elapsed time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'elapsed_time', + 'unique_id': 'DummyAppliance_12-state_elapsed_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_elapsed_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Elapsed time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_elapsed_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_id', + 'unique_id': 'DummyAppliance_12-state_program_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program', + 'options': list([ + 'almond_macaroons_1_tray', + 'almond_macaroons_2_trays', + 'apple_pie', + 'apple_sponge', + 'auto_roast', + 'baguettes', + 'baiser_one_large', + 'baiser_several_small', + 'beef_fillet_low_temperature_cooking', + 'beef_fillet_roast', + 'beef_hash', + 'beef_wellington', + 'belgian_sponge_cake', + 'biscuits_short_crust_pastry_1_tray', + 'biscuits_short_crust_pastry_2_trays', + 'blueberry_muffins', + 'bottom_heat', + 'braised_beef', + 'braised_veal', + 'butter_cake', + 'carp', + 'cheese_souffle', + 'chicken_thighs', + 'chicken_whole', + 'chocolate_hazlenut_cake_one_large', + 'chocolate_hazlenut_cake_several_small', + 'choux_buns', + 'conventional_heat', + 'custom_program_1', + 'custom_program_10', + 'custom_program_11', + 'custom_program_12', + 'custom_program_13', + 'custom_program_14', + 'custom_program_15', + 'custom_program_16', + 'custom_program_17', + 'custom_program_18', + 'custom_program_19', + 'custom_program_2', + 'custom_program_20', + 'custom_program_3', + 'custom_program_4', + 'custom_program_5', + 'custom_program_6', + 'custom_program_7', + 'custom_program_8', + 'custom_program_9', + 'dark_mixed_grain_bread', + 'defrost', + 'descale', + 'drop_cookies_1_tray', + 'drop_cookies_2_trays', + 'drying', + 'duck', + 'eco_fan_heat', + 'economy_grill', + 'evaporate_water', + 'fan_grill', + 'fan_plus', + 'flat_bread', + 'fruit_flan_puff_pastry', + 'fruit_flan_short_crust_pastry', + 'fruit_streusel_cake', + 'full_grill', + 'ginger_loaf', + 'goose_stuffed', + 'goose_unstuffed', + 'ham_roast', + 'heat_crockery', + 'intensive_bake', + 'keeping_warm', + 'leg_of_lamb', + 'lemon_meringue_pie', + 'linzer_augen_1_tray', + 'linzer_augen_2_trays', + 'low_temperature_cooking', + 'madeira_cake', + 'marble_cake', + 'meat_loaf', + 'microwave', + 'mixed_rye_bread', + 'moisture_plus_auto_roast', + 'moisture_plus_conventional_heat', + 'moisture_plus_fan_plus', + 'moisture_plus_intensive_bake', + 'multigrain_rolls', + 'no_program', + 'osso_buco', + 'pikeperch_fillet_with_vegetables', + 'pizza_oil_cheese_dough_baking_tray', + 'pizza_oil_cheese_dough_round_baking_tine', + 'pizza_yeast_dough_baking_tray', + 'pizza_yeast_dough_round_baking_tine', + 'plaited_loaf', + 'plaited_swiss_loaf', + 'pork_belly', + 'pork_fillet_low_temperature_cooking', + 'pork_fillet_roast', + 'pork_smoked_ribs_low_temperature_cooking', + 'pork_smoked_ribs_roast', + 'pork_with_crackling', + 'potato_cheese_gratin', + 'potato_gratin', + 'prove_15_min', + 'prove_30_min', + 'prove_45_min', + 'pyrolytic', + 'quiche_lorraine', + 'rabbit', + 'rack_of_lamb_with_vegetables', + 'roast_beef_low_temperature_cooking', + 'roast_beef_roast', + 'rye_rolls', + 'sachertorte', + 'saddle_of_lamb_low_temperature_cooking', + 'saddle_of_lamb_roast', + 'saddle_of_roebuck', + 'saddle_of_veal_low_temperature_cooking', + 'saddle_of_veal_roast', + 'saddle_of_venison', + 'salmon_fillet', + 'salmon_trout', + 'savoury_flan_puff_pastry', + 'savoury_flan_short_crust_pastry', + 'seeded_loaf', + 'shabbat_program', + 'spelt_bread', + 'sponge_base', + 'springform_tin_15cm', + 'springform_tin_20cm', + 'springform_tin_25cm', + 'steam_bake', + 'steam_cooking', + 'stollen', + 'swiss_farmhouse_bread', + 'swiss_roll', + 'tart_flambe', + 'tiger_bread', + 'top_heat', + 'trout', + 'turkey_drumsticks', + 'turkey_whole', + 'vanilla_biscuits_1_tray', + 'vanilla_biscuits_2_trays', + 'veal_fillet_low_temperature_cooking', + 'veal_fillet_roast', + 'veal_knuckle', + 'viennese_apple_strudel', + 'walnut_bread', + 'walnut_muffins', + 'white_bread_baking_tin', + 'white_bread_on_tray', + 'white_rolls', + 'yom_tov', + 'yorkshire_pudding', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'defrost', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.oven_program_phase', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Program phase', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_phase', + 'unique_id': 'DummyAppliance_12-state_program_phase', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_phase-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program phase', + 'options': list([ + 'energy_save', + 'heating_up', + 'not_running', + 'process_finished', + 'process_running', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_phase', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'heating_up', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.oven_program_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': 'Program type', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'program_type', + 'unique_id': 'DummyAppliance_12-state_program_type', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_program_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Oven Program type', + 'options': list([ + 'automatic_program', + 'cleaning_care_program', + 'maintenance_program', + 'normal_operation_mode', + 'own_program', + ]), + }), + 'context': , + 'entity_id': 'sensor.oven_program_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'own_program', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-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.oven_remaining_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': 'Remaining time', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'DummyAppliance_12-state_remaining_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-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.oven_start_in', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Start in', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start_time', + 'unique_id': 'DummyAppliance_12-state_start_time', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_start_in-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Oven Start in', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_start_in', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_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.oven_target_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': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'DummyAppliance_12-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_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.oven_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': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'DummyAppliance_12-state_temperature_1', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.oven_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Oven Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.oven_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '19.54', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.refrigerator-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -2815,6 +4904,62 @@ 'state': '0.0', }) # --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_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.washing_machine_target_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': 'Target temperature', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'target_temperature', + 'unique_id': 'Dummy_Appliance_3-state_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_states_api_push[platforms0][sensor.washing_machine_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Washing machine Target temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.washing_machine_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_sensor_states_api_push[platforms0][sensor.washing_machine_water_consumption-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/snapshots/test_switch.ambr b/tests/components/miele/snapshots/test_switch.ambr index c8ca88c5b59..769b08271a5 100644 --- a/tests/components/miele/snapshots/test_switch.ambr +++ b/tests/components/miele/snapshots/test_switch.ambr @@ -95,6 +95,54 @@ 'state': 'off', }) # --- +# name: test_switch_states[platforms0][switch.oven_power-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': None, + 'entity_id': 'switch.oven_power', + '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', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_switch_states[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -287,6 +335,54 @@ 'state': 'off', }) # --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-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': None, + 'entity_id': 'switch.oven_power', + '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', + 'platform': 'miele', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'power', + 'unique_id': 'DummyAppliance_12-poweronoff', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_states_api_push[platforms0][switch.oven_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Oven Power', + }), + 'context': , + 'entity_id': 'switch.oven_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_states_api_push[platforms0][switch.refrigerator_supercooling-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/miele/test_init.py b/tests/components/miele/test_init.py index dd3f3b95d02..cdf1a39b421 100644 --- a/tests/components/miele/test_init.py +++ b/tests/components/miele/test_init.py @@ -109,7 +109,7 @@ async def test_devices_multiple_created_count( """Test that multiple devices are created.""" await setup_integration(hass, mock_config_entry) - assert len(device_registry.devices) == 4 + assert len(device_registry.devices) == 5 async def test_device_info( @@ -200,11 +200,13 @@ async def test_setup_all_platforms( ) freezer.tick(timedelta(seconds=130)) + prev_devices = len(device_registry.devices) + async_fire_time_changed(hass) await hass.async_block_till_done() - assert len(device_registry.devices) == 6 + assert len(device_registry.devices) == prev_devices + 2 # Check a sample sensor for each new device assert hass.states.get("sensor.dishwasher").state == "in_use" - assert hass.states.get("sensor.oven_temperature").state == "175.0" + assert hass.states.get("sensor.oven_temperature_2").state == "175.0" diff --git a/tests/components/miele/test_sensor.py b/tests/components/miele/test_sensor.py index 3f66f36f556..f35404a665b 100644 --- a/tests/components/miele/test_sensor.py +++ b/tests/components/miele/test_sensor.py @@ -1,15 +1,24 @@ """Tests for miele sensor module.""" +from datetime import timedelta from unittest.mock import MagicMock +from freezegun.api import FrozenDateTimeFactory +from pymiele import MieleDevices import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.miele.const import DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_json_object_fixture, + snapshot_platform, +) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @@ -56,6 +65,184 @@ async def test_hob_sensor_states( await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) +@pytest.mark.parametrize("load_device_file", ["fridge_freezer.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_fridge_freezer_sensor_states( + hass: HomeAssistant, + mock_miele_client: MagicMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + setup_platform: None, +) -> None: + """Test sensor state.""" + + await snapshot_platform(hass, entity_registry, snapshot, setup_platform.entry_id) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_oven_temperatures_scenario( + hass: HomeAssistant, + mock_miele_client: MagicMock, + setup_platform: None, + mock_config_entry: MockConfigEntry, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Parametrized test for verifying temperature sensors for oven devices.""" + + # Initial state when the oven is and created for the first time - don't know if it supports core temperature (probe) + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 0) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 0) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 0) + + # Simulate temperature settings, no probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2150 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.5 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.5", 1) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 1) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 1) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 1) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 2) + check_sensor_state(hass, "sensor.oven_core_temperature", None, 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", None, 2) + + # Simulate temperature settings with probe temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = 8000 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + 80.0 + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0]["value_raw"] = 3000 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = 30.0 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = 2183 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = 21.83 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "21.83", 3) + check_sensor_state(hass, "sensor.oven_target_temperature", "80.0", 3) + check_sensor_state(hass, "sensor.oven_core_temperature", "22.0", 2) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "30.0", 3) + + # Simulate unsetting temperature + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["targetTemperature"][0]["value_localized"] = ( + None + ) + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_raw" + ] = -32768 + device_fixture["DummyOven"]["state"]["coreTargetTemperature"][0][ + "value_localized" + ] = None + device_fixture["DummyOven"]["state"]["temperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["temperature"][0]["value_localized"] = None + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = -32768 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = None + + freezer.tick(timedelta(seconds=130)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + check_sensor_state(hass, "sensor.oven_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_target_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_temperature", "unknown", 4) + check_sensor_state(hass, "sensor.oven_core_target_temperature", "unknown", 4) + + +def check_sensor_state( + hass: HomeAssistant, + sensor_entity: str, + expected: str, + step: int, +): + """Check the state of sensor matches the expected state.""" + + state = hass.states.get(sensor_entity) + + if expected is None: + assert state is None, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected nothing" + ) + else: + assert state is not None, f"Missing entity: {sensor_entity}" + assert state.state == expected, ( + f"[{sensor_entity}] Step {step + 1}: got {state.state}, expected {expected}" + ) + + +@pytest.mark.parametrize("load_device_file", ["oven.json"]) +@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) +async def test_temperature_sensor_registry_lookup( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_miele_client: MagicMock, + setup_platform: None, + device_fixture: MieleDevices, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that core temperature sensor is provided by the integration after looking up in entity registry.""" + + # Initial state, the oven is showing core temperature (probe) + freezer.tick(timedelta(seconds=130)) + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_raw"] = 2200 + device_fixture["DummyOven"]["state"]["coreTemperature"][0]["value_localized"] = 22.0 + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "sensor.oven_core_temperature" + + assert hass.states.get(entity_id) is not None + assert hass.states.get(entity_id).state == "22.0" + + # reload device when turned off, reporting the invalid value + mock_miele_client.get_devices.return_value = await async_load_json_object_fixture( + hass, "oven.json", DOMAIN + ) + + # unload config entry and reload to make sure that the entity is still provided + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unavailable" + + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "unknown" + + @pytest.mark.parametrize("load_device_file", ["vacuum_device.json"]) @pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)]) @pytest.mark.usefixtures("entity_registry_enabled_by_default") From 0acfb81d500ee049ab11b8bdd0d77ba84d78ef35 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 15 Jul 2025 19:53:29 +0800 Subject: [PATCH 1421/1664] Clean up YoLink entities on startup (#148718) --- homeassistant/components/yolink/__init__.py | 14 ++++ tests/components/yolink/conftest.py | 77 +++++++++++++++++++++ tests/components/yolink/test_init.py | 38 ++++++++++ 3 files changed, 129 insertions(+) create mode 100644 tests/components/yolink/conftest.py create mode 100644 tests/components/yolink/test_init.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7132fd6a414..96db2ab555a 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -165,6 +165,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN][entry.entry_id] = YoLinkHomeStore( yolink_home, device_coordinators ) + + # Clean up yolink devices which are not associated to the account anymore. + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + for device_entry in device_entries: + for identifier in device_entry.identifiers: + if ( + identifier[0] == DOMAIN + and device_coordinators.get(identifier[1]) is None + ): + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=entry.entry_id + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) async def async_yolink_unload(event) -> None: diff --git a/tests/components/yolink/conftest.py b/tests/components/yolink/conftest.py new file mode 100644 index 00000000000..2090cd57f2f --- /dev/null +++ b/tests/components/yolink/conftest.py @@ -0,0 +1,77 @@ +"""Provide common fixtures for the YoLink integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from yolink.home_manager import YoLinkHome + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.yolink.api import ConfigEntryAuth +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "12345" +CLIENT_SECRET = "6789" +DOMAIN = "yolink" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="mock_auth_manager") +def mock_auth_manager() -> Generator[MagicMock]: + """Mock the authentication manager.""" + with patch( + "homeassistant.components.yolink.api.ConfigEntryAuth", autospec=True + ) as mock_auth: + mock_auth.return_value = MagicMock(spec=ConfigEntryAuth) + yield mock_auth + + +@pytest.fixture(name="mock_yolink_home") +def mock_yolink_home() -> Generator[AsyncMock]: + """Mock YoLink home instance.""" + with patch( + "homeassistant.components.yolink.YoLinkHome", autospec=True + ) as mock_home: + mock_home.return_value = AsyncMock(spec=YoLinkHome) + yield mock_home + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry for YoLink.""" + config_entry = MockConfigEntry( + unique_id=DOMAIN, + domain=DOMAIN, + title="yolink", + data={ + "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "scope": "create", + }, + }, + options={}, + ) + config_entry.add_to_hass(hass) + return config_entry diff --git a/tests/components/yolink/test_init.py b/tests/components/yolink/test_init.py new file mode 100644 index 00000000000..11d0528dcce --- /dev/null +++ b/tests/components/yolink/test_init.py @@ -0,0 +1,38 @@ +"""Tests for the yolink integration.""" + +import pytest + +from homeassistant.components.yolink import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("setup_credentials", "mock_auth_manager", "mock_yolink_home") +async def test_device_remove_devices( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we can only remove a device that no longer exists.""" + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "stale_device_id")}, + ) + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + + assert len(device_entries) == 1 + device_entry = device_entries[0] + assert device_entry.identifiers == {(DOMAIN, "stale_device_id")} + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 0 From cd94685b7d41afcd993bff39810864e1e7ded91a Mon Sep 17 00:00:00 2001 From: Samuel Xiao <40679757+XiaoLing-git@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:55:13 +0800 Subject: [PATCH 1422/1664] Add Fan platform to Switchbot cloud (#148304) Co-authored-by: Joost Lekkerkerker --- .../components/switchbot_cloud/__init__.py | 13 +- .../components/switchbot_cloud/fan.py | 120 +++++++++++ .../components/switchbot_cloud/sensor.py | 1 + tests/components/switchbot_cloud/test_fan.py | 187 ++++++++++++++++++ 4 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/switchbot_cloud/fan.py create mode 100644 tests/components/switchbot_cloud/test_fan.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index b87a569abda..482c5c4a9e6 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -29,6 +29,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, + Platform.FAN, Platform.LOCK, Platform.SENSOR, Platform.SWITCH, @@ -51,6 +52,7 @@ class SwitchbotDevices: sensors: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) vacuums: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) locks: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) + fans: list[tuple[Device, SwitchBotCoordinator]] = field(default_factory=list) @dataclass @@ -96,7 +98,6 @@ async def make_switchbot_devices( for device in devices ] ) - return devices_data @@ -177,6 +178,16 @@ async def make_device_data( else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Battery Circulator Fan", + "Circulator Fan", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.fans.append((device, coordinator)) + devices_data.sensors.append((device, coordinator)) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" diff --git a/homeassistant/components/switchbot_cloud/fan.py b/homeassistant/components/switchbot_cloud/fan.py new file mode 100644 index 00000000000..d7cf82520ec --- /dev/null +++ b/homeassistant/components/switchbot_cloud/fan.py @@ -0,0 +1,120 @@ +"""Support for the Switchbot Battery Circulator fan.""" + +import asyncio +from typing import Any + +from switchbot_api import ( + BatteryCirculatorFanCommands, + BatteryCirculatorFanMode, + CommonCommands, +) + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudFan(data.api, device, coordinator) + for device, coordinator in data.devices.fans + ) + + +class SwitchBotCloudFan(SwitchBotCloudEntity, FanEntity): + """Representation of a SwitchBot Battery Circulator Fan.""" + + _attr_name = None + + _attr_supported_features = ( + FanEntityFeature.SET_SPEED + | FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + _attr_preset_modes = list(BatteryCirculatorFanMode) + + _attr_is_on: bool | None = None + + @property + def is_on(self) -> bool | None: + """Return true if the entity is on.""" + return self._attr_is_on + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + + power: str = self.coordinator.data["power"] + mode: str = self.coordinator.data["mode"] + fan_speed: str = self.coordinator.data["fanSpeed"] + self._attr_is_on = power == "on" + self._attr_preset_mode = mode + self._attr_percentage = int(fan_speed) + self._attr_supported_features = ( + FanEntityFeature.PRESET_MODE + | FanEntityFeature.TURN_OFF + | FanEntityFeature.TURN_ON + ) + if self.is_on and self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + self._attr_supported_features |= FanEntityFeature.SET_SPEED + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + await self.send_api_command(CommonCommands.ON) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(self.preset_mode), + ) + if self.preset_mode == BatteryCirculatorFanMode.DIRECT.value: + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(self.percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan.""" + await self.send_api_command(CommonCommands.OFF) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=str(BatteryCirculatorFanMode.DIRECT.value), + ) + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_SPEED, + parameters=str(percentage), + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.send_api_command( + command=BatteryCirculatorFanCommands.SET_WIND_MODE, + parameters=preset_mode, + ) + await asyncio.sleep(5) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index f93df234289..75e994b484e 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -91,6 +91,7 @@ CO2_DESCRIPTION = SensorEntityDescription( SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = { "Bot": (BATTERY_DESCRIPTION,), + "Battery Circulator Fan": (BATTERY_DESCRIPTION,), "Meter": ( TEMPERATURE_DESCRIPTION, HUMIDITY_DESCRIPTION, diff --git a/tests/components/switchbot_cloud/test_fan.py b/tests/components/switchbot_cloud/test_fan.py new file mode 100644 index 00000000000..4a9eb527818 --- /dev/null +++ b/tests/components/switchbot_cloud/test_fan.py @@ -0,0 +1,187 @@ +"""Test for the Switchbot Battery Circulator Fan.""" + +from unittest.mock import patch + +from switchbot_api import Device, SwitchBotAPI + +from homeassistant.components.fan import ( + ATTR_PERCENTAGE, + ATTR_PRESET_MODE, + DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + SERVICE_SET_PRESET_MODE, + SERVICE_TURN_ON, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + STATE_OFF, + STATE_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +async def test_coordinator_data_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test coordinator data is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + None, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_UNKNOWN + + +async def test_turn_on(hass: HomeAssistant, mock_list_devices, mock_get_status) -> None: + """Test turning on the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + +async def test_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test turning off the fan.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called() + + state = hass.states.get(entity_id) + assert state.state == STATE_OFF + + +async def test_set_percentage( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set percentage.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "off", "mode": "direct", "fanSpeed": "5"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: entity_id, ATTR_PERCENTAGE: 5}, + blocking=True, + ) + mock_send_command.assert_called() + + +async def test_set_preset_mode( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test set preset mode.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="battery-fan-id-1", + deviceName="battery-fan-1", + deviceType="Battery Circulator Fan", + hubDeviceId="test-hub-id", + ), + ] + mock_get_status.side_effect = [ + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "direct", "fanSpeed": "0"}, + {"power": "on", "mode": "baby", "fanSpeed": "0"}, + ] + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "fan.battery_fan_1" + state = hass.states.get(entity_id) + + assert state.state == STATE_ON + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + FAN_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: "baby"}, + blocking=True, + ) + mock_send_command.assert_called_once() From b89b248b4c7ceaabbfadeca24b05ea39d72bc124 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:18:14 +0200 Subject: [PATCH 1423/1664] Add tuya snapshots for qxj category (#148802) --- tests/components/tuya/__init__.py | 8 + .../qxj_temp_humidity_external_probe.json | 65 +++ .../tuya/fixtures/qxj_weather_station.json | 412 +++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 479 ++++++++++++++++++ 4 files changed, 964 insertions(+) create mode 100644 tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json create mode 100644 tests/components/tuya/fixtures/qxj_weather_station.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 09606c7e116..c8f54fa275d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -75,6 +75,14 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qxj_temp_humidity_external_probe": [ + # https://github.com/home-assistant/core/issues/136472 + Platform.SENSOR, + ], + "qxj_weather_station": [ + # https://github.com/orgs/home-assistant/discussions/318 + Platform.SENSOR, + ], "rqbj_gas_sensor": [ # https://github.com/orgs/home-assistant/discussions/100 Platform.BINARY_SENSOR, diff --git a/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json new file mode 100644 index 00000000000..caccb0b9234 --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_temp_humidity_external_probe.json @@ -0,0 +1,65 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1708196692712PHOeqy", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff00f6abe0563b284t77p", + "name": "Frysen", + "category": "qxj", + "product_id": "is2indt9nlth6esa", + "product_name": "T & H Sensor with external probe", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-27T15:19:27+00:00", + "create_time": "2025-01-27T15:19:27+00:00", + "update_time": "2025-01-27T15:19:27+00:00", + "function": {}, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -99, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 1200, + "scale": 1, + "step": 1 + } + } + }, + "status": { + "temp_current": 222, + "humidity_value": 38, + "battery_state": "high", + "temp_current_external": -130 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/qxj_weather_station.json b/tests/components/tuya/fixtures/qxj_weather_station.json new file mode 100644 index 00000000000..c52086213fd --- /dev/null +++ b/tests/components/tuya/fixtures/qxj_weather_station.json @@ -0,0 +1,412 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1751921699759JsVujI", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf84c743a84eb2c8abeurz", + "name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "category": "qxj", + "product_id": "fsea1lat3vuktbt6", + "product_name": "BR 7-in-1 WLAN Wetterstation Anthrazit", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-07T17:43:41+00:00", + "create_time": "2025-07-07T17:43:41+00:00", + "update_time": "2025-07-07T17:43:41+00:00", + "function": { + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + } + }, + "status_range": { + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "battery_state": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + }, + "windspeed_unit_convert": { + "type": "Enum", + "value": { + "range": ["mph"] + } + }, + "pressure_unit_convert": { + "type": "Enum", + "value": { + "range": ["hpa", "inhg", "mmhg"] + } + }, + "rain_unit_convert": { + "type": "Enum", + "value": { + "range": ["mm", "inch"] + } + }, + "bright_unit_convert": { + "type": "Enum", + "value": { + "range": ["lux", "fc", "wm2"] + } + }, + "fault_type": { + "type": "Enum", + "value": { + "range": [ + "normal", + "ch1_offline", + "ch2_offline", + "ch3_offline", + "offline" + ] + } + }, + "battery_status": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_1": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_2": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "battery_state_3": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "temp_current_external": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_1": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_1": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_2": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_2": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current_external_3": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "humidity_outdoor_3": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "atmospheric_pressture": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 3000, + "max": 12000, + "scale": 1, + "step": 1 + } + }, + "pressure_drop": { + "type": "Integer", + "value": { + "unit": "hPa", + "min": 0, + "max": 15, + "scale": 0, + "step": 1 + } + }, + "windspeed_avg": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "windspeed_gust": { + "type": "Integer", + "value": { + "unit": "m/s", + "min": 0, + "max": 700, + "scale": 1, + "step": 1 + } + }, + "wind_direct": { + "type": "Enum", + "value": { + "range": [ + "north", + "north_north_east", + "north_east", + "east_north_east", + "east", + "east_south_east", + "south_east", + "south_south_east", + "south", + "south_south_west", + "south_west", + "west_south_west", + "west", + "west_north_west", + "north_west", + "north_north_west" + ] + } + }, + "rain_24h": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 1000000, + "scale": 3, + "step": 1 + } + }, + "rain_rate": { + "type": "Integer", + "value": { + "unit": "mm", + "min": 0, + "max": 999999, + "scale": 3, + "step": 1 + } + }, + "uv_index": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 180, + "scale": 1, + "step": 1 + } + }, + "bright_value": { + "type": "Integer", + "value": { + "unit": "lux", + "min": 0, + "max": 238000, + "scale": 0, + "step": 100 + } + }, + "dew_point_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -400, + "max": 800, + "scale": 1, + "step": 1 + } + }, + "feellike_temp": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "heat_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": 260, + "max": 500, + "scale": 1, + "step": 1 + } + }, + "windchill_index": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -650, + "max": 600, + "scale": 1, + "step": 1 + } + }, + "com_index": { + "type": "Enum", + "value": { + "range": ["moist", "dry", "comfortable"] + } + } + }, + "status": { + "temp_current": 240, + "humidity_value": 52, + "battery_state": "high", + "temp_unit_convert": "c", + "windspeed_unit_convert": "m_s", + "pressure_unit_convert": "hpa", + "rain_unit_convert": "mm", + "bright_unit_convert": "lux", + "fault_type": "normal", + "battery_status": "low", + "battery_state_1": "high", + "battery_state_2": "high", + "battery_state_3": "low", + "temp_current_external": -400, + "humidity_outdoor": 0, + "temp_current_external_1": 193, + "humidity_outdoor_1": 99, + "temp_current_external_2": 252, + "humidity_outdoor_2": 0, + "temp_current_external_3": -400, + "humidity_outdoor_3": 0, + "atmospheric_pressture": 10040, + "pressure_drop": 0, + "windspeed_avg": 0, + "windspeed_gust": 0, + "wind_direct": "none", + "rain_24h": 0, + "rain_rate": 0, + "uv_index": 0, + "bright_value": 0, + "dew_point_temp": -400, + "feellike_temp": -650, + "heat_index": 260, + "windchill_index": -650, + "com_index": "none" + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f63c75567ef..8cf51062a73 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1265,6 +1265,485 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-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.frysen_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bff00f6abe0563b284t77pbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Frysen Battery state', + }), + 'context': , + 'entity_id': 'sensor.frysen_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bff00f6abe0563b284t77phumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Frysen Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.frysen_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '38.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_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.frysen_probe_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': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-13.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_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.frysen_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bff00f6abe0563b284t77ptemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_temp_humidity_external_probe][sensor.frysen_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Frysen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.frysen_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '22.2', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-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.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + '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': 'Battery state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_state', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbattery_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Battery state', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_battery_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_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.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '52.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_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.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Illuminance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'illuminance', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurzbright_value', + 'unit_of_measurement': 'lx', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'illuminance', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Illuminance', + 'state_class': , + 'unit_of_measurement': 'lx', + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_illuminance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_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.br_7_in_1_wlan_wetterstation_anthrazit_probe_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': 'Probe temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_external', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current_external', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-40.0', + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_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.br_7_in_1_wlan_wetterstation_anthrazit_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.bf84c743a84eb2c8abeurztemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[qxj_weather_station][sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'BR 7-in-1 WLAN Wetterstation Anthrazit Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.br_7_in_1_wlan_wetterstation_anthrazit_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24.0', + }) +# --- # name: test_platform_setup_and_discovery[rqbj_gas_sensor][sensor.gas_sensor_gas-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From c0585611623798a82e6542a4540cbcfbe7494cfe Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 09:53:01 -0400 Subject: [PATCH 1424/1664] Add initalize for abstract template entities (#147504) --- .../template/alarm_control_panel.py | 22 ++--------- .../components/template/binary_sensor.py | 16 ++------ homeassistant/components/template/button.py | 10 ++++- homeassistant/components/template/cover.py | 11 ++---- homeassistant/components/template/entity.py | 22 ++++++++++- homeassistant/components/template/fan.py | 11 ++---- homeassistant/components/template/image.py | 10 ++++- homeassistant/components/template/light.py | 11 ++---- homeassistant/components/template/lock.py | 5 ++- homeassistant/components/template/number.py | 16 ++++---- homeassistant/components/template/select.py | 11 ++---- homeassistant/components/template/sensor.py | 16 ++------ homeassistant/components/template/switch.py | 23 +++--------- .../components/template/template_entity.py | 37 ++++++------------- .../components/template/trigger_entity.py | 2 +- homeassistant/components/template/vacuum.py | 11 ++---- homeassistant/components/template/weather.py | 9 ++--- tests/components/template/test_entity.py | 2 +- tests/components/template/test_sensor.py | 4 +- .../template/test_template_entity.py | 2 +- 20 files changed, 106 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a308d55e443..97896e08a68 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -32,8 +32,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -42,7 +40,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import Script from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -213,6 +211,8 @@ class AbstractTemplateAlarmControlPanel( ): """Representation of a templated Alarm Control Panel features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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 @@ -363,12 +363,8 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP unique_id: str | None, ) -> None: """Initialize the panel.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateAlarmControlPanel.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -379,11 +375,6 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -434,11 +425,6 @@ class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControl self.add_script(action_id, action_config, name, DOMAIN) self._attr_supported_features |= supported_feature - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 6d41a5804b6..caac43712a7 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -39,8 +39,6 @@ from homeassistant.const import ( from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -51,7 +49,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_AVAILABILITY_TEMPLATE from .helpers import async_setup_template_platform from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity @@ -161,6 +159,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) """A virtual binary sensor that triggers from another sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -169,11 +168,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) unique_id: str | None, ) -> None: """Initialize the Template binary sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + TemplateEntity.__init__(self, hass, config, unique_id) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] @@ -182,10 +177,6 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) self._delay_on_raw = config.get(CONF_DELAY_ON) self._delay_off = None self._delay_off_raw = config.get(CONF_DELAY_OFF) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_added_to_hass(self) -> None: """Restore state.""" @@ -258,6 +249,7 @@ class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity) class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = BINARY_SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index c52e2dae5a0..397fc5f4174 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -3,12 +3,14 @@ from __future__ import annotations import logging +from typing import TYPE_CHECKING import voluptuous as vol from homeassistant.components.button import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BUTTON_DOMAIN, + ENTITY_ID_FORMAT, ButtonEntity, ) from homeassistant.config_entries import ConfigEntry @@ -84,6 +86,7 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -92,8 +95,11 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): unique_id: str | None, ) -> None: """Initialize the button.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + + if TYPE_CHECKING: + assert self._attr_name is not None + # Scripts can be an empty list, therefore we need to check for None if (action := config.get(CONF_PRESS)) is not None: self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 9d6391d80c9..bceac7811f4 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -32,12 +32,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -162,6 +161,8 @@ async def async_setup_platform( class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity): """Representation of a template cover features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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 @@ -397,12 +398,8 @@ class StateCoverEntity(TemplateEntity, AbstractTemplateCover): unique_id, ) -> None: """Initialize the Template cover.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateCover.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 3617d9acdee..a97a5ac6571 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -3,21 +3,39 @@ from collections.abc import Sequence from typing import Any +from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.device import async_device_info_to_link_from_device_id +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.typing import ConfigType + +from .const import CONF_OBJECT_ID class AbstractTemplateEntity(Entity): """Actions linked to a template entity.""" - def __init__(self, hass: HomeAssistant) -> None: + _entity_id_format: str + + def __init__(self, hass: HomeAssistant, config: ConfigType) -> None: """Initialize the entity.""" self.hass = hass self._action_scripts: dict[str, Script] = {} + if self.hass: + 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 + ) + + self._attr_device_info = async_device_info_to_link_from_device_id( + self.hass, + config.get(CONF_DEVICE_ID), + ) + @property def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 95086375f4b..34faba353d0 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -154,6 +153,8 @@ async def async_setup_platform( class AbstractTemplateFan(AbstractTemplateEntity, FanEntity): """Representation of a template fan features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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 @@ -436,12 +437,8 @@ class StateFanEntity(TemplateEntity, AbstractTemplateFan): unique_id, ) -> None: """Initialize the fan.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateFan.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 5f7f06faf4f..ed7093cfcdb 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -7,7 +7,11 @@ from typing import Any import voluptuous as vol -from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN, ImageEntity +from homeassistant.components.image import ( + DOMAIN as IMAGE_DOMAIN, + ENTITY_ID_FORMAT, + ImageEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -91,6 +95,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): _attr_should_poll = False _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -99,7 +104,7 @@ class StateImageEntity(TemplateEntity, ImageEntity): unique_id: str | None, ) -> None: """Initialize the image.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] self._attr_device_info = async_device_info_to_link_from_device_id( @@ -135,6 +140,7 @@ class TriggerImageEntity(TriggerEntity, ImageEntity): """Image entity based on trigger data.""" _attr_image_url: str | None = None + _entity_id_format = ENTITY_ID_FORMAT domain = IMAGE_DOMAIN extra_template_keys = (CONF_URL,) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 438c295ecd5..fb97d95db3d 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -43,13 +43,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import color as color_util from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform from .template_entity import ( @@ -215,6 +214,8 @@ async def async_setup_platform( class AbstractTemplateLight(AbstractTemplateEntity, LightEntity): """Representation of a template lights features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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__( # pylint: disable=super-init-not-called @@ -893,12 +894,8 @@ class StateLightEntity(TemplateEntity, AbstractTemplateLight): unique_id: str | None, ) -> None: """Initialize the light.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLight.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 20bc098d130..581a037c3d7 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.lock import ( DOMAIN as LOCK_DOMAIN, + ENTITY_ID_FORMAT, PLATFORM_SCHEMA as LOCK_PLATFORM_SCHEMA, LockEntity, LockEntityFeature, @@ -104,6 +105,8 @@ async def async_setup_platform( class AbstractTemplateLock(AbstractTemplateEntity, LockEntity): """Representation of a template lock features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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 @@ -283,7 +286,7 @@ class StateLockEntity(TemplateEntity, AbstractTemplateLock): unique_id: str | None, ) -> None: """Initialize the lock.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateLock.__init__(self, config) name = self._attr_name if TYPE_CHECKING: diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index fa1e2790a9d..e0b8e7594ce 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.components.number import ( DEFAULT_MIN_VALUE, DEFAULT_STEP, DOMAIN as NUMBER_DOMAIN, + ENTITY_ID_FORMAT, NumberEntity, ) from homeassistant.config_entries import ConfigEntry @@ -25,7 +26,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -115,6 +115,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): """Representation of a template number.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -123,8 +124,10 @@ class StateNumberEntity(TemplateEntity, NumberEntity): unique_id: str | None, ) -> None: """Initialize the number.""" - super().__init__(hass, config=config, unique_id=unique_id) - assert self._attr_name is not None + TemplateEntity.__init__(self, hass, config, unique_id) + if TYPE_CHECKING: + assert self._attr_name is not None + self._value_template = config[CONF_STATE] self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN) @@ -136,10 +139,6 @@ class StateNumberEntity(TemplateEntity, NumberEntity): self._attr_native_step = DEFAULT_STEP self._attr_native_min_value = DEFAULT_MIN_VALUE self._attr_native_max_value = DEFAULT_MAX_VALUE - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _async_setup_templates(self) -> None: @@ -188,6 +187,7 @@ class StateNumberEntity(TemplateEntity, NumberEntity): class TriggerNumberEntity(TriggerEntity, NumberEntity): """Number entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = NUMBER_DOMAIN extra_template_keys = ( CONF_STATE, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 55b5c7375f8..d5abf7033a9 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -11,13 +11,13 @@ from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, DOMAIN as SELECT_DOMAIN, + ENTITY_ID_FORMAT, SelectEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -93,6 +93,8 @@ async def async_setup_entry( class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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 @@ -132,7 +134,7 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): unique_id: str | None, ) -> None: """Initialize the select.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateSelect.__init__(self, config) name = self._attr_name @@ -142,11 +144,6 @@ class TemplateSelect(TemplateEntity, AbstractTemplateSelect): if (select_option := config.get(CONF_SELECT_OPTION)) is not None: self.add_script(CONF_SELECT_OPTION, select_option, name, DOMAIN) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - @callback def _async_setup_templates(self) -> None: """Set up templates.""" diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 11fe279fdfb..6fc0588d9c7 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -44,8 +44,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -55,7 +53,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator -from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, CONF_OBJECT_ID +from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE from .helpers import async_setup_template_platform from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity from .trigger_entity import TriggerEntity @@ -199,6 +197,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): """Representation of a Template Sensor.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -207,7 +206,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + super().__init__(hass, config, unique_id) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state_class = config.get(CONF_STATE_CLASS) @@ -215,14 +214,6 @@ class StateSensorEntity(TemplateEntity, SensorEntity): self._attr_last_reset_template: template.Template | None = config.get( ATTR_LAST_RESET ) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) @callback def _async_setup_templates(self) -> None: @@ -266,6 +257,7 @@ class StateSensorEntity(TemplateEntity, SensorEntity): class TriggerSensorEntity(TriggerEntity, RestoreSensor): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index e2ccb5a8a82..7c1abd6d852 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -30,8 +30,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector, template -from homeassistant.helpers.device import async_device_info_to_link_from_device_id -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -40,7 +38,7 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator -from .const import CONF_OBJECT_ID, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN +from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN from .helpers import async_setup_template_platform from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, @@ -154,6 +152,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -162,11 +161,8 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): unique_id: str | None, ) -> None: """Initialize the Template switch.""" - super().__init__(hass, config=config, unique_id=unique_id) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) + super().__init__(hass, config, unique_id) + name = self._attr_name if TYPE_CHECKING: assert name is not None @@ -180,10 +176,6 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): self._state: bool | None = False self._attr_assumed_state = self._template is None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @callback def _update_state(self, result): @@ -246,6 +238,7 @@ class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): """Switch entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = SWITCH_DOMAIN def __init__( @@ -256,6 +249,7 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): ) -> None: """Initialize the entity.""" super().__init__(hass, coordinator, config) + name = self._rendered.get(CONF_NAME, DEFAULT_NAME) self._template = config.get(CONF_STATE) if on_action := config.get(CONF_TURN_ON): @@ -268,11 +262,6 @@ class TriggerSwitchEntity(TriggerEntity, SwitchEntity, RestoreEntity): self._to_render_simple.append(CONF_STATE) self._parse_result.add(CONF_STATE) - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) - async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index e404821e651..b5081189cf3 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -240,17 +240,11 @@ class TemplateEntity(AbstractTemplateEntity): def __init__( self, hass: HomeAssistant, - *, - availability_template: Template | None = None, - icon_template: Template | None = None, - entity_picture_template: Template | None = None, - attribute_templates: dict[str, Template] | None = None, - config: ConfigType | None = None, - fallback_name: str | None = None, - unique_id: str | None = None, + config: ConfigType, + unique_id: str | None, ) -> None: """Template Entity.""" - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} @@ -269,22 +263,13 @@ class TemplateEntity(AbstractTemplateEntity): | None ) = None self._run_variables: ScriptVariables | dict - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - self._run_variables = {} - self._blueprint_inputs = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - self._run_variables = config.get(CONF_VARIABLES, {}) - self._blueprint_inputs = config.get("raw_blueprint_inputs") + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + self._run_variables = config.get(CONF_VARIABLES, {}) + self._blueprint_inputs = config.get("raw_blueprint_inputs") class DummyState(State): """None-state for template entities not yet added to the state machine.""" @@ -302,7 +287,7 @@ class TemplateEntity(AbstractTemplateEntity): variables = {"this": DummyState()} # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name + self._attr_name = None if self._friendly_name_template: with contextlib.suppress(TemplateError): self._attr_name = self._friendly_name_template.async_render( diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 4565e86843a..66c57eb2aab 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -30,7 +30,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module """Initialize the entity.""" CoordinatorEntity.__init__(self, coordinator) TriggerBaseEntity.__init__(self, hass, config) - AbstractTemplateEntity.__init__(self, hass) + AbstractTemplateEntity.__init__(self, hass, config) self._state_render_error = False diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index d9c416f4863..143eb837bb5 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -34,11 +34,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_OBJECT_ID, DOMAIN +from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity from .helpers import async_setup_template_platform @@ -147,6 +146,8 @@ async def async_setup_platform( class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity): """Representation of a template vacuum features.""" + _entity_id_format = ENTITY_ID_FORMAT + # 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 @@ -302,12 +303,8 @@ class TemplateStateVacuumEntity(TemplateEntity, AbstractTemplateVacuum): unique_id, ) -> None: """Initialize the vacuum.""" - TemplateEntity.__init__(self, hass, config=config, unique_id=unique_id) + TemplateEntity.__init__(self, hass, config, unique_id) AbstractTemplateVacuum.__init__(self, config) - if (object_id := config.get(CONF_OBJECT_ID)) is not None: - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, object_id, hass=hass - ) name = self._attr_name if TYPE_CHECKING: assert name is not None diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 66ead388d5d..671a2ad0bac 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -35,7 +35,6 @@ from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -153,6 +152,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" _attr_should_poll = False + _entity_id_format = ENTITY_ID_FORMAT def __init__( self, @@ -161,9 +161,8 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): unique_id: str | None, ) -> None: """Initialize the Template weather.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config, unique_id) - name = self._attr_name self._condition_template = config[CONF_CONDITION_TEMPLATE] self._temperature_template = config[CONF_TEMPERATURE_TEMPLATE] self._humidity_template = config[CONF_HUMIDITY_TEMPLATE] @@ -191,8 +190,6 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) - self._condition = None self._temperature = None self._humidity = None @@ -486,6 +483,7 @@ class WeatherExtraStoredData(ExtraStoredData): class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): """Sensor entity based on trigger data.""" + _entity_id_format = ENTITY_ID_FORMAT domain = WEATHER_DOMAIN extra_template_keys = ( CONF_CONDITION_TEMPLATE, @@ -501,6 +499,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): ) -> None: """Initialize.""" super().__init__(hass, coordinator, config) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 67a85839982..4a6940c2813 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None) + entity = abstract_entity.AbstractTemplateEntity(None, {}) with pytest.raises(NotImplementedError): _ = entity.referenced_blueprint diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index e89e98601d6..9aba8511192 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1141,7 +1141,7 @@ async def test_duplicate_templates(hass: HomeAssistant) -> None: "unique_id": "listening-test-event", "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "unique_id": "hello_name-id", "device_class": "battery", @@ -1360,7 +1360,7 @@ async def test_trigger_conditional_entity_invalid_condition( { "trigger": {"platform": "event", "event_type": "test_event"}, "sensors": { - "hello": { + "hello_name": { "friendly_name": "Hello Name", "value_template": "{{ trigger.event.data.beer }}", "entity_picture_template": "{{ '/local/dogs.png' }}", diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index d66fc2710c9..b743f7e2d9f 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,7 +9,7 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None) + entity = template_entity.TemplateEntity(None, {}, "something_unique") with pytest.raises(ValueError, match="^hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello")) From 087a938a7d2194f9c34c3c8bce1a26b1040cfe18 Mon Sep 17 00:00:00 2001 From: Myles Eftos Date: Wed, 16 Jul 2025 00:32:59 +1000 Subject: [PATCH 1425/1664] Add forecast service to amberelectric (#144848) Co-authored-by: G Johansson --- .../components/amberelectric/__init__.py | 13 +- .../components/amberelectric/const.py | 12 +- .../components/amberelectric/coordinator.py | 25 +- .../components/amberelectric/helpers.py | 25 ++ .../components/amberelectric/icons.json | 5 + .../components/amberelectric/sensor.py | 8 +- .../components/amberelectric/services.py | 121 ++++++++++ .../components/amberelectric/services.yaml | 16 ++ .../components/amberelectric/strings.json | 58 ++++- tests/components/amberelectric/__init__.py | 12 + tests/components/amberelectric/conftest.py | 179 +++++++++++++- tests/components/amberelectric/helpers.py | 150 +++++++++++- .../amberelectric/test_coordinator.py | 28 +-- .../components/amberelectric/test_helpers.py | 17 ++ tests/components/amberelectric/test_sensor.py | 225 +++++++----------- .../components/amberelectric/test_services.py | 202 ++++++++++++++++ 16 files changed, 879 insertions(+), 217 deletions(-) create mode 100644 homeassistant/components/amberelectric/helpers.py create mode 100644 homeassistant/components/amberelectric/services.py create mode 100644 homeassistant/components/amberelectric/services.yaml create mode 100644 tests/components/amberelectric/test_helpers.py create mode 100644 tests/components/amberelectric/test_services.py diff --git a/homeassistant/components/amberelectric/__init__.py b/homeassistant/components/amberelectric/__init__.py index 9eab6f42ad3..06641327946 100644 --- a/homeassistant/components/amberelectric/__init__.py +++ b/homeassistant/components/amberelectric/__init__.py @@ -2,11 +2,22 @@ import amberelectric +from homeassistant.components.sensor import ConfigType from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv -from .const import CONF_SITE_ID, PLATFORMS +from .const import CONF_SITE_ID, DOMAIN, PLATFORMS from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .services import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Amber component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, entry: AmberConfigEntry) -> bool: diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index 56324628ed6..bdb9aa3186c 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -1,14 +1,24 @@ """Amber Electric Constants.""" import logging +from typing import Final from homeassistant.const import Platform -DOMAIN = "amberelectric" +DOMAIN: Final = "amberelectric" CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" +ATTR_CONFIG_ENTRY_ID = "config_entry_id" +ATTR_CHANNEL_TYPE = "channel_type" + ATTRIBUTION = "Data provided by Amber Electric" LOGGER = logging.getLogger(__package__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] + +SERVICE_GET_FORECASTS = "get_forecasts" + +GENERAL_CHANNEL = "general" +CONTROLLED_LOAD_CHANNEL = "controlled_load" +FEED_IN_CHANNEL = "feed_in" diff --git a/homeassistant/components/amberelectric/coordinator.py b/homeassistant/components/amberelectric/coordinator.py index 1edf64ba0d6..a1efef26aae 100644 --- a/homeassistant/components/amberelectric/coordinator.py +++ b/homeassistant/components/amberelectric/coordinator.py @@ -10,7 +10,6 @@ from amberelectric.models.actual_interval import ActualInterval from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.rest import ApiException from homeassistant.config_entries import ConfigEntry @@ -18,6 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import LOGGER +from .helpers import normalize_descriptor type AmberConfigEntry = ConfigEntry[AmberUpdateCoordinator] @@ -49,27 +49,6 @@ def is_feed_in(interval: ActualInterval | CurrentInterval | ForecastInterval) -> return interval.channel_type == ChannelType.FEEDIN -def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: - """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" - if descriptor is None: - return None - if descriptor.value == "spike": - return "spike" - if descriptor.value == "high": - return "high" - if descriptor.value == "neutral": - return "neutral" - if descriptor.value == "low": - return "low" - if descriptor.value == "veryLow": - return "very_low" - if descriptor.value == "extremelyLow": - return "extremely_low" - if descriptor.value == "negative": - return "negative" - return None - - class AmberUpdateCoordinator(DataUpdateCoordinator): """AmberUpdateCoordinator - In charge of downloading the data for a site, which all the sensors read.""" @@ -103,7 +82,7 @@ class AmberUpdateCoordinator(DataUpdateCoordinator): "grid": {}, } try: - data = self._api.get_current_prices(self.site_id, next=48) + data = self._api.get_current_prices(self.site_id, next=288) intervals = [interval.actual_instance for interval in data] except ApiException as api_exception: raise UpdateFailed("Missing price data, skipping update") from api_exception diff --git a/homeassistant/components/amberelectric/helpers.py b/homeassistant/components/amberelectric/helpers.py new file mode 100644 index 00000000000..c383c21f276 --- /dev/null +++ b/homeassistant/components/amberelectric/helpers.py @@ -0,0 +1,25 @@ +"""Formatting helpers used to convert things.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +DESCRIPTOR_MAP: dict[str, str] = { + PriceDescriptor.SPIKE: "spike", + PriceDescriptor.HIGH: "high", + PriceDescriptor.NEUTRAL: "neutral", + PriceDescriptor.LOW: "low", + PriceDescriptor.VERYLOW: "very_low", + PriceDescriptor.EXTREMELYLOW: "extremely_low", + PriceDescriptor.NEGATIVE: "negative", +} + + +def normalize_descriptor(descriptor: PriceDescriptor | None) -> str | None: + """Return the snake case versions of descriptor names. Returns None if the name is not recognized.""" + if descriptor in DESCRIPTOR_MAP: + return DESCRIPTOR_MAP[descriptor] + return None + + +def format_cents_to_dollars(cents: float) -> float: + """Return a formatted conversion from cents to dollars.""" + return round(cents / 100, 2) diff --git a/homeassistant/components/amberelectric/icons.json b/homeassistant/components/amberelectric/icons.json index 7dd6ae3217c..a2d0a0a5486 100644 --- a/homeassistant/components/amberelectric/icons.json +++ b/homeassistant/components/amberelectric/icons.json @@ -22,5 +22,10 @@ } } } + }, + "services": { + "get_forecasts": { + "service": "mdi:transmission-tower" + } } } diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py index 7276ddb26a5..f7a61bea5a5 100644 --- a/homeassistant/components/amberelectric/sensor.py +++ b/homeassistant/components/amberelectric/sensor.py @@ -23,16 +23,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTRIBUTION -from .coordinator import AmberConfigEntry, AmberUpdateCoordinator, normalize_descriptor +from .coordinator import AmberConfigEntry, AmberUpdateCoordinator +from .helpers import format_cents_to_dollars, normalize_descriptor UNIT = f"{CURRENCY_DOLLAR}/{UnitOfEnergy.KILO_WATT_HOUR}" -def format_cents_to_dollars(cents: float) -> float: - """Return a formatted conversion from cents to dollars.""" - return round(cents / 100, 2) - - def friendly_channel_type(channel_type: str) -> str: """Return a human readable version of the channel type.""" if channel_type == "controlled_load": diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py new file mode 100644 index 00000000000..074a2f0ac88 --- /dev/null +++ b/homeassistant/components/amberelectric/services.py @@ -0,0 +1,121 @@ +"""Amber Electric Service class.""" + +from amberelectric.models.channel import ChannelType +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.selector import ConfigEntrySelector +from homeassistant.util.json import JsonValueType + +from .const import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, + CONTROLLED_LOAD_CHANNEL, + DOMAIN, + FEED_IN_CHANNEL, + GENERAL_CHANNEL, + SERVICE_GET_FORECASTS, +) +from .coordinator import AmberConfigEntry +from .helpers import format_cents_to_dollars, normalize_descriptor + +GET_FORECASTS_SCHEMA = vol.Schema( + { + ATTR_CONFIG_ENTRY_ID: ConfigEntrySelector({"integration": DOMAIN}), + ATTR_CHANNEL_TYPE: vol.In( + [GENERAL_CHANNEL, CONTROLLED_LOAD_CHANNEL, FEED_IN_CHANNEL] + ), + } +) + + +def async_get_entry(hass: HomeAssistant, config_entry_id: str) -> AmberConfigEntry: + """Get the Amber config entry.""" + if not (entry := hass.config_entries.async_get_entry(config_entry_id)): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="integration_not_found", + translation_placeholders={"target": config_entry_id}, + ) + if entry.state is not ConfigEntryState.LOADED: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="not_loaded", + translation_placeholders={"target": entry.title}, + ) + return entry + + +def get_forecasts(channel_type: str, data: dict) -> list[JsonValueType]: + """Return an array of forecasts.""" + results: list[JsonValueType] = [] + + if channel_type not in data["forecasts"]: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="channel_not_found", + translation_placeholders={"channel_type": channel_type}, + ) + + intervals = data["forecasts"][channel_type] + + for interval in intervals: + datum = {} + datum["duration"] = interval.duration + datum["date"] = interval.var_date.isoformat() + datum["nem_date"] = interval.nem_time.isoformat() + datum["per_kwh"] = format_cents_to_dollars(interval.per_kwh) + if interval.channel_type == ChannelType.FEEDIN: + datum["per_kwh"] = datum["per_kwh"] * -1 + datum["spot_per_kwh"] = format_cents_to_dollars(interval.spot_per_kwh) + datum["start_time"] = interval.start_time.isoformat() + datum["end_time"] = interval.end_time.isoformat() + datum["renewables"] = round(interval.renewables) + datum["spike_status"] = interval.spike_status.value + datum["descriptor"] = normalize_descriptor(interval.descriptor) + + if interval.range is not None: + datum["range_min"] = format_cents_to_dollars(interval.range.min) + datum["range_max"] = format_cents_to_dollars(interval.range.max) + + if interval.advanced_price is not None: + multiplier = -1 if interval.channel_type == ChannelType.FEEDIN else 1 + datum["advanced_price_low"] = multiplier * format_cents_to_dollars( + interval.advanced_price.low + ) + datum["advanced_price_predicted"] = multiplier * format_cents_to_dollars( + interval.advanced_price.predicted + ) + datum["advanced_price_high"] = multiplier * format_cents_to_dollars( + interval.advanced_price.high + ) + + results.append(datum) + + return results + + +def setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Amber integration.""" + + async def handle_get_forecasts(call: ServiceCall) -> ServiceResponse: + channel_type = call.data[ATTR_CHANNEL_TYPE] + entry = async_get_entry(hass, call.data[ATTR_CONFIG_ENTRY_ID]) + coordinator = entry.runtime_data + forecasts = get_forecasts(channel_type, coordinator.data) + return {"forecasts": forecasts} + + hass.services.async_register( + DOMAIN, + SERVICE_GET_FORECASTS, + handle_get_forecasts, + GET_FORECASTS_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/amberelectric/services.yaml b/homeassistant/components/amberelectric/services.yaml new file mode 100644 index 00000000000..89a7027fee0 --- /dev/null +++ b/homeassistant/components/amberelectric/services.yaml @@ -0,0 +1,16 @@ +get_forecasts: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: amberelectric + channel_type: + required: true + selector: + select: + options: + - general + - controlled_load + - feed_in + translation_key: channel_type diff --git a/homeassistant/components/amberelectric/strings.json b/homeassistant/components/amberelectric/strings.json index 684a5a2a0cc..f9eba4a1f27 100644 --- a/homeassistant/components/amberelectric/strings.json +++ b/homeassistant/components/amberelectric/strings.json @@ -1,25 +1,61 @@ { "config": { + "error": { + "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", + "no_site": "No site provided", + "unknown_error": "[%key:common::config_flow::error::unknown%]" + }, "step": { + "site": { + "data": { + "site_id": "Site NMI", + "site_name": "Site name" + }, + "description": "Select the NMI of the site you would like to add" + }, "user": { "data": { "api_token": "[%key:common::config_flow::data::api_token%]", "site_id": "Site ID" }, "description": "Go to {api_url} to generate an API key" - }, - "site": { - "data": { - "site_id": "Site NMI", - "site_name": "Site Name" - }, - "description": "Select the NMI of the site you would like to add" } + } + }, + "services": { + "get_forecasts": { + "name": "Get price forecasts", + "description": "Retrieves price forecasts from Amber Electric for a site.", + "fields": { + "config_entry_id": { + "description": "The config entry of the site to get forecasts for.", + "name": "Config entry" + }, + "channel_type": { + "name": "Channel type", + "description": "The channel to get forecasts for." + } + } + } + }, + "exceptions": { + "integration_not_found": { + "message": "Config entry \"{target}\" not found in registry." }, - "error": { - "invalid_api_token": "[%key:common::config_flow::error::invalid_api_key%]", - "no_site": "No site provided", - "unknown_error": "[%key:common::config_flow::error::unknown%]" + "not_loaded": { + "message": "{target} is not loaded." + }, + "channel_not_found": { + "message": "There is no {channel_type} channel at this site." + } + }, + "selector": { + "channel_type": { + "options": { + "general": "General", + "controlled_load": "Controlled load", + "feed_in": "Feed-in" + } } } } diff --git a/tests/components/amberelectric/__init__.py b/tests/components/amberelectric/__init__.py index 9eae18c65aa..8ee603cee14 100644 --- a/tests/components/amberelectric/__init__.py +++ b/tests/components/amberelectric/__init__.py @@ -1 +1,13 @@ """Tests for the amberelectric 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/amberelectric/conftest.py b/tests/components/amberelectric/conftest.py index ce4073db71b..57f93074883 100644 --- a/tests/components/amberelectric/conftest.py +++ b/tests/components/amberelectric/conftest.py @@ -1,10 +1,59 @@ """Provide common Amber fixtures.""" -from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator, Generator +from unittest.mock import AsyncMock, Mock, patch +from amberelectric.models.interval import Interval import pytest +from homeassistant.components.amberelectric.const import ( + CONF_SITE_ID, + CONF_SITE_NAME, + DOMAIN, +) +from homeassistant.const import CONF_API_TOKEN + +from .helpers import ( + CONTROLLED_LOAD_CHANNEL, + FEED_IN_CHANNEL, + FORECASTS, + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_CHANNEL, + GENERAL_CHANNEL_WITH_RANGE, + GENERAL_FORECASTS, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + +MOCK_API_TOKEN = "psk_0000000000000000" + + +def create_amber_config_entry( + site_id: str, entry_id: str, name: str +) -> MockConfigEntry: + """Create an Amber config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_TOKEN: MOCK_API_TOKEN, + CONF_SITE_NAME: name, + CONF_SITE_ID: site_id, + }, + entry_id=entry_id, + ) + + +@pytest.fixture +def mock_amber_client() -> Generator[AsyncMock]: + """Mock the Amber API client.""" + with patch( + "homeassistant.components.amberelectric.amberelectric.AmberApi", + autospec=True, + ) as mock_client: + yield mock_client + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock]: @@ -13,3 +62,129 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.amberelectric.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +async def general_channel_config_entry(): + """Generate the default Amber config entry.""" + return create_amber_config_entry(GENERAL_ONLY_SITE_ID, GENERAL_ONLY_SITE_ID, "home") + + +@pytest.fixture +async def general_channel_and_controlled_load_config_entry(): + """Generate the default Amber config entry for site with controlled load.""" + return create_amber_config_entry( + GENERAL_AND_CONTROLLED_SITE_ID, GENERAL_AND_CONTROLLED_SITE_ID, "home" + ) + + +@pytest.fixture +async def general_channel_and_feed_in_config_entry(): + """Generate the default Amber config entry for site with feed in.""" + return create_amber_config_entry( + GENERAL_AND_FEED_IN_SITE_ID, GENERAL_AND_FEED_IN_SITE_ID, "home" + ) + + +@pytest.fixture +def general_channel_prices() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL + + +@pytest.fixture +def general_channel_prices_with_range() -> list[Interval]: + """List containing general channel prices.""" + return GENERAL_CHANNEL_WITH_RANGE + + +@pytest.fixture +def controlled_load_channel_prices() -> list[Interval]: + """List containing controlled load channel prices.""" + return CONTROLLED_LOAD_CHANNEL + + +@pytest.fixture +def feed_in_channel_prices() -> list[Interval]: + """List containing feed in channel prices.""" + return FEED_IN_CHANNEL + + +@pytest.fixture +def forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return FORECASTS + + +@pytest.fixture +def general_forecast_prices() -> list[Interval]: + """List containing forecasts with advanced prices.""" + return GENERAL_FORECASTS + + +@pytest.fixture +def mock_amber_client_general_channel( + mock_amber_client: AsyncMock, general_channel_prices: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_channel_with_range( + mock_amber_client: AsyncMock, general_channel_prices_with_range: list[Interval] +) -> Generator[AsyncMock]: + """Fake general channel prices with a range.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_channel_prices_with_range + return mock_amber_client + + +@pytest.fixture +def mock_amber_client_general_and_controlled_load( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + controlled_load_channel_prices: list[Interval], +) -> Generator[AsyncMock]: + """Fake general channel and controlled load channel prices.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + controlled_load_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_and_feed_in( + mock_amber_client: AsyncMock, + general_channel_prices: list[Interval], + feed_in_channel_prices: list[Interval], +) -> AsyncGenerator[Mock]: + """Set up general channel and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = ( + general_channel_prices + feed_in_channel_prices + ) + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_forecasts( + mock_amber_client: AsyncMock, forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel, controlled load and feed in channel.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = forecast_prices + return mock_amber_client + + +@pytest.fixture +async def mock_amber_client_general_forecasts( + mock_amber_client: AsyncMock, general_forecast_prices: list[Interval] +) -> AsyncGenerator[Mock]: + """Set up general channel only.""" + client = mock_amber_client.return_value + client.get_current_prices.return_value = general_forecast_prices + return mock_amber_client diff --git a/tests/components/amberelectric/helpers.py b/tests/components/amberelectric/helpers.py index 971f3690a0d..d4f968f01d1 100644 --- a/tests/components/amberelectric/helpers.py +++ b/tests/components/amberelectric/helpers.py @@ -3,11 +3,13 @@ from datetime import datetime, timedelta from amberelectric.models.actual_interval import ActualInterval +from amberelectric.models.advanced_price import AdvancedPrice from amberelectric.models.channel import ChannelType from amberelectric.models.current_interval import CurrentInterval from amberelectric.models.forecast_interval import ForecastInterval from amberelectric.models.interval import Interval from amberelectric.models.price_descriptor import PriceDescriptor +from amberelectric.models.range import Range from amberelectric.models.spike_status import SpikeStatus from dateutil import parser @@ -15,12 +17,16 @@ from dateutil import parser def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> Interval: """Generate a mock actual interval.""" start_time = end_time - timedelta(minutes=30) + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 return Interval( ActualInterval( type="ActualInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -34,16 +40,23 @@ def generate_actual_interval(channel_type: ChannelType, end_time: datetime) -> I def generate_current_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, + end_time: datetime, + range=False, ) -> Interval: """Generate a mock current price.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( CurrentInterval( type="CurrentInterval", duration=30, spot_per_kwh=1.0, - per_kwh=8.0, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -56,18 +69,28 @@ def generate_current_interval( ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + + return interval + def generate_forecast_interval( - channel_type: ChannelType, end_time: datetime + channel_type: ChannelType, end_time: datetime, range=False, advanced_price=False ) -> Interval: """Generate a mock forecast interval.""" start_time = end_time - timedelta(minutes=30) - return Interval( + per_kwh = 8.8 + if channel_type == ChannelType.CONTROLLEDLOAD: + per_kwh = 4.4 + if channel_type == ChannelType.FEEDIN: + per_kwh = 1.1 + interval = Interval( ForecastInterval( type="ForecastInterval", duration=30, spot_per_kwh=1.1, - per_kwh=8.8, + per_kwh=per_kwh, date=start_time.date(), nem_time=end_time, start_time=start_time, @@ -79,12 +102,20 @@ def generate_forecast_interval( estimate=True, ) ) + if range: + interval.actual_instance.range = Range(min=6.7, max=9.1) + if advanced_price: + interval.actual_instance.advanced_price = AdvancedPrice( + low=6.7, predicted=9.0, high=10.2 + ) + return interval GENERAL_ONLY_SITE_ID = "01FG2K6V5TB6X9W0EWPPMZD6MJ" GENERAL_AND_CONTROLLED_SITE_ID = "01FG2MC8RF7GBC4KJXP3YFZ162" GENERAL_AND_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW84VP50S" GENERAL_AND_CONTROLLED_FEED_IN_SITE_ID = "01FG2MCD8KTRZR9MNNW847S50S" +GENERAL_FOR_FAIL = "01JVCEYVSD5HGJG0KT7RNM91GG" GENERAL_CHANNEL = [ generate_current_interval( @@ -101,6 +132,21 @@ GENERAL_CHANNEL = [ ), ] +GENERAL_CHANNEL_WITH_RANGE = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:00:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T09:30:00+10:00"), range=True + ), + generate_forecast_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T10:00:00+10:00"), range=True + ), +] + CONTROLLED_LOAD_CHANNEL = [ generate_current_interval( ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") @@ -131,3 +177,93 @@ FEED_IN_CHANNEL = [ ChannelType.FEEDIN, parser.parse("2021-09-21T10:00:00+10:00") ), ] + +GENERAL_FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] + +FORECASTS = [ + generate_current_interval( + ChannelType.GENERAL, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.CONTROLLEDLOAD, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_current_interval( + ChannelType.FEEDIN, parser.parse("2021-09-21T08:30:00+10:00") + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.GENERAL, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.CONTROLLEDLOAD, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:00:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T09:30:00+10:00"), + range=True, + advanced_price=True, + ), + generate_forecast_interval( + ChannelType.FEEDIN, + parser.parse("2021-09-21T10:00:00+10:00"), + range=True, + advanced_price=True, + ), +] diff --git a/tests/components/amberelectric/test_coordinator.py b/tests/components/amberelectric/test_coordinator.py index 6faabc924b4..0e82d81f4e8 100644 --- a/tests/components/amberelectric/test_coordinator.py +++ b/tests/components/amberelectric/test_coordinator.py @@ -9,7 +9,6 @@ from unittest.mock import Mock, patch from amberelectric import ApiException from amberelectric.models.channel import Channel, ChannelType from amberelectric.models.interval import Interval -from amberelectric.models.price_descriptor import PriceDescriptor from amberelectric.models.site import Site from amberelectric.models.site_status import SiteStatus from amberelectric.models.spike_status import SpikeStatus @@ -17,10 +16,7 @@ from dateutil import parser import pytest from homeassistant.components.amberelectric.const import CONF_SITE_ID, CONF_SITE_NAME -from homeassistant.components.amberelectric.coordinator import ( - AmberUpdateCoordinator, - normalize_descriptor, -) +from homeassistant.components.amberelectric.coordinator import AmberUpdateCoordinator from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed @@ -98,18 +94,6 @@ def mock_api_current_price() -> Generator: yield instance -def test_normalize_descriptor() -> None: - """Test normalizing descriptors works correctly.""" - assert normalize_descriptor(None) is None - assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" - assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" - assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" - assert normalize_descriptor(PriceDescriptor.LOW) == "low" - assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" - assert normalize_descriptor(PriceDescriptor.HIGH) == "high" - assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" - - async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) -> None: """Test fetching a site with only a general channel.""" @@ -120,7 +104,7 @@ async def test_fetch_general_site(hass: HomeAssistant, current_price_api: Mock) result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -152,7 +136,7 @@ async def test_fetch_no_general_site( await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) @@ -166,7 +150,7 @@ async def test_fetch_api_error(hass: HomeAssistant, current_price_api: Mock) -> result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_ONLY_SITE_ID, next=48 + GENERAL_ONLY_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -217,7 +201,7 @@ async def test_fetch_general_and_controlled_load_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_CONTROLLED_SITE_ID, next=48 + GENERAL_AND_CONTROLLED_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance @@ -257,7 +241,7 @@ async def test_fetch_general_and_feed_in_site( result = await data_service._async_update_data() current_price_api.get_current_prices.assert_called_with( - GENERAL_AND_FEED_IN_SITE_ID, next=48 + GENERAL_AND_FEED_IN_SITE_ID, next=288 ) assert result["current"].get("general") == GENERAL_CHANNEL[0].actual_instance diff --git a/tests/components/amberelectric/test_helpers.py b/tests/components/amberelectric/test_helpers.py new file mode 100644 index 00000000000..958c60fd1b3 --- /dev/null +++ b/tests/components/amberelectric/test_helpers.py @@ -0,0 +1,17 @@ +"""Test formatters.""" + +from amberelectric.models.price_descriptor import PriceDescriptor + +from homeassistant.components.amberelectric.helpers import normalize_descriptor + + +def test_normalize_descriptor() -> None: + """Test normalizing descriptors works correctly.""" + assert normalize_descriptor(None) is None + assert normalize_descriptor(PriceDescriptor.NEGATIVE) == "negative" + assert normalize_descriptor(PriceDescriptor.EXTREMELYLOW) == "extremely_low" + assert normalize_descriptor(PriceDescriptor.VERYLOW) == "very_low" + assert normalize_descriptor(PriceDescriptor.LOW) == "low" + assert normalize_descriptor(PriceDescriptor.NEUTRAL) == "neutral" + assert normalize_descriptor(PriceDescriptor.HIGH) == "high" + assert normalize_descriptor(PriceDescriptor.SPIKE) == "spike" diff --git a/tests/components/amberelectric/test_sensor.py b/tests/components/amberelectric/test_sensor.py index 203b65d6df6..0d979a2021c 100644 --- a/tests/components/amberelectric/test_sensor.py +++ b/tests/components/amberelectric/test_sensor.py @@ -1,119 +1,26 @@ """Test the Amber Electric Sensors.""" -from collections.abc import AsyncGenerator -from unittest.mock import Mock, patch - -from amberelectric.models.current_interval import CurrentInterval -from amberelectric.models.interval import Interval -from amberelectric.models.range import Range import pytest -from homeassistant.components.amberelectric.const import ( - CONF_SITE_ID, - CONF_SITE_NAME, - DOMAIN, -) -from homeassistant.const import CONF_API_TOKEN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .helpers import ( - CONTROLLED_LOAD_CHANNEL, - FEED_IN_CHANNEL, - GENERAL_AND_CONTROLLED_SITE_ID, - GENERAL_AND_FEED_IN_SITE_ID, - GENERAL_CHANNEL, - GENERAL_ONLY_SITE_ID, -) - -from tests.common import MockConfigEntry - -MOCK_API_TOKEN = "psk_0000000000000000" +from . import MockConfigEntry, setup_integration -@pytest.fixture -async def setup_general(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_SITE_NAME: "mock_title", - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_ONLY_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock(return_value=GENERAL_CHANNEL) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_controlled_load( - hass: HomeAssistant, -) -> AsyncGenerator[Mock]: - """Set up general channel and controller load channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_CONTROLLED_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + CONTROLLED_LOAD_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -@pytest.fixture -async def setup_general_and_feed_in(hass: HomeAssistant) -> AsyncGenerator[Mock]: - """Set up general channel and feed in channel.""" - MockConfigEntry( - domain="amberelectric", - data={ - CONF_API_TOKEN: MOCK_API_TOKEN, - CONF_SITE_ID: GENERAL_AND_FEED_IN_SITE_ID, - }, - ).add_to_hass(hass) - - instance = Mock() - with patch( - "amberelectric.AmberApi", - return_value=instance, - ) as mock_update: - instance.get_current_prices = Mock( - return_value=GENERAL_CHANNEL + FEED_IN_CHANNEL - ) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - yield mock_update.return_value - - -async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price - assert price.state == "0.08" + assert price.state == "0.09" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.09 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -126,32 +33,36 @@ async def test_general_price_sensor(hass: HomeAssistant, setup_general: Mock) -> assert attributes.get("range_min") is None assert attributes.get("range_max") is None - with_range: list[CurrentInterval] = GENERAL_CHANNEL - with_range[0].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_price_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Price sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price") assert price attributes = price.attributes - assert attributes.get("range_min") == 0.08 - assert attributes.get("range_max") == 0.12 + assert attributes.get("range_min") == 0.07 + assert attributes.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Price sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price") assert price - assert price.state == "0.08" + assert price.state == "0.04" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == 0.08 + assert attributes["per_kwh"] == 0.04 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -163,17 +74,20 @@ async def test_general_and_controlled_load_price_sensor(hass: HomeAssistant) -> assert attributes["attribution"] == "Data provided by Amber Electric" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price") assert price - assert price.state == "-0.08" + assert price.state == "-0.01" attributes = price.attributes assert attributes["duration"] == 30 assert attributes["date"] == "2021-09-21" - assert attributes["per_kwh"] == -0.08 + assert attributes["per_kwh"] == -0.01 assert attributes["nem_date"] == "2021-09-21T08:30:00+10:00" assert attributes["spot_per_kwh"] == 0.01 assert attributes["start_time"] == "2021-09-21T08:00:00+10:00" @@ -185,10 +99,12 @@ async def test_general_and_feed_in_price_sensor(hass: HomeAssistant) -> None: assert attributes["attribution"] == "Data provided by Amber Electric" +@pytest.mark.usefixtures("mock_amber_client_general_channel") async def test_general_forecast_sensor( - hass: HomeAssistant, setup_general: Mock + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry ) -> None: """Test the General Forecast sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price @@ -212,29 +128,33 @@ async def test_general_forecast_sensor( assert first_forecast.get("range_min") is None assert first_forecast.get("range_max") is None - with_range: list[Interval] = GENERAL_CHANNEL - with_range[1].actual_instance.range = Range(min=7.8, max=12.4) - - setup_general.get_current_price.return_value = with_range - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() +@pytest.mark.usefixtures("mock_amber_client_general_channel_with_range") +async def test_general_forecast_sensor_with_range( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: + """Test the General Forecast sensor with a range.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_forecast") assert price attributes = price.attributes first_forecast = attributes["forecasts"][0] - assert first_forecast.get("range_min") == 0.08 - assert first_forecast.get("range_max") == 0.12 + assert first_forecast.get("range_min") == 0.07 + assert first_forecast.get("range_max") == 0.09 -@pytest.mark.usefixtures("setup_general_and_controlled_load") -async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_controlled_load_forecast_sensor( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: """Test the Controlled Load Forecast sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_forecast") assert price - assert price.state == "0.09" + assert price.state == "0.04" attributes = price.attributes assert attributes["channel_type"] == "controlledLoad" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -242,7 +162,7 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == 0.09 + assert first_forecast["per_kwh"] == 0.04 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -252,13 +172,16 @@ async def test_controlled_load_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_feed_in_forecast_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Forecast sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_forecast") assert price - assert price.state == "-0.09" + assert price.state == "-0.01" attributes = price.attributes assert attributes["channel_type"] == "feedIn" assert attributes["attribution"] == "Data provided by Amber Electric" @@ -266,7 +189,7 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: first_forecast = attributes["forecasts"][0] assert first_forecast["duration"] == 30 assert first_forecast["date"] == "2021-09-21" - assert first_forecast["per_kwh"] == -0.09 + assert first_forecast["per_kwh"] == -0.01 assert first_forecast["nem_date"] == "2021-09-21T09:00:00+10:00" assert first_forecast["spot_per_kwh"] == 0.01 assert first_forecast["start_time"] == "2021-09-21T08:30:00+10:00" @@ -276,38 +199,52 @@ async def test_feed_in_forecast_sensor(hass: HomeAssistant) -> None: assert first_forecast["descriptor"] == "very_low" -@pytest.mark.usefixtures("setup_general") -def test_renewable_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_renewable_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Testing the creation of the Amber renewables sensor.""" + await setup_integration(hass, general_channel_config_entry) + assert len(hass.states.async_all()) == 6 sensor = hass.states.get("sensor.mock_title_renewables") assert sensor assert sensor.state == "51" -@pytest.mark.usefixtures("setup_general") -def test_general_price_descriptor_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_channel") +async def test_general_price_descriptor_descriptor_sensor( + hass: HomeAssistant, general_channel_config_entry: MockConfigEntry +) -> None: """Test the General Price Descriptor sensor.""" + await setup_integration(hass, general_channel_config_entry) assert len(hass.states.async_all()) == 6 price = hass.states.get("sensor.mock_title_general_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_controlled_load") -def test_general_and_controlled_load_price_descriptor_sensor( +@pytest.mark.usefixtures("mock_amber_client_general_and_controlled_load") +async def test_general_and_controlled_load_price_descriptor_sensor( hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, ) -> None: """Test the Controlled Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_controlled_load_price_descriptor") assert price assert price.state == "extremely_low" -@pytest.mark.usefixtures("setup_general_and_feed_in") -def test_general_and_feed_in_price_descriptor_sensor(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_amber_client_general_and_feed_in") +async def test_general_and_feed_in_price_descriptor_sensor( + hass: HomeAssistant, general_channel_and_feed_in_config_entry: MockConfigEntry +) -> None: """Test the Feed In Price Descriptor sensor.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + assert len(hass.states.async_all()) == 9 price = hass.states.get("sensor.mock_title_feed_in_price_descriptor") assert price diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py new file mode 100644 index 00000000000..7ef895a5d88 --- /dev/null +++ b/tests/components/amberelectric/test_services.py @@ -0,0 +1,202 @@ +"""Test the Amber Service object.""" + +import re + +import pytest +import voluptuous as vol + +from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS +from homeassistant.components.amberelectric.services import ( + ATTR_CHANNEL_TYPE, + ATTR_CONFIG_ENTRY_ID, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from . import setup_integration +from .helpers import ( + GENERAL_AND_CONTROLLED_SITE_ID, + GENERAL_AND_FEED_IN_SITE_ID, + GENERAL_ONLY_SITE_ID, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_general_forecasts( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.09 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_controlled_load_forecasts( + hass: HomeAssistant, + general_channel_and_controlled_load_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_controlled_load_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_CONTROLLED_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == 0.04 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_get_feed_in_forecasts( + hass: HomeAssistant, + general_channel_and_feed_in_config_entry: MockConfigEntry, +) -> None: + """Test fetching general forecasts.""" + await setup_integration(hass, general_channel_and_feed_in_config_entry) + result = await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_AND_FEED_IN_SITE_ID, + ATTR_CHANNEL_TYPE: "feed_in", + }, + blocking=True, + return_response=True, + ) + assert len(result["forecasts"]) == 3 + + first = result["forecasts"][0] + assert first["duration"] == 30 + assert first["date"] == "2021-09-21" + assert first["nem_date"] == "2021-09-21T09:00:00+10:00" + assert first["per_kwh"] == -0.01 + assert first["spot_per_kwh"] == 0.01 + assert first["start_time"] == "2021-09-21T08:30:00+10:00" + assert first["end_time"] == "2021-09-21T09:00:00+10:00" + assert first["renewables"] == 50 + assert first["spike_status"] == "none" + assert first["descriptor"] == "very_low" + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_incorrect_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is incorrect.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + vol.error.MultipleInvalid, + match=re.escape( + "value must be one of ['controlled_load', 'feed_in', 'general'] for dictionary value @ data['channel_type']" + ), + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "incorrect", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_general_forecasts") +async def test_unavailable_channel_type( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test error when the channel type is not found.""" + await setup_integration(hass, general_channel_config_entry) + + with pytest.raises( + ServiceValidationError, match="There is no controlled_load channel at this site" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: GENERAL_ONLY_SITE_ID, + ATTR_CHANNEL_TYPE: "controlled_load", + }, + blocking=True, + return_response=True, + ) + + +@pytest.mark.usefixtures("mock_amber_client_forecasts") +async def test_service_entry_availability( + hass: HomeAssistant, + general_channel_config_entry: MockConfigEntry, +) -> None: + """Test the services without valid entry.""" + general_channel_config_entry.add_to_hass(hass) + mock_config_entry2 = MockConfigEntry(domain=DOMAIN) + mock_config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(general_channel_config_entry.entry_id) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError, match="Mock Title is not loaded"): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + { + ATTR_CONFIG_ENTRY_ID: mock_config_entry2.entry_id, + ATTR_CHANNEL_TYPE: "general", + }, + blocking=True, + return_response=True, + ) + + with pytest.raises( + ServiceValidationError, + match='Config entry "bad-config_id" not found in registry', + ): + await hass.services.async_call( + DOMAIN, + SERVICE_GET_FORECASTS, + {ATTR_CONFIG_ENTRY_ID: "bad-config_id", ATTR_CHANNEL_TYPE: "general"}, + blocking=True, + return_response=True, + ) From fd10fa1fba8f8ab16a5ad96eb356e7716c978e27 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:49:08 +0200 Subject: [PATCH 1426/1664] Add reauthentication flow to Uptime Kuma (#148772) --- .../components/uptime_kuma/config_flow.py | 47 +++++++++++++ .../components/uptime_kuma/coordinator.py | 4 +- .../components/uptime_kuma/quality_scale.yaml | 2 +- .../components/uptime_kuma/strings.json | 13 +++- .../uptime_kuma/test_config_flow.py | 70 +++++++++++++++++++ tests/components/uptime_kuma/test_init.py | 29 +++++++- 6 files changed, 160 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 9866f08bef3..30f9d7ae9ba 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -38,6 +39,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Optional(CONF_API_KEY, default=""): str, } ) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): @@ -77,3 +79,48 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm reauthentication dialog.""" + errors: dict[str, str] = {} + + entry = self._get_reauth_entry() + + if user_input is not None: + session = async_get_clientsession(self.hass, entry.data[CONF_VERIFY_SSL]) + uptime_kuma = UptimeKuma( + session, + entry.data[CONF_URL], + user_input[CONF_API_KEY], + ) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_REAUTH_DATA_SCHEMA, suggested_values=user_input + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/coordinator.py b/homeassistant/components/uptime_kuma/coordinator.py index 788d37cfb84..297bd83e7c8 100644 --- a/homeassistant/components/uptime_kuma/coordinator.py +++ b/homeassistant/components/uptime_kuma/coordinator.py @@ -16,7 +16,7 @@ from pythonkuma import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -59,7 +59,7 @@ class UptimeKumaDataUpdateCoordinator( try: metrics = await self.api.metrics() except UptimeKumaAuthenticationException as e: - raise ConfigEntryError( + raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="auth_failed_exception", ) from e diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 145cbf58448..c3d88f7e3c8 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -38,7 +38,7 @@ rules: integration-owner: done log-when-unavailable: done parallel-updates: done - reauthentication-flow: todo + reauthentication-flow: done test-coverage: done # Gold diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 8cd361cccea..0321db1c221 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -13,6 +13,16 @@ "verify_ssl": "Enable SSL certificate verification for secure connections. Disable only if connecting to an Uptime Kuma instance using a self-signed certificate or via IP address", "api_key": "Enter an API key. To create a new API key navigate to **Settings → API Keys** and select **Add API Key**" } + }, + "reauth_confirm": { + "title": "Re-authenticate with Uptime Kuma: {name}", + "description": "The API key for **{name}** is invalid. To re-authenticate with Uptime Kuma provide a new API key below", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -21,7 +31,8 @@ "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "entity": { diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index b70cb9d353c..3c1bf902ce8 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -120,3 +120,73 @@ async def test_form_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reauth flow.""" + config_entry.add_to_hass(hass) + result = await 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"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reauth_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reauth flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "newapikey"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert config_entry.data[CONF_API_KEY] == "newapikey" + + assert len(hass.config_entries.async_entries()) == 1 diff --git a/tests/components/uptime_kuma/test_init.py b/tests/components/uptime_kuma/test_init.py index 57390da60d5..6e2ef43b14d 100644 --- a/tests/components/uptime_kuma/test_init.py +++ b/tests/components/uptime_kuma/test_init.py @@ -5,7 +5,8 @@ from unittest.mock import AsyncMock import pytest from pythonkuma import UptimeKumaAuthenticationException, UptimeKumaException -from homeassistant.config_entries import ConfigEntryState +from homeassistant.components.uptime_kuma.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -50,3 +51,29 @@ async def test_config_entry_not_ready( await hass.async_block_till_done() assert config_entry.state is state + + +async def test_config_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, +) -> None: + """Test config entry auth error starts reauth flow.""" + + mock_pythonkuma.metrics.side_effect = UptimeKumaAuthenticationException + 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.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id From e5fe243a8633785a2a3ac2c2a26556ab8a7cad81 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:03:47 -0400 Subject: [PATCH 1427/1664] Remove device id references from button and image (#148826) --- homeassistant/components/template/button.py | 5 ----- homeassistant/components/template/image.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 397fc5f4174..26d339b7e33 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -17,7 +17,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -105,10 +104,6 @@ class StateButtonEntity(TemplateEntity, ButtonEntity): self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN) self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_state = None - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index ed7093cfcdb..57e7c6ffc55 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, selector -from homeassistant.helpers.device import async_device_info_to_link_from_device_id from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -107,10 +106,6 @@ class StateImageEntity(TemplateEntity, ImageEntity): TemplateEntity.__init__(self, hass, config, unique_id) ImageEntity.__init__(self, hass, config[CONF_VERIFY_SSL]) self._url_template = config[CONF_URL] - self._attr_device_info = async_device_info_to_link_from_device_id( - hass, - config.get(CONF_DEVICE_ID), - ) @property def entity_picture(self) -> str | None: From 35097602d77e4d7813af80353398a5898da2a12f Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Tue, 15 Jul 2025 11:04:31 -0400 Subject: [PATCH 1428/1664] Remove unnecessary hass if check in AbstractTemplateEntity (#148828) --- homeassistant/components/template/entity.py | 22 +++++++++---------- tests/components/template/test_entity.py | 8 ++----- .../template/test_template_entity.py | 6 +---- 3 files changed, 14 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index a97a5ac6571..481db182713 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -1,5 +1,6 @@ """Template entity base class.""" +from abc import abstractmethod from collections.abc import Sequence from typing import Any @@ -25,26 +26,25 @@ class AbstractTemplateEntity(Entity): self.hass = hass self._action_scripts: dict[str, Script] = {} - if self.hass: - 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 - ) - - self._attr_device_info = async_device_info_to_link_from_device_id( - self.hass, - config.get(CONF_DEVICE_ID), + 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 ) + self._attr_device_info = async_device_info_to_link_from_device_id( + self.hass, + config.get(CONF_DEVICE_ID), + ) + @property + @abstractmethod def referenced_blueprint(self) -> str | None: """Return referenced blueprint or None.""" - raise NotImplementedError @callback + @abstractmethod def _render_script_variables(self) -> dict: """Render configured variables.""" - raise NotImplementedError def add_script( self, diff --git a/tests/components/template/test_entity.py b/tests/components/template/test_entity.py index 4a6940c2813..8e98d8c94a7 100644 --- a/tests/components/template/test_entity.py +++ b/tests/components/template/test_entity.py @@ -9,9 +9,5 @@ from homeassistant.core import HomeAssistant async def test_template_entity_not_implemented(hass: HomeAssistant) -> None: """Test abstract template entity raises not implemented error.""" - entity = abstract_entity.AbstractTemplateEntity(None, {}) - with pytest.raises(NotImplementedError): - _ = entity.referenced_blueprint - - with pytest.raises(NotImplementedError): - entity._render_script_variables() + with pytest.raises(TypeError): + _ = abstract_entity.AbstractTemplateEntity(hass, {}) diff --git a/tests/components/template/test_template_entity.py b/tests/components/template/test_template_entity.py index b743f7e2d9f..7fe3870ae1e 100644 --- a/tests/components/template/test_template_entity.py +++ b/tests/components/template/test_template_entity.py @@ -9,12 +9,8 @@ from homeassistant.helpers import template async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: """Test template entity requires hass to be set before accepting templates.""" - entity = template_entity.TemplateEntity(None, {}, "something_unique") + entity = template_entity.TemplateEntity(hass, {}, "something_unique") - with pytest.raises(ValueError, match="^hass cannot be None"): - entity.add_template_attribute("_hello", template.Template("Hello")) - - entity.hass = object() with pytest.raises(ValueError, match="^template.hass cannot be None"): entity.add_template_attribute("_hello", template.Template("Hello", None)) From 2c2ac4b6692fb9c8ecac342d416f7cbf2130ed78 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 15 Jul 2025 08:08:19 -0700 Subject: [PATCH 1429/1664] Throw an error from reload_themes if themes are invalid (#148827) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/frontend/__init__.py | 7 +++++ tests/components/frontend/test_init.py | 30 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 9694c299b23..2f2a8e93b1e 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -26,6 +26,7 @@ from homeassistant.const import ( EVENT_THEMES_UPDATED, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service from homeassistant.helpers.icon import async_get_icons from homeassistant.helpers.json import json_dumps_sorted @@ -543,6 +544,12 @@ async def _async_setup_themes( """Reload themes.""" config = await async_hass_config_yaml(hass) new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) + + try: + THEME_SCHEMA(new_themes) + except vol.Invalid as err: + raise HomeAssistantError(f"Failed to reload themes: {err}") from err + hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f28742cdd0a..a6c35513dc3 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -26,6 +26,7 @@ from homeassistant.components.frontend import ( ) from homeassistant.components.websocket_api import TYPE_RESULT from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component @@ -408,6 +409,35 @@ async def test_themes_reload_themes( assert msg["result"]["default_theme"] == "default" +@pytest.mark.usefixtures("frontend") +async def test_themes_reload_invalid( + hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket +) -> None: + """Test frontend.reload_themes service with an invalid theme.""" + + with patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"happy": {"primary-color": "pink"}}}}, + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + with ( + patch( + "homeassistant.components.frontend.async_hass_config_yaml", + return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + ), + pytest.raises(HomeAssistantError, match="Failed to reload themes"), + ): + await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) + + await themes_ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) + + msg = await themes_ws_client.receive_json() + + assert msg["result"]["themes"] == {"happy": {"primary-color": "pink"}} + assert msg["result"]["default_theme"] == "default" + + async def test_missing_themes(ws_client: MockHAClientWebSocket) -> None: """Test that themes API works when themes are not defined.""" await ws_client.send_json({"id": 5, "type": "frontend/get_themes"}) From 5b29d6bbdfbf46306b74696d0bf40c7226a76dc9 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 15 Jul 2025 17:25:22 +0200 Subject: [PATCH 1430/1664] Set icon for off state for light domain (#148749) --- homeassistant/components/light/icons.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json index 6218c733f4c..c0b478e895d 100644 --- a/homeassistant/components/light/icons.json +++ b/homeassistant/components/light/icons.json @@ -2,6 +2,9 @@ "entity_component": { "_": { "default": "mdi:lightbulb", + "state": { + "off": "mdi:lightbulb-off" + }, "state_attributes": { "effect": { "default": "mdi:circle-medium", From 8bd51a7fd1470495a87674ec08404f92b36268f3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Jul 2025 17:38:19 +0200 Subject: [PATCH 1431/1664] Use ffmpeg for generic cameras in go2rtc (#148818) --- homeassistant/components/go2rtc/__init__.py | 5 ++++ tests/components/go2rtc/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 4e15b93330c..8d3e988dd14 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -306,6 +306,11 @@ class WebRTCProvider(CameraWebRTCProvider): await self.teardown() raise HomeAssistantError("Camera has no stream source") + if camera.platform.platform_name == "generic": + # This is a workaround to use ffmpeg for generic cameras + # A proper fix will be added in the future together with supporting multiple streams per camera + stream_source = "ffmpeg:" + stream_source + if not self.async_is_supported(stream_source): await self.teardown() raise HomeAssistantError("Stream source is not supported by go2rtc") diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index 2abdf724f61..dcbcb629d11 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -670,3 +670,32 @@ async def test_async_get_image( HomeAssistantError, match="Stream source is not supported by go2rtc" ): await async_get_image(hass, camera.entity_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_generic_workaround( + hass: HomeAssistant, + init_test_integration: MockCamera, + rest_client: AsyncMock, +) -> None: + """Test workaround for generic integration cameras.""" + camera = init_test_integration + assert isinstance(camera._webrtc_provider, WebRTCProvider) + + image_bytes = load_fixture_bytes("snapshot.jpg", DOMAIN) + + 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"): + image = await async_get_image(hass, camera.entity_id) + assert image.content == image_bytes + + rest_client.streams.add.assert_called_once_with( + camera.entity_id, + [ + "ffmpeg:https://my_stream_url.m3u8", + f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", + f"ffmpeg:{camera.entity_id}#video=mjpeg", + ], + ) From 3e0628cec2c365a0276a3fdea03fcba030af58dd Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 18:58:42 +0200 Subject: [PATCH 1432/1664] Fix entity and device selectors (#148580) --- .../components/ai_task/services.yaml | 7 +-- .../components/assist_satellite/services.yaml | 7 +-- homeassistant/helpers/selector.py | 44 ++++++++++++++++--- tests/helpers/test_selector.py | 21 ++++++++- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index 194c0e07bc3..feefa70a30b 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -15,9 +15,10 @@ generate_data: required: false selector: entity: - domain: ai_task - supported_features: - - ai_task.AITaskEntityFeature.GENERATE_DATA + filter: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_DATA structure: advanced: true required: false diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 8433eb6102d..ed292e1626c 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -68,9 +68,10 @@ ask_question: required: true selector: entity: - domain: assist_satellite - supported_features: - - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION + filter: + domain: assist_satellite + supported_features: + - assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION question: required: false example: "What kind of music would you like to play?" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index bc24113251c..83524fac24c 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -160,6 +160,22 @@ ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( ) +# Legacy entity selector config schema used directly under entity selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration that provided the entity + vol.Optional("integration"): str, + # Domain the entity belongs to + vol.Optional("domain"): vol.All(cv.ensure_list, [str]), + # Device class of the entity + vol.Optional("device_class"): vol.All(cv.ensure_list, [str]), + } +) + + class EntityFilterSelectorConfig(TypedDict, total=False): """Class to represent a single entity selector config.""" @@ -179,10 +195,22 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema( vol.Optional("model"): str, # Model ID of device vol.Optional("model_id"): str, - # Device has to contain entities matching this selector - vol.Optional("entity"): vol.All( - cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] - ), + } +) + + +# Legacy device selector config schema used directly under device selectors +# is provided for backwards compatibility and remains feature frozen. +# New filtering features should be added under the `filter` key instead. +# https://github.com/home-assistant/frontend/pull/15302 +LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA = vol.Schema( + { + # Integration linked to it with a config entry + vol.Optional("integration"): str, + # Manufacturer of device + vol.Optional("manufacturer"): str, + # Model of device + vol.Optional("model"): str, } ) @@ -714,9 +742,13 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA.schema ).extend( { + # Device has to contain entities matching this selector + vol.Optional("entity"): vol.All( + cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA] + ), vol.Optional("multiple", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, @@ -794,7 +826,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( - ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA.schema + LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA.schema ).extend( { vol.Optional("exclude_entities"): [str], diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 0e68992d0e4..159f295ab2f 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -88,7 +88,6 @@ def _test_selector( ({"integration": "zha"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf"}, ("abc123",), (None,)), ({"model": "mock-model"}, ("abc123",), (None,)), - ({"model_id": "mock-model_id"}, ("abc123",), (None,)), ({"manufacturer": "mock-manuf", "model": "mock-model"}, ("abc123",), (None,)), ( {"integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model"}, @@ -128,6 +127,7 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", } }, ("abc123",), @@ -140,11 +140,13 @@ def _test_selector( "integration": "zha", "manufacturer": "mock-manuf", "model": "mock-model", + "model_id": "mock-model_id", }, { "integration": "matter", "manufacturer": "other-mock-manuf", "model": "other-mock-model", + "model_id": "other-mock-model_id", }, ] }, @@ -158,6 +160,19 @@ def test_device_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("device", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + "schema", + [ + # model_id should be used under the filter key + {"model_id": "mock-model_id"}, + ], +) +def test_device_selector_schema_error(schema) -> None: + """Test device selector.""" + with pytest.raises(vol.Invalid): + selector.validate_selector({"device": schema}) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), [ @@ -290,10 +305,12 @@ def test_entity_selector_schema(schema, valid_selections, invalid_selections) -> {"filter": [{"supported_features": ["light.FooEntityFeature.blah"]}]}, # Unknown feature enum member {"filter": [{"supported_features": ["light.LightEntityFeature.blah"]}]}, + # supported_features should be used under the filter key + {"supported_features": ["light.LightEntityFeature.EFFECT"]}, ], ) def test_entity_selector_schema_error(schema) -> None: - """Test number selector.""" + """Test entity selector.""" with pytest.raises(vol.Invalid): selector.validate_selector({"entity": schema}) From 36156d9c544c6e713ae84668247f61e138785bb9 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 19:43:44 +0200 Subject: [PATCH 1433/1664] Update orjson to 3.11.0 (#148840) --- 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 9e21c5830e4..f56c44d494a 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.10.18 +orjson==3.11.0 packaging>=23.1 paho-mqtt==2.1.0 Pillow==11.3.0 diff --git a/pyproject.toml b/pyproject.toml index 860b4af379d..6946993e6af 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.10.18", + "orjson==3.11.0", "packaging>=23.1", "psutil-home-assistant==0.0.1", "python-slugify==8.0.4", diff --git a/requirements.txt b/requirements.txt index 118d2bedfa6..896ff44a3c7 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.10.18 +orjson==3.11.0 packaging>=23.1 psutil-home-assistant==0.0.1 python-slugify==8.0.4 From e89ae021d83a053d9f7ecfe5814ffe28503d48a3 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:10:16 +0200 Subject: [PATCH 1434/1664] Clean up validate_supported_features in selector helper (#148843) --- homeassistant/helpers/selector.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 83524fac24c..0fa5403ad2b 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -117,11 +117,8 @@ def _validate_supported_feature(supported_feature: str) -> int: raise vol.Invalid(f"Unknown supported feature '{supported_feature}'") from exc -def _validate_supported_features(supported_features: int | list[str]) -> int: - """Validate a supported feature and resolve an enum string to its value.""" - - if isinstance(supported_features, int): - return supported_features +def _validate_supported_features(supported_features: list[str]) -> int: + """Validate supported features and resolve enum strings to their value.""" feature_mask = 0 From 9caf46c68b2533a86f17810c81ce84fe76339ca3 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 15 Jul 2025 20:17:54 +0200 Subject: [PATCH 1435/1664] Bump `imgw_pib` library to version 1.4.0 (#148831) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 3 ++- tests/components/imgw_pib/snapshots/test_diagnostics.ambr | 7 +++++++ 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index 631bce3fbc9..a24e5d23907 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.2.0"] + "requirements": ["imgw_pib==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8fe43a3198c..486bd1242f1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7e3da48a19..f6f6af12452 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.2.0 +imgw_pib==1.4.0 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index e0b091e5ff3..ad5ad992688 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import HydrologicalData, SensorData +from imgw_pib import NO_ALERT, Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -25,6 +25,7 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), + alert=Alert(value=NO_ALERT), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 08f3690136e..1521bc8320a 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -22,6 +22,13 @@ 'version': 1, }), 'hydrological_data': dict({ + 'alert': dict({ + 'level': None, + 'probability': None, + 'valid_from': None, + 'valid_to': None, + 'value': 'no_alert', + }), 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', From d14a0e01911358055a545812abb3ec1d53c36059 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:18:47 +0200 Subject: [PATCH 1436/1664] Bump pythonkuma to v0.3.1 (#148834) --- homeassistant/components/uptime_kuma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/uptime_kuma/manifest.json b/homeassistant/components/uptime_kuma/manifest.json index 6f20d4ae20f..42fac89a976 100644 --- a/homeassistant/components/uptime_kuma/manifest.json +++ b/homeassistant/components/uptime_kuma/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_polling", "loggers": ["pythonkuma"], "quality_scale": "bronze", - "requirements": ["pythonkuma==0.3.0"] + "requirements": ["pythonkuma==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 486bd1242f1..b09aff3f4bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2526,7 +2526,7 @@ python-vlc==3.0.18122 pythonegardia==1.0.52 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6f6af12452..081a7d6ed5e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2090,7 +2090,7 @@ python-technove==2.0.0 python-telegram-bot[socks]==21.5 # homeassistant.components.uptime_kuma -pythonkuma==0.3.0 +pythonkuma==0.3.1 # homeassistant.components.tile pytile==2024.12.0 From 648dce2fa39ca63cbf5d53ec29892f4aac515961 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:19:14 +0200 Subject: [PATCH 1437/1664] Add diagnostics platform to Uptime Kuma (#148835) --- .../components/uptime_kuma/diagnostics.py | 23 +++++++++++ .../components/uptime_kuma/quality_scale.yaml | 2 +- .../snapshots/test_diagnostics.ambr | 41 +++++++++++++++++++ .../uptime_kuma/test_diagnostics.py | 28 +++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/uptime_kuma/diagnostics.py create mode 100644 tests/components/uptime_kuma/snapshots/test_diagnostics.ambr create mode 100644 tests/components/uptime_kuma/test_diagnostics.py diff --git a/homeassistant/components/uptime_kuma/diagnostics.py b/homeassistant/components/uptime_kuma/diagnostics.py new file mode 100644 index 00000000000..48e23adc40d --- /dev/null +++ b/homeassistant/components/uptime_kuma/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics platform for Uptime Kuma.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import UptimeKumaConfigEntry + +TO_REDACT = {"monitor_url", "monitor_hostname"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: UptimeKumaConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + return async_redact_data( + {k: asdict(v) for k, v in entry.runtime_data.data.items()}, TO_REDACT + ) diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index c3d88f7e3c8..469ecad8d7b 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -43,7 +43,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: is not locally discoverable diff --git a/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..97e40e821da --- /dev/null +++ b/tests/components/uptime_kuma/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + '1': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 1, + 'monitor_name': 'Monitor 1', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 1, + 'monitor_type': 'http', + 'monitor_url': '**REDACTED**', + }), + '2': dict({ + 'monitor_cert_days_remaining': 0, + 'monitor_cert_is_valid': 0, + 'monitor_hostname': None, + 'monitor_id': 2, + 'monitor_name': 'Monitor 2', + 'monitor_port': None, + 'monitor_response_time': 28, + 'monitor_status': 1, + 'monitor_type': 'port', + 'monitor_url': None, + }), + '3': dict({ + 'monitor_cert_days_remaining': 90, + 'monitor_cert_is_valid': 1, + 'monitor_hostname': None, + 'monitor_id': 3, + 'monitor_name': 'Monitor 3', + 'monitor_port': None, + 'monitor_response_time': 120, + 'monitor_status': 0, + 'monitor_type': 'json-query', + 'monitor_url': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/uptime_kuma/test_diagnostics.py b/tests/components/uptime_kuma/test_diagnostics.py new file mode 100644 index 00000000000..92d98d49b75 --- /dev/null +++ b/tests/components/uptime_kuma/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests Uptime Kuma diagnostics platform.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) From f5b785acd50269268fa7502afdcf2c7b46299004 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:44:32 +0200 Subject: [PATCH 1438/1664] Update youtubeaio to 2.0.0 (#148814) --- homeassistant/components/youtube/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json index a1a71f6712e..56b0f0fdd3a 100644 --- a/homeassistant/components/youtube/manifest.json +++ b/homeassistant/components/youtube/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/youtube", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["youtubeaio==1.1.5"] + "requirements": ["youtubeaio==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b09aff3f4bc..79d34968d39 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3172,7 +3172,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 081a7d6ed5e..05c9ff6adf2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2619,7 +2619,7 @@ yolink-api==0.5.7 youless-api==2.2.0 # homeassistant.components.youtube -youtubeaio==1.1.5 +youtubeaio==2.0.0 # homeassistant.components.media_extractor yt-dlp[default]==2025.06.09 From 381bd489d801e816315d38da0717e89fef4060eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Jul 2025 22:13:03 +0200 Subject: [PATCH 1439/1664] Do not add template config entry to source device (#148756) --- homeassistant/components/template/entity.py | 9 ++-- tests/components/template/test_init.py | 49 +++++++++++++++------ 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py index 481db182713..31c48917a1f 100644 --- a/homeassistant/components/template/entity.py +++ b/homeassistant/components/template/entity.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.const import CONF_DEVICE_ID from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers.device import async_device_info_to_link_from_device_id +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 @@ -31,10 +31,9 @@ class AbstractTemplateEntity(Entity): self._entity_id_format, object_id, hass=self.hass ) - self._attr_device_info = async_device_info_to_link_from_device_id( - self.hass, - config.get(CONF_DEVICE_ID), - ) + device_registry = dr.async_get(hass) + if (device_id := config.get(CONF_DEVICE_ID)) is not None: + self.device_entry = device_registry.async_get(device_id) @property @abstractmethod diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py index cab940d4c66..0d593da9fba 100644 --- a/tests/components/template/test_init.py +++ b/tests/components/template/test_init.py @@ -9,7 +9,7 @@ from homeassistant import config from homeassistant.components.template import DOMAIN from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -369,6 +369,7 @@ async def async_yaml_patch_helper(hass: HomeAssistant, filename: str) -> None: async def test_change_device( hass: HomeAssistant, device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, config_entry_options: dict[str, str], config_user_input: dict[str, str], ) -> None: @@ -379,6 +380,19 @@ async def test_change_device( changed in the integration options. """ + def check_template_entities( + template_entity_id: str, + device_id: str | None = None, + ) -> None: + """Check that the template entity is linked to the correct device.""" + template_entity_ids: list[str] = [] + for template_entity in entity_registry.entities.get_entries_for_config_entry_id( + template_config_entry.entry_id + ): + template_entity_ids.append(template_entity.entity_id) + assert template_entity.device_id == device_id + assert template_entity_ids == [template_entity_id] + # Configure devices registry entry_device1 = MockConfigEntry() entry_device1.add_to_hass(hass) @@ -413,9 +427,14 @@ async def test_change_device( assert await hass.config_entries.async_setup(template_config_entry.entry_id) await hass.async_block_till_done() - # Confirm that the config entry has been added to the device 1 registry (current) - current_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id in current_device.config_entries + template_entity_id = f"{config_entry_options['template_type']}.my_template" + + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 1 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id1) # Change config options to use device 2 and reload the integration result = await hass.config_entries.options.async_init( @@ -427,13 +446,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 1 registry - previous_device = device_registry.async_get(device_id=device_id1) - assert template_config_entry.entry_id not in previous_device.config_entries - - # Confirm that the config entry has been added to the device 2 registry (current) - current_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id in current_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are linked to device 2 + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, device_id2) # Change the config options to remove the device and reload the integration result = await hass.config_entries.options.async_init( @@ -445,9 +463,12 @@ async def test_change_device( ) await hass.async_block_till_done() - # Confirm that the config entry has been removed from the device 2 registry - previous_device = device_registry.async_get(device_id=device_id2) - assert template_config_entry.entry_id not in previous_device.config_entries + # Confirm that the template config entry has not been added to either device + # and that the entities are not linked to any device + for device_id in (device_id1, device_id2): + device = device_registry.async_get(device_id=device_id) + assert template_config_entry.entry_id not in device.config_entries + check_template_entities(template_entity_id, None) # Confirm that there is no device with the helper config entry assert ( From 3cb579d5857bfd96f16b7d65e2e970b9eacca0d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Jul 2025 22:13:26 +0200 Subject: [PATCH 1440/1664] Do not add statistics config entry to source device (#148731) --- .../components/statistics/__init__.py | 45 ++++- .../components/statistics/config_flow.py | 20 ++- homeassistant/components/statistics/sensor.py | 12 +- tests/components/statistics/test_init.py | 166 ++++++++++++++++-- 4 files changed, 213 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py index f800c82f1f9..34799e366d1 100644 --- a/homeassistant/components/statistics/__init__.py +++ b/homeassistant/components/statistics/__init__.py @@ -1,5 +1,7 @@ """The statistics component.""" +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant @@ -7,15 +9,21 @@ from homeassistant.helpers.device import ( async_entity_id_to_device_id, async_remove_stale_devices_links_keep_entity_device, ) -from homeassistant.helpers.helper_integration import async_handle_source_entity_changes +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) DOMAIN = "statistics" PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Statistics from a config entry.""" + # This can be removed in HA Core 2026.2 async_remove_stale_devices_links_keep_entity_device( hass, entry.entry_id, @@ -36,6 +44,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( async_handle_source_entity_changes( hass, + add_helper_config_entry_to_device=False, helper_config_entry_id=entry.entry_id, set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, source_device_id=async_entity_id_to_device_id( @@ -52,6 +61,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug( + "Migrating from version %s.%s", config_entry.version, config_entry.minor_version + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + if config_entry.version == 1: + options = {**config_entry.options} + if config_entry.minor_version < 2: + # Remove the statistics config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, options[CONF_ENTITY_ID] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Statistics config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/statistics/config_flow.py b/homeassistant/components/statistics/config_flow.py index fb8c09868d5..d9ff172e0a4 100644 --- a/homeassistant/components/statistics/config_flow.py +++ b/homeassistant/components/statistics/config_flow.py @@ -161,6 +161,8 @@ OPTIONS_FLOW = { class StatisticsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Statistics.""" + MINOR_VERSION = 2 + config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW @@ -234,15 +236,15 @@ async def ws_start_preview( ) preview_entity = StatisticsSensor( hass, - entity_id, - name, - None, - state_characteristic, - sampling_size, - max_age, - msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), - msg["user_input"].get(CONF_PRECISION), - msg["user_input"].get(CONF_PERCENTILE), + source_entity_id=entity_id, + name=name, + unique_id=None, + state_characteristic=state_characteristic, + samples_max_buffer_size=sampling_size, + samples_max_age=max_age, + samples_keep_last=msg["user_input"].get(CONF_KEEP_LAST_SAMPLE), + precision=msg["user_input"].get(CONF_PRECISION), + percentile=msg["user_input"].get(CONF_PERCENTILE), ) preview_entity.hass = hass diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index a5c5f10ecd0..8129a000b91 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -46,7 +46,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -659,6 +659,7 @@ class StatisticsSensor(SensorEntity): def __init__( self, hass: HomeAssistant, + *, source_entity_id: str, name: str, unique_id: str | None, @@ -673,10 +674,11 @@ class StatisticsSensor(SensorEntity): self._attr_name: str = name self._attr_unique_id: str | None = unique_id self._source_entity_id: str = source_entity_id - self._attr_device_info = async_device_info_to_link_from_entity( - hass, - source_entity_id, - ) + if source_entity_id: # Guard against empty source_entity_id in preview mode + self.device_entry = async_entity_id_to_device( + hass, + source_entity_id, + ) self.is_binary: bool = ( split_entity_id(self._source_entity_id)[0] == BINARY_SENSOR_DOMAIN ) diff --git a/tests/components/statistics/test_init.py b/tests/components/statistics/test_init.py index c11045a2eb2..2312daa8c52 100644 --- a/tests/components/statistics/test_init.py +++ b/tests/components/statistics/test_init.py @@ -10,7 +10,7 @@ from homeassistant.components import statistics from homeassistant.components.statistics import DOMAIN from homeassistant.components.statistics.config_flow import StatisticsConfigFlowHandler from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event @@ -85,6 +85,7 @@ def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[s """Track entity registry actions for an entity.""" events = [] + @callback def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: """Add entity registry updated event to the list.""" events.append(event.data["action"]) @@ -173,7 +174,7 @@ async def test_device_cleaning( devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_before_reload) == 3 + assert len(devices_before_reload) == 2 # Config entry reload await hass.config_entries.async_reload(statistics_config_entry.entry_id) @@ -188,9 +189,7 @@ async def test_device_cleaning( devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( statistics_config_entry.entry_id ) - assert len(devices_after_reload) == 1 - - assert devices_after_reload[0].id == source_device1_entry.id + assert len(devices_after_reload) == 0 async def test_async_handle_source_entity_changes_source_entity_removed( @@ -201,6 +200,53 @@ async def test_async_handle_source_entity_changes_source_entity_removed( sensor_config_entry: ConfigEntry, sensor_device: dr.DeviceEntry, sensor_entity_entry: er.RegistryEntry, +) -> None: + """Test the statistics config entry is removed when the source entity is removed.""" + assert await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + + events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) + + # Remove the source sensor's config entry from the device, this removes the + # source sensor + with patch( + "homeassistant.components.statistics.async_unload_entry", + wraps=statistics.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + sensor_device.id, remove_config_entry_id=sensor_config_entry.entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the device is removed + assert not device_registry.async_get(sensor_device.id) + + # Check that the statistics config entry is removed + assert statistics_config_entry.entry_id not in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == ["remove"] + + +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + statistics_config_entry: MockConfigEntry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, + sensor_entity_entry: er.RegistryEntry, ) -> None: """Test the statistics config entry is removed when the source entity is removed.""" # Add another config entry to the sensor device @@ -217,7 +263,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -234,7 +280,10 @@ async def test_async_handle_source_entity_changes_source_entity_removed( await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the helper entity is removed + assert not entity_registry.async_get("sensor.my_statistics") + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -261,7 +310,7 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -276,7 +325,11 @@ async def test_async_handle_source_entity_changes_source_entity_removed_from_dev await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is removed from the device + # Check that the entity is no longer linked to the source device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id is None + + # Check that the statistics config entry is not in the device sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries @@ -309,7 +362,7 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) assert statistics_config_entry.entry_id not in sensor_device_2.config_entries @@ -326,11 +379,15 @@ async def test_async_handle_source_entity_changes_source_entity_moved_other_devi await hass.async_block_till_done() mock_unload_entry.assert_called_once() - # Check that the statistics config entry is moved to the other device + # Check that the entity is linked to the other device + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_device_2.id + + # Check that the history_stats config entry is not in any of the devices sensor_device = device_registry.async_get(sensor_device.id) assert statistics_config_entry.entry_id not in sensor_device.config_entries sensor_device_2 = device_registry.async_get(sensor_device_2.id) - assert statistics_config_entry.entry_id in sensor_device_2.config_entries + assert statistics_config_entry.entry_id not in sensor_device_2.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() @@ -355,7 +412,7 @@ async def test_async_handle_source_entity_new_entity_id( assert statistics_entity_entry.device_id == sensor_entity_entry.device_id sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries events = track_entity_registry_actions(hass, statistics_entity_entry.entity_id) @@ -373,12 +430,91 @@ async def test_async_handle_source_entity_new_entity_id( # Check that the statistics config entry is updated with the new entity ID assert statistics_config_entry.options["entity_id"] == "sensor.new_entity_id" - # Check that the helper config is still in the device + # Check that the helper config is not in the device sensor_device = device_registry.async_get(sensor_device.id) - assert statistics_config_entry.entry_id in sensor_device.config_entries + assert statistics_config_entry.entry_id not in sensor_device.config_entries # Check that the statistics config entry is not removed assert statistics_config_entry.entry_id in hass.config_entries.async_entry_ids() # Check we got the expected events assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + sensor_entity_entry: er.RegistryEntry, + sensor_device: dr.DeviceEntry, +) -> None: + """Test migration from v1.1 removes statistics config entry from device.""" + + statistics_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": sensor_entity_entry.entity_id, + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=1, + minor_version=1, + ) + statistics_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + sensor_device.id, add_config_entry_id=statistics_config_entry.entry_id + ) + + # Check preconditions + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id in sensor_device.config_entries + + await hass.config_entries.async_setup(statistics_config_entry.entry_id) + await hass.async_block_till_done() + + assert statistics_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + sensor_device = device_registry.async_get(sensor_device.id) + assert statistics_config_entry.entry_id not in sensor_device.config_entries + statistics_entity_entry = entity_registry.async_get("sensor.my_statistics") + assert statistics_entity_entry.device_id == sensor_entity_entry.device_id + + assert statistics_config_entry.version == 1 + assert statistics_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My statistics", + "entity_id": "sensor.test", + "state_characteristic": "mean", + "keep_last_sample": False, + "percentile": 50.0, + "precision": 2.0, + "sampling_size": 20.0, + }, + title="My statistics", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 849a25e3ccaadf8fd2fb11e3efda89c385438af5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 15 Jul 2025 22:19:32 +0200 Subject: [PATCH 1441/1664] Handle changes to source entities in mold_indicator helper (#148823) Co-authored-by: G Johansson --- .../components/mold_indicator/__init__.py | 117 ++- .../components/mold_indicator/config_flow.py | 3 + .../components/mold_indicator/sensor.py | 4 +- tests/components/mold_indicator/test_init.py | 679 +++++++++++++++++- 4 files changed, 798 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py index c426b942af5..e252338d4d8 100644 --- a/homeassistant/components/mold_indicator/__init__.py +++ b/homeassistant/components/mold_indicator/__init__.py @@ -1,15 +1,93 @@ """Calculates mold growth indication from temperature and humidity.""" +from __future__ import annotations + +from collections.abc import Callable +import logging + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device import ( + async_entity_id_to_device_id, + async_remove_stale_devices_links_keep_entity_device, +) +from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.helper_integration import ( + async_handle_source_entity_changes, + async_remove_helper_config_entry_from_source_device, +) + +from .const import CONF_INDOOR_HUMIDITY, CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Mold indicator from a config entry.""" + # This can be removed in HA Core 2026.2 + async_remove_stale_devices_links_keep_entity_device( + hass, entry.entry_id, entry.options[CONF_INDOOR_HUMIDITY] + ) + + def set_source_entity_id_or_uuid(source_entity_id: str) -> None: + hass.config_entries.async_update_entry( + entry, + options={**entry.options, CONF_INDOOR_HUMIDITY: source_entity_id}, + ) + + entry.async_on_unload( + # We use async_handle_source_entity_changes to track changes to the humidity + # sensor, but not the temperature sensors because the mold_indicator links + # to the humidity sensor's device. + async_handle_source_entity_changes( + hass, + add_helper_config_entry_to_device=False, + helper_config_entry_id=entry.entry_id, + set_source_entity_id_or_uuid=set_source_entity_id_or_uuid, + source_device_id=async_entity_id_to_device_id( + hass, entry.options[CONF_INDOOR_HUMIDITY] + ), + source_entity_id_or_uuid=entry.options[CONF_INDOOR_HUMIDITY], + ) + ) + + for temp_sensor in (CONF_INDOOR_TEMP, CONF_OUTDOOR_TEMP): + + def get_temp_sensor_updater( + temp_sensor: str, + ) -> Callable[[Event[er.EventEntityRegistryUpdatedData]], None]: + """Return a function to update the config entry with the new temp sensor.""" + + @callback + def async_sensor_updated( + event: Event[er.EventEntityRegistryUpdatedData], + ) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] != "update": + return + if "entity_id" not in data["changes"]: + return + + # Entity_id changed, update the config entry + hass.config_entries.async_update_entry( + entry, + options={**entry.options, temp_sensor: data["entity_id"]}, + ) + + return async_sensor_updated + + entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, entry.options[temp_sensor], get_temp_sensor_updater(temp_sensor) + ) + ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -24,3 +102,40 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + if config_entry.minor_version < 2: + # Remove the mold indicator config entry from the source device + if source_device_id := async_entity_id_to_device_id( + hass, config_entry.options[CONF_INDOOR_HUMIDITY] + ): + async_remove_helper_config_entry_from_source_device( + hass, + helper_config_entry_id=config_entry.entry_id, + source_device_id=source_device_id, + ) + hass.config_entries.async_update_entry( + config_entry, version=1, minor_version=2 + ) + + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/mold_indicator/config_flow.py b/homeassistant/components/mold_indicator/config_flow.py index 5e5512a60bf..d370752fff9 100644 --- a/homeassistant/components/mold_indicator/config_flow.py +++ b/homeassistant/components/mold_indicator/config_flow.py @@ -101,6 +101,9 @@ class MoldIndicatorConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): config_flow = CONFIG_FLOW options_flow = OPTIONS_FLOW + VERSION = 1 + MINOR_VERSION = 2 + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" return cast(str, options[CONF_NAME]) diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 451cc65fb55..62906ea65ae 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -35,7 +35,7 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.device import async_device_info_to_link_from_entity +from homeassistant.helpers.device import async_entity_id_to_device from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -173,7 +173,7 @@ class MoldIndicator(SensorEntity): self._indoor_hum: float | None = None self._crit_temp: float | None = None if indoor_humidity_sensor: - self._attr_device_info = async_device_info_to_link_from_entity( + self.device_entry = async_entity_id_to_device( hass, indoor_humidity_sensor, ) diff --git a/tests/components/mold_indicator/test_init.py b/tests/components/mold_indicator/test_init.py index 5fd6b11c8fe..bfa8ad3a0ef 100644 --- a/tests/components/mold_indicator/test_init.py +++ b/tests/components/mold_indicator/test_init.py @@ -2,12 +2,190 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant +from unittest.mock import patch + +import pytest + +from homeassistant.components import mold_indicator +from homeassistant.components.mold_indicator.config_flow import ( + MoldIndicatorConfigFlowHandler, +) +from homeassistant.components.mold_indicator.const import ( + CONF_CALIBRATION_FACTOR, + CONF_INDOOR_HUMIDITY, + CONF_INDOOR_TEMP, + CONF_OUTDOOR_TEMP, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import CONF_NAME +from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.event import async_track_entity_registry_updated_event from tests.common import MockConfigEntry +@pytest.fixture +def indoor_humidity_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_humidity_device( + device_registry: dr.DeviceRegistry, indoor_humidity_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_humidity_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:ED")}, + ) + + +@pytest.fixture +def indoor_humidity_entity_entry( + entity_registry: er.EntityRegistry, + indoor_humidity_config_entry: ConfigEntry, + indoor_humidity_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_humidity", + config_entry=indoor_humidity_config_entry, + device_id=indoor_humidity_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def indoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def indoor_temperature_device( + device_registry: dr.DeviceRegistry, indoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=indoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EE")}, + ) + + +@pytest.fixture +def indoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + indoor_temperature_config_entry: ConfigEntry, + indoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_indoor_temperature", + config_entry=indoor_temperature_config_entry, + device_id=indoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def outdoor_temperature_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def outdoor_temperature_device( + device_registry: dr.DeviceRegistry, outdoor_temperature_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=outdoor_temperature_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def outdoor_temperature_entity_entry( + entity_registry: er.EntityRegistry, + outdoor_temperature_config_entry: ConfigEntry, + outdoor_temperature_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique_outdoor_temperature", + config_entry=outdoor_temperature_config_entry, + device_id=outdoor_temperature_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def mold_indicator_config_entry( + hass: HomeAssistant, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a mold_indicator config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=MoldIndicatorConfigFlowHandler.VERSION, + minor_version=MoldIndicatorConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry + + +@pytest.fixture +def expected_helper_device_id( + request: pytest.FixtureRequest, + indoor_humidity_device: dr.DeviceEntry, +) -> str | None: + """Fixture to provide the expected helper device ID.""" + return indoor_humidity_device.id if request.param == "humidity_device_id" else None + + +def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: + """Track entity registry actions for an entity.""" + events = [] + + @callback + def add_event(event: Event[er.EventEntityRegistryUpdatedData]) -> None: + """Add entity registry updated event to the list.""" + events.append(event.data["action"]) + + async_track_entity_registry_updated_event(hass, entity_id, add_event) + + return events + + async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None: """Test unload an entry.""" @@ -15,3 +193,500 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert await hass.config_entries.async_unload(loaded_entry.entry_id) await hass.async_block_till_done() assert loaded_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_device_cleaning( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test cleaning of devices linked to the helper config entry.""" + + # Source entity device config entry + source_config_entry = MockConfigEntry() + source_config_entry.add_to_hass(hass) + + # Device entry of the source entity + source_device1_entry = device_registry.async_get_or_create( + config_entry_id=source_config_entry.entry_id, + identifiers={("sensor", "identifier_test1")}, + connections={("mac", "30:31:32:33:34:01")}, + ) + + # Source entity registry + source_entity = entity_registry.async_get_or_create( + "sensor", + "indoor", + "humidity", + config_entry=source_config_entry, + device_id=source_device1_entry.id, + ) + await hass.async_block_till_done() + assert entity_registry.async_get("sensor.indoor_humidity") is not None + + # Configure the configuration entry for helper + helper_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="Test", + ) + helper_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # Device entry incorrectly linked to config entry + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test2")}, + connections={("mac", "30:31:32:33:34:02")}, + ) + device_registry.async_get_or_create( + config_entry_id=helper_config_entry.entry_id, + identifiers={("sensor", "identifier_test3")}, + connections={("mac", "30:31:32:33:34:03")}, + ) + await hass.async_block_till_done() + + # Before reloading the config entry, 3 devices are expected to be linked + devices_before_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_before_reload) == 2 + + # Config entry reload + await hass.config_entries.async_reload(helper_config_entry.entry_id) + await hass.async_block_till_done() + + # Confirm the link between the source entity device and the helper entity + helper_entity = entity_registry.async_get("sensor.mold_indicator") + assert helper_entity is not None + assert helper_entity.device_id == source_entity.device_id + + # After reloading the config entry, only one linked device is expected + devices_after_reload = device_registry.devices.get_devices_for_config_entry_id( + helper_config_entry.entry_id + ) + assert len(devices_after_reload) == 0 + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the device is removed + assert not device_registry.async_get(source_device.id) + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "expected_helper_device_id", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", None, ["update"]), + ("sensor.test_unique_indoor_temperature", "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_shared_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the mold_indicator config entry is removed when the source entity is removed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + # Add another config entry to the source device + other_config_entry = MockConfigEntry() + other_config_entry.add_to_hass(hass) + device_registry.async_update_device( + source_entity_entry.device_id, add_config_entry_id=other_config_entry.entry_id + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity's config entry from the device, this removes the + # source entity + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + device_registry.async_update_device( + source_device.id, remove_config_entry_id=source_entity_entry.config_entry_id + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + mock_unload_entry.assert_not_called() + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check if the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ( + "source_entity_id", + "unload_entry_calls", + "expected_helper_device_id", + "expected_events", + ), + [ + ("sensor.test_unique_indoor_humidity", 1, None, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, "humidity_device_id", []), + ("sensor.test_unique_outdoor_temperature", 0, "humidity_device_id", []), + ], + indirect=["expected_helper_device_id"], +) +async def test_async_handle_source_entity_changes_source_entity_removed_from_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_helper_device_id: str | None, + expected_events: list[str], +) -> None: + """Test the source entity removed from the source device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Remove the source entity from the device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=None + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert mold_indicator_entity_entry.device_id == expected_helper_device_id + + # Check that the mold_indicator config entry is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "unload_entry_calls", "expected_events"), + [ + ("sensor.test_unique_indoor_humidity", 1, ["update"]), + ("sensor.test_unique_indoor_temperature", 0, []), + ("sensor.test_unique_outdoor_temperature", 0, []), + ], +) +async def test_async_handle_source_entity_changes_source_entity_moved_other_device( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + unload_entry_calls: int, + expected_events: list[str], +) -> None: + """Test the source entity is moved to another device.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + source_device_2 = device_registry.async_get_or_create( + config_entry_id=source_entity_entry.config_entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:FF")}, + ) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Move the source entity to another device + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, device_id=source_device_2.id + ) + await hass.async_block_till_done() + assert len(mock_unload_entry.mock_calls) == unload_entry_calls + + # Check that the helper entity is linked to the expected source device + indoor_humidity_entity_entry = entity_registry.async_get( + indoor_humidity_entity_entry.entity_id + ) + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + # Check that the mold_indicator config entry is not in any of the devices + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + source_device_2 = device_registry.async_get(source_device_2.id) + assert mold_indicator_config_entry.entry_id not in source_device_2.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == expected_events + + +@pytest.mark.parametrize( + ("source_entity_id", "config_key"), + [ + ("sensor.test_unique_indoor_humidity", CONF_INDOOR_HUMIDITY), + ("sensor.test_unique_indoor_temperature", CONF_INDOOR_TEMP), + ("sensor.test_unique_outdoor_temperature", CONF_OUTDOOR_TEMP), + ], +) +async def test_async_handle_source_entity_new_entity_id( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + mold_indicator_config_entry: MockConfigEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + source_entity_id: str, + config_key: str, +) -> None: + """Test the source entity's entity ID is changed.""" + source_entity_entry = entity_registry.async_get(source_entity_id) + + assert await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + source_device = device_registry.async_get(source_entity_entry.device_id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + events = track_entity_registry_actions(hass, mold_indicator_entity_entry.entity_id) + + # Change the source entity's entity ID + with patch( + "homeassistant.components.mold_indicator.async_unload_entry", + wraps=mold_indicator.async_unload_entry, + ) as mock_unload_entry: + entity_registry.async_update_entity( + source_entity_entry.entity_id, new_entity_id="sensor.new_entity_id" + ) + await hass.async_block_till_done() + mock_unload_entry.assert_called_once() + + # Check that the mold_indicator config entry is updated with the new entity ID + assert mold_indicator_config_entry.options[config_key] == "sensor.new_entity_id" + + # Check that the helper config is not in the device + source_device = device_registry.async_get(source_device.id) + assert mold_indicator_config_entry.entry_id not in source_device.config_entries + + # Check that the mold_indicator config entry is not removed + assert mold_indicator_config_entry.entry_id in hass.config_entries.async_entry_ids() + + # Check we got the expected events + assert events == [] + + +async def test_migration_1_1( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + indoor_humidity_device: dr.DeviceEntry, + indoor_humidity_entity_entry: er.RegistryEntry, + indoor_temperature_entity_entry: er.RegistryEntry, + outdoor_temperature_entity_entry: er.RegistryEntry, +) -> None: + """Test migration from v1.1 removes mold_indicator config entry from device.""" + + mold_indicator_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: "My mold indicator", + CONF_INDOOR_HUMIDITY: indoor_humidity_entity_entry.entity_id, + CONF_INDOOR_TEMP: indoor_temperature_entity_entry.entity_id, + CONF_OUTDOOR_TEMP: outdoor_temperature_entity_entry.entity_id, + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=1, + minor_version=1, + ) + mold_indicator_config_entry.add_to_hass(hass) + + # Add the helper config entry to the device + device_registry.async_update_device( + indoor_humidity_device.id, + add_config_entry_id=mold_indicator_config_entry.entry_id, + ) + + # Check preconditions + switch_device = device_registry.async_get(indoor_humidity_device.id) + assert mold_indicator_config_entry.entry_id in switch_device.config_entries + + await hass.config_entries.async_setup(mold_indicator_config_entry.entry_id) + await hass.async_block_till_done() + + assert mold_indicator_config_entry.state is ConfigEntryState.LOADED + + # Check that the helper config entry is removed from the device and the helper + # entity is linked to the source device + switch_device = device_registry.async_get(switch_device.id) + assert mold_indicator_config_entry.entry_id not in switch_device.config_entries + mold_indicator_entity_entry = entity_registry.async_get("sensor.my_mold_indicator") + assert ( + mold_indicator_entity_entry.device_id == indoor_humidity_entity_entry.device_id + ) + + assert mold_indicator_config_entry.version == 1 + assert mold_indicator_config_entry.minor_version == 2 + + +async def test_migration_from_future_version( + hass: HomeAssistant, +) -> None: + """Test migration from future version.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + CONF_NAME: DEFAULT_NAME, + CONF_INDOOR_HUMIDITY: "sensor.indoor_humidity", + CONF_INDOOR_TEMP: "sensor.indoor_temp", + CONF_OUTDOOR_TEMP: "sensor.outdoor_temp", + CONF_CALIBRATION_FACTOR: 2.0, + }, + title="My mold indicator", + version=2, + minor_version=1, + ) + 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.MIGRATION_ERROR From 828f0f8b26d38fe8fdf757d203d4a950ce2c2519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 15 Jul 2025 22:43:40 +0200 Subject: [PATCH 1442/1664] Update aioairzone-cloud to v0.6.14 (#148820) --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 37 ++++++++++++---- .../airzone_cloud/test_binary_sensor.py | 2 +- tests/components/airzone_cloud/test_sensor.py | 10 ++--- tests/components/airzone_cloud/util.py | 42 +++++++++++-------- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e185ed89106..3a494aa361e 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.13"] + "requirements": ["aioairzone-cloud==0.6.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index 79d34968d39..f716b5a5518 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.13 +aioairzone-cloud==0.6.14 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 05c9ff6adf2..c65d4cf545e 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.13 +aioairzone-cloud==0.6.14 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 4bd7bfaccdd..3d566e6297b 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -210,10 +210,35 @@ 'ws-connected': True, }), }), + 'air-quality': dict({ + 'airqsensor1': dict({ + 'aq-active': False, + 'aq-index': 1, + 'aq-pm-1': 3, + 'aq-pm-10': 3, + 'aq-pm-2.5': 4, + 'aq-present': True, + 'aq-status': 'good', + 'available': True, + 'double-set-point': False, + 'id': 'airqsensor1', + 'installation': 'installation1', + 'is-connected': True, + 'name': 'CapteurQ', + 'problems': False, + 'system': 1, + 'web-server': 'webserver1', + 'ws-connected': True, + 'zone': 1, + }), + }), 'groups': dict({ 'group1': dict({ 'action': 1, 'active': True, + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'hot-water': list([ 'dhw1', @@ -332,6 +357,9 @@ 'aidoo1', 'aidoo_pro', ]), + 'air-quality': list([ + 'airqsensor1', + ]), 'available': True, 'groups': list([ 'group1', @@ -377,6 +405,7 @@ }), 'systems': dict({ 'system1': dict({ + 'aq-active': False, 'aq-index': 1, 'aq-pm-1': 3, 'aq-pm-10': 3, @@ -463,6 +492,7 @@ 'action': 1, 'active': True, 'air-demand': True, + 'air-quality-id': 'airqsensor1', 'aq-active': False, 'aq-index': 1, 'aq-mode-conf': 'auto', @@ -528,19 +558,12 @@ 'action': 6, 'active': False, 'air-demand': False, - 'aq-active': False, - 'aq-index': 1, 'aq-mode-conf': 'auto', 'aq-mode-values': list([ 'off', 'on', 'auto', ]), - 'aq-pm-1': 3, - 'aq-pm-10': 3, - 'aq-pm-2.5': 4, - 'aq-present': True, - 'aq-status': 'good', 'available': True, 'double-set-point': False, 'floor-demand': False, diff --git a/tests/components/airzone_cloud/test_binary_sensor.py b/tests/components/airzone_cloud/test_binary_sensor.py index bb2d0f78060..d88f66e6b2c 100644 --- a/tests/components/airzone_cloud/test_binary_sensor.py +++ b/tests/components/airzone_cloud/test_binary_sensor.py @@ -45,7 +45,7 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None: assert state.state == STATE_OFF state = hass.states.get("binary_sensor.dormitorio_air_quality_active") - assert state.state == STATE_OFF + assert state is None state = hass.states.get("binary_sensor.dormitorio_battery") assert state.state == STATE_OFF diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py index 672e10adedb..330a9efbef1 100644 --- a/tests/components/airzone_cloud/test_sensor.py +++ b/tests/components/airzone_cloud/test_sensor.py @@ -59,19 +59,19 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: # Zones state = hass.states.get("sensor.dormitorio_air_quality_index") - assert state.state == "1" + assert state is None state = hass.states.get("sensor.dormitorio_battery") assert state.state == "54" state = hass.states.get("sensor.dormitorio_pm1") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_pm2_5") - assert state.state == "4" + assert state is None state = hass.states.get("sensor.dormitorio_pm10") - assert state.state == "3" + assert state is None state = hass.states.get("sensor.dormitorio_signal_percentage") assert state.state == "76" @@ -82,7 +82,7 @@ async def test_airzone_create_sensors(hass: HomeAssistant) -> None: state = hass.states.get("sensor.dormitorio_humidity") assert state.state == "24" - state = hass.states.get("sensor.dormitorio_air_quality_index") + state = hass.states.get("sensor.salon_air_quality_index") assert state.state == "1" state = hass.states.get("sensor.salon_pm1") diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 52b0ae0bec3..835011f8c8c 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -19,6 +19,7 @@ from aioairzone_cloud.const import ( API_AZ_ACS, API_AZ_AIDOO, API_AZ_AIDOO_PRO, + API_AZ_AIRQSENSOR, API_AZ_SYSTEM, API_AZ_ZONE, API_CELSIUS, @@ -170,6 +171,17 @@ GET_INSTALLATION_MOCK = { }, API_WS_ID: WS_ID, }, + { + API_CONFIG: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_DEVICE_ID: "airqsensor1", + API_NAME: "CapteurQ", + API_TYPE: API_AZ_AIRQSENSOR, + API_META: {}, + API_WS_ID: WS_ID, + }, ], }, { @@ -394,11 +406,6 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: if device.get_id() == "system1": return { API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_ERRORS: [ { API_OLD_ID: "error-id", @@ -419,14 +426,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: True, API_AIR_ACTIVE: True, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 30, API_MODE: OperationMode.COOLING.value, @@ -466,14 +467,8 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: return { API_ACTIVE: False, API_AIR_ACTIVE: False, - API_AQ_ACTIVE: False, API_AQ_MODE_CONF: "auto", API_AQ_MODE_VALUES: ["off", "on", "auto"], - API_AQ_PM_1: 3, - API_AQ_PM_2P5: 4, - API_AQ_PM_10: 3, - API_AQ_PRESENT: True, - API_AQ_QUALITY: "good", API_DOUBLE_SET_POINT: False, API_HUMIDITY: 24, API_MODE: OperationMode.COOLING.value, @@ -504,6 +499,19 @@ def mock_get_device_status(device: Device) -> dict[str, Any]: API_LOCAL_TEMP: {API_FAH: 77, API_CELSIUS: 25}, API_WARNINGS: [], } + if device.get_id() == "airqsensor1": + return { + API_AQ_ACTIVE: False, + API_AQ_MODE_CONF: "auto", + API_AQ_MODE_VALUES: ["off", "on", "auto"], + API_AQ_PM_1: 3, + API_AQ_PM_2P5: 4, + API_AQ_PM_10: 3, + API_AQ_PRESENT: True, + API_AQ_QUALITY: "good", + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + } return {} From d46e0e132b05ce0abe5a77dcf6dd505e316fbf4a Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Tue, 15 Jul 2025 22:46:37 +0200 Subject: [PATCH 1443/1664] Add reconfigure flow to Uptime Kuma (#148833) --- .../components/uptime_kuma/config_flow.py | 104 +++++++++++++----- .../components/uptime_kuma/quality_scale.yaml | 2 +- .../components/uptime_kuma/strings.json | 16 ++- .../uptime_kuma/test_config_flow.py | 90 +++++++++++++++ 4 files changed, 180 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/uptime_kuma/config_flow.py b/homeassistant/components/uptime_kuma/config_flow.py index 30f9d7ae9ba..da71084d1bc 100644 --- a/homeassistant/components/uptime_kuma/config_flow.py +++ b/homeassistant/components/uptime_kuma/config_flow.py @@ -16,6 +16,7 @@ from yarl import URL from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( TextSelector, @@ -42,6 +43,29 @@ STEP_USER_DATA_SCHEMA = vol.Schema( STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_API_KEY, default=""): str}) +async def validate_connection( + hass: HomeAssistant, + url: URL | str, + verify_ssl: bool, + api_key: str, +) -> dict[str, str]: + """Validate Uptime Kuma connectivity.""" + errors: dict[str, str] = {} + session = async_get_clientsession(hass, verify_ssl) + uptime_kuma = UptimeKuma(session, url, api_key) + + try: + await uptime_kuma.metrics() + except UptimeKumaAuthenticationException: + errors["base"] = "invalid_auth" + except UptimeKumaException: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + return errors + + class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Uptime Kuma.""" @@ -54,19 +78,14 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): url = URL(user_input[CONF_URL]) self._async_abort_entries_match({CONF_URL: url.human_repr()}) - session = async_get_clientsession(self.hass, user_input[CONF_VERIFY_SSL]) - uptime_kuma = UptimeKuma(session, url, user_input[CONF_API_KEY]) - - try: - await uptime_kuma.metrics() - except UptimeKumaAuthenticationException: - errors["base"] = "invalid_auth" - except UptimeKumaException: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): return self.async_create_entry( title=url.host or "", data={**user_input, CONF_URL: url.human_repr()}, @@ -95,23 +114,14 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): entry = self._get_reauth_entry() if user_input is not None: - session = async_get_clientsession(self.hass, entry.data[CONF_VERIFY_SSL]) - uptime_kuma = UptimeKuma( - session, - entry.data[CONF_URL], - user_input[CONF_API_KEY], - ) - - try: - await uptime_kuma.metrics() - except UptimeKumaAuthenticationException: - errors["base"] = "invalid_auth" - except UptimeKumaException: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + if not ( + errors := await validate_connection( + self.hass, + entry.data[CONF_URL], + entry.data[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): return self.async_update_reload_and_abort( entry, data_updates=user_input, @@ -124,3 +134,37 @@ class UptimeKumaConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfigure flow.""" + errors: dict[str, str] = {} + + entry = self._get_reconfigure_entry() + + if user_input is not None: + url = URL(user_input[CONF_URL]) + self._async_abort_entries_match({CONF_URL: url.human_repr()}) + + if not ( + errors := await validate_connection( + self.hass, + url, + user_input[CONF_VERIFY_SSL], + user_input[CONF_API_KEY], + ) + ): + return self.async_update_reload_and_abort( + entry, + data_updates={**user_input, CONF_URL: url.human_repr()}, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + data_schema=STEP_USER_DATA_SCHEMA, + suggested_values=user_input or entry.data, + ), + errors=errors, + ) diff --git a/homeassistant/components/uptime_kuma/quality_scale.yaml b/homeassistant/components/uptime_kuma/quality_scale.yaml index 469ecad8d7b..876318c8917 100644 --- a/homeassistant/components/uptime_kuma/quality_scale.yaml +++ b/homeassistant/components/uptime_kuma/quality_scale.yaml @@ -66,7 +66,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: todo + reconfiguration-flow: done repair-issues: status: exempt comment: has no repairs diff --git a/homeassistant/components/uptime_kuma/strings.json b/homeassistant/components/uptime_kuma/strings.json index 0321db1c221..87dcf6e8cf7 100644 --- a/homeassistant/components/uptime_kuma/strings.json +++ b/homeassistant/components/uptime_kuma/strings.json @@ -23,6 +23,19 @@ "data_description": { "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" } + }, + "reconfigure": { + "title": "Update configuration for Uptime Kuma", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "url": "[%key:component::uptime_kuma::config::step::user::data_description::url%]", + "verify_ssl": "[%key:component::uptime_kuma::config::step::user::data_description::verify_ssl%]", + "api_key": "[%key:component::uptime_kuma::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -32,7 +45,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "entity": { diff --git a/tests/components/uptime_kuma/test_config_flow.py b/tests/components/uptime_kuma/test_config_flow.py index 3c1bf902ce8..ab695107b9b 100644 --- a/tests/components/uptime_kuma/test_config_flow.py +++ b/tests/components/uptime_kuma/test_config_flow.py @@ -190,3 +190,93 @@ async def test_flow_reauth_errors( assert config_entry.data[CONF_API_KEY] == "newapikey" assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure( + hass: HomeAssistant, + config_entry: MockConfigEntry, +) -> None: + """Test reconfigure flow.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 + + +@pytest.mark.parametrize( + ("raise_error", "text_error"), + [ + (UptimeKumaConnectionException, "cannot_connect"), + (UptimeKumaAuthenticationException, "invalid_auth"), + (ValueError, "unknown"), + ], +) +@pytest.mark.usefixtures("mock_pythonkuma") +async def test_flow_reconfigure_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_pythonkuma: AsyncMock, + raise_error: Exception, + text_error: str, +) -> None: + """Test reconfigure flow errors and recover.""" + config_entry.add_to_hass(hass) + result = await config_entry.start_reconfigure_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_pythonkuma.metrics.side_effect = raise_error + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": text_error} + + mock_pythonkuma.metrics.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data == { + CONF_URL: "https://uptime.example.org:3001/", + CONF_VERIFY_SSL: False, + CONF_API_KEY: "newapikey", + } + + assert len(hass.config_entries.async_entries()) == 1 From 7f2a32d4ebc4298311b0ea763e03c28b5224f692 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 15 Jul 2025 23:11:55 +0200 Subject: [PATCH 1444/1664] Remove not needed go2rtc stream config (#148836) --- homeassistant/components/go2rtc/__init__.py | 1 - tests/components/go2rtc/test_init.py | 3 --- 2 files changed, 4 deletions(-) diff --git a/homeassistant/components/go2rtc/__init__.py b/homeassistant/components/go2rtc/__init__.py index 8d3e988dd14..aeedb847090 100644 --- a/homeassistant/components/go2rtc/__init__.py +++ b/homeassistant/components/go2rtc/__init__.py @@ -328,7 +328,6 @@ class WebRTCProvider(CameraWebRTCProvider): # Connection problems to the camera will be logged by the first stream # Therefore setting it to debug will not hide any important logs f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) diff --git a/tests/components/go2rtc/test_init.py b/tests/components/go2rtc/test_init.py index dcbcb629d11..0a071f45ef7 100644 --- a/tests/components/go2rtc/test_init.py +++ b/tests/components/go2rtc/test_init.py @@ -120,7 +120,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -139,7 +138,6 @@ async def _test_setup_and_signaling( [ "rtsp://stream", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) @@ -696,6 +694,5 @@ async def test_generic_workaround( [ "ffmpeg:https://my_stream_url.m3u8", f"ffmpeg:{camera.entity_id}#audio=opus#query=log_level=debug", - f"ffmpeg:{camera.entity_id}#video=mjpeg", ], ) From 38e4e18f60dea3e4a5a5c029131abe99e1fe1303 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 01:41:56 +0200 Subject: [PATCH 1445/1664] Bump IMGW-PIB to version 1.4.1 (#148849) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/imgw_pib/conftest.py | 2 +- .../imgw_pib/snapshots/test_diagnostics.ambr | 14 +++++++------- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index a24e5d23907..e2032b6d51a 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.0"] + "requirements": ["imgw_pib==1.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f716b5a5518..1b8fc7b8801 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.0 +imgw_pib==1.4.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c65d4cf545e..2b4ff300a02 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.0 +imgw_pib==1.4.1 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index ad5ad992688..c3f87288573 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -25,7 +25,7 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), - alert=Alert(value=NO_ALERT), + hydrological_alert=Alert(value=NO_ALERT), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index 1521bc8320a..be2afee3da9 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -22,13 +22,6 @@ 'version': 1, }), 'hydrological_data': dict({ - 'alert': dict({ - 'level': None, - 'probability': None, - 'valid_from': None, - 'valid_to': None, - 'value': 'no_alert', - }), 'flood_alarm': None, 'flood_alarm_level': dict({ 'name': 'Flood Alarm Level', @@ -41,6 +34,13 @@ 'unit': None, 'value': None, }), + 'hydrological_alert': dict({ + 'level': None, + 'probability': None, + 'valid_from': None, + 'valid_to': None, + 'value': 'no_alert', + }), 'latitude': None, 'longitude': None, 'river': 'River Name', From 57e4270b7b75f420815dd8e518cc1512725e5340 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 16 Jul 2025 08:06:49 +0200 Subject: [PATCH 1446/1664] Make exceptions translatable in inexogy integration (#148865) --- homeassistant/components/discovergy/__init__.py | 9 +++++++-- homeassistant/components/discovergy/coordinator.py | 11 +++++++++-- .../components/discovergy/quality_scale.yaml | 8 ++++++-- homeassistant/components/discovergy/strings.json | 11 +++++++++++ 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index 0a8b7422f84..65687debd3a 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import create_async_httpx_client +from .const import DOMAIN from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -30,10 +31,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) - # if no exception is raised everything is fine to go meters = await client.meters() except discovergyError.InvalidLogin as err: - raise ConfigEntryAuthFailed("Invalid email or password") from err + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from err except Exception as err: raise ConfigEntryNotReady( - "Unexpected error while while getting meters" + translation_domain=DOMAIN, + translation_key="cannot_connect_meters_setup", ) from err # Init coordinators for meters diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index e3f26ad49f8..2c77ab2388e 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -14,6 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) type DiscovergyConfigEntry = ConfigEntry[list[DiscovergyUpdateCoordinator]] @@ -51,7 +53,12 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): ) except InvalidLogin as err: raise ConfigEntryAuthFailed( - "Auth expired while fetching last reading" + translation_domain=DOMAIN, + translation_key="invalid_auth", ) from err except (HTTPError, DiscovergyClientError) as err: - raise UpdateFailed(f"Error while fetching last reading: {err}") from err + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="reading_update_failed", + translation_placeholders={"meter_id": self.meter.meter_id}, + ) from err diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index 56af1d97304..a8f140f258c 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -72,12 +72,16 @@ rules: entity-device-class: done entity-disabled-by-default: done entity-translations: done - exception-translations: todo + exception-translations: done icon-translations: status: exempt comment: | The integration does not provide any additional icons. - reconfiguration-flow: todo + reconfiguration-flow: + status: exempt + comment: | + No configuration besides credentials. + New credentials will create a new config entry. repair-issues: status: exempt comment: | diff --git a/homeassistant/components/discovergy/strings.json b/homeassistant/components/discovergy/strings.json index 0058f874a36..911a4a1c4f5 100644 --- a/homeassistant/components/discovergy/strings.json +++ b/homeassistant/components/discovergy/strings.json @@ -23,6 +23,17 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "exceptions": { + "invalid_auth": { + "message": "Authentication failed. Please check your inexogy email and password." + }, + "cannot_connect_meters_setup": { + "message": "Failed to connect and retrieve meters from inexogy during setup. Please ensure the service is reachable and try again." + }, + "reading_update_failed": { + "message": "Error fetching the latest reading for meter {meter_id} from inexogy. The service might be temporarily unavailable or there's a connection issue. Check logs for more details." + } + }, "system_health": { "info": { "api_endpoint_reachable": "inexogy API endpoint reachable" From 549069e22cbb3de107f3b504783a3328f139288f Mon Sep 17 00:00:00 2001 From: Pete Sage <76050312+PeteRager@users.noreply.github.com> Date: Wed, 16 Jul 2025 02:09:24 -0400 Subject: [PATCH 1447/1664] Add guard to prevent exception in Sonos Favorites (#148854) --- homeassistant/components/sonos/favorites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py index 8824c56a762..c1e1b4f80df 100644 --- a/homeassistant/components/sonos/favorites.py +++ b/homeassistant/components/sonos/favorites.py @@ -72,7 +72,7 @@ class SonosFavorites(SonosHouseholdCoordinator): """Process the event payload in an async lock and update entities.""" event_id = event.variables["favorites_update_id"] container_ids = event.variables["container_update_i_ds"] - if not (match := re.search(r"FV:2,(\d+)", container_ids)): + if not container_ids or not (match := re.search(r"FV:2,(\d+)", container_ids)): return container_id = int(match.group(1)) From ffc2b0a8cf0b7efb8a837b993d6f0b610174a611 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 16:09:54 +1000 Subject: [PATCH 1448/1664] Add mock for listen in Teslemetry tests (#148853) --- tests/components/teslemetry/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index 0152543e512..b9b5efae6ec 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -119,8 +119,17 @@ def mock_energy_history(): @pytest.fixture(autouse=True) -def mock_add_listener(): +def mock_stream_listen(): """Mock Teslemetry Stream listen method.""" + with patch( + "teslemetry_stream.TeslemetryStream.listen", + ) as mock_stream_listen: + yield mock_stream_listen + + +@pytest.fixture(autouse=True) +def mock_add_listener(): + """Mock Teslemetry Stream add listener method.""" with patch( "teslemetry_stream.TeslemetryStream.async_add_listener", ) as mock_add_listener: From 2011e643905233d8e63310d1fd5852252484204c Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 16 Jul 2025 08:10:29 +0200 Subject: [PATCH 1449/1664] Different fixes in user-facing strings of `nasweb` (#148830) --- homeassistant/components/nasweb/strings.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nasweb/strings.json b/homeassistant/components/nasweb/strings.json index 2e1ea55ffcb..73b91768374 100644 --- a/homeassistant/components/nasweb/strings.json +++ b/homeassistant/components/nasweb/strings.json @@ -15,7 +15,7 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "missing_internal_url": "Make sure Home Assistant has a valid internal URL", - "missing_nasweb_data": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant.", + "missing_nasweb_data": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant.", "missing_status": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device.", "unknown": "[%key:common::config_flow::error::unknown%]" }, @@ -25,13 +25,13 @@ }, "exceptions": { "config_entry_error_invalid_authentication": { - "message": "Invalid username/password. Most likely user changed password or was removed. Delete this entry and create a new one with the correct username/password." + "message": "Invalid username/password. Most likely the user has changed their password or has been removed. Delete this entry and create a new one with the correct username/password." }, "config_entry_error_internal_error": { - "message": "Something isn't right with device internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" + "message": "Something isn't right with the device's internal configuration. Try restarting the device and Home Assistant. If the issue persists contact support at {support_email}" }, "config_entry_error_no_status_update": { - "message": "Did not received any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" + "message": "Did not receive any status updates within the expected time window. Make sure the Home Assistant internal URL is reachable from the NASweb device. If the issue persists contact support at {support_email}" }, "config_entry_error_missing_internal_url": { "message": "[%key:component::nasweb::config::error::missing_internal_url%]" @@ -43,7 +43,7 @@ "entity": { "switch": { "switch_output": { - "name": "Relay Switch {index}" + "name": "Relay switch {index}" } }, "sensor": { @@ -52,8 +52,8 @@ "state": { "undefined": "Undefined", "tamper": "Tamper", - "active": "Active", - "normal": "Normal", + "active": "[%key:common::state::active%]", + "normal": "[%key:common::state::normal%]", "problem": "Problem" } } From 9c933ef01fd3632644c3d1983105af25a3f82d7b Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:10:56 +0200 Subject: [PATCH 1450/1664] Add support for HmIPW-DRBL4 in homematicip_cloud (#148844) --- homeassistant/components/homematicip_cloud/cover.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index f9986e0c526..931b689fb08 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -12,6 +12,7 @@ from homematicip.device import ( FullFlushShutter, GarageDoorModuleTormatic, HoermannDrivesModule, + WiredDinRailBlind4, ) from homematicip.group import ExtendedLinkedShutterGroup @@ -48,7 +49,7 @@ async def async_setup_entry( for device in hap.home.devices: if isinstance(device, BlindModule): entities.append(HomematicipBlindModule(hap, device)) - elif isinstance(device, DinRailBlind4): + elif isinstance(device, (DinRailBlind4, WiredDinRailBlind4)): entities.extend( HomematicipMultiCoverSlats(hap, device, channel=channel) for channel in range(1, 5) From 27ad459ae06633d739e7108bd502c4dcb1f10b59 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:11:55 +0200 Subject: [PATCH 1451/1664] Add tuya snapshots for more humidifiers (cs category) (#148797) --- tests/components/tuya/__init__.py | 14 ++ .../tuya/fixtures/cs_emma_dehumidifier.json | 129 ++++++++++++++++++ .../tuya/fixtures/cs_smart_dry_plus.json | 32 +++++ .../tuya/snapshots/test_binary_sensor.ambr | 98 +++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 104 ++++++++++++++ .../tuya/snapshots/test_humidifier.ambr | 110 +++++++++++++++ .../tuya/snapshots/test_select.ambr | 61 +++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++++++ .../tuya/snapshots/test_switch.ambr | 98 +++++++++++++ 9 files changed, 699 insertions(+) create mode 100644 tests/components/tuya/fixtures/cs_emma_dehumidifier.json create mode 100644 tests/components/tuya/fixtures/cs_smart_dry_plus.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c8f54fa275d..129930b810f 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -31,6 +31,20 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "cs_emma_dehumidifier": [ + # https://github.com/home-assistant/core/issues/119865 + Platform.BINARY_SENSOR, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], + "cs_smart_dry_plus": [ + # https://github.com/home-assistant/core/issues/119865 + Platform.FAN, + Platform.HUMIDIFIER, + ], "cwwsq_cleverio_pf100": [ # https://github.com/home-assistant/core/issues/144745 Platform.NUMBER, diff --git a/tests/components/tuya/fixtures/cs_emma_dehumidifier.json b/tests/components/tuya/fixtures/cs_emma_dehumidifier.json new file mode 100644 index 00000000000..8a2fd881262 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_emma_dehumidifier.json @@ -0,0 +1,129 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Dehumidifer", + "category": "cs", + "product_id": "ka2wfrdoogpvgzfi", + "product_name": "Emma Dehumidifier - eeese air care", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2024-11-06T18:25:00+00:00", + "create_time": "2024-11-06T18:25:00+00:00", + "update_time": "2024-11-06T18:25:00+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "dehumidify_set_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 25, + "max": 80, + "scale": 0, + "step": 5 + } + }, + "fan_speed_enum": { + "type": "Enum", + "value": { + "range": ["low", "high"] + } + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "child_lock": { + "type": "Boolean", + "value": {} + }, + "humidity_indoor": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "countdown_set": { + "type": "Enum", + "value": { + "range": ["cancel", "1h", "2h", "3h"] + } + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "h", + "min": 0, + "max": 24, + "scale": 0, + "step": 1 + } + }, + "fault": { + "type": "Bitmap", + "value": { + "label": ["tankfull", "defrost", "E1", "E2", "L3", "L4", "L2"] + } + } + }, + "status": { + "switch": false, + "dehumidify_set_value": 25, + "fan_speed_enum": "low", + "anion": false, + "child_lock": false, + "humidity_indoor": 48, + "countdown_set": "cancel", + "countdown_left": 0, + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/cs_smart_dry_plus.json b/tests/components/tuya/fixtures/cs_smart_dry_plus.json new file mode 100644 index 00000000000..ff922f506c5 --- /dev/null +++ b/tests/components/tuya/fixtures/cs_smart_dry_plus.json @@ -0,0 +1,32 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Dehumidifier ", + "category": "cs", + "product_id": "vmxuxszzjwp5smli", + "product_name": "the Smart Dry Plus\u2122 Connect Dehumidifier ", + "online": true, + "sub": false, + "time_zone": "+10:00", + "active_time": "2024-05-28T01:57:58+00:00", + "create_time": "2024-05-28T01:57:58+00:00", + "update_time": "2024-05-28T01:57:58+00:00", + "function": {}, + "status_range": { + "fault": { + "type": "Bitmap", + "value": { + "label": ["E1", "E2"] + } + } + }, + "status": { + "fault": 0 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index efd995b3280..81f41bc1fdc 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -146,6 +146,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-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': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Defrost', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'defrost', + 'unique_id': 'tuya.mock_device_iddefrost', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_defrost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Defrost', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_defrost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-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': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Tank full', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tankfull', + 'unique_id': 'tuya.mock_device_idtankfull', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][binary_sensor.dehumidifer_tank_full-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'problem', + 'friendly_name': 'Dehumidifer Tank full', + }), + 'context': , + 'entity_id': 'binary_sensor.dehumidifer_tank_full', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[mcs_door_sensor][binary_sensor.door_garage_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index cbd3c997625..2a7ea120dd5 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -49,6 +49,110 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifer', + '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.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][fan.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer', + 'preset_modes': list([ + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.dehumidifier', + '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': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][fan.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifier ', + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][fan.bree-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_humidifier.ambr b/tests/components/tuya/snapshots/test_humidifier.ambr index c22005e123d..3389f927eb4 100644 --- a/tests/components/tuya/snapshots/test_humidifier.ambr +++ b/tests/components/tuya/snapshots/test_humidifier.ambr @@ -56,3 +56,113 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 80, + 'min_humidity': 25, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][humidifier.dehumidifer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifer', + 'max_humidity': 80, + 'min_humidity': 25, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_humidity': 100, + 'min_humidity': 0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'humidifier', + 'entity_category': None, + 'entity_id': 'humidifier.dehumidifier', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.mock_device_idswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_smart_dry_plus][humidifier.dehumidifier-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'dehumidifier', + 'friendly_name': 'Dehumidifier ', + 'max_humidity': 100, + 'min_humidity': 0, + 'supported_features': , + }), + 'context': , + 'entity_id': 'humidifier.dehumidifier', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index e8337fb4fbf..2c5b0e86619 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -117,6 +117,67 @@ 'state': 'cancel', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.dehumidifer_countdown', + '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': 'Countdown', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'countdown', + 'unique_id': 'tuya.mock_device_idcountdown_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][select.dehumidifer_countdown-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Countdown', + 'options': list([ + 'cancel', + '1h', + '2h', + '3h', + ]), + }), + 'context': , + 'entity_id': 'select.dehumidifer_countdown', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 8cf51062a73..530c9fccde2 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -52,6 +52,59 @@ 'state': '47.0', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_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.dehumidifer_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.mock_device_idhumidity_indoor', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][sensor.dehumidifer_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'Dehumidifer Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.dehumidifer_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index bf970a6ffbb..1ba823e192d 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -48,6 +48,104 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_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.dehumidifer_child_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:account-lock', + 'original_name': 'Child lock', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'child_lock', + 'unique_id': 'tuya.mock_device_idchild_lock', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_child_lock-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Child lock', + 'icon': 'mdi:account-lock', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_child_lock', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-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.dehumidifer_ionizer', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:atom', + 'original_name': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.mock_device_idanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cs_emma_dehumidifier][switch.dehumidifer_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Dehumidifer Ionizer', + 'icon': 'mdi:atom', + }), + 'context': , + 'entity_id': 'switch.dehumidifer_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From bcec29763f46d4a925e5865884e32cb6d75d6a14 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 16:16:36 +1000 Subject: [PATCH 1452/1664] Fix button platform parent class in Teslemetry (#148863) --- homeassistant/components/teslemetry/button.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py index cf1d6157ec1..12772b894b6 100644 --- a/homeassistant/components/teslemetry/button.py +++ b/homeassistant/components/teslemetry/button.py @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TeslemetryConfigEntry -from .entity import TeslemetryVehiclePollingEntity +from .entity import TeslemetryVehicleStreamEntity from .helpers import handle_command, handle_vehicle_command from .models import TeslemetryVehicleData @@ -74,7 +74,7 @@ async def async_setup_entry( ) -class TeslemetryButtonEntity(TeslemetryVehiclePollingEntity, ButtonEntity): +class TeslemetryButtonEntity(TeslemetryVehicleStreamEntity, ButtonEntity): """Base class for Teslemetry buttons.""" api: Vehicle From 9db5b0b3b75d1b7f616e2e2d6e64c6e6dcf52c1a Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 16 Jul 2025 08:51:16 +0200 Subject: [PATCH 1453/1664] Validate selectors in the service helper (#148857) --- homeassistant/helpers/service.py | 3 +++ tests/helpers/test_service.py | 46 +++++++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3186c211eaa..f9c846c60fa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -19,6 +19,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, CONF_ACTION, CONF_ENTITY_ID, + CONF_SELECTOR, CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE, CONF_SERVICE_TEMPLATE, @@ -54,6 +55,7 @@ from . import ( config_validation as cv, device_registry, entity_registry, + selector, target as target_helpers, template, translation, @@ -166,6 +168,7 @@ def validate_supported_feature(supported_feature: str) -> Any: # to their values. Full validation is done by hassfest.services _FIELD_SCHEMA = vol.Schema( { + vol.Optional(CONF_SELECTOR): selector.validate_selector, vol.Optional("filter"): { vol.Optional("attribute"): { vol.Required(str): [vol.All(str, validate_attribute_option)], diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 0191827cd58..f4d0846c262 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -987,7 +987,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None: "test_domain": { "test_service": { "description": "", - "fields": {"test": {"selector": {"text": None}}}, + "fields": {"test": {"selector": {"text": {}}}}, "name": "", } } @@ -1013,6 +1013,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME advanced_stuff: fields: temperature: @@ -1024,6 +1031,13 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: - light.ColorMode.COLOR_TEMP selector: number: + entity: + selector: + entity: + filter: + domain: alarm_control_panel + supported_features: + - alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME """ domain = "test_domain" @@ -1065,7 +1079,20 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + }, + }, }, }, }, @@ -1074,7 +1101,20 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: "attribute": {"supported_color_modes": ["color_temp"]}, "supported_features": [1], }, - "selector": {"number": None}, + "selector": {"number": {}}, + }, + "entity": { + "selector": { + "entity": { + "filter": [ + { + "domain": ["alarm_control_panel"], + "supported_features": [1], + } + ], + "multiple": False, + }, + }, }, }, "name": "", From d8de6e34dde374c3dec8edde22185db5358c1400 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:24:20 +0200 Subject: [PATCH 1454/1664] Add support for Tuya ks category (tower fan) (#148811) --- homeassistant/components/tuya/fan.py | 22 +++- homeassistant/components/tuya/light.py | 9 ++ homeassistant/components/tuya/switch.py | 8 ++ tests/components/tuya/__init__.py | 6 + .../tuya/fixtures/ks_tower_fan.json | 107 ++++++++++++++++++ tests/components/tuya/snapshots/test_fan.ambr | 64 +++++++++++ .../components/tuya/snapshots/test_light.ambr | 57 ++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++++ 8 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 tests/components/tuya/fixtures/ks_tower_fan.json diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index f96ea2c0a65..90f4132cef0 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -26,11 +26,23 @@ from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData TUYA_SUPPORT_TYPE = { - "cs", # Dehumidifier - "fs", # Fan - "fsd", # Fan with Light - "fskg", # Fan wall switch - "kj", # Air Purifier + # Dehumidifier + # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + "cs", + # Fan + # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + "fs", + # Ceiling Fan Light + # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + "fsd", + # Fan wall switch + "fskg", + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj", + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks", } diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 3f8fc7d0fb9..b6d0332e03a 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -242,6 +242,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + translation_key="backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Unknown light product # Found as VECINO RGBW as provided by diagnostics # Not documented diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f455424c2c1..bfe80ec67bf 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -431,6 +431,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Undocumented tower fan + # https://github.com/orgs/home-assistant/discussions/329 + "ks": ( + SwitchEntityDescription( + key=DPCode.ANION, + translation_key="ionizer", + ), + ), # Alarm Host # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk "mal": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 129930b810f..6427a69cdea 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -79,6 +79,12 @@ DEVICE_MOCKS = { Platform.SELECT, Platform.SWITCH, ], + "ks_tower_fan": [ + # https://github.com/orgs/home-assistant/discussions/329 + Platform.FAN, + Platform.LIGHT, + Platform.SWITCH, + ], "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, diff --git a/tests/components/tuya/fixtures/ks_tower_fan.json b/tests/components/tuya/fixtures/ks_tower_fan.json new file mode 100644 index 00000000000..071596e8e6c --- /dev/null +++ b/tests/components/tuya/fixtures/ks_tower_fan.json @@ -0,0 +1,107 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Tower Fan CA-407G Smart", + "category": "ks", + "product_id": "j9fa8ahzac8uvlfl", + "product_name": "Tower Fan CA-407G Smart", + "online": true, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-07-14T11:22:54+00:00", + "create_time": "2025-07-14T11:22:54+00:00", + "update_time": "2025-07-14T11:22:54+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "fan_speed": { + "type": "Integer", + "value": { + "unit": "", + "min": 1, + "max": 12, + "scale": 0, + "step": 1 + } + }, + "mode": { + "type": "Enum", + "value": { + "range": ["ordinary", "nature", "sleep"] + } + }, + "switch_horizontal": { + "type": "Boolean", + "value": {} + }, + "anion": { + "type": "Boolean", + "value": {} + }, + "light": { + "type": "Boolean", + "value": {} + }, + "countdown_left": { + "type": "Integer", + "value": { + "unit": "min", + "min": 0, + "max": 721, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "fan_speed": 5, + "mode": "ordinary", + "switch_horizontal": true, + "anion": false, + "light": true, + "countdown_left": 0 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_fan.ambr b/tests/components/tuya/snapshots/test_fan.ambr index 2a7ea120dd5..ff795c150c9 100644 --- a/tests/components/tuya/snapshots/test_fan.ambr +++ b/tests/components/tuya/snapshots/test_fan.ambr @@ -210,3 +210,67 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.tower_fan_ca_407g_smart', + '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.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][fan.tower_fan_ca_407g_smart-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart', + 'oscillating': True, + 'percentage': 37, + 'percentage_step': 1.0, + 'preset_mode': 'ordinary', + 'preset_modes': list([ + 'ordinary', + 'nature', + 'sleep', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'fan.tower_fan_ca_407g_smart', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b9395b3d682..b83e9484853 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,3 +56,60 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-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': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + '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': 'Backlight', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'backlight', + 'unique_id': 'tuya.mock_device_idlight', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Tower Fan CA-407G Smart Backlight', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.tower_fan_ca_407g_smart_backlight', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1ba823e192d..4e6af0fa7d3 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -677,6 +677,54 @@ 'state': 'off', }) # --- +# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-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': None, + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + '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': 'Ionizer', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ionizer', + 'unique_id': 'tuya.mock_device_idanion', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ks_tower_fan][switch.tower_fan_ca_407g_smart_ionizer-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Tower Fan CA-407G Smart Ionizer', + }), + 'context': , + 'entity_id': 'switch.tower_fan_ca_407g_smart_ionizer', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[mal_alarm_host][switch.multifunction_alarm_arm_beep-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From fae6b375cdf854de24e507163efd5013c6a2128c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:39:22 +0200 Subject: [PATCH 1455/1664] Fix incorrectly rejected device classes in tuya (#148596) --- homeassistant/components/number/__init__.py | 1 + homeassistant/components/tuya/number.py | 19 ++++++++++- homeassistant/components/tuya/sensor.py | 11 +++++++ .../tuya/snapshots/test_sensor.ambr | 32 ++++++++++++++----- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 3e9d3448af2..054f888ba33 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -39,6 +39,7 @@ from .const import ( # noqa: F401 DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, + DEVICE_CLASS_UNITS, DEVICE_CLASSES_SCHEMA, DOMAIN, SERVICE_SET_VALUE, diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index cb248d42739..4fb180ffd08 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -5,6 +5,7 @@ from __future__ import annotations from tuya_sharing import CustomerDevice, Manager from homeassistant.components.number import ( + DEVICE_CLASS_UNITS as NUMBER_DEVICE_CLASS_UNITS, NumberDeviceClass, NumberEntity, NumberEntityDescription, @@ -15,7 +16,14 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import ( + DEVICE_CLASS_UNITS, + DOMAIN, + LOGGER, + TUYA_DISCOVERY_NEW, + DPCode, + DPType, +) from .entity import TuyaEntity from .models import IntegerTypeData @@ -371,6 +379,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in NUMBER_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -378,6 +389,12 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in number entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 9caf642d403..d1220e08728 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -8,6 +8,7 @@ from tuya_sharing import CustomerDevice, Manager from tuya_sharing.device import DeviceStatusRange from homeassistant.components.sensor import ( + DEVICE_CLASS_UNITS as SENSOR_DEVICE_CLASS_UNITS, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -32,6 +33,7 @@ from . import TuyaConfigEntry from .const import ( DEVICE_CLASS_UNITS, DOMAIN, + LOGGER, TUYA_DISCOVERY_NEW, DPCode, DPType, @@ -1438,6 +1440,9 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.device_class is not None and not self.device_class.startswith(DOMAIN) and description.native_unit_of_measurement is None + # we do not need to check mappings if the API UOM is allowed + and self.native_unit_of_measurement + not in SENSOR_DEVICE_CLASS_UNITS[self.device_class] ): # We cannot have a device class, if the UOM isn't set or the # device class cannot be found in the validation mapping. @@ -1445,6 +1450,12 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity): self.native_unit_of_measurement is None or self.device_class not in DEVICE_CLASS_UNITS ): + LOGGER.debug( + "Device class %s ignored for incompatible unit %s in sensor entity %s", + self.device_class, + self.native_unit_of_measurement, + self.unique_id, + ) self._attr_device_class = None return diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 530c9fccde2..f0350f12c48 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -181,8 +181,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Filter duration', 'platform': 'tuya', @@ -197,6 +200,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_filter_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Filter duration', 'state_class': , 'unit_of_measurement': 'min', @@ -233,8 +237,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'UV runtime', 'platform': 'tuya', @@ -249,6 +256,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_uv_runtime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain UV runtime', 'state_class': , 'unit_of_measurement': 's', @@ -333,8 +341,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water pump duration', 'platform': 'tuya', @@ -349,6 +360,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_pump_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Water pump duration', 'state_class': , 'unit_of_measurement': 'min', @@ -385,8 +397,11 @@ }), 'name': None, 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Water usage duration', 'platform': 'tuya', @@ -401,6 +416,7 @@ # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][sensor.pixi_smart_drinking_fountain_water_usage_duration-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'PIXI Smart Drinking Fountain Water usage duration', 'state_class': , 'unit_of_measurement': 'min', @@ -509,7 +525,7 @@ 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'tuya.eb0c772dabbb19d653ssi5cur_power', - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }) # --- # name: test_platform_setup_and_discovery[cz_dual_channel_metering][sensor.hvac_meter_power-state] @@ -518,7 +534,7 @@ 'device_class': 'power', 'friendly_name': 'HVAC Meter Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , 'entity_id': 'sensor.hvac_meter_power', @@ -683,7 +699,7 @@ 'supported_features': 0, 'translation_key': 'power', 'unique_id': 'tuya.mocked_device_idcur_power', - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }) # --- # name: test_platform_setup_and_discovery[dlq_earu_electric_eawcpt][sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power-state] @@ -692,7 +708,7 @@ 'device_class': 'power', 'friendly_name': '一路带计量磁保持通断器 Power', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': 'W', }), 'context': , 'entity_id': 'sensor.yi_lu_dai_ji_liang_ci_bao_chi_tong_duan_qi_power', From bafd342d5dfd741786b4c6e9feca7059dcfceca9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:54:44 +0200 Subject: [PATCH 1456/1664] Add initial support for tuya cwjwq (#148420) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/select.py | 9 ++ homeassistant/components/tuya/sensor.py | 9 ++ homeassistant/components/tuya/strings.json | 16 +++ homeassistant/components/tuya/switch.py | 8 ++ tests/components/tuya/__init__.py | 6 ++ .../fixtures/cwjwq_smart_odor_eliminator.json | 66 ++++++++++++ .../tuya/snapshots/test_select.ambr | 57 ++++++++++ .../tuya/snapshots/test_sensor.ambr | 101 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 +++++++++ 10 files changed, 321 insertions(+) create mode 100644 tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 61da1239554..863ef451eaa 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -406,6 +406,7 @@ class DPCode(StrEnum): WIRELESS_ELECTRICITY = "wireless_electricity" WORK_MODE = "work_mode" # Working mode WORK_POWER = "work_power" + WORK_STATE_E = "work_state_e" @dataclass diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 4ad4355f876..22229b3f6bf 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -55,6 +55,15 @@ SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SelectEntityDescription( + key=DPCode.WORK_MODE, + entity_category=EntityCategory.CONFIG, + translation_key="odor_elimination_mode", + ), + ), # Multi-functional Sensor # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 "dgnbj": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index d1220e08728..a4e1e931a5f 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -220,6 +220,15 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { state_class=SensorStateClass.MEASUREMENT, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + TuyaSensorEntityDescription( + key=DPCode.WORK_STATE_E, + translation_key="odor_elimination_status", + ), + *BATTERY_SENSORS, + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index a5302b2e88b..d5ccfffb79c 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -485,6 +485,13 @@ "level_9": "Level 9", "level_10": "High" } + }, + "odor_elimination_mode": { + "name": "Odor elimination mode", + "state": { + "smart": "Smart", + "interim": "Interim" + } } }, "sensor": { @@ -697,6 +704,15 @@ }, "water_time": { "name": "Water usage duration" + }, + "odor_elimination_status": { + "name": "Status", + "state": { + "work": "Working", + "standby": "[%key:common::state::standby%]", + "charging": "[%key:common::state::charging%]", + "charge_done": "Charge done" + } } }, "switch": { diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index bfe80ec67bf..2cc7970d45a 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -85,6 +85,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Smart Odor Eliminator-Pro + # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 + "cwjwq": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Smart Pet Feeder # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld "cwwsq": ( diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 6427a69cdea..f0e2596fc81 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -45,6 +45,12 @@ DEVICE_MOCKS = { Platform.FAN, Platform.HUMIDIFIER, ], + "cwjwq_smart_odor_eliminator": [ + # https://github.com/orgs/home-assistant/discussions/79 + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ], "cwwsq_cleverio_pf100": [ # https://github.com/home-assistant/core/issues/144745 Platform.NUMBER, diff --git a/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json b/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json new file mode 100644 index 00000000000..a4a9fc6aaff --- /dev/null +++ b/tests/components/tuya/fixtures/cwjwq_smart_odor_eliminator.json @@ -0,0 +1,66 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1750837476328i3TNXQ", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf6574iutyikgwkx", + "name": "Smart Odor Eliminator-Pro", + "category": "cwjwq", + "product_id": "agwu93lr", + "product_name": "Smart Odor Eliminator-Pro", + "online": false, + "sub": false, + "time_zone": "+02:00", + "active_time": "2025-06-25T07:43:07+00:00", + "create_time": "2025-06-25T07:43:07+00:00", + "update_time": "2025-06-25T07:43:07+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["smart", "interim"] + } + }, + "work_state_e": { + "type": "Enum", + "value": { + "range": ["work", "standby", "charging", "charge_done"] + } + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch": false, + "work_mode": "smart", + "work_state_e": "work", + "battery_percentage": 43 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 2c5b0e86619..6f45f63dcfa 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -178,6 +178,63 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + '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': 'Odor elimination mode', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_mode', + 'unique_id': 'tuya.bf6574iutyikgwkxwork_mode', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][select.smart_odor_eliminator_pro_odor_elimination_mode-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Odor elimination mode', + 'options': list([ + 'smart', + 'interim', + ]), + }), + 'context': , + 'entity_id': 'select.smart_odor_eliminator_pro_odor_elimination_mode', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[kj_bladeless_tower_fan][select.bree_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f0350f12c48..b637839333d 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -105,6 +105,107 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_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.smart_odor_eliminator_pro_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bf6574iutyikgwkxbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Smart Odor Eliminator-Pro Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-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.smart_odor_eliminator_pro_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'odor_elimination_status', + 'unique_id': 'tuya.bf6574iutyikgwkxwork_state_e', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][sensor.smart_odor_eliminator_pro_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Status', + }), + 'context': , + 'entity_id': 'sensor.smart_odor_eliminator_pro_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][sensor.cleverio_pf100_last_amount-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 4e6af0fa7d3..1ed4e9fdc1b 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -146,6 +146,54 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-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': None, + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bf6574iutyikgwkxswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[cwjwq_smart_odor_eliminator][switch.smart_odor_eliminator_pro_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Smart Odor Eliminator-Pro Switch', + }), + 'context': , + 'entity_id': 'switch.smart_odor_eliminator_pro_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[cwysj_pixi_smart_drinking_fountain][switch.pixi_smart_drinking_fountain_filter_reset-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 84e3dac406e3e28e1e4d6f135f00acda0e2bce0d Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 16 Jul 2025 18:05:17 +1000 Subject: [PATCH 1457/1664] Update vehicle type handling in Teslemetry (#148862) --- .../components/teslemetry/__init__.py | 2 +- .../components/teslemetry/binary_sensor.py | 2 +- .../components/teslemetry/climate.py | 4 +- homeassistant/components/teslemetry/cover.py | 11 +- .../components/teslemetry/device_tracker.py | 2 +- homeassistant/components/teslemetry/lock.py | 4 +- .../components/teslemetry/media_player.py | 2 +- homeassistant/components/teslemetry/number.py | 2 +- homeassistant/components/teslemetry/select.py | 2 +- homeassistant/components/teslemetry/sensor.py | 4 +- homeassistant/components/teslemetry/switch.py | 3 +- homeassistant/components/teslemetry/update.py | 2 +- tests/components/teslemetry/conftest.py | 7 +- tests/components/teslemetry/const.py | 34 +- .../snapshots/test_binary_sensor.ambr | 2382 +---------------- .../teslemetry/snapshots/test_climate.ambr | 3 +- .../teslemetry/test_binary_sensor.py | 2 + tests/components/teslemetry/test_climate.py | 1 - tests/components/teslemetry/test_cover.py | 2 +- .../teslemetry/test_device_tracker.py | 1 - .../components/teslemetry/test_diagnostics.py | 3 + tests/components/teslemetry/test_init.py | 12 +- .../teslemetry/test_media_player.py | 1 - tests/components/teslemetry/test_sensor.py | 7 +- 24 files changed, 105 insertions(+), 2390 deletions(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 3ffc6c43efb..688a254a731 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -133,7 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - ) firmware = vehicle_metadata[vin].get("firmware", "Unknown") stream_vehicle = stream.get_vehicle(vin) - poll = product["command_signing"] == "off" + poll = vehicle_metadata[vin].get("polling", False) vehicles.append( TeslemetryVehicleData( diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py index 6905cefdc30..5db73c7aa06 100644 --- a/homeassistant/components/teslemetry/binary_sensor.py +++ b/homeassistant/components/teslemetry/binary_sensor.py @@ -542,7 +542,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py index 1bc52b23026..000e1b136c8 100644 --- a/homeassistant/components/teslemetry/climate.py +++ b/homeassistant/components/teslemetry/climate.py @@ -67,7 +67,7 @@ async def async_setup_entry( TeslemetryVehiclePollingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingClimateEntity( vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes ) @@ -77,7 +77,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingCabinOverheatProtectionEntity( vehicle, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py index f6ff71ab0cc..5c86d6e19fe 100644 --- a/homeassistant/components/teslemetry/cover.py +++ b/homeassistant/components/teslemetry/cover.py @@ -45,7 +45,7 @@ async def async_setup_entry( chain( ( TeslemetryVehiclePollingWindowEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingWindowEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ), @@ -53,7 +53,7 @@ async def async_setup_entry( TeslemetryVehiclePollingChargePortEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingChargePortEntity( vehicle, entry.runtime_data.scopes ) @@ -63,7 +63,7 @@ async def async_setup_entry( TeslemetryVehiclePollingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingFrontTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -73,7 +73,7 @@ async def async_setup_entry( TeslemetryVehiclePollingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingRearTrunkEntity( vehicle, entry.runtime_data.scopes ) @@ -82,7 +82,8 @@ async def async_setup_entry( ( TeslemetrySunroofEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles - if vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") + if vehicle.poll + and vehicle.coordinator.data.get("vehicle_config_sun_roof_installed") ), ) ) diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py index eb2c220ebbd..0e1b3edf69a 100644 --- a/homeassistant/components/teslemetry/device_tracker.py +++ b/homeassistant/components/teslemetry/device_tracker.py @@ -89,7 +89,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in DESCRIPTIONS: - if vehicle.api.pre2021 or vehicle.firmware < description.streaming_firmware: + if vehicle.poll or vehicle.firmware < description.streaming_firmware: if description.polling_prefix: entities.append( TeslemetryVehiclePollingDeviceTrackerEntity( diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py index fda52357f5c..7e98d6338ba 100644 --- a/homeassistant/components/teslemetry/lock.py +++ b/homeassistant/components/teslemetry/lock.py @@ -42,7 +42,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingVehicleLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) @@ -52,7 +52,7 @@ async def async_setup_entry( TeslemetryVehiclePollingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingCableLockEntity( vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py index bf1fffed583..9ffc02e4307 100644 --- a/homeassistant/components/teslemetry/media_player.py +++ b/homeassistant/components/teslemetry/media_player.py @@ -53,7 +53,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingMediaEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6" + if vehicle.poll or vehicle.firmware < "2025.2.6" else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py index bb9f5b588a0..bccefcaf6cb 100644 --- a/homeassistant/components/teslemetry/number.py +++ b/homeassistant/components/teslemetry/number.py @@ -145,7 +145,7 @@ async def async_setup_entry( description, entry.runtime_data.scopes, ) - if vehicle.api.pre2021 or vehicle.firmware < "2024.26" + if vehicle.poll or vehicle.firmware < "2024.26" else TeslemetryStreamingNumberEntity( vehicle, description, diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index c24c47feb2e..fec54b75880 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -180,7 +180,7 @@ async def async_setup_entry( TeslemetryVehiclePollingSelectEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 + if vehicle.poll or vehicle.firmware < "2024.26" or description.streaming_listener is None else TeslemetryStreamingSelectEntity( diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py index b50c9b4d0ce..1ffe073cc5c 100644 --- a/homeassistant/components/teslemetry/sensor.py +++ b/homeassistant/components/teslemetry/sensor.py @@ -1565,7 +1565,7 @@ async def async_setup_entry( for vehicle in entry.runtime_data.vehicles: for description in VEHICLE_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and description.streaming_listener and vehicle.firmware >= description.streaming_firmware ): @@ -1575,7 +1575,7 @@ async def async_setup_entry( for time_description in VEHICLE_TIME_DESCRIPTIONS: if ( - not vehicle.api.pre2021 + not vehicle.poll and vehicle.firmware >= time_description.streaming_firmware ): entities.append( diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py index f607429be46..aae973cf315 100644 --- a/homeassistant/components/teslemetry/switch.py +++ b/homeassistant/components/teslemetry/switch.py @@ -147,8 +147,7 @@ async def async_setup_entry( TeslemetryVehiclePollingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) - if vehicle.api.pre2021 - or vehicle.firmware < description.streaming_firmware + if vehicle.poll or vehicle.firmware < description.streaming_firmware else TeslemetryStreamingVehicleSwitchEntity( vehicle, description, entry.runtime_data.scopes ) diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py index 144a97039fc..7e0b727ba79 100644 --- a/homeassistant/components/teslemetry/update.py +++ b/homeassistant/components/teslemetry/update.py @@ -39,7 +39,7 @@ async def async_setup_entry( async_add_entities( TeslemetryVehiclePollingUpdateEntity(vehicle, entry.runtime_data.scopes) - if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25" + if vehicle.poll or vehicle.firmware < "2024.44.25" else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes) for vehicle in entry.runtime_data.vehicles ) diff --git a/tests/components/teslemetry/conftest.py b/tests/components/teslemetry/conftest.py index b9b5efae6ec..ffcc74d5587 100644 --- a/tests/components/teslemetry/conftest.py +++ b/tests/components/teslemetry/conftest.py @@ -14,6 +14,7 @@ from .const import ( ENERGY_HISTORY, LIVE_STATUS, METADATA, + METADATA_LEGACY, PRODUCTS, SITE_INFO, VEHICLE_DATA, @@ -53,9 +54,9 @@ def mock_vehicle_data() -> Generator[AsyncMock]: def mock_legacy(): """Mock Tesla Fleet Api products method.""" with patch( - "tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True - ) as mock_pre2021: - yield mock_pre2021 + "tesla_fleet_api.teslemetry.Teslemetry.metadata", return_value=METADATA_LEGACY + ) as mock_products: + yield mock_products @pytest.fixture(autouse=True) diff --git a/tests/components/teslemetry/const.py b/tests/components/teslemetry/const.py index 3bfa452e38d..7b671bbeaaa 100644 --- a/tests/components/teslemetry/const.py +++ b/tests/components/teslemetry/const.py @@ -37,6 +37,32 @@ COMMAND_ERRORS = (COMMAND_REASON, COMMAND_NOREASON, COMMAND_ERROR, COMMAND_NOERR RESPONSE_OK = {"response": {}, "error": None} METADATA = { + "uid": "abc-123", + "region": "NA", + "scopes": [ + "openid", + "offline_access", + "user_data", + "vehicle_device_data", + "vehicle_cmds", + "vehicle_charging_cmds", + "vehicle_location", + "energy_device_data", + "energy_cmds", + ], + "vehicles": { + "LRW3F7EK4NC700000": { + "proxy": True, + "access": True, + "polling": False, + "firmware": "2026.0.0", + "discounted": False, + "fleet_telemetry": "1.0.2", + "name": "Home Assistant", + } + }, +} +METADATA_LEGACY = { "uid": "abc-123", "region": "NA", "scopes": [ @@ -56,6 +82,9 @@ METADATA = { "access": True, "polling": True, "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } @@ -68,7 +97,10 @@ METADATA_NOSCOPE = { "proxy": False, "access": True, "polling": True, - "firmware": "2024.44.25", + "firmware": "2026.0.0", + "discounted": True, + "fleet_telemetry": "unknown", + "name": "Home Assistant", } }, } diff --git a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr index 06ec0a60434..2b920a0cfdc 100644 --- a/tests/components/teslemetry/snapshots/test_binary_sensor.ambr +++ b/tests/components/teslemetry/snapshots/test_binary_sensor.ambr @@ -240,102 +240,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-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.test_automatic_blind_spot_camera', - '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': 'Automatic blind spot camera', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_blind_spot_camera', - 'unique_id': 'LRW3F7EK4NC700000-automatic_blind_spot_camera', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_blind_spot_camera-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-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.test_automatic_emergency_braking_off', - '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': 'Automatic emergency braking off', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'automatic_emergency_braking_off', - 'unique_id': 'LRW3F7EK4NC700000-automatic_emergency_braking_off', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_automatic_emergency_braking_off-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_battery_heater-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -382,151 +286,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-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.test_blind_spot_collision_warning_chime', - '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': 'Blind spot collision warning chime', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'blind_spot_collision_warning_chime', - 'unique_id': 'LRW3F7EK4NC700000-blind_spot_collision_warning_chime', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_blind_spot_collision_warning_chime-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-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.test_bms_full_charge', - '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': 'BMS full charge', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'bms_full_charge_complete', - 'unique_id': 'LRW3F7EK4NC700000-bms_full_charge_complete', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_bms_full_charge-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-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.test_brake_pedal', - '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': 'Brake pedal', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'brake_pedal', - 'unique_id': 'LRW3F7EK4NC700000-brake_pedal', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_brake_pedal-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_cabin_overheat_protection_active-entry] @@ -578,55 +338,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_cellular-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': , - 'entity_id': 'binary_sensor.test_cellular', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Cellular', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'cellular', - 'unique_id': 'LRW3F7EK4NC700000-cellular', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_cellular-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_charge_cable-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -673,103 +384,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-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.test_charge_enable_request', - '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': 'Charge enable request', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_enable_request', - 'unique_id': 'LRW3F7EK4NC700000-charge_enable_request', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_enable_request-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-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.test_charge_port_cold_weather_mode', - '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': 'Charge port cold weather mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'charge_port_cold_weather_mode', - 'unique_id': 'LRW3F7EK4NC700000-charge_port_cold_weather_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_charge_port_cold_weather_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_charger_has_multiple_phases-entry] @@ -817,7 +432,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor[binary_sensor.test_dashcam-entry] @@ -869,390 +484,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-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.test_dc_to_dc_converter', - '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': 'DC to DC converter', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'dc_dc_enable', - 'unique_id': 'LRW3F7EK4NC700000-dc_dc_enable', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_dc_to_dc_converter-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-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.test_defrost_for_preconditioning', - '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': 'Defrost for preconditioning', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'defrost_for_preconditioning', - 'unique_id': 'LRW3F7EK4NC700000-defrost_for_preconditioning', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_defrost_for_preconditioning-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-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.test_drive_rail', - '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': 'Drive rail', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'drive_rail', - 'unique_id': 'LRW3F7EK4NC700000-drive_rail', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_drive_rail-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-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.test_driver_seat_belt', - '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': 'Driver seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-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.test_driver_seat_occupied', - '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': 'Driver seat occupied', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'driver_seat_occupied', - 'unique_id': 'LRW3F7EK4NC700000-driver_seat_occupied', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_driver_seat_occupied-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-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.test_emergency_lane_departure_avoidance', - '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': 'Emergency lane departure avoidance', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'emergency_lane_departure_avoidance', - 'unique_id': 'LRW3F7EK4NC700000-emergency_lane_departure_avoidance', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_emergency_lane_departure_avoidance-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_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': 'binary_sensor', - 'entity_category': None, - 'entity_id': 'binary_sensor.test_european_vehicle', - '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': 'European vehicle', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'europe_vehicle', - 'unique_id': 'LRW3F7EK4NC700000-europe_vehicle', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_european_vehicle-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-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.test_fast_charger_present', - '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': 'Fast charger present', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'fast_charger_present', - 'unique_id': 'LRW3F7EK4NC700000-fast_charger_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_fast_charger_present-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor[binary_sensor.test_front_driver_door-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1299,7 +530,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_driver_window-entry] @@ -1348,7 +579,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_door-entry] @@ -1397,7 +628,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_front_passenger_window-entry] @@ -1446,633 +677,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-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': , - 'entity_id': 'binary_sensor.test_gps_state', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'GPS state', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'gps_state', - 'unique_id': 'LRW3F7EK4NC700000-gps_state', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_gps_state-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-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.test_guest_mode_enabled', - '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': 'Guest mode enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'guest_mode_enabled', - 'unique_id': 'LRW3F7EK4NC700000-guest_mode_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_guest_mode_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-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.test_hazard_lights', - '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': 'Hazard lights', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_hazards_active', - 'unique_id': 'LRW3F7EK4NC700000-lights_hazards_active', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hazard_lights-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-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.test_high_beams', - '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': 'High beams', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'lights_high_beams', - 'unique_id': 'LRW3F7EK4NC700000-lights_high_beams', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_beams-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-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': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'High voltage interlock loop fault', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvil', - 'unique_id': 'LRW3F7EK4NC700000-hvil', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_high_voltage_interlock_loop_fault-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-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.test_homelink_nearby', - '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': 'Homelink nearby', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'homelink_nearby', - 'unique_id': 'LRW3F7EK4NC700000-homelink_nearby', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_homelink_nearby-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-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.test_hvac_auto_mode', - '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': 'HVAC auto mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'hvac_auto_mode', - 'unique_id': 'LRW3F7EK4NC700000-hvac_auto_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_hvac_auto_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-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.test_located_at_favorite', - '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': 'Located at favorite', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_favorite', - 'unique_id': 'LRW3F7EK4NC700000-located_at_favorite', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_favorite-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-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.test_located_at_home', - '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': 'Located at home', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_home', - 'unique_id': 'LRW3F7EK4NC700000-located_at_home', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_home-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-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.test_located_at_work', - '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': 'Located at work', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'located_at_work', - 'unique_id': 'LRW3F7EK4NC700000-located_at_work', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_located_at_work-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-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.test_offroad_lightbar', - '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': 'Offroad lightbar', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'offroad_lightbar_present', - 'unique_id': 'LRW3F7EK4NC700000-offroad_lightbar_present', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_offroad_lightbar-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-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.test_passenger_seat_belt', - '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': 'Passenger seat belt', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'passenger_seat_belt', - 'unique_id': 'LRW3F7EK4NC700000-passenger_seat_belt', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_passenger_seat_belt-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-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.test_pin_to_drive_enabled', - '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': 'PIN to Drive enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'pin_to_drive_enabled', - 'unique_id': 'LRW3F7EK4NC700000-pin_to_drive_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_pin_to_drive_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_preconditioning-entry] @@ -2168,55 +773,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-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.test_rear_display_hvac', - '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': 'Rear display HVAC', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'rear_display_hvac_enabled', - 'unique_id': 'LRW3F7EK4NC700000-rear_display_hvac_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_rear_display_hvac-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_door-entry] @@ -2265,7 +822,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_driver_window-entry] @@ -2314,7 +871,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_door-entry] @@ -2363,7 +920,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_rear_passenger_window-entry] @@ -2412,103 +969,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-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.test_remote_start', - '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': 'Remote start', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'remote_start_enabled', - 'unique_id': 'LRW3F7EK4NC700000-remote_start_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_remote_start-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-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.test_right_hand_drive', - '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': 'Right hand drive', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'right_hand_drive', - 'unique_id': 'LRW3F7EK4NC700000-right_hand_drive', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_right_hand_drive-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_scheduled_charging_pending-entry] @@ -2556,151 +1017,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-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.test_seat_vent_enabled', - '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': 'Seat vent enabled', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'seat_vent_enabled', - 'unique_id': 'LRW3F7EK4NC700000-seat_vent_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_seat_vent_enabled-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-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.test_service_mode', - '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': 'Service mode', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'service_mode', - 'unique_id': 'LRW3F7EK4NC700000-service_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_service_mode-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-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.test_speed_limited', - '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': 'Speed limited', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'speed_limit_mode', - 'unique_id': 'LRW3F7EK4NC700000-speed_limit_mode', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_speed_limited-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor[binary_sensor.test_status-entry] @@ -2749,55 +1066,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-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.test_supercharger_session_trip_planner', - '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': 'Supercharger session trip planner', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'supercharger_session_trip_planner', - 'unique_id': 'LRW3F7EK4NC700000-supercharger_session_trip_planner', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_supercharger_session_trip_planner-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor[binary_sensor.test_tire_pressure_warning_front_left-entry] @@ -3093,103 +1362,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-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': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Wi-Fi', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wifi', - 'unique_id': 'LRW3F7EK4NC700000-wifi', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wi_fi-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-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.test_wiper_heat', - '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': 'Wiper heat', - 'platform': 'teslemetry', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'wiper_heat_enabled', - 'unique_id': 'LRW3F7EK4NC700000-wiper_heat_enabled', - 'unit_of_measurement': None, - }) -# --- -# name: test_binary_sensor[binary_sensor.test_wiper_heat-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.energy_site_backup_capable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3256,32 +1428,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_blind_spot_camera-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic blind spot camera', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_blind_spot_camera', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_automatic_emergency_braking_off-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Automatic emergency braking off', - }), - 'context': , - 'entity_id': 'binary_sensor.test_automatic_emergency_braking_off', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_battery_heater-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3293,46 +1439,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_blind_spot_collision_warning_chime-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Blind spot collision warning chime', - }), - 'context': , - 'entity_id': 'binary_sensor.test_blind_spot_collision_warning_chime', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_bms_full_charge-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test BMS full charge', - }), - 'context': , - 'entity_id': 'binary_sensor.test_bms_full_charge', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_brake_pedal-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Brake pedal', - }), - 'context': , - 'entity_id': 'binary_sensor.test_brake_pedal', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_cabin_overheat_protection_active-statealt] @@ -3349,20 +1456,6 @@ 'state': 'off', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_cellular-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Cellular', - }), - 'context': , - 'entity_id': 'binary_sensor.test_cellular', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_charge_cable-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3374,33 +1467,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_enable_request-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge enable request', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_enable_request', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_charge_port_cold_weather_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Charge port cold weather mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_charge_port_cold_weather_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_charger_has_multiple_phases-statealt] @@ -3413,7 +1480,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'unavailable', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_dashcam-statealt] @@ -3430,110 +1497,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_dc_to_dc_converter-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test DC to DC converter', - }), - 'context': , - 'entity_id': 'binary_sensor.test_dc_to_dc_converter', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_defrost_for_preconditioning-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Defrost for preconditioning', - }), - 'context': , - 'entity_id': 'binary_sensor.test_defrost_for_preconditioning', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_drive_rail-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Drive rail', - }), - 'context': , - 'entity_id': 'binary_sensor.test_drive_rail', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_driver_seat_occupied-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Driver seat occupied', - }), - 'context': , - 'entity_id': 'binary_sensor.test_driver_seat_occupied', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_emergency_lane_departure_avoidance-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Emergency lane departure avoidance', - }), - 'context': , - 'entity_id': 'binary_sensor.test_emergency_lane_departure_avoidance', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_european_vehicle-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test European vehicle', - }), - 'context': , - 'entity_id': 'binary_sensor.test_european_vehicle', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_fast_charger_present-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Fast charger present', - }), - 'context': , - 'entity_id': 'binary_sensor.test_fast_charger_present', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_door-statealt] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -3545,7 +1508,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_driver_window-statealt] @@ -3559,7 +1522,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_door-statealt] @@ -3573,7 +1536,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_front_passenger_window-statealt] @@ -3587,178 +1550,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_gps_state-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test GPS state', - }), - 'context': , - 'entity_id': 'binary_sensor.test_gps_state', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_guest_mode_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Guest mode enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_guest_mode_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hazard_lights-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Hazard lights', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hazard_lights', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_beams-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test High beams', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_beams', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_high_voltage_interlock_loop_fault-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'problem', - 'friendly_name': 'Test High voltage interlock loop fault', - }), - 'context': , - 'entity_id': 'binary_sensor.test_high_voltage_interlock_loop_fault', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_homelink_nearby-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Homelink nearby', - }), - 'context': , - 'entity_id': 'binary_sensor.test_homelink_nearby', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_hvac_auto_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test HVAC auto mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_hvac_auto_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_favorite-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at favorite', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_favorite', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_home-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at home', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_home', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_located_at_work-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Located at work', - }), - 'context': , - 'entity_id': 'binary_sensor.test_located_at_work', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_offroad_lightbar-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Offroad lightbar', - }), - 'context': , - 'entity_id': 'binary_sensor.test_offroad_lightbar', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_passenger_seat_belt-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Passenger seat belt', - }), - 'context': , - 'entity_id': 'binary_sensor.test_passenger_seat_belt', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_pin_to_drive_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test PIN to Drive enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_pin_to_drive_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_preconditioning-statealt] @@ -3784,20 +1576,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_rear_display_hvac-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Rear display HVAC', - }), - 'context': , - 'entity_id': 'binary_sensor.test_rear_display_hvac', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_door-statealt] @@ -3811,7 +1590,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_driver_window-statealt] @@ -3825,7 +1604,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_door-statealt] @@ -3839,7 +1618,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_rear_passenger_window-statealt] @@ -3853,33 +1632,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_remote_start-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Remote start', - }), - 'context': , - 'entity_id': 'binary_sensor.test_remote_start', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_right_hand_drive-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Right hand drive', - }), - 'context': , - 'entity_id': 'binary_sensor.test_right_hand_drive', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_scheduled_charging_pending-statealt] @@ -3892,46 +1645,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_seat_vent_enabled-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Seat vent enabled', - }), - 'context': , - 'entity_id': 'binary_sensor.test_seat_vent_enabled', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_service_mode-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Service mode', - }), - 'context': , - 'entity_id': 'binary_sensor.test_service_mode', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_speed_limited-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Speed limited', - }), - 'context': , - 'entity_id': 'binary_sensor.test_speed_limited', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'off', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_status-statealt] @@ -3945,20 +1659,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_supercharger_session_trip_planner-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Supercharger session trip planner', - }), - 'context': , - 'entity_id': 'binary_sensor.test_supercharger_session_trip_planner', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', + 'state': 'on', }) # --- # name: test_binary_sensor_refresh[binary_sensor.test_tire_pressure_warning_front_left-statealt] @@ -4044,33 +1745,6 @@ 'state': 'on', }) # --- -# name: test_binary_sensor_refresh[binary_sensor.test_wi_fi-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'connectivity', - 'friendly_name': 'Test Wi-Fi', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wi_fi', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- -# name: test_binary_sensor_refresh[binary_sensor.test_wiper_heat-statealt] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Test Wiper heat', - }), - 'context': , - 'entity_id': 'binary_sensor.test_wiper_heat', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unknown', - }) -# --- # name: test_binary_sensors_connectivity[binary_sensor.test_cellular-state] 'on' # --- diff --git a/tests/components/teslemetry/snapshots/test_climate.ambr b/tests/components/teslemetry/snapshots/test_climate.ambr index 1aa68b59ee3..11708be7e39 100644 --- a/tests/components/teslemetry/snapshots/test_climate.ambr +++ b/tests/components/teslemetry/snapshots/test_climate.ambr @@ -407,9 +407,8 @@ ]), 'max_temp': 40, 'min_temp': 30, - 'supported_features': , + 'supported_features': , 'target_temp_step': 5, - 'temperature': None, }), 'context': , 'entity_id': 'climate.test_cabin_overheat_protection', diff --git a/tests/components/teslemetry/test_binary_sensor.py b/tests/components/teslemetry/test_binary_sensor.py index 0f5588fe323..b3871c52420 100644 --- a/tests/components/teslemetry/test_binary_sensor.py +++ b/tests/components/teslemetry/test_binary_sensor.py @@ -23,6 +23,7 @@ async def test_binary_sensor( hass: HomeAssistant, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" @@ -37,6 +38,7 @@ async def test_binary_sensor_refresh( entity_registry: er.EntityRegistry, mock_vehicle_data: AsyncMock, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Tests that the binary sensor entities are correct.""" diff --git a/tests/components/teslemetry/test_climate.py b/tests/components/teslemetry/test_climate.py index 27bed45c51f..f6c158fbd80 100644 --- a/tests/components/teslemetry/test_climate.py +++ b/tests/components/teslemetry/test_climate.py @@ -273,7 +273,6 @@ async def test_climate_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the climate entity is correct.""" mock_metadata.return_value = METADATA_NOSCOPE diff --git a/tests/components/teslemetry/test_cover.py b/tests/components/teslemetry/test_cover.py index e3933931c9f..2ba6d391cfc 100644 --- a/tests/components/teslemetry/test_cover.py +++ b/tests/components/teslemetry/test_cover.py @@ -55,7 +55,6 @@ async def test_cover_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct without scopes.""" @@ -67,6 +66,7 @@ async def test_cover_noscope( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_cover_services( hass: HomeAssistant, + mock_legacy: AsyncMock, ) -> None: """Tests that the cover entities are correct.""" diff --git a/tests/components/teslemetry/test_device_tracker.py b/tests/components/teslemetry/test_device_tracker.py index ea0ee08e64f..7edabe9ec6f 100644 --- a/tests/components/teslemetry/test_device_tracker.py +++ b/tests/components/teslemetry/test_device_tracker.py @@ -49,7 +49,6 @@ async def test_device_tracker_noscope( entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, mock_vehicle_data: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the device tracker entities are correct.""" diff --git a/tests/components/teslemetry/test_diagnostics.py b/tests/components/teslemetry/test_diagnostics.py index 18182b14321..5737a5ebe2c 100644 --- a/tests/components/teslemetry/test_diagnostics.py +++ b/tests/components/teslemetry/test_diagnostics.py @@ -1,5 +1,7 @@ """Test the Telemetry Diagnostics.""" +from unittest.mock import AsyncMock + from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion @@ -18,6 +20,7 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, snapshot: SnapshotAssertion, freezer: FrozenDateTimeFactory, + mock_legacy: AsyncMock, ) -> None: """Test diagnostics.""" diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index 54c9ca0dad9..e177865d2f9 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -14,7 +14,13 @@ from tesla_fleet_api.exceptions import ( from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -72,6 +78,7 @@ async def test_vehicle_refresh_error( mock_vehicle_data: AsyncMock, side_effect: TeslaFleetError, state: ConfigEntryState, + mock_legacy: AsyncMock, ) -> None: """Test coordinator refresh with an error.""" mock_vehicle_data.side_effect = side_effect @@ -107,6 +114,7 @@ async def test_energy_site_refresh_error( assert entry.state is state +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_vehicle_stream( hass: HomeAssistant, mock_add_listener: AsyncMock, @@ -121,7 +129,7 @@ async def test_vehicle_stream( assert state.state == STATE_UNKNOWN state = hass.states.get("binary_sensor.test_user_present") - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE mock_add_listener.send( { diff --git a/tests/components/teslemetry/test_media_player.py b/tests/components/teslemetry/test_media_player.py index ab8f21ceda4..8b7a91cfe2c 100644 --- a/tests/components/teslemetry/test_media_player.py +++ b/tests/components/teslemetry/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_noscope( snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, mock_metadata: AsyncMock, - mock_legacy: AsyncMock, ) -> None: """Tests that the media player entities are correct without required scope.""" diff --git a/tests/components/teslemetry/test_sensor.py b/tests/components/teslemetry/test_sensor.py index 296f9e8bff4..e8f413433c1 100644 --- a/tests/components/teslemetry/test_sensor.py +++ b/tests/components/teslemetry/test_sensor.py @@ -1,6 +1,6 @@ """Test the Teslemetry sensor platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest @@ -26,6 +26,7 @@ async def test_sensors( entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, mock_vehicle_data: AsyncMock, + mock_legacy: AsyncMock, ) -> None: """Tests that the sensor entities with the legacy polling are correct.""" @@ -33,9 +34,7 @@ async def test_sensors( async_fire_time_changed(hass) await hass.async_block_till_done() - # Force the vehicle to use polling - with patch("tesla_fleet_api.teslemetry.Vehicle.pre2021", return_value=True): - entry = await setup_platform(hass, [Platform.SENSOR]) + entry = await setup_platform(hass, [Platform.SENSOR]) assert_entities(hass, entry.entry_id, entity_registry, snapshot) From 6833bf190002ecd3dc21e5f80e788d94a2a89e0a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:15:44 +0200 Subject: [PATCH 1458/1664] Add battery status and configuration entities to Tuya thermostat (wk) (#148821) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/number.py | 9 +++ homeassistant/components/tuya/sensor.py | 3 + homeassistant/components/tuya/strings.json | 3 + tests/components/tuya/__init__.py | 2 + .../tuya/snapshots/test_number.ambr | 57 +++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 53 +++++++++++++++++ 7 files changed, 128 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 863ef451eaa..b8bb5ea483f 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -352,6 +352,7 @@ class DPCode(StrEnum): TEMP_BOILING_C = "temp_boiling_c" TEMP_BOILING_F = "temp_boiling_f" TEMP_CONTROLLER = "temp_controller" + TEMP_CORRECTION = "temp_correction" TEMP_CURRENT = "temp_current" # Current temperature in °C TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F TEMP_CURRENT_EXTERNAL = ( diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 4fb180ffd08..68777d75a90 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -295,6 +295,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": ( + NumberEntityDescription( + key=DPCode.TEMP_CORRECTION, + translation_key="temp_correction", + entity_category=EntityCategory.CONFIG, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index a4e1e931a5f..6e8da29ef53 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1077,6 +1077,9 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { ), *BATTERY_SENSORS, ), + # Thermostat + # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + "wk": (*BATTERY_SENSORS,), # Two-way temperature and humidity switch # "MOES Temperature and Humidity Smart Switch Module MS-103" # Documentation not found diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index d5ccfffb79c..ee1df183f36 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -219,6 +219,9 @@ }, "down_delay": { "name": "Down delay" + }, + "temp_correction": { + "name": "Temperature correction" } }, "select": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index f0e2596fc81..5f91571f35d 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -126,6 +126,8 @@ DEVICE_MOCKS = { "wk_wifi_smart_gas_boiler_thermostat": [ # https://github.com/orgs/home-assistant/discussions/243 Platform.CLIMATE, + Platform.NUMBER, + Platform.SENSOR, Platform.SWITCH, ], "wsdcg_temperature_humidity": [ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 6d741e4e76c..de65f6e6c6b 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -56,3 +56,60 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + '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': 'Temperature correction', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temp_correction', + 'unique_id': 'tuya.bfb45cb8a9452fba66lexgtemp_correction', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Temperature correction', + 'max': 9.9, + 'min': -9.9, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-1.5', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index b637839333d..6bf3bf67a32 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1966,6 +1966,59 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_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.wifi_smart_gas_boiler_thermostat_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.bfb45cb8a9452fba66lexgbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][sensor.wifi_smart_gas_boiler_thermostat_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'WiFi Smart Gas Boiler Thermostat Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.wifi_smart_gas_boiler_thermostat_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- # name: test_platform_setup_and_discovery[wsdcg_temperature_humidity][sensor.np_downstairs_north_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 033d8b3dfb6380fe382e0edb1b34ac4318c5f090 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:38:43 +0200 Subject: [PATCH 1459/1664] Add snapshot tests for tuya co2bj and gyd categories (#148872) --- tests/components/tuya/__init__.py | 12 + .../tuya/fixtures/co2bj_air_detector.json | 174 ++++++++++++ .../tuya/fixtures/gyd_night_light.json | 266 +++++++++++++++++ .../tuya/snapshots/test_binary_sensor.ambr | 49 ++++ .../components/tuya/snapshots/test_light.ambr | 73 +++++ .../tuya/snapshots/test_number.ambr | 59 ++++ .../tuya/snapshots/test_select.ambr | 61 ++++ .../tuya/snapshots/test_sensor.ambr | 267 ++++++++++++++++++ .../components/tuya/snapshots/test_siren.ambr | 50 ++++ tests/components/tuya/test_siren.py | 55 ++++ 10 files changed, 1066 insertions(+) create mode 100644 tests/components/tuya/fixtures/co2bj_air_detector.json create mode 100644 tests/components/tuya/fixtures/gyd_night_light.json create mode 100644 tests/components/tuya/snapshots/test_siren.ambr create mode 100644 tests/components/tuya/test_siren.py diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5f91571f35d..086a6a3832a 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -23,6 +23,14 @@ DEVICE_MOCKS = { Platform.COVER, Platform.LIGHT, ], + "co2bj_air_detector": [ + # https://github.com/home-assistant/core/issues/133173 + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + ], "cs_arete_two_12l_dehumidifier_air_purifier": [ Platform.BINARY_SENSOR, Platform.FAN, @@ -75,6 +83,10 @@ DEVICE_MOCKS = { # https://github.com/home-assistant/core/issues/143499 Platform.SENSOR, ], + "gyd_night_light": [ + # https://github.com/home-assistant/core/issues/133173 + Platform.LIGHT, + ], "kg_smart_valve": [ # https://github.com/home-assistant/core/issues/148347 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/co2bj_air_detector.json b/tests/components/tuya/fixtures/co2bj_air_detector.json new file mode 100644 index 00000000000..8d7e744fb52 --- /dev/null +++ b/tests/components/tuya/fixtures/co2bj_air_detector.json @@ -0,0 +1,174 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1732306182276g6jQLp", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "eb14fd1dd93ca2ea34vpin", + "name": "AQI", + "category": "co2bj", + "product_id": "yrr3eiyiacm31ski", + "product_name": "AIR_DETECTOR ", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2025-01-02T05:14:50+00:00", + "create_time": "2025-01-02T05:14:50+00:00", + "update_time": "2025-01-02T05:14:50+00:00", + "function": { + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "co2_state": { + "type": "Enum", + "value": { + "range": ["alarm", "normal"] + } + }, + "co2_value": { + "type": "Integer", + "value": { + "unit": "ppm", + "min": 0, + "max": 5000, + "scale": 0, + "step": 1 + } + }, + "alarm_volume": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high", "mute"] + } + }, + "alarm_time": { + "type": "Integer", + "value": { + "unit": "s", + "min": 1, + "max": 60, + "scale": 0, + "step": 1 + } + }, + "alarm_switch": { + "type": "Boolean", + "value": {} + }, + "battery_percentage": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "alarm_bright": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103", + "min": -9, + "max": 199, + "scale": 0, + "step": 1 + } + }, + "humidity_value": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "pm25_value": { + "type": "Integer", + "value": { + "unit": "ug/m3", + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "voc_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + }, + "ch2o_value": { + "type": "Integer", + "value": { + "unit": "mg/m3", + "min": 0, + "max": 9999, + "scale": 3, + "step": 1 + } + } + }, + "status": { + "co2_state": "normal", + "co2_value": 541, + "alarm_volume": "low", + "alarm_time": 1, + "alarm_switch": false, + "battery_percentage": 100, + "alarm_bright": 98, + "temp_current": 26, + "humidity_value": 53, + "pm25_value": 17, + "voc_value": 18, + "ch2o_value": 2 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/gyd_night_light.json b/tests/components/tuya/fixtures/gyd_night_light.json new file mode 100644 index 00000000000..28f2b8e8f46 --- /dev/null +++ b/tests/components/tuya/fixtures/gyd_night_light.json @@ -0,0 +1,266 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1732306182276g6jQLp", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + + "id": "eb3e988f33c233290cfs3l", + "name": "Colorful PIR Night Light", + "category": "gyd", + "product_id": "lgekqfxdabipm3tn", + "product_name": "Colorful PIR Night Light", + "online": true, + "sub": false, + "time_zone": "+07:00", + "active_time": "2024-07-18T12:02:37+00:00", + "create_time": "2024-07-18T12:02:37+00:00", + "update_time": "2024-07-18T12:02:37+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "control_data": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value": { + "type": "Integer", + "value": { + "min": 10, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "temp_value": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + }, + "colour_data": { + "type": "Json", + "value": {} + }, + "scene_data": { + "type": "Json", + "value": {} + }, + "countdown": { + "type": "Integer", + "value": { + "unit": "s", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "device_mode": { + "type": "Enum", + "value": { + "range": ["auto", "manual"] + } + }, + "pir_state": { + "type": "Enum", + "value": { + "range": ["pir", "none"] + } + }, + "cds": { + "type": "Enum", + "value": { + "range": ["2000lux", "300lux", "50lux", "10lux", "5lux"] + } + }, + "pir_sensitivity": { + "type": "Enum", + "value": { + "range": ["low", "middle", "high"] + } + }, + "pir_delay": { + "type": "Integer", + "value": { + "unit": "s", + "min": 5, + "max": 3600, + "scale": 0, + "step": 1 + } + }, + "switch_pir": { + "type": "Boolean", + "value": {} + }, + "standby_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 480, + "scale": 0, + "step": 1 + } + }, + "standby_bright": { + "type": "Integer", + "value": { + "min": 0, + "max": 1000, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "switch_led": false, + "work_mode": "white", + "bright_value": 1000, + "temp_value": 1, + "colour_data": { + "h": 0, + "s": 1000, + "v": 1000 + }, + "scene_data": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 0, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown": 0, + "device_mode": "auto", + "pir_state": "none", + "cds": "5lux", + "pir_sensitivity": "middle", + "pir_delay": 30, + "switch_pir": true, + "standby_time": 1, + "standby_bright": 146 + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_binary_sensor.ambr b/tests/components/tuya/snapshots/test_binary_sensor.ambr index 81f41bc1fdc..267f61aabd0 100644 --- a/tests/components/tuya/snapshots/test_binary_sensor.ambr +++ b/tests/components/tuya/snapshots/test_binary_sensor.ambr @@ -1,4 +1,53 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-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.aqi_safety', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Safety', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinco2_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][binary_sensor.aqi_safety-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'AQI Safety', + }), + 'context': , + 'entity_id': 'binary_sensor.aqi_safety', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][binary_sensor.dehumidifier_defrost-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index b83e9484853..5b0afb289ac 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,6 +56,79 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + '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.colorful_pir_night_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': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.eb3e988f33c233290cfs3lswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Colorful PIR Night Light', + 'hs_color': None, + 'max_color_temp_kelvin': 6500, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 153, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.colorful_pir_night_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[ks_tower_fan][light.tower_fan_ca_407g_smart_backlight-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index de65f6e6c6b..125a0680de9 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -1,4 +1,63 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.aqi_alarm_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_duration', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][number.aqi_alarm_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'AQI Alarm duration', + 'max': 60.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.aqi_alarm_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.0', + }) +# --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index 6f45f63dcfa..a2d52a893c9 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -56,6 +56,67 @@ 'state': 'forward', }) # --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.aqi_volume', + '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': 'Volume', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'volume', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinalarm_volume', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][select.aqi_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Volume', + 'options': list([ + 'low', + 'middle', + 'high', + 'mute', + ]), + }), + 'context': , + 'entity_id': 'select.aqi_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][select.dehumidifier_countdown-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 6bf3bf67a32..57e73eccda5 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1,4 +1,271 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_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.aqi_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinbattery_percentage', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'AQI Battery', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-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.aqi_formaldehyde', + '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': 'Formaldehyde', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'formaldehyde', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinch2o_value', + 'unit_of_measurement': 'mg/m3', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_formaldehyde-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI Formaldehyde', + 'state_class': , + 'unit_of_measurement': 'mg/m3', + }), + 'context': , + 'entity_id': 'sensor.aqi_formaldehyde', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.002', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_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.aqi_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'humidity', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinhumidity_value', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'humidity', + 'friendly_name': 'AQI Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.aqi_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '53.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_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.aqi_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': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'temperature', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpintemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'AQI Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.aqi_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '26.0', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-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.aqi_volatile_organic_compounds', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Volatile organic compounds', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'voc', + 'unique_id': 'tuya.eb14fd1dd93ca2ea34vpinvoc_value', + 'unit_of_measurement': 'mg/m³', + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][sensor.aqi_volatile_organic_compounds-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volatile_organic_compounds', + 'friendly_name': 'AQI Volatile organic compounds', + 'state_class': , + 'unit_of_measurement': 'mg/m³', + }), + 'context': , + 'entity_id': 'sensor.aqi_volatile_organic_compounds', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.018', + }) +# --- # name: test_platform_setup_and_discovery[cs_arete_two_12l_dehumidifier_air_purifier][sensor.dehumidifier_humidity-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_siren.ambr b/tests/components/tuya/snapshots/test_siren.ambr new file mode 100644 index 00000000000..8a6faa31c43 --- /dev/null +++ b/tests/components/tuya/snapshots/test_siren.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': , + 'entity_id': 'siren.aqi', + '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.eb14fd1dd93ca2ea34vpinalarm_switch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[co2bj_air_detector][siren.aqi-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AQI', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.aqi', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/tuya/test_siren.py b/tests/components/tuya/test_siren.py new file mode 100644 index 00000000000..69ccc14e407 --- /dev/null +++ b/tests/components/tuya/test_siren.py @@ -0,0 +1,55 @@ +"""Test Tuya siren platform.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion +from tuya_sharing import CustomerDevice + +from homeassistant.components.tuya import ManagerCompat +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import DEVICE_MOCKS, initialize_entry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_and_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test platform setup and discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + "mock_device_code", [k for k, v in DEVICE_MOCKS.items() if Platform.SIREN not in v] +) +@patch("homeassistant.components.tuya.PLATFORMS", [Platform.SIREN]) +async def test_platform_setup_no_discovery( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, + entity_registry: er.EntityRegistry, +) -> None: + """Test platform setup without discovery.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) From 8a73511b02f79de772314509a82dd4378a3aeeb0 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 16 Jul 2025 10:44:04 +0200 Subject: [PATCH 1460/1664] Add inactive reason sensor to Husqvarna Automower (#147684) --- .../components/husqvarna_automower/icons.json | 3 + .../components/husqvarna_automower/sensor.py | 23 ++++++- .../husqvarna_automower/strings.json | 8 +++ .../snapshots/test_sensor.ambr | 60 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/husqvarna_automower/icons.json b/homeassistant/components/husqvarna_automower/icons.json index e1b355959d9..e9d023bd3cc 100644 --- a/homeassistant/components/husqvarna_automower/icons.json +++ b/homeassistant/components/husqvarna_automower/icons.json @@ -24,6 +24,9 @@ "error": { "default": "mdi:alert-circle-outline" }, + "inactive_reason": { + "default": "mdi:sleep" + }, "my_lawn_last_time_completed": { "default": "mdi:clock-outline" }, diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 0a059fdd706..72f65320efd 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -7,7 +7,13 @@ import logging from operator import attrgetter from typing import TYPE_CHECKING, Any -from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea +from aioautomower.model import ( + InactiveReasons, + MowerAttributes, + MowerModes, + RestrictedReasons, + WorkArea, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -166,6 +172,13 @@ ERROR_KEY_LIST = list( dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES]) ) +INACTIVE_REASONS: list = [ + InactiveReasons.NONE, + InactiveReasons.PLANNING, + InactiveReasons.SEARCHING_FOR_SATELLITES, +] + + RESTRICTED_REASONS: list = [ RestrictedReasons.ALL_WORK_AREAS_COMPLETED, RestrictedReasons.DAILY_LIMIT, @@ -389,6 +402,14 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = ( option_fn=lambda data: RESTRICTED_REASONS, value_fn=attrgetter("planner.restricted_reason"), ), + AutomowerSensorEntityDescription( + key="inactive_reason", + translation_key="inactive_reason", + exists_fn=lambda data: data.capabilities.work_areas, + device_class=SensorDeviceClass.ENUM, + option_fn=lambda data: INACTIVE_REASONS, + value_fn=attrgetter("mower.inactive_reason"), + ), AutomowerSensorEntityDescription( key="work_area", translation_key="work_area", diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index 9e808c66878..62843d67ae2 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -213,6 +213,14 @@ "zone_generator_problem": "Zone generator problem" } }, + "inactive_reason": { + "name": "Inactive reason", + "state": { + "none": "No inactivity", + "planning": "Planning", + "searching_for_satellites": "Searching for satellites" + } + }, "my_lawn_last_time_completed": { "name": "My lawn last time completed" }, diff --git a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr index 109e6614545..0fe46c24254 100644 --- a/tests/components/husqvarna_automower/snapshots/test_sensor.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_sensor.ambr @@ -585,6 +585,66 @@ 'state': '40', }) # --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Inactive reason', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'inactive_reason', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_inactive_reason', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_snapshot[sensor.test_mower_1_inactive_reason-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Test Mower 1 Inactive reason', + 'options': list([ + , + , + , + ]), + }), + 'context': , + 'entity_id': 'sensor.test_mower_1_inactive_reason', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'none', + }) +# --- # name: test_sensor_snapshot[sensor.test_mower_1_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From a57d48fd3101020a07407e7919dc9d8f67bff7a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 10:55:28 +0200 Subject: [PATCH 1461/1664] Add OpenRouter integration (#143098) --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/open_router/__init__.py | 58 +++++++ .../components/open_router/config_flow.py | 118 ++++++++++++++ homeassistant/components/open_router/const.py | 6 + .../components/open_router/conversation.py | 133 ++++++++++++++++ .../components/open_router/manifest.json | 13 ++ .../components/open_router/quality_scale.yaml | 88 +++++++++++ .../components/open_router/strings.json | 37 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 4 + requirements_test_all.txt | 4 + tests/components/open_router/__init__.py | 13 ++ tests/components/open_router/conftest.py | 128 +++++++++++++++ .../snapshots/test_conversation.ambr | 16 ++ .../open_router/test_config_flow.py | 146 ++++++++++++++++++ .../open_router/test_conversation.py | 52 +++++++ 19 files changed, 836 insertions(+) create mode 100644 homeassistant/components/open_router/__init__.py create mode 100644 homeassistant/components/open_router/config_flow.py create mode 100644 homeassistant/components/open_router/const.py create mode 100644 homeassistant/components/open_router/conversation.py create mode 100644 homeassistant/components/open_router/manifest.json create mode 100644 homeassistant/components/open_router/quality_scale.yaml create mode 100644 homeassistant/components/open_router/strings.json create mode 100644 tests/components/open_router/__init__.py create mode 100644 tests/components/open_router/conftest.py create mode 100644 tests/components/open_router/snapshots/test_conversation.ambr create mode 100644 tests/components/open_router/test_config_flow.py create mode 100644 tests/components/open_router/test_conversation.py diff --git a/.strict-typing b/.strict-typing index 626fc10a4c2..18e72162a23 100644 --- a/.strict-typing +++ b/.strict-typing @@ -377,6 +377,7 @@ homeassistant.components.onedrive.* homeassistant.components.onewire.* homeassistant.components.onkyo.* homeassistant.components.open_meteo.* +homeassistant.components.open_router.* homeassistant.components.openai_conversation.* homeassistant.components.openexchangerates.* homeassistant.components.opensky.* diff --git a/CODEOWNERS b/CODEOWNERS index c0bed7f100a..05c17b5498d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1102,6 +1102,8 @@ build.json @home-assistant/supervisor /tests/components/onvif/ @hunterjm @jterrace /homeassistant/components/open_meteo/ @frenck /tests/components/open_meteo/ @frenck +/homeassistant/components/open_router/ @joostlek +/tests/components/open_router/ @joostlek /homeassistant/components/openai_conversation/ @balloob /tests/components/openai_conversation/ @balloob /homeassistant/components/openerz/ @misialq diff --git a/homeassistant/components/open_router/__init__.py b/homeassistant/components/open_router/__init__.py new file mode 100644 index 00000000000..477fabca54c --- /dev/null +++ b/homeassistant/components/open_router/__init__.py @@ -0,0 +1,58 @@ +"""The OpenRouter integration.""" + +from __future__ import annotations + +from openai import AsyncOpenAI, AuthenticationError, OpenAIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.httpx_client import get_async_client + +from .const import LOGGER + +PLATFORMS = [Platform.CONVERSATION] + +type OpenRouterConfigEntry = ConfigEntry[AsyncOpenAI] + + +async def async_setup_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Set up OpenRouter from a config entry.""" + client = AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=entry.data[CONF_API_KEY], + http_client=get_async_client(hass), + ) + + # Cache current platform data which gets added to each request (caching done by library) + _ = await hass.async_add_executor_job(client.platform_headers) + + try: + async for _ in client.with_options(timeout=10.0).models.list(): + break + except AuthenticationError as err: + LOGGER.error("Invalid API key: %s", err) + raise ConfigEntryError("Invalid API key") from err + except OpenAIError as err: + raise ConfigEntryNotReady(err) from err + + entry.runtime_data = client + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener( + hass: HomeAssistant, entry: OpenRouterConfigEntry +) -> None: + """Handle update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: OpenRouterConfigEntry) -> bool: + """Unload OpenRouter.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py new file mode 100644 index 00000000000..48d37d79cc6 --- /dev/null +++ b/homeassistant/components/open_router/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for OpenRouter integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from openai import AsyncOpenAI +from python_open_router import OpenRouterClient, OpenRouterError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + ConfigSubentryFlow, + SubentryFlowResult, +) +from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.core import callback +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, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class OpenRouterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for OpenRouter.""" + + VERSION = 1 + + @classmethod + @callback + def async_get_supported_subentry_types( + cls, config_entry: ConfigEntry + ) -> dict[str, type[ConfigSubentryFlow]]: + """Return subentries supported by this handler.""" + return {"conversation": ConversationFlowHandler} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + self._async_abort_entries_match(user_input) + client = OpenRouterClient( + user_input[CONF_API_KEY], async_get_clientsession(self.hass) + ) + try: + await client.get_key_data() + except OpenRouterError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="OpenRouter", + data=user_input, + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + +class ConversationFlowHandler(ConfigSubentryFlow): + """Handle subentry flow.""" + + def __init__(self) -> None: + """Initialize the subentry flow.""" + self.options: dict[str, str] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """User flow to create a sensor subentry.""" + if user_input is not None: + return self.async_create_entry( + title=self.options[user_input[CONF_MODEL]], 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), + ) + 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( + { + vol.Required(CONF_MODEL): SelectSelector( + SelectSelectorConfig( + options=options, mode=SelectSelectorMode.DROPDOWN, sort=True + ), + ), + } + ), + ) diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py new file mode 100644 index 00000000000..e357f28d6d5 --- /dev/null +++ b/homeassistant/components/open_router/const.py @@ -0,0 +1,6 @@ +"""Constants for the OpenRouter integration.""" + +import logging + +DOMAIN = "open_router" +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py new file mode 100644 index 00000000000..48720e7c829 --- /dev/null +++ b/homeassistant/components/open_router/conversation.py @@ -0,0 +1,133 @@ +"""Conversation support for OpenRouter.""" + +from typing import Literal + +import openai +from openai.types.chat import ( + ChatCompletionAssistantMessageParam, + ChatCompletionMessageParam, + ChatCompletionSystemMessageParam, + ChatCompletionUserMessageParam, +) + +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.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import OpenRouterConfigEntry +from .const import DOMAIN, LOGGER + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: OpenRouterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up conversation entities.""" + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [OpenRouterConversationEntity(config_entry, subentry)], + config_subentry_id=subentry_id, + ) + + +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 None + + 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": + return ChatCompletionAssistantMessageParam( + role="assistant", content=content.content + ) + LOGGER.warning("Could not convert message to Completions API: %s", content) + return None + + +class OpenRouterConversationEntity(conversation.ConversationEntity): + """OpenRouter conversation agent.""" + + 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_name = subentry.title + self._attr_unique_id = subentry.subentry_id + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def _async_handle_message( + self, + user_input: conversation.ConversationInput, + chat_log: conversation.ChatLog, + ) -> conversation.ConversationResult: + """Process a sentence.""" + options = self.subentry.data + + try: + await chat_log.async_provide_llm_data( + user_input.as_llm_context(DOMAIN), + options.get(CONF_LLM_HASS_API), + None, + user_input.extra_system_prompt, + ) + except conversation.ConverseError as err: + return err.as_conversation_result() + + messages = [ + m + for content in chat_log.content + if (m := _convert_content_to_chat_message(content)) + ] + + client = self.entry.runtime_data + + try: + result = await client.chat.completions.create( + model=self.model, + messages=messages, + 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 + + chat_log.async_add_assistant_content_without_tools( + conversation.AssistantContent( + agent_id=user_input.agent_id, + content=result_message.content, + ) + ) + + intent_response = intent.IntentResponse(language=user_input.language) + assert type(chat_log.content[-1]) is conversation.AssistantContent + intent_response.async_set_speech(chat_log.content[-1].content or "") + return conversation.ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json new file mode 100644 index 00000000000..64b7319a902 --- /dev/null +++ b/homeassistant/components/open_router/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "open_router", + "name": "OpenRouter", + "after_dependencies": ["assist_pipeline", "intent"], + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/open_router", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["openai==1.93.3", "python-open-router==0.2.0"] +} diff --git a/homeassistant/components/open_router/quality_scale.yaml b/homeassistant/components/open_router/quality_scale.yaml new file mode 100644 index 00000000000..9b71a29dc6b --- /dev/null +++ b/homeassistant/components/open_router/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No actions are implemented + appropriate-polling: + status: exempt + comment: the integration does not poll + brands: done + common-modules: + status: exempt + comment: the integration currently implements only one platform and has no coordinator + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No actions are implemented + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: the integration does not 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: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: the integration has no options + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: the integration only implements a stateless conversation entity. + integration-owner: done + log-when-unavailable: + status: exempt + comment: the integration only integrates state-less entities + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Service can't be discovered + discovery: + status: exempt + comment: Service can't be discovered + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: + status: exempt + comment: no suitable device class for the conversation entity + entity-disabled-by-default: + status: exempt + comment: only one conversation entity + entity-translations: done + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: + status: exempt + comment: the integration has no repairs + stale-devices: + status: exempt + comment: only one device per entry, is deleted with the entry. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json new file mode 100644 index 00000000000..93936b4d92b --- /dev/null +++ b/homeassistant/components/open_router/strings.json @@ -0,0 +1,37 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "An OpenRouter API key" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "config_subentries": { + "conversation": { + "step": { + "user": { + "description": "Configure the new conversation agent", + "data": { + "model": "Model" + } + } + }, + "initiate_flow": { + "user": "Add conversation agent" + }, + "entry_type": "Conversation agent" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 92319af9617..49695b695ac 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -449,6 +449,7 @@ FLOWS = { "onkyo", "onvif", "open_meteo", + "open_router", "openai_conversation", "openexchangerates", "opengarage", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 277400bec02..480a88e1ae4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4621,6 +4621,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "open_router": { + "name": "OpenRouter", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "openai_conversation": { "name": "OpenAI Conversation", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 25039f7f386..bff6c93967e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3526,6 +3526,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.open_router.*] +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.openai_conversation.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 1b8fc7b8801..4a79b0ad597 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1596,6 +1596,7 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation openai==1.93.3 @@ -2476,6 +2477,9 @@ python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.4.0 +# homeassistant.components.open_router +python-open-router==0.2.0 + # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b4ff300a02..2b4fa6c91cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,6 +1364,7 @@ open-garage==0.2.0 # homeassistant.components.open_meteo open-meteo==0.3.2 +# homeassistant.components.open_router # homeassistant.components.openai_conversation openai==1.93.3 @@ -2049,6 +2050,9 @@ python-mpd2==3.1.1 # homeassistant.components.mystrom python-mystrom==2.4.0 +# homeassistant.components.open_router +python-open-router==0.2.0 + # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/tests/components/open_router/__init__.py b/tests/components/open_router/__init__.py new file mode 100644 index 00000000000..3858e866315 --- /dev/null +++ b/tests/components/open_router/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the OpenRouter 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/open_router/conftest.py b/tests/components/open_router/conftest.py new file mode 100644 index 00000000000..e2e0fbb2c37 --- /dev/null +++ b/tests/components/open_router/conftest.py @@ -0,0 +1,128 @@ +"""Fixtures for OpenRouter integration tests.""" + +from collections.abc import AsyncGenerator, Generator +from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock, patch + +from openai.types import CompletionUsage +from openai.types.chat import ChatCompletion, ChatCompletionMessage +from openai.types.chat.chat_completion import Choice +import pytest + +from homeassistant.components.open_router.const import DOMAIN +from homeassistant.config_entries import ConfigSubentryData +from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.open_router.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + title="OpenRouter", + domain=DOMAIN, + data={ + CONF_API_KEY: "bla", + }, + subentries_data=[ + ConfigSubentryData( + data={CONF_MODEL: "gpt-3.5-turbo"}, + subentry_id="ABCDEF", + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ) + ], + ) + + +@dataclass +class Model: + """Mock model data.""" + + id: str + name: str + + +@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, + ), + ): + 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", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="Hello, how can I help you?", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-3.5-turbo-0613", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + ) + yield client + + +@pytest.fixture +async def mock_open_router_client() -> AsyncGenerator[AsyncMock]: + """Initialize integration.""" + with patch( + "homeassistant.components.open_router.config_flow.OpenRouterClient", + autospec=True, + ) as mock_client: + client = mock_client.return_value + yield client + + +@pytest.fixture(autouse=True) +async def setup_ha(hass: HomeAssistant) -> None: + """Set up Home Assistant.""" + assert await async_setup_component(hass, "homeassistant", {}) + + +async def get_generator_from_data[DataT](items: list[DataT]) -> AsyncGenerator[DataT]: + """Return async generator.""" + for item in items: + yield item diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr new file mode 100644 index 00000000000..90f9097e854 --- /dev/null +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -0,0 +1,16 @@ +# serializer version: 1 +# name: test_default_prompt + list([ + dict({ + 'attachments': None, + 'content': 'hello', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'Hello, how can I help you?', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py new file mode 100644 index 00000000000..6be258dca38 --- /dev/null +++ b/tests/components/open_router/test_config_flow.py @@ -0,0 +1,146 @@ +"""Test the OpenRouter config flow.""" + +from unittest.mock import AsyncMock + +import pytest +from python_open_router import OpenRouterError + +from homeassistant.components.open_router.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigSubentry +from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_full_flow( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test the full config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_API_KEY: "bla"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_API_KEY: "bla"} + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (OpenRouterError("exception"), "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_errors( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test we handle errors from the OpenRouter API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + mock_open_router_client.get_key_data.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_open_router_client.get_key_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test aborting the flow if an entry already exists.""" + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "bla"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_create_conversation_agent( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent.""" + + mock_config_entry.add_to_hass(hass) + + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_MODEL: "gpt-3.5-turbo"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + subentry_id = list(mock_config_entry.subentries)[0] + assert ( + ConfigSubentry( + data={CONF_MODEL: "gpt-3.5-turbo"}, + subentry_id=subentry_id, + subentry_type="conversation", + title="GPT-3.5 Turbo", + unique_id=None, + ) + in mock_config_entry.subentries.values() + ) diff --git a/tests/components/open_router/test_conversation.py b/tests/components/open_router/test_conversation.py new file mode 100644 index 00000000000..043dae2ff30 --- /dev/null +++ b/tests/components/open_router/test_conversation.py @@ -0,0 +1,52 @@ +"""Tests for the OpenRouter integration.""" + +from unittest.mock import AsyncMock + +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr, intent + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 + + +@pytest.fixture(autouse=True) +def freeze_the_time(): + """Freeze the time.""" + with freeze_time("2024-05-24 12:00:00", tz_offset=0): + yield + + +async def test_default_prompt( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test that the default prompt works.""" + await setup_integration(hass, mock_config_entry) + result = await conversation.async_converse( + hass, + "hello", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + 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["extra_headers"] == { + "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", + "X-Title": "Home Assistant", + } From fe8384719d931b4c3481fc450b7299e688f7f637 Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:18:14 +0200 Subject: [PATCH 1462/1664] Bump pyenphase to 2.2.2 (#148870) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 278045001fc..320179bf2df 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.2.1"], + "requirements": ["pyenphase==2.2.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 4a79b0ad597..f89f00451de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1963,7 +1963,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b4fa6c91cf..8f3345ae688 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.2.1 +pyenphase==2.2.2 # homeassistant.components.everlights pyeverlights==0.1.0 From ce4a811b96256471e42067e8699914107f3eeabf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 11:55:50 +0200 Subject: [PATCH 1463/1664] Add `hydrological alert` sensor to IMGW-PIB integration (#148848) --- homeassistant/components/imgw_pib/icons.json | 3 + homeassistant/components/imgw_pib/sensor.py | 32 +++++++++ .../components/imgw_pib/strings.json | 35 ++++++++++ tests/components/imgw_pib/conftest.py | 10 ++- .../imgw_pib/snapshots/test_diagnostics.ambr | 10 +-- .../imgw_pib/snapshots/test_sensor.ambr | 65 +++++++++++++++++++ 6 files changed, 148 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json index b9226276af6..0265c6c2ec0 100644 --- a/homeassistant/components/imgw_pib/icons.json +++ b/homeassistant/components/imgw_pib/icons.json @@ -1,6 +1,9 @@ { "entity": { "sensor": { + "hydrological_alert": { + "default": "mdi:alert-octagon-outline" + }, "water_flow": { "default": "mdi:waves-arrow-right" }, diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py index 1c49bfb2dc0..7084889220c 100644 --- a/homeassistant/components/imgw_pib/sensor.py +++ b/homeassistant/components/imgw_pib/sensor.py @@ -4,7 +4,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any +from imgw_pib.const import HYDROLOGICAL_ALERTS_MAP, NO_ALERT from imgw_pib.model import HydrologicalData from homeassistant.components.sensor import ( @@ -28,14 +30,36 @@ from .entity import ImgwPibEntity PARALLEL_UPDATES = 0 +def gen_alert_attributes(data: HydrologicalData) -> dict[str, Any] | None: + """Generate attributes for the alert entity.""" + if data.hydrological_alert.value == NO_ALERT: + return None + + return { + "level": data.hydrological_alert.level, + "probability": data.hydrological_alert.probability, + "valid_from": data.hydrological_alert.valid_from, + "valid_to": data.hydrological_alert.valid_to, + } + + @dataclass(frozen=True, kw_only=True) class ImgwPibSensorEntityDescription(SensorEntityDescription): """IMGW-PIB sensor entity description.""" value: Callable[[HydrologicalData], StateType] + attrs: Callable[[HydrologicalData], dict[str, Any] | None] | None = None SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="hydrological_alert", + translation_key="hydrological_alert", + device_class=SensorDeviceClass.ENUM, + options=list(HYDROLOGICAL_ALERTS_MAP.values()), + value=lambda data: data.hydrological_alert.value, + attrs=gen_alert_attributes, + ), ImgwPibSensorEntityDescription( key="water_flow", translation_key="water_flow", @@ -109,3 +133,11 @@ class ImgwPibSensorEntity(ImgwPibEntity, SensorEntity): def native_value(self) -> StateType: """Return the value reported by the sensor.""" return self.entity_description.value(self.coordinator.data) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the state attributes.""" + if self.entity_description.attrs: + return self.entity_description.attrs(self.coordinator.data) + + return None diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json index fc92ca573ab..7adb1673c8a 100644 --- a/homeassistant/components/imgw_pib/strings.json +++ b/homeassistant/components/imgw_pib/strings.json @@ -21,6 +21,41 @@ }, "entity": { "sensor": { + "hydrological_alert": { + "name": "Hydrological alert", + "state": { + "no_alert": "No alert", + "hydrological_drought": "Hydrological drought", + "rapid_water_level_rise": "Rapid water level rise" + }, + "state_attributes": { + "level": { + "name": "Level", + "state": { + "none": "None", + "orange": "Orange", + "red": "Red", + "yellow": "Yellow" + } + }, + "options": { + "state": { + "no_alert": "[%key:component::imgw_pib::entity::sensor::hydrological_alert::state::no_alert%]", + "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%]" + } + }, + "probability": { + "name": "Probability" + }, + "valid_from": { + "name": "Valid from" + }, + "valid_to": { + "name": "Valid to" + } + } + }, "water_flow": { "name": "Water flow" }, diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py index c3f87288573..0ba09c27e0e 100644 --- a/tests/components/imgw_pib/conftest.py +++ b/tests/components/imgw_pib/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Generator from datetime import UTC, datetime from unittest.mock import AsyncMock, patch -from imgw_pib import NO_ALERT, Alert, HydrologicalData, SensorData +from imgw_pib import Alert, HydrologicalData, SensorData import pytest from homeassistant.components.imgw_pib.const import DOMAIN @@ -25,7 +25,13 @@ HYDROLOGICAL_DATA = HydrologicalData( water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), water_flow=SensorData(name="Water Flow", value=123.45), water_flow_measurement_date=datetime(2024, 4, 27, 10, 5, tzinfo=UTC), - hydrological_alert=Alert(value=NO_ALERT), + hydrological_alert=Alert( + value="rapid_water_level_rise", + valid_from=datetime(2024, 4, 27, 7, 0, tzinfo=UTC), + valid_to=datetime(2024, 4, 28, 11, 0, tzinfo=UTC), + level="yellow", + probability=80, + ), ) diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr index be2afee3da9..420a9300d3d 100644 --- a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -35,11 +35,11 @@ 'value': None, }), 'hydrological_alert': dict({ - 'level': None, - 'probability': None, - 'valid_from': None, - 'valid_to': None, - 'value': 'no_alert', + 'level': 'yellow', + 'probability': 80, + 'valid_from': '2024-04-27T07:00:00+00:00', + 'valid_to': '2024-04-28T11:00:00+00:00', + 'value': 'rapid_water_level_rise', }), 'latitude': None, 'longitude': None, diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr index 97bb6eefef3..276ea41eecf 100644 --- a/tests/components/imgw_pib/snapshots/test_sensor.ambr +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -1,4 +1,69 @@ # serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrological alert', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hydrological_alert', + 'unique_id': '123_hydrological_alert', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.river_name_station_name_hydrological_alert-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'enum', + 'friendly_name': 'River Name (Station Name) Hydrological alert', + 'level': 'yellow', + 'options': list([ + 'no_alert', + 'hydrological_drought', + 'rapid_water_level_rise', + ]), + 'probability': 80, + 'valid_from': datetime.datetime(2024, 4, 27, 7, 0, tzinfo=datetime.timezone.utc), + 'valid_to': datetime.datetime(2024, 4, 28, 11, 0, tzinfo=datetime.timezone.utc), + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_hydrological_alert', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'rapid_water_level_rise', + }) +# --- # name: test_sensor[sensor.river_name_station_name_water_flow-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 29e105b0ef985531c8561f5cbc8ca8f8f4c5de94 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 16 Jul 2025 12:19:31 +0200 Subject: [PATCH 1464/1664] Set default mode for number selector to box (#148773) --- homeassistant/helpers/selector.py | 10 ++++++---- tests/helpers/test_selector.py | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 0fa5403ad2b..7bd1ee9ddf3 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1108,10 +1108,12 @@ class NumberSelectorMode(StrEnum): def validate_slider(data: Any) -> Any: """Validate configuration.""" - if data["mode"] == "box": - return data + has_min_max = "min" in data and "max" in data - if "min" not in data or "max" not in data: + if "mode" not in data: + data["mode"] = "slider" if has_min_max else "box" + + if data["mode"] == "slider" and not has_min_max: raise vol.Invalid("min and max are required in slider mode") return data @@ -1134,7 +1136,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): "any", vol.All(vol.Coerce(float), vol.Range(min=1e-3)) ), vol.Optional(CONF_UNIT_OF_MEASUREMENT): str, - vol.Optional(CONF_MODE, default=NumberSelectorMode.SLIDER): vol.All( + vol.Optional(CONF_MODE): vol.All( vol.Coerce(NumberSelectorMode), lambda val: val.value ), vol.Optional("translation_key"): str, diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 159f295ab2f..dc25206177b 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -427,6 +427,7 @@ def test_assist_pipeline_selector_schema( ({"mode": "box"}, (10,), ()), ({"mode": "box", "step": "any"}, (), ()), ({"mode": "slider", "min": 0, "max": 1, "step": "any"}, (), ()), + ({}, (), ()), ], ) def test_number_selector_schema(schema, valid_selections, invalid_selections) -> None: @@ -434,10 +435,28 @@ def test_number_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("number", schema, valid_selections, invalid_selections) +def test_number_selector_schema_default_mode() -> None: + """Test number selector default mode set on min/max.""" + assert selector.selector({"number": {"min": 10, "max": 50}}).config == { + "mode": "slider", + "min": 10.0, + "max": 50.0, + "step": 1.0, + } + assert selector.selector({"number": {}}).config == { + "mode": "box", + "step": 1.0, + } + assert selector.selector({"number": {"min": "10"}}).config == { + "mode": "box", + "min": 10.0, + "step": 1.0, + } + + @pytest.mark.parametrize( "schema", [ - {}, # Must have mandatory fields {"mode": "slider"}, # Must have min+max in slider mode ], ) From a6828898d165a439f23ed634579c6c2951431710 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 16 Jul 2025 12:25:10 +0200 Subject: [PATCH 1465/1664] Add sensor platform to SMHI (#139295) --- homeassistant/components/smhi/__init__.py | 2 +- homeassistant/components/smhi/coordinator.py | 5 + homeassistant/components/smhi/entity.py | 8 +- homeassistant/components/smhi/icons.json | 27 ++ homeassistant/components/smhi/sensor.py | 139 +++++++ homeassistant/components/smhi/strings.json | 34 ++ homeassistant/components/smhi/weather.py | 1 + .../smhi/snapshots/test_sensor.ambr | 370 ++++++++++++++++++ tests/components/smhi/test_sensor.py | 26 ++ 9 files changed, 610 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/smhi/icons.json create mode 100644 homeassistant/components/smhi/sensor.py create mode 100644 tests/components/smhi/snapshots/test_sensor.ambr create mode 100644 tests/components/smhi/test_sensor.py diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 1869b333071..085cbdcbbce 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator -PLATFORMS = [Platform.WEATHER] +PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool: diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py index 511ba8b38d9..ba7542694df 100644 --- a/homeassistant/components/smhi/coordinator.py +++ b/homeassistant/components/smhi/coordinator.py @@ -61,3 +61,8 @@ class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]): daily=_forecast_daily, hourly=_forecast_hourly, ) + + @property + def current(self) -> SMHIForecast: + """Return the current metrics.""" + return self.data.daily[0] diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py index 89dca3360ca..fb565a7fc51 100644 --- a/homeassistant/components/smhi/entity.py +++ b/homeassistant/components/smhi/entity.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,7 +17,6 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): _attr_attribution = "Swedish weather institute (SMHI)" _attr_has_entity_name = True - _attr_name = None def __init__( self, @@ -36,6 +36,12 @@ class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]): ) self.update_entity_data() + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.update_entity_data() + super()._handle_coordinator_update() + @abstractmethod def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/homeassistant/components/smhi/icons.json b/homeassistant/components/smhi/icons.json new file mode 100644 index 00000000000..5c62b8f03b4 --- /dev/null +++ b/homeassistant/components/smhi/icons.json @@ -0,0 +1,27 @@ +{ + "entity": { + "sensor": { + "thunder": { + "default": "mdi:lightning-bolt" + }, + "total_cloud": { + "default": "mdi:cloud" + }, + "low_cloud": { + "default": "mdi:cloud-arrow-down" + }, + "medium_cloud": { + "default": "mdi:cloud-arrow-right" + }, + "high_cloud": { + "default": "mdi:cloud-arrow-up" + }, + "precipitation_category": { + "default": "mdi:weather-pouring" + }, + "frozen_precipitation": { + "default": "mdi:weather-snowy-rainy" + } + } + } +} diff --git a/homeassistant/components/smhi/sensor.py b/homeassistant/components/smhi/sensor.py new file mode 100644 index 00000000000..bba207c0f09 --- /dev/null +++ b/homeassistant/components/smhi/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for SMHI integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator +from .entity import SmhiWeatherBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_percentage_values(entity: SMHISensor, key: str) -> int | None: + """Return percentage values in correct range.""" + value: int | None = entity.coordinator.current.get(key) # type: ignore[assignment] + if value is not None and 0 <= value <= 100: + return value + if value is not None: + return 0 + return None + + +@dataclass(frozen=True, kw_only=True) +class SMHISensorEntityDescription(SensorEntityDescription): + """Describes SMHI sensor entity.""" + + value_fn: Callable[[SMHISensor], StateType | datetime] + + +SENSOR_DESCRIPTIONS: tuple[SMHISensorEntityDescription, ...] = ( + SMHISensorEntityDescription( + key="thunder", + translation_key="thunder", + value_fn=lambda entity: get_percentage_values(entity, "thunder"), + native_unit_of_measurement=PERCENTAGE, + ), + SMHISensorEntityDescription( + key="total_cloud", + translation_key="total_cloud", + value_fn=lambda entity: get_percentage_values(entity, "total_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="low_cloud", + translation_key="low_cloud", + value_fn=lambda entity: get_percentage_values(entity, "low_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="medium_cloud", + translation_key="medium_cloud", + value_fn=lambda entity: get_percentage_values(entity, "medium_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="high_cloud", + translation_key="high_cloud", + value_fn=lambda entity: get_percentage_values(entity, "high_cloud"), + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + SMHISensorEntityDescription( + key="precipitation_category", + translation_key="precipitation_category", + value_fn=lambda entity: str( + get_percentage_values(entity, "precipitation_category") + ), + device_class=SensorDeviceClass.ENUM, + options=["0", "1", "2", "3", "4", "5", "6"], + ), + SMHISensorEntityDescription( + key="frozen_precipitation", + translation_key="frozen_precipitation", + value_fn=lambda entity: get_percentage_values(entity, "frozen_precipitation"), + native_unit_of_measurement=PERCENTAGE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SMHIConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up SMHI sensor platform.""" + + coordinator = entry.runtime_data + location = entry.data + async_add_entities( + SMHISensor( + location[CONF_LOCATION][CONF_LATITUDE], + location[CONF_LOCATION][CONF_LONGITUDE], + coordinator=coordinator, + entity_description=description, + ) + for description in SENSOR_DESCRIPTIONS + ) + + +class SMHISensor(SmhiWeatherBaseEntity, SensorEntity): + """Representation of a SMHI Sensor.""" + + entity_description: SMHISensorEntityDescription + + def __init__( + self, + latitude: str, + longitude: str, + coordinator: SMHIDataUpdateCoordinator, + entity_description: SMHISensorEntityDescription, + ) -> None: + """Initiate SMHI Sensor.""" + self.entity_description = entity_description + super().__init__( + latitude, + longitude, + coordinator, + ) + self._attr_unique_id = f"{latitude}, {longitude}-{entity_description.key}" + + def update_entity_data(self) -> None: + """Refresh the entity data.""" + if self.coordinator.data.daily: + self._attr_native_value = self.entity_description.value_fn(self) diff --git a/homeassistant/components/smhi/strings.json b/homeassistant/components/smhi/strings.json index 3d2a790e6b6..b6c8f2049fe 100644 --- a/homeassistant/components/smhi/strings.json +++ b/homeassistant/components/smhi/strings.json @@ -23,5 +23,39 @@ "error": { "wrong_location": "Location Sweden only" } + }, + "entity": { + "sensor": { + "thunder": { + "name": "Thunder probability" + }, + "total_cloud": { + "name": "Total cloud coverage" + }, + "low_cloud": { + "name": "Low cloud coverage" + }, + "medium_cloud": { + "name": "Medium cloud coverage" + }, + "high_cloud": { + "name": "High cloud coverage" + }, + "precipitation_category": { + "name": "Precipitation category", + "state": { + "0": "No precipitation", + "1": "Snow", + "2": "Snow and rain", + "3": "Rain", + "4": "Drizzle", + "5": "Freezing rain", + "6": "Freezing drizzle" + } + }, + "frozen_precipitation": { + "name": "Frozen precipitation" + } + } } } diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 5faef04e03d..ccfff7cc2e5 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -111,6 +111,7 @@ class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity): _attr_supported_features = ( WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY ) + _attr_name = None def update_entity_data(self) -> None: """Refresh the entity data.""" diff --git a/tests/components/smhi/snapshots/test_sensor.ambr b/tests/components/smhi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..8fbdf229494 --- /dev/null +++ b/tests/components/smhi/snapshots/test_sensor.ambr @@ -0,0 +1,370 @@ +# serializer version: 1 +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-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.test_frozen_precipitation', + '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': 'Frozen precipitation', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'frozen_precipitation', + 'unique_id': '59.32624, 17.84197-frozen_precipitation', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_frozen_precipitation-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Frozen precipitation', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_frozen_precipitation', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-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.test_high_cloud_coverage', + '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': 'High cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'high_cloud', + 'unique_id': '59.32624, 17.84197-high_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_high_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test High cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_high_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-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.test_low_cloud_coverage', + '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': 'Low cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'low_cloud', + 'unique_id': '59.32624, 17.84197-low_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_low_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Low cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_low_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-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.test_medium_cloud_coverage', + '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': 'Medium cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'medium_cloud', + 'unique_id': '59.32624, 17.84197-medium_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_medium_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Medium cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_medium_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '88', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_precipitation_category', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Precipitation category', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'precipitation_category', + 'unique_id': '59.32624, 17.84197-precipitation_category', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_precipitation_category-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'device_class': 'enum', + 'friendly_name': 'Test Precipitation category', + 'options': list([ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_precipitation_category', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-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.test_thunder_probability', + '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': 'Thunder probability', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'thunder', + 'unique_id': '59.32624, 17.84197-thunder', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_thunder_probability-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Thunder probability', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_thunder_probability', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '37', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-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.test_total_cloud_coverage', + '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': 'Total cloud coverage', + 'platform': 'smhi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cloud', + 'unique_id': '59.32624, 17.84197-total_cloud', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor_setup[load_platforms0][sensor.test_total_cloud_coverage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Swedish weather institute (SMHI)', + 'friendly_name': 'Test Total cloud coverage', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_total_cloud_coverage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/smhi/test_sensor.py b/tests/components/smhi/test_sensor.py new file mode 100644 index 00000000000..a56340af1b5 --- /dev/null +++ b/tests/components/smhi/test_sensor.py @@ -0,0 +1,26 @@ +"""Test for the smhi weather entity.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) +async def test_sensor_setup( + hass: HomeAssistant, + entity_registry: EntityRegistry, + load_int: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test for successfully setting up the smhi sensors.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) From e28f02d1635f2701cbd002ac08aee247de52244f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:28:18 +0200 Subject: [PATCH 1466/1664] Add initial support for tuya qccdz (#148874) --- homeassistant/components/tuya/switch.py | 8 ++ tests/components/tuya/__init__.py | 4 + .../fixtures/qccdz_ac_charging_control.json | 105 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++++ 4 files changed, 165 insertions(+) create mode 100644 tests/components/tuya/fixtures/qccdz_ac_charging_control.json diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index 2cc7970d45a..67f3ba9cb81 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -545,6 +545,14 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { device_class=SwitchDeviceClass.OUTLET, ), ), + # AC charging + # Not documented + "qccdz": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + translation_key="switch", + ), + ), # Unknown product with switch capabilities # Fond in some diffusers, plugs and PIR flood lights # Not documented diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 086a6a3832a..7f08f704fe5 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -113,6 +113,10 @@ DEVICE_MOCKS = { Platform.BINARY_SENSOR, Platform.SENSOR, ], + "qccdz_ac_charging_control": [ + # https://github.com/home-assistant/core/issues/136207 + Platform.SWITCH, + ], "qxj_temp_humidity_external_probe": [ # https://github.com/home-assistant/core/issues/136472 Platform.SENSOR, diff --git a/tests/components/tuya/fixtures/qccdz_ac_charging_control.json b/tests/components/tuya/fixtures/qccdz_ac_charging_control.json new file mode 100644 index 00000000000..1ae5e966de7 --- /dev/null +++ b/tests/components/tuya/fixtures/qccdz_ac_charging_control.json @@ -0,0 +1,105 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "1737479380414pasuj4", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf83514d9c14b426f0fz5y", + "name": "AC charging control box", + "category": "qccdz", + "product_id": "7bvgooyjhiua1yyq", + "product_name": "AC charging control box", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-21T17:00:03+00:00", + "create_time": "2025-01-21T17:00:03+00:00", + "update_time": "2025-01-21T17:00:03+00:00", + "function": { + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + } + }, + "status_range": { + "work_state": { + "type": "Enum", + "value": { + "range": [ + "charger_free", + "charger_insert", + "charger_free_fault", + "charger_wait", + "charger_charging", + "charger_pause", + "charger_end", + "charger_fault" + ] + } + }, + "work_mode": { + "type": "Enum", + "value": { + "range": [ + "charge_now", + "charge_pct", + "charge_energy", + "charge_schedule" + ] + } + }, + "balance_energy": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 0, + "max": 99999999, + "scale": 3, + "step": 1 + } + }, + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "switch": { + "type": "Boolean", + "value": {} + }, + "charge_energy_once": { + "type": "Integer", + "value": { + "unit": "kW\u00b7h", + "min": 1, + "max": 999999, + "scale": 2, + "step": 1 + } + } + }, + "status": { + "work_state": "charger_free", + "work_mode": "charge_now", + "balance_energy": 0, + "clear_energy": false, + "switch": false, + "charge_energy_once": 1 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 1ed4e9fdc1b..dc47486e980 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -869,6 +869,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-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': None, + 'entity_id': 'switch.ac_charging_control_box_switch', + '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': 'Switch', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'tuya.bf83514d9c14b426f0fz5yswitch', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[qccdz_ac_charging_control][switch.ac_charging_control_box_switch-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'AC charging control box Switch', + }), + 'context': , + 'entity_id': 'switch.ac_charging_control_box_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- # name: test_platform_setup_and_discovery[sfkzq_valve_controller][switch.sprinkler_cesare_switch-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 26a9af7371eaf9bce1b8859cddae71178f7b97ed Mon Sep 17 00:00:00 2001 From: Josef Zweck Date: Wed, 16 Jul 2025 13:26:46 +0200 Subject: [PATCH 1467/1664] Add search functionality to jellyfin (#148822) --- .../components/jellyfin/browse_media.py | 47 +++++++++++++++++++ .../components/jellyfin/media_player.py | 15 +++++- tests/components/jellyfin/conftest.py | 1 + .../components/jellyfin/test_media_player.py | 41 ++++++++++++++++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/jellyfin/browse_media.py b/homeassistant/components/jellyfin/browse_media.py index 9eee4bbb363..9dc84971a21 100644 --- a/homeassistant/components/jellyfin/browse_media.py +++ b/homeassistant/components/jellyfin/browse_media.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import partial from typing import Any from jellyfin_apiclient_python import JellyfinClient @@ -12,6 +13,7 @@ from homeassistant.components.media_player import ( BrowseMedia, MediaClass, MediaType, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant @@ -156,6 +158,51 @@ def fetch_items( ] +async def search_items( + hass: HomeAssistant, client: JellyfinClient, user_id: str, query: SearchMediaQuery +) -> list[BrowseMedia]: + """Search items in Jellyfin server.""" + search_result: list[BrowseMedia] = [] + + items: list[dict[str, Any]] = [] + # Search for items based on media filter classes (or all if none specified) + media_types: list[MediaClass] | list[None] = [] + if query.media_filter_classes: + media_types = query.media_filter_classes + else: + media_types = [None] + + for media_type in media_types: + items_dict: dict[str, Any] = await hass.async_add_executor_job( + partial( + client.jellyfin.search_media_items, + term=query.search_query, + media=media_type, + parent_id=query.media_content_id, + ) + ) + items.extend(items_dict.get("Items", [])) + + for item in items: + content_type: str = item["MediaType"] + + response = BrowseMedia( + media_class=CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS.get( + content_type, MediaClass.DIRECTORY + ), + media_content_id=item["Id"], + media_content_type=content_type, + title=item["Name"], + thumbnail=get_artwork_url(client, item), + can_play=bool(content_type in PLAYABLE_MEDIA_TYPES), + can_expand=item.get("IsFolder", False), + children=None, + ) + search_result.append(response) + + return search_result + + async def get_media_info( hass: HomeAssistant, client: JellyfinClient, diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py index b71c0bf93c9..6f3c41d282f 100644 --- a/homeassistant/components/jellyfin/media_player.py +++ b/homeassistant/components/jellyfin/media_player.py @@ -11,12 +11,14 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, MediaPlayerState, MediaType, + SearchMedia, + SearchMediaQuery, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.dt import parse_datetime -from .browse_media import build_item_response, build_root_response +from .browse_media import build_item_response, build_root_response, search_items from .client_wrapper import get_artwork_url from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator @@ -196,6 +198,7 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.SEEK + | MediaPlayerEntityFeature.SEARCH_MEDIA ) if "Mute" in commands: @@ -274,3 +277,13 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity): media_content_type, media_content_id, ) + + async def async_search_media( + self, + query: SearchMediaQuery, + ) -> SearchMedia: + """Search the media player.""" + result = await search_items( + self.hass, self.coordinator.api_client, self.coordinator.user_id, query + ) + return SearchMedia(result=result) diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index c3732714177..71088dea2ea 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -81,6 +81,7 @@ def mock_api() -> MagicMock: jf_api.get_item.side_effect = api_get_item_side_effect jf_api.get_media_folders.return_value = load_json_fixture("get-media-folders.json") jf_api.user_items.side_effect = api_user_items_side_effect + jf_api.search_media_items.return_value = load_json_fixture("user-items.json") return jf_api diff --git a/tests/components/jellyfin/test_media_player.py b/tests/components/jellyfin/test_media_player.py index 404fdc801ee..b4506f5a607 100644 --- a/tests/components/jellyfin/test_media_player.py +++ b/tests/components/jellyfin/test_media_player.py @@ -363,6 +363,47 @@ async def test_browse_media( ) +async def test_search_media( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, +) -> None: + """Test Jellyfin browse media.""" + client = await hass_ws_client() + + # browse root folder + await client.send_json( + { + "id": 1, + "type": "media_player/search_media", + "entity_id": "media_player.jellyfin_device", + "media_content_id": "", + "media_content_type": "", + "search_query": "Fake Item 1", + "media_filter_classes": ["movie"], + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["result"] == [ + { + "title": "FOLDER", + "media_class": MediaClass.DIRECTORY.value, + "media_content_type": "string", + "media_content_id": "FOLDER-UUID", + "children_media_class": None, + "can_play": False, + "can_expand": True, + "can_search": False, + "not_shown": 0, + "thumbnail": "http://localhost/Items/21af9851-8e39-43a9-9c47-513d3b9e99fc/Images/Primary.jpg", + "children": [], + } + ] + + async def test_new_client_connected( hass: HomeAssistant, init_integration: MockConfigEntry, From 02a11638b38c375823dd407cc5f7b8b68539a1ce Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 16 Jul 2025 05:11:29 -0700 Subject: [PATCH 1468/1664] Add Google AI STT (#147563) --- .../__init__.py | 21 +- .../config_flow.py | 28 ++ .../const.py | 14 +- .../strings.json | 32 ++ .../google_generative_ai_conversation/stt.py | 254 +++++++++++++++ .../conftest.py | 8 + .../snapshots/test_diagnostics.ambr | 8 + .../snapshots/test_init.ambr | 31 ++ .../test_config_flow.py | 211 ++++++++---- .../test_init.py | 61 +++- .../test_stt.py | 303 ++++++++++++++++++ 11 files changed, 897 insertions(+), 74 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/stt.py create mode 100644 tests/components/google_generative_ai_conversation/test_stt.py diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 1ff9f355c06..3c1c9cad0b0 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -36,12 +36,14 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, DEFAULT_AI_TASK_NAME, + DEFAULT_STT_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, LOGGER, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, ) @@ -55,6 +57,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( Platform.AI_TASK, Platform.CONVERSATION, + Platform.STT, Platform.TTS, ) @@ -301,7 +304,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: - _add_ai_task_subentry(hass, entry) + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_TITLE, @@ -350,8 +353,7 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: - # Add AI Task subentry with default options - _add_ai_task_subentry(hass, entry) + _add_ai_task_and_stt_subentries(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) if entry.version == 2 and entry.minor_version == 3: @@ -393,10 +395,10 @@ async def async_migrate_entry( return True -def _add_ai_task_subentry( +def _add_ai_task_and_stt_subentries( hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry ) -> None: - """Add AI Task subentry to the config entry.""" + """Add AI Task and STT subentries to the config entry.""" hass.config_entries.async_add_subentry( entry, ConfigSubentry( @@ -406,3 +408,12 @@ def _add_ai_task_subentry( unique_id=None, ), ) + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_STT_OPTIONS), + subentry_type="stt", + title=DEFAULT_STT_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index 7d1429b110e..e760187bc66 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -49,6 +49,8 @@ from .const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, + DEFAULT_STT_PROMPT, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, @@ -57,6 +59,8 @@ from .const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TEMPERATURE, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, @@ -144,6 +148,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -191,6 +201,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Return subentries supported by this integration.""" return { "conversation": LLMSubentryFlowHandler, + "stt": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler, "ai_task_data": LLMSubentryFlowHandler, } @@ -228,6 +239,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): options = RECOMMENDED_TTS_OPTIONS.copy() elif self._subentry_type == "ai_task_data": options = RECOMMENDED_AI_TASK_OPTIONS.copy() + elif self._subentry_type == "stt": + options = RECOMMENDED_STT_OPTIONS.copy() else: options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: @@ -304,6 +317,8 @@ async def google_generative_ai_config_option_schema( default_name = DEFAULT_TTS_NAME elif subentry_type == "ai_task_data": default_name = DEFAULT_AI_TASK_NAME + elif subentry_type == "stt": + default_name = DEFAULT_STT_NAME else: default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { @@ -331,6 +346,17 @@ async def google_generative_ai_config_option_schema( ), } ) + elif subentry_type == "stt": + schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT) + }, + ): TemplateSelector(), + } + ) schema.update( { @@ -388,6 +414,8 @@ async def google_generative_ai_config_option_schema( if subentry_type == "tts": default_model = RECOMMENDED_TTS_MODEL + elif subentry_type == "stt": + default_model = RECOMMENDED_STT_MODEL else: default_model = RECOMMENDED_CHAT_MODEL diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index b7091fe0222..ba7af5147c5 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -5,18 +5,23 @@ import logging from homeassistant.const import CONF_LLM_HASS_API from homeassistant.helpers import llm +LOGGER = logging.getLogger(__package__) + DOMAIN = "google_generative_ai_conversation" DEFAULT_TITLE = "Google Generative AI" -LOGGER = logging.getLogger(__package__) -CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" +DEFAULT_STT_NAME = "Google AI STT" DEFAULT_TTS_NAME = "Google AI TTS" DEFAULT_AI_TASK_NAME = "Google AI Task" +CONF_PROMPT = "prompt" +DEFAULT_STT_PROMPT = "Transcribe the attached audio" + CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash" +RECOMMENDED_STT_MODEL = RECOMMENDED_CHAT_MODEL RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts" CONF_TEMPERATURE = "temperature" RECOMMENDED_TEMPERATURE = 1.0 @@ -43,6 +48,11 @@ RECOMMENDED_CONVERSATION_OPTIONS = { CONF_RECOMMENDED: True, } +RECOMMENDED_STT_OPTIONS = { + CONF_PROMPT: DEFAULT_STT_PROMPT, + CONF_RECOMMENDED: True, +} + RECOMMENDED_TTS_OPTIONS = { CONF_RECOMMENDED: True, } diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 774f41f0279..5af1fe33ce4 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -61,6 +61,38 @@ "invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting." } }, + "stt": { + "initiate_flow": { + "user": "Add Speech-to-Text service", + "reconfigure": "Reconfigure Speech-to-Text service" + }, + "entry_type": "Speech-to-Text", + "step": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "prompt": "Instructions", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + }, + "data_description": { + "prompt": "Instruct how the LLM should transcribe the audio." + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } + }, "tts": { "initiate_flow": { "user": "Add Text-to-Speech service", diff --git a/homeassistant/components/google_generative_ai_conversation/stt.py b/homeassistant/components/google_generative_ai_conversation/stt.py new file mode 100644 index 00000000000..bdf8a2fd7bf --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/stt.py @@ -0,0 +1,254 @@ +"""Speech to text support for Google Generative AI.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable + +from google.genai.errors import APIError, ClientError +from google.genai.types import Part + +from homeassistant.components import stt +from homeassistant.config_entries import ConfigEntry, ConfigSubentry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + LOGGER, + RECOMMENDED_STT_MODEL, +) +from .entity import GoogleGenerativeAILLMBaseEntity +from .helpers import convert_to_wav + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up STT entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "stt": + continue + + async_add_entities( + [GoogleGenerativeAISttEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAISttEntity( + stt.SpeechToTextEntity, GoogleGenerativeAILLMBaseEntity +): + """Google Generative AI speech-to-text entity.""" + + def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the STT entity.""" + super().__init__(config_entry, subentry, RECOMMENDED_STT_MODEL) + + @property + def supported_languages(self) -> list[str]: + """Return a list of supported languages.""" + return [ + "af-ZA", + "sq-AL", + "am-ET", + "ar-DZ", + "ar-BH", + "ar-EG", + "ar-IQ", + "ar-IL", + "ar-JO", + "ar-KW", + "ar-LB", + "ar-MA", + "ar-OM", + "ar-QA", + "ar-SA", + "ar-PS", + "ar-TN", + "ar-AE", + "ar-YE", + "hy-AM", + "az-AZ", + "eu-ES", + "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", + "en-AU", + "en-CA", + "en-GH", + "en-HK", + "en-IN", + "en-IE", + "en-KE", + "en-NZ", + "en-NG", + "en-PK", + "en-PH", + "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", + "es-AR", + "es-BO", + "es-CL", + "es-CO", + "es-CR", + "es-DO", + "es-EC", + "es-SV", + "es-GT", + "es-HN", + "es-MX", + "es-NI", + "es-PA", + "es-PY", + "es-PE", + "es-PR", + "es-ES", + "es-US", + "es-UY", + "es-VE", + "su-ID", + "sw-KE", + "sw-TZ", + "sv-SE", + "ta-IN", + "ta-MY", + "ta-SG", + "ta-LK", + "te-IN", + "th-TH", + "tr-TR", + "uk-UA", + "ur-IN", + "ur-PK", + "uz-UZ", + "vi-VN", + "zu-ZA", + ] + + @property + def supported_formats(self) -> list[stt.AudioFormats]: + """Return a list of supported formats.""" + # https://ai.google.dev/gemini-api/docs/audio#supported-formats + return [stt.AudioFormats.WAV, stt.AudioFormats.OGG] + + @property + def supported_codecs(self) -> list[stt.AudioCodecs]: + """Return a list of supported codecs.""" + return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS] + + @property + def supported_bit_rates(self) -> list[stt.AudioBitRates]: + """Return a list of supported bit rates.""" + return [stt.AudioBitRates.BITRATE_16] + + @property + def supported_sample_rates(self) -> list[stt.AudioSampleRates]: + """Return a list of supported sample rates.""" + return [stt.AudioSampleRates.SAMPLERATE_16000] + + @property + def supported_channels(self) -> list[stt.AudioChannels]: + """Return a list of supported channels.""" + # Per https://ai.google.dev/gemini-api/docs/audio + # If the audio source contains multiple channels, Gemini combines those channels into a single channel. + return [stt.AudioChannels.CHANNEL_MONO] + + async def async_process_audio_stream( + self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes] + ) -> stt.SpeechResult: + """Process an audio stream to STT service.""" + audio_data = b"" + async for chunk in stream: + audio_data += chunk + if metadata.format == stt.AudioFormats.WAV: + audio_data = convert_to_wav( + audio_data, + f"audio/L{metadata.bit_rate.value};rate={metadata.sample_rate.value}", + ) + + try: + response = await self._genai_client.aio.models.generate_content( + model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL), + contents=[ + self.subentry.data.get(CONF_PROMPT, DEFAULT_STT_PROMPT), + Part.from_bytes( + data=audio_data, + mime_type=f"audio/{metadata.format.value}", + ), + ], + config=self.create_generate_content_config(), + ) + except (APIError, ClientError, ValueError) as err: + LOGGER.error("Error during STT: %s", err) + else: + if response.text: + return stt.SpeechResult( + response.text, + stt.SpeechResultState.SUCCESS, + ) + + return stt.SpeechResult(None, stt.SpeechResultState.ERROR) diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index da5976f46c4..b19482957b2 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -9,6 +9,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, ) from homeassistant.config_entries import ConfigEntry @@ -39,6 +40,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_id": "ulid-conversation", "unique_id": None, }, + { + "data": {}, + "subentry_type": "stt", + "title": DEFAULT_STT_NAME, + "subentry_id": "ulid-stt", + "unique_id": None, + }, { "data": {}, "subentry_type": "tts", diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index d3e27eb99d2..bceb12a9256 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -34,6 +34,14 @@ 'title': 'Google AI Conversation', 'unique_id': None, }), + 'ulid-stt': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-stt', + 'subentry_type': 'stt', + 'title': 'Google AI STT', + 'unique_id': None, + }), 'ulid-tts': dict({ 'data': dict({ }), diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index a0d34f49d37..0c57935589b 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -32,6 +32,37 @@ 'sw_version': None, 'via_device_id': None, }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-stt', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI STT', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index bf3e2aedb45..52def1d06bb 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Google Generative AI Conversation config flow.""" +from typing import Any from unittest.mock import Mock, patch import pytest @@ -21,6 +22,7 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, @@ -28,8 +30,11 @@ from homeassistant.components.google_generative_ai_conversation.const import ( RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_MAX_TOKENS, + RECOMMENDED_STT_MODEL, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TOP_K, RECOMMENDED_TOP_P, + RECOMMENDED_TTS_MODEL, RECOMMENDED_TTS_OPTIONS, RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, ) @@ -64,11 +69,17 @@ def get_models_pager(): ) model_15_pro.name = "models/gemini-1.5-pro-latest" + model_25_flash_tts = Mock( + supported_actions=["generateContent"], + ) + model_25_flash_tts.name = "models/gemini-2.5-flash-preview-tts" + async def models_pager(): yield model_25_flash yield model_20_flash yield model_15_flash yield model_15_pro + yield model_25_flash_tts return models_pager() @@ -129,6 +140,12 @@ async def test_form(hass: HomeAssistant) -> None: "title": DEFAULT_AI_TASK_NAME, "unique_id": None, }, + { + "subentry_type": "stt", + "data": RECOMMENDED_STT_OPTIONS, + "title": DEFAULT_STT_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -157,22 +174,35 @@ async def test_duplicate_entry(hass: HomeAssistant) -> None: assert result["reason"] == "already_configured" -async def test_creating_conversation_subentry( +@pytest.mark.parametrize( + ("subentry_type", "options"), + [ + ("conversation", RECOMMENDED_CONVERSATION_OPTIONS), + ("stt", RECOMMENDED_STT_OPTIONS), + ("tts", RECOMMENDED_TTS_OPTIONS), + ("ai_task_data", RECOMMENDED_AI_TASK_OPTIONS), + ], +) +async def test_creating_subentry( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + options: dict[str, Any], ) -> None: - """Test creating a conversation subentry.""" + """Test creating a subentry.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "conversation"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) - assert result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.FORM, result assert result["step_id"] == "set_options" assert not result["errors"] @@ -182,31 +212,117 @@ async def test_creating_conversation_subentry( ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS}, + result["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "Mock name" + assert result2["data"] == expected_options - processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() - processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 - assert result2["data"] == processed_options + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" -async def test_creating_tts_subentry( +@pytest.mark.parametrize( + ("subentry_type", "recommended_model", "options"), + [ + ( + "conversation", + RECOMMENDED_CHAT_MODEL, + { + CONF_PROMPT: "You are Mario", + CONF_LLM_HASS_API: ["assist"], + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_USE_GOOGLE_SEARCH_TOOL: RECOMMENDED_USE_GOOGLE_SEARCH_TOOL, + }, + ), + ( + "stt", + RECOMMENDED_STT_MODEL, + { + CONF_PROMPT: "Transcribe this", + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_STT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "tts", + RECOMMENDED_TTS_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_TTS_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ( + "ai_task_data", + RECOMMENDED_CHAT_MODEL, + { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: RECOMMENDED_CHAT_MODEL, + CONF_TEMPERATURE: 1.0, + CONF_TOP_P: 1.0, + CONF_TOP_K: 1, + CONF_MAX_TOKENS: 1024, + CONF_HARASSMENT_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_HATE_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_SEXUAL_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + CONF_DANGEROUS_BLOCK_THRESHOLD: "BLOCK_MEDIUM_AND_ABOVE", + }, + ), + ], +) +async def test_creating_subentry_custom_options( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, + subentry_type: str, + recommended_model: str, + options: dict[str, Any], ) -> None: - """Test creating a TTS subentry.""" + """Test creating a subentry with custom options.""" + old_subentries = set(mock_config_entry.subentries) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "tts"), + (mock_config_entry.entry_id, subentry_type), context={"source": config_entries.SOURCE_USER}, ) @@ -214,75 +330,52 @@ async def test_creating_tts_subentry( assert result["step_id"] == "set_options" assert not result["errors"] - old_subentries = set(mock_config_entry.subentries) - + # Uncheck recommended to show custom options with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_NAME: "Mock TTS", **RECOMMENDED_TTS_OPTIONS}, + result["data_schema"]({CONF_RECOMMENDED: False}), ) - await hass.async_block_till_done() + assert result2["type"] is FlowResultType.FORM - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Mock TTS" - assert result2["data"] == RECOMMENDED_TTS_OPTIONS + # Find the schema key for CONF_CHAT_MODEL and check its default + schema_dict = result2["data_schema"].schema + chat_model_key = next(key for key in schema_dict if key.schema == CONF_CHAT_MODEL) + assert chat_model_key.default() == recommended_model + models_in_selector = [ + opt["value"] for opt in schema_dict[chat_model_key].config["options"] + ] + assert recommended_model in models_in_selector - assert len(mock_config_entry.subentries) == 4 - - new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] - new_subentry = mock_config_entry.subentries[new_subentry_id] - - assert new_subentry.subentry_type == "tts" - assert new_subentry.data == RECOMMENDED_TTS_OPTIONS - assert new_subentry.title == "Mock TTS" - - -async def test_creating_ai_task_subentry( - hass: HomeAssistant, - mock_init_component: None, - mock_config_entry: MockConfigEntry, -) -> None: - """Test creating an AI task subentry.""" + # Submit the form with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), ): - result = await hass.config_entries.subentries.async_init( - (mock_config_entry.entry_id, "ai_task_data"), - context={"source": config_entries.SOURCE_USER}, - ) - - assert result["type"] is FlowResultType.FORM, result - assert result["step_id"] == "set_options" - assert not result["errors"] - - old_subentries = set(mock_config_entry.subentries) - - with patch( - "google.genai.models.AsyncModels.list", - return_value=get_models_pager(), - ): - result2 = await hass.config_entries.subentries.async_configure( - result["flow_id"], - {CONF_NAME: "Mock AI Task", **RECOMMENDED_AI_TASK_OPTIONS}, + result3 = await hass.config_entries.subentries.async_configure( + result2["flow_id"], + result2["data_schema"]({CONF_NAME: "Mock name", **options}), ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Mock AI Task" - assert result2["data"] == RECOMMENDED_AI_TASK_OPTIONS + expected_options = options.copy() + if CONF_PROMPT in expected_options: + expected_options[CONF_PROMPT] = expected_options[CONF_PROMPT].strip() + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Mock name" + assert result3["data"] == expected_options - assert len(mock_config_entry.subentries) == 4 + assert len(mock_config_entry.subentries) == len(old_subentries) + 1 new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] new_subentry = mock_config_entry.subentries[new_subentry_id] - assert new_subentry.subentry_type == "ai_task_data" - assert new_subentry.data == RECOMMENDED_AI_TASK_OPTIONS - assert new_subentry.title == "Mock AI Task" + assert new_subentry.subentry_type == subentry_type + assert new_subentry.data == expected_options + assert new_subentry.title == "Mock name" async def test_creating_conversation_subentry_not_loaded( diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index e154f9d33c9..fbd52dc9245 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -11,11 +11,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + DEFAULT_STT_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CONVERSATION_OPTIONS, + RECOMMENDED_STT_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ( @@ -489,7 +491,7 @@ async def test_migration_from_v1( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -516,6 +518,14 @@ async def test_migration_from_v1( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -721,7 +731,7 @@ async def test_migration_from_v1_disabled( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -748,6 +758,14 @@ async def test_migration_from_v1_disabled( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME assert not device_registry.async_get_device( identifiers={(DOMAIN, mock_config_entry.entry_id)} @@ -860,7 +878,7 @@ async def test_migration_from_v1_with_multiple_keys( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -873,6 +891,10 @@ async def test_migration_from_v1_with_multiple_keys( assert subentry.subentry_type == "ai_task_data" assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS assert subentry.title == DEFAULT_AI_TASK_NAME + subentry = list(entry.subentries.values())[3] + assert subentry.subentry_type == "stt" + assert subentry.data == RECOMMENDED_STT_OPTIONS + assert subentry.title == DEFAULT_STT_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -963,7 +985,7 @@ async def test_migration_from_v1_with_same_keys( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -990,6 +1012,14 @@ async def test_migration_from_v1_with_same_keys( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -1090,10 +1120,11 @@ async def test_migration_from_v2_1( """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core - 2025.7.0b0-2025.7.0b1 and add AI Task subentry: + 2025.7.0b0-2025.7.0b1 and add AI Task and STT subentries: - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) - Add AI Task subentry (Added in version 2.3) + - Add STT subentry (Added in version 2.3) """ # Create a v2.1 config entry with 2 subentries, devices and entities options = { @@ -1184,7 +1215,7 @@ async def test_migration_from_v2_1( assert entry.minor_version == 4 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 4 + assert len(entry.subentries) == 5 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -1211,6 +1242,14 @@ async def test_migration_from_v2_1( assert len(ai_task_subentries) == 1 assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + stt_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "stt" + ] + assert len(stt_subentries) == 1 + assert stt_subentries[0].data == RECOMMENDED_STT_OPTIONS + assert stt_subentries[0].title == DEFAULT_STT_NAME subentry = conversation_subentries[0] @@ -1320,8 +1359,8 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert entry.version == 2 assert entry.minor_version == 4 - # Check we now have conversation, tts and ai_task_data subentries - assert len(entry.subentries) == 3 + # Check we now have conversation, tts, stt, and ai_task_data subentries + assert len(entry.subentries) == 4 subentries = { subentry.subentry_type: subentry for subentry in entry.subentries.values() @@ -1336,6 +1375,12 @@ async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + # Find and verify the stt subentry + ai_task_subentry = subentries["stt"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_STT_NAME + assert ai_task_subentry.data == RECOMMENDED_STT_OPTIONS + # Verify conversation subentry is still there and unchanged conversation_subentry = subentries["conversation"] assert conversation_subentry is not None diff --git a/tests/components/google_generative_ai_conversation/test_stt.py b/tests/components/google_generative_ai_conversation/test_stt.py new file mode 100644 index 00000000000..90c58ebba16 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_stt.py @@ -0,0 +1,303 @@ +"""Tests for the Google Generative AI Conversation STT entity.""" + +from __future__ import annotations + +from collections.abc import AsyncIterable, Generator +from unittest.mock import AsyncMock, Mock, patch + +from google.genai import types +import pytest + +from homeassistant.components import stt +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + DEFAULT_STT_PROMPT, + DOMAIN, + RECOMMENDED_STT_MODEL, +) +from homeassistant.config_entries import ConfigSubentry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import API_ERROR_500, CLIENT_ERROR_BAD_REQUEST + +from tests.common import MockConfigEntry + +TEST_CHAT_MODEL = "models/gemini-2.5-flash" +TEST_PROMPT = "Please transcribe the audio." + + +async def _async_get_audio_stream(data: bytes) -> AsyncIterable[bytes]: + """Yield the audio data.""" + yield data + + +@pytest.fixture +def mock_genai_client() -> Generator[AsyncMock]: + """Mock genai.Client.""" + client = Mock() + client.aio.models.get = AsyncMock() + client.aio.models.generate_content = AsyncMock( + return_value=types.GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "This is a test transcription."}], + "role": "model", + } + } + ] + ) + ) + with patch( + "homeassistant.components.google_generative_ai_conversation.Client", + return_value=client, + ) as mock_client: + yield mock_client.return_value + + +@pytest.fixture +async def setup_integration( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Set up the test environment.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + + sub_entry = ConfigSubentry( + data={ + CONF_CHAT_MODEL: TEST_CHAT_MODEL, + CONF_PROMPT: TEST_PROMPT, + }, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + + config_entry.runtime_data = mock_genai_client + + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_entity_properties(hass: HomeAssistant) -> None: + """Test STT entity properties.""" + entity: stt.SpeechToTextEntity = hass.data[stt.DOMAIN].get_entity( + "stt.google_ai_stt" + ) + assert entity is not None + assert isinstance(entity.supported_languages, list) + assert stt.AudioFormats.WAV in entity.supported_formats + assert stt.AudioFormats.OGG in entity.supported_formats + assert stt.AudioCodecs.PCM in entity.supported_codecs + assert stt.AudioCodecs.OPUS in entity.supported_codecs + assert stt.AudioBitRates.BITRATE_16 in entity.supported_bit_rates + assert stt.AudioSampleRates.SAMPLERATE_16000 in entity.supported_sample_rates + assert stt.AudioChannels.CHANNEL_MONO in entity.supported_channels + + +@pytest.mark.parametrize( + ("audio_format", "call_convert_to_wav"), + [ + (stt.AudioFormats.WAV, True), + (stt.AudioFormats.OGG, False), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_success( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + audio_format: stt.AudioFormats, + call_convert_to_wav: bool, +) -> None: + """Test STT processing audio stream successfully.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=audio_format, + codec=stt.AudioCodecs.PCM, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + with patch( + "homeassistant.components.google_generative_ai_conversation.stt.convert_to_wav", + return_value=b"converted_wav_bytes", + ) as mock_convert_to_wav: + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.SUCCESS + assert result.text == "This is a test transcription." + + if call_convert_to_wav: + mock_convert_to_wav.assert_called_once_with( + b"test_audio_bytes", "audio/L16;rate=16000" + ) + else: + mock_convert_to_wav.assert_not_called() + + mock_genai_client.aio.models.generate_content.assert_called_once() + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == TEST_CHAT_MODEL + + contents = call_args.kwargs["contents"] + assert contents[0] == TEST_PROMPT + assert isinstance(contents[1], types.Part) + assert contents[1].inline_data.mime_type == f"audio/{audio_format.value}" + if call_convert_to_wav: + assert contents[1].inline_data.data == b"converted_wav_bytes" + else: + assert contents[1].inline_data.data == b"test_audio_bytes" + + +@pytest.mark.parametrize( + "side_effect", + [ + API_ERROR_500, + CLIENT_ERROR_BAD_REQUEST, + ValueError("Test value error"), + ], +) +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_api_error( + hass: HomeAssistant, + mock_genai_client: AsyncMock, + side_effect: Exception, +) -> None: + """Test STT processing audio stream with API errors.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.side_effect = side_effect + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("setup_integration") +async def test_stt_process_audio_stream_empty_response( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test STT processing with an empty response from the API.""" + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + mock_genai_client.aio.models.generate_content.return_value = ( + types.GenerateContentResponse(candidates=[]) + ) + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + result = await entity.async_process_audio_stream(metadata, audio_stream) + + assert result.result == stt.SpeechResultState.ERROR + assert result.text is None + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_prompt( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default prompt is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no prompt + sub_entry = ConfigSubentry( + data={CONF_CHAT_MODEL: TEST_CHAT_MODEL}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + contents = call_args.kwargs["contents"] + assert contents[0] == DEFAULT_STT_PROMPT + + +@pytest.mark.usefixtures("mock_genai_client") +async def test_stt_uses_default_model( + hass: HomeAssistant, + mock_genai_client: AsyncMock, +) -> None: + """Test that the default model is used if none is configured.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_API_KEY: "bla"}, version=2, minor_version=1 + ) + config_entry.add_to_hass(hass) + config_entry.runtime_data = mock_genai_client + + # Subentry with no model + sub_entry = ConfigSubentry( + data={CONF_PROMPT: TEST_PROMPT}, + subentry_type="stt", + title="Google AI STT", + unique_id=None, + ) + hass.config_entries.async_add_subentry(config_entry, sub_entry) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity = hass.data[stt.DOMAIN].get_entity("stt.google_ai_stt") + + metadata = stt.SpeechMetadata( + language="en-US", + format=stt.AudioFormats.OGG, + codec=stt.AudioCodecs.OPUS, + bit_rate=stt.AudioBitRates.BITRATE_16, + sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, + channel=stt.AudioChannels.CHANNEL_MONO, + ) + audio_stream = _async_get_audio_stream(b"test_audio_bytes") + + await entity.async_process_audio_stream(metadata, audio_stream) + + call_args = mock_genai_client.aio.models.generate_content.call_args + assert call_args.kwargs["model"] == RECOMMENDED_STT_MODEL From 62e3802ff28d1291c08041436fb38646e7d140ec Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Jul 2025 14:22:42 +0200 Subject: [PATCH 1469/1664] Deprecate MediaPlayerState.STANDBY (#148151) Co-authored-by: Franck Nijhof --- homeassistant/components/media_player/__init__.py | 3 ++- homeassistant/components/media_player/const.py | 9 ++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index d0c6bcabfcf..b2cb7d76e8f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -1041,7 +1041,8 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if self.state in { MediaPlayerState.OFF, - MediaPlayerState.STANDBY, + # Not comparing to MediaPlayerState.STANDBY to avoid deprecation warning + "standby", }: await self.async_turn_on() else: diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index 8d85d7cd106..f842ccccb65 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -5,6 +5,7 @@ from functools import partial from homeassistant.helpers.deprecation import ( DeprecatedConstantEnum, + EnumWithDeprecatedMembers, all_with_deprecated_constants, check_if_deprecated_constant, dir_with_deprecated_constants, @@ -50,7 +51,13 @@ ATTR_SOUND_MODE_LIST = "sound_mode_list" DOMAIN = "media_player" -class MediaPlayerState(StrEnum): +class MediaPlayerState( + StrEnum, + metaclass=EnumWithDeprecatedMembers, + deprecated={ + "STANDBY": ("MediaPlayerState.OFF or MediaPlayerState.IDLE", "2026.8.0"), + }, +): """State of media player entities.""" OFF = "off" From 0d79f7db51f806ab27042723667c8b827a5ff9ba Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 16 Jul 2025 14:43:55 +0200 Subject: [PATCH 1470/1664] Update mypy-dev to 1.18.0a2 (#148880) --- homeassistant/components/androidtv_remote/helpers.py | 2 +- homeassistant/components/bthome/coordinator.py | 2 +- homeassistant/components/bthome/device_trigger.py | 2 +- .../components/islamic_prayer_times/coordinator.py | 6 +++--- homeassistant/components/mikrotik/coordinator.py | 4 ++-- homeassistant/components/shelly/coordinator.py | 2 +- homeassistant/components/shelly/utils.py | 2 +- homeassistant/components/squeezebox/media_player.py | 2 +- homeassistant/components/transmission/coordinator.py | 4 ++-- homeassistant/components/unifiprotect/data.py | 4 ++-- homeassistant/components/xiaomi_ble/coordinator.py | 2 +- requirements_test.txt | 2 +- 12 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/androidtv_remote/helpers.py b/homeassistant/components/androidtv_remote/helpers.py index a67d5839ee6..9052a414393 100644 --- a/homeassistant/components/androidtv_remote/helpers.py +++ b/homeassistant/components/androidtv_remote/helpers.py @@ -27,4 +27,4 @@ def create_api(hass: HomeAssistant, host: str, enable_ime: bool) -> AndroidTVRem def get_enable_ime(entry: AndroidTVRemoteConfigEntry) -> bool: """Get value of enable_ime option or its default value.""" - return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) + return entry.options.get(CONF_ENABLE_IME, CONF_ENABLE_IME_DEFAULT_VALUE) # type: ignore[no-any-return] diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py index 2ef29541f40..6ab88c48c46 100644 --- a/homeassistant/components/bthome/coordinator.py +++ b/homeassistant/components/bthome/coordinator.py @@ -45,7 +45,7 @@ class BTHomePassiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class BTHomePassiveBluetoothDataProcessor[_T]( diff --git a/homeassistant/components/bthome/device_trigger.py b/homeassistant/components/bthome/device_trigger.py index 6d194714c64..b9e01051419 100644 --- a/homeassistant/components/bthome/device_trigger.py +++ b/homeassistant/components/bthome/device_trigger.py @@ -70,7 +70,7 @@ def get_event_classes_by_device_id(hass: HomeAssistant, device_id: str) -> list[ bthome_config_entry = next( entry for entry in config_entries if entry and entry.domain == DOMAIN ) - return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + return bthome_config_entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) # type: ignore[no-any-return] def get_event_types_by_event_class(event_class: str) -> set[str]: diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index a6cd3fb151e..8bd7e5904b0 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -54,7 +54,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def calc_method(self) -> str: """Return the calculation method.""" - return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) # type: ignore[no-any-return] @property def lat_adj_method(self) -> str: @@ -68,12 +68,12 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim @property def midnight_mode(self) -> str: """Return the midnight mode.""" - return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) # type: ignore[no-any-return] @property def school(self) -> str: """Return the school.""" - return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) # type: ignore[no-any-return] def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: """Fetch prayer times for the specified date.""" diff --git a/homeassistant/components/mikrotik/coordinator.py b/homeassistant/components/mikrotik/coordinator.py index c68b13eeca8..a94d3b4b64e 100644 --- a/homeassistant/components/mikrotik/coordinator.py +++ b/homeassistant/components/mikrotik/coordinator.py @@ -83,12 +83,12 @@ class MikrotikData: @property def arp_enabled(self) -> bool: """Return arp_ping option setting.""" - return self.config_entry.options.get(CONF_ARP_PING, False) + return self.config_entry.options.get(CONF_ARP_PING, False) # type: ignore[no-any-return] @property def force_dhcp(self) -> bool: """Return force_dhcp option setting.""" - return self.config_entry.options.get(CONF_FORCE_DHCP, False) + return self.config_entry.options.get(CONF_FORCE_DHCP, False) # type: ignore[no-any-return] def get_info(self, param: str) -> str: """Return device model name.""" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index fa434588b34..9291d7aa70f 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -163,7 +163,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice]( @property def sleep_period(self) -> int: """Sleep period of the device.""" - return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) + return self.config_entry.data.get(CONF_SLEEP_PERIOD, 0) # type: ignore[no-any-return] def async_setup(self, pending_platforms: list[Platform] | None = None) -> None: """Set up the coordinator.""" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 953fcbace06..1af365debfb 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -451,7 +451,7 @@ def get_rpc_entity_name( def get_device_entry_gen(entry: ConfigEntry) -> int: """Return the device generation from config entry.""" - return entry.data.get(CONF_GEN, 1) + return entry.data.get(CONF_GEN, 1) # type: ignore[no-any-return] def get_rpc_key_instances( diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index f37faa4e115..dc426d76588 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -287,7 +287,7 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def browse_limit(self) -> int: """Return the step to be used for volume up down.""" - return self.coordinator.config_entry.options.get( + return self.coordinator.config_entry.options.get( # type: ignore[no-any-return] CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT ) diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index afe2660e711..458f719e5f2 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -60,12 +60,12 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): @property def limit(self) -> int: """Return limit.""" - return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) + return self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT) # type: ignore[no-any-return] @property def order(self) -> str: """Return order.""" - return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) + return self.config_entry.options.get(CONF_ORDER, DEFAULT_ORDER) # type: ignore[no-any-return] async def _async_update_data(self) -> SessionStats: """Update transmission data.""" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index baecc7f8323..1c03febe74b 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -93,12 +93,12 @@ class ProtectData: @property def disable_stream(self) -> bool: """Check if RTSP is disabled.""" - return self._entry.options.get(CONF_DISABLE_RTSP, False) + return self._entry.options.get(CONF_DISABLE_RTSP, False) # type: ignore[no-any-return] @property def max_events(self) -> int: """Max number of events to load at once.""" - return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) + return self._entry.options.get(CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA) # type: ignore[no-any-return] @callback def async_subscribe_adopt( diff --git a/homeassistant/components/xiaomi_ble/coordinator.py b/homeassistant/components/xiaomi_ble/coordinator.py index 69fc427013a..a07b7fde3b1 100644 --- a/homeassistant/components/xiaomi_ble/coordinator.py +++ b/homeassistant/components/xiaomi_ble/coordinator.py @@ -67,7 +67,7 @@ class XiaomiActiveBluetoothProcessorCoordinator( @property def sleepy_device(self) -> bool: """Return True if the device is a sleepy device.""" - return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) + return self.entry.data.get(CONF_SLEEPY_DEVICE, self.device_data.sleepy_device) # type: ignore[no-any-return] class XiaomiPassiveBluetoothDataProcessor[_T]( diff --git a/requirements_test.txt b/requirements_test.txt index 386e380911a..b758a7b517a 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.17.0a4 +mypy-dev==1.18.0a2 pre-commit==4.2.0 pydantic==2.11.7 pylint==3.3.7 From 3e465da89208633c8b238ad9a89cdac2b763797e Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 16 Jul 2025 19:52:53 +0700 Subject: [PATCH 1471/1664] Add Code Interpreter tool for OpenAI Conversation (#148383) --- .../openai_conversation/config_flow.py | 21 ++--- .../components/openai_conversation/const.py | 2 + .../components/openai_conversation/entity.py | 19 +++- .../openai_conversation/strings.json | 2 + .../openai_conversation/__init__.py | 89 +++++++++++++++++++ .../openai_conversation/conftest.py | 11 ++- .../openai_conversation/test_config_flow.py | 38 +++++++- .../openai_conversation/test_conversation.py | 48 ++++++++++ 8 files changed, 206 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ce6872c7c20..aa1c967ca8f 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -42,6 +42,7 @@ from homeassistant.helpers.typing import VolDictType from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, @@ -60,6 +61,7 @@ from .const import ( DOMAIN, RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CODE_INTERPRETER, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, @@ -312,7 +314,12 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): options = self.options errors: dict[str, str] = {} - step_schema: VolDictType = {} + step_schema: VolDictType = { + vol.Optional( + CONF_CODE_INTERPRETER, + default=RECOMMENDED_CODE_INTERPRETER, + ): bool, + } model = options[CONF_CHAT_MODEL] @@ -375,18 +382,6 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow): ) } - if not step_schema: - if self._is_new: - return self.async_create_entry( - title=options.pop(CONF_NAME), - data=options, - ) - return self.async_update_and_abort( - self._get_entry(), - self._get_reconfigure_subentry(), - data=options, - ) - if user_input is not None: if user_input.get(CONF_WEB_SEARCH): if user_input.get(CONF_WEB_SEARCH_USER_LOCATION): diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index a15f71118c0..cacef6fcff9 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -13,6 +13,7 @@ DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" +CONF_CODE_INTERPRETER = "code_interpreter" CONF_FILENAMES = "filenames" CONF_MAX_TOKENS = "max_tokens" CONF_PROMPT = "prompt" @@ -27,6 +28,7 @@ CONF_WEB_SEARCH_CITY = "city" CONF_WEB_SEARCH_REGION = "region" CONF_WEB_SEARCH_COUNTRY = "country" CONF_WEB_SEARCH_TIMEZONE = "timezone" +RECOMMENDED_CODE_INTERPRETER = False RECOMMENDED_CHAT_MODEL = "gpt-4o-mini" RECOMMENDED_MAX_TOKENS = 3000 RECOMMENDED_REASONING_EFFORT = "low" diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 7679bef83f1..93713c78d9c 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -38,6 +38,10 @@ from openai.types.responses import ( WebSearchToolParam, ) from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.tool_param import ( + CodeInterpreter, + CodeInterpreterContainerCodeInterpreterToolAuto, +) from openai.types.responses.web_search_tool_param import UserLocation import voluptuous as vol from voluptuous_openapi import convert @@ -52,6 +56,7 @@ from homeassistant.util import slugify from .const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_REASONING_EFFORT, CONF_TEMPERATURE, @@ -292,7 +297,7 @@ class OpenAIBaseLLMEntity(Entity): """Generate an answer for the chat log.""" options = self.subentry.data - tools: list[ToolParam] | None = None + tools: list[ToolParam] = [] if chat_log.llm_api: tools = [ _format_tool(tool, chat_log.llm_api.custom_serializer) @@ -314,10 +319,18 @@ class OpenAIBaseLLMEntity(Entity): country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), ) - if tools is None: - tools = [] tools.append(web_search) + if options.get(CONF_CODE_INTERPRETER): + tools.append( + CodeInterpreter( + type="code_interpreter", + container=CodeInterpreterContainerCodeInterpreterToolAuto( + type="auto" + ), + ) + ) + model_args = { "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), "input": [], diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index 5011fc9cf99..fef955b4fa9 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -48,12 +48,14 @@ "model": { "title": "Model-specific options", "data": { + "code_interpreter": "Enable code interpreter tool", "reasoning_effort": "Reasoning effort", "web_search": "Enable web search", "search_context_size": "Search context size", "user_location": "Include home location" }, "data_description": { + "code_interpreter": "This tool, also known as the python tool to the model, allows it to run code to answer questions", "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt", "web_search": "Allow the model to search the web for the latest information before generating a response", "search_context_size": "High level guidance for the amount of context window space to use for the search", diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index 11dc978250a..c10c23df237 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1,6 +1,12 @@ """Tests for the OpenAI Conversation integration.""" from openai.types.responses import ( + ResponseCodeInterpreterCallCodeDeltaEvent, + ResponseCodeInterpreterCallCodeDoneEvent, + ResponseCodeInterpreterCallCompletedEvent, + ResponseCodeInterpreterCallInProgressEvent, + ResponseCodeInterpreterCallInterpretingEvent, + ResponseCodeInterpreterToolCall, ResponseContentPartAddedEvent, ResponseContentPartDoneEvent, ResponseFunctionCallArgumentsDeltaEvent, @@ -239,3 +245,86 @@ def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEve type="response.output_item.done", ), ] + + +def create_code_interpreter_item( + id: str, code: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(code, str): + code = [code] + + container_id = "cntr_A" + events = [ + ResponseOutputItemAddedEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code="", + container_id=container_id, + outputs=None, + type="code_interpreter_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseCodeInterpreterCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.in_progress", + ), + ] + + events.extend( + ResponseCodeInterpreterCallCodeDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call_code.delta", + ) + for delta in code + ) + + code = "".join(code) + + events.extend( + [ + ResponseCodeInterpreterCallCodeDoneEvent( + item_id=id, + output_index=output_index, + code=code, + sequence_number=0, + type="response.code_interpreter_call_code.done", + ), + ResponseCodeInterpreterCallInterpretingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.interpreting", + ), + ResponseCodeInterpreterCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.code_interpreter_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseCodeInterpreterToolCall( + id=id, + code=code, + container_id=container_id, + outputs=None, + status="completed", + type="code_interpreter_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 84c907a7c2e..b58e6c31f38 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -156,9 +156,10 @@ def mock_create_stream() -> Generator[AsyncMock]: ) yield ResponseInProgressEvent( response=response, - sequence_number=0, + sequence_number=1, type="response.in_progress", ) + sequence_number = 2 response.status = "completed" for value in events: @@ -173,6 +174,8 @@ def mock_create_stream() -> Generator[AsyncMock]: response.error = value break + value.sequence_number = sequence_number + sequence_number += 1 yield value if isinstance(value, ResponseErrorEvent): @@ -181,19 +184,19 @@ def mock_create_stream() -> Generator[AsyncMock]: if response.status == "incomplete": yield ResponseIncompleteEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.incomplete", ) elif response.status == "failed": yield ResponseFailedEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.failed", ) else: yield ResponseCompletedEvent( response=response, - sequence_number=0, + sequence_number=sequence_number, type="response.completed", ) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index 0ccbc39160a..6d8fb143f88 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -13,6 +13,7 @@ from homeassistant.components.openai_conversation.config_flow import ( ) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + CONF_CODE_INTERPRETER, CONF_MAX_TOKENS, CONF_PROMPT, CONF_REASONING_EFFORT, @@ -311,6 +312,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), { @@ -321,6 +323,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: RECOMMENDED_TOP_P, CONF_MAX_TOKENS: 10000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ), ( # options for web search without user location @@ -343,6 +346,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -355,6 +359,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), # Test that current options are showed as suggested values @@ -373,6 +378,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -389,6 +395,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), { @@ -401,6 +408,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: True, }, ), ( # Case 2: reasoning model @@ -424,7 +432,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, }, - {CONF_REASONING_EFFORT: "high"}, + {CONF_REASONING_EFFORT: "high", CONF_CODE_INTERPRETER: False}, ), { CONF_RECOMMENDED: False, @@ -434,6 +442,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: False, }, ), # Test that old options are removed after reconfiguration @@ -445,6 +454,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_CHAT_MODEL: "gpt-4o", CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, + CONF_CODE_INTERPRETER: True, CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "low", CONF_WEB_SEARCH_USER_LOCATION: True, @@ -476,6 +486,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "high", + CONF_CODE_INTERPRETER: True, }, ( { @@ -504,6 +515,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: True, }, ( { @@ -518,6 +530,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non }, { CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), { @@ -528,6 +541,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ), ( # Case 4: reasoning to web search @@ -540,6 +554,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_TOP_P: 0.9, CONF_MAX_TOKENS: 1000, CONF_REASONING_EFFORT: "low", + CONF_CODE_INTERPRETER: True, }, ( { @@ -556,6 +571,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), { @@ -568,6 +584,7 @@ async def test_form_invalid_auth(hass: HomeAssistant, side_effect, error) -> Non CONF_WEB_SEARCH: True, CONF_WEB_SEARCH_CONTEXT_SIZE: "high", CONF_WEB_SEARCH_USER_LOCATION: False, + CONF_CODE_INTERPRETER: False, }, ), ], @@ -718,6 +735,7 @@ async def test_subentry_web_search_user_location( CONF_WEB_SEARCH_REGION: "California", CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", + CONF_CODE_INTERPRETER: False, } @@ -817,12 +835,24 @@ async def test_creating_ai_task_subentry_advanced( }, ) - assert result3.get("type") is FlowResultType.CREATE_ENTRY - assert result3.get("title") == "Advanced AI Task" - assert result3.get("data") == { + assert result3.get("type") is FlowResultType.FORM + assert result3.get("step_id") == "model" + + # Configure model settings + result4 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CODE_INTERPRETER: False, + }, + ) + + assert result4.get("type") is FlowResultType.CREATE_ENTRY + assert result4.get("title") == "Advanced AI Task" + assert result4.get("data") == { CONF_RECOMMENDED: False, CONF_CHAT_MODEL: "gpt-4o", CONF_MAX_TOKENS: 200, CONF_TEMPERATURE: 0.5, CONF_TOP_P: 0.9, + CONF_CODE_INTERPRETER: False, } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 39cd129e1ba..dafcba7bfeb 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -16,6 +16,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.components.homeassistant.exposed_entities import async_expose_entity from homeassistant.components.openai_conversation.const import ( + CONF_CODE_INTERPRETER, CONF_WEB_SEARCH, CONF_WEB_SEARCH_CITY, CONF_WEB_SEARCH_CONTEXT_SIZE, @@ -30,6 +31,7 @@ from homeassistant.helpers import intent from homeassistant.setup import async_setup_component from . import ( + create_code_interpreter_item, create_function_tool_call_item, create_message_item, create_reasoning_item, @@ -485,3 +487,49 @@ async def test_web_search( ] assert result.response.response_type == intent.IntentResponseType.ACTION_DONE assert result.response.speech["plain"]["speech"] == message, result.response.speech + + +async def test_code_interpreter( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + mock_create_stream, + mock_chat_log: MockChatLog, # noqa: F811 +) -> None: + """Test code_interpreter tool.""" + subentry = next(iter(mock_config_entry.subentries.values())) + hass.config_entries.async_update_subentry( + mock_config_entry, + subentry, + data={ + **subentry.data, + CONF_CODE_INTERPRETER: True, + }, + ) + await hass.config_entries.async_reload(mock_config_entry.entry_id) + + message = "I’ve calculated it with Python: the square root of 55555 is approximately 235.70108188126758." + mock_create_stream.return_value = [ + ( + *create_code_interpreter_item( + id="ci_A", + code=["import", " math", "\n", "math", ".sqrt", "(", "555", "55", ")"], + output_index=0, + ), + *create_message_item(id="msg_A", text=message, output_index=1), + ) + ] + + result = await conversation.async_converse( + hass, + "Please use the python tool to calculate square root of 55555", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.openai_conversation", + ) + + assert mock_create_stream.mock_calls[0][2]["tools"] == [ + {"type": "code_interpreter", "container": {"type": "auto"}} + ] + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert result.response.speech["plain"]["speech"] == message, result.response.speech From 412035b9705ab1df65808c984ebb1ad12156ec6b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 15:07:53 +0200 Subject: [PATCH 1472/1664] Add devices to OpenRouter (#148888) --- homeassistant/components/open_router/conversation.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 48720e7c829..48fb1ec44cb 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -16,6 +16,7 @@ from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import intent +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenRouterConfigEntry @@ -61,13 +62,20 @@ def _convert_content_to_chat_message( class OpenRouterConversationEntity(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_name = subentry.title self._attr_unique_id = subentry.subentry_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + entry_type=DeviceEntryType.SERVICE, + ) @property def supported_languages(self) -> list[str] | Literal["*"]: From 840e0d1388f9ca66dc123c5a62dcb36dc0ce7e67 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:19:22 +0200 Subject: [PATCH 1473/1664] Clean up ModuleWrapper from loader (#148488) --- homeassistant/loader.py | 79 ----------------------------------------- 1 file changed, 79 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 1e338be0a0f..07c4a934573 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -10,7 +10,6 @@ import asyncio from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass -import functools as ft import importlib import logging import os @@ -1650,77 +1649,6 @@ class CircularDependency(LoaderError): self.args[1].insert(0, domain) -def _load_file( - hass: HomeAssistant, comp_or_platform: str, base_paths: list[str] -) -> ComponentProtocol | None: - """Try to load specified file. - - Looks in config dir first, then built-in components. - Only returns it if also found to be valid. - Async friendly. - """ - cache = hass.data[DATA_COMPONENTS] - if module := cache.get(comp_or_platform): - return cast(ComponentProtocol, module) - - for path in (f"{base}.{comp_or_platform}" for base in base_paths): - try: - module = importlib.import_module(path) - - # In Python 3 you can import files from directories that do not - # contain the file __init__.py. A directory is a valid module if - # it contains a file with the .py extension. In this case Python - # will succeed in importing the directory as a module and call it - # a namespace. We do not care about namespaces. - # This prevents that when only - # custom_components/switch/some_platform.py exists, - # the import custom_components.switch would succeed. - # __file__ was unset for namespaces before Python 3.7 - if getattr(module, "__file__", None) is None: - continue - - cache[comp_or_platform] = module - - return cast(ComponentProtocol, module) - - except ImportError as err: - # This error happens if for example custom_components/switch - # exists and we try to load switch.demo. - # Ignore errors for custom_components, custom_components.switch - # and custom_components.switch.demo. - white_listed_errors = [] - parts = [] - for part in path.split("."): - parts.append(part) - white_listed_errors.append(f"No module named '{'.'.join(parts)}'") - - if str(err) not in white_listed_errors: - _LOGGER.exception( - "Error loading %s. Make sure all dependencies are installed", path - ) - - return None - - -class ModuleWrapper: - """Class to wrap a Python module and auto fill in hass argument.""" - - def __init__(self, hass: HomeAssistant, module: ComponentProtocol) -> None: - """Initialize the module wrapper.""" - self._hass = hass - self._module = module - - def __getattr__(self, attr: str) -> Any: - """Fetch an attribute.""" - value = getattr(self._module, attr) - - if hasattr(value, "__bind_hass"): - value = ft.partial(value, self._hass) - - setattr(self, attr, value) - return value - - def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT: """Decorate function to indicate that first argument is hass. @@ -1744,13 +1672,6 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: sys.path_importer_cache.pop(hass.config.config_dir, None) -def _lookup_path(hass: HomeAssistant) -> list[str]: - """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode or hass.config.safe_mode: - return [PACKAGE_BUILTIN] - return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] - - def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: """Test if a component module is loaded.""" return module in hass.data[DATA_COMPONENTS] From b68de0af88012c4f937fd10c696d6da27693f892 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:48:39 +0200 Subject: [PATCH 1474/1664] Change deprecated media_player state standby to off in PlayStation Network (#148885) --- homeassistant/components/playstation_network/media_player.py | 2 -- .../playstation_network/snapshots/test_media_player.ambr | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/playstation_network/media_player.py b/homeassistant/components/playstation_network/media_player.py index 0a9b8fe6162..bdbc2a5ddd4 100644 --- a/homeassistant/components/playstation_network/media_player.py +++ b/homeassistant/components/playstation_network/media_player.py @@ -125,8 +125,6 @@ class PsnMediaPlayerEntity( if session.title_id is not None else MediaPlayerState.ON ) - if session.status == "standby": - return MediaPlayerState.STANDBY return MediaPlayerState.OFF @property diff --git a/tests/components/playstation_network/snapshots/test_media_player.ambr b/tests/components/playstation_network/snapshots/test_media_player.ambr index 69024c2326f..891509b351c 100644 --- a/tests/components/playstation_network/snapshots/test_media_player.ambr +++ b/tests/components/playstation_network/snapshots/test_media_player.ambr @@ -39,9 +39,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'receiver', - 'entity_picture_local': None, 'friendly_name': 'PlayStation Vita', - 'media_content_type': , 'supported_features': , }), 'context': , @@ -49,7 +47,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'standby', + 'state': 'off', }) # --- # name: test_media_player_psvita[presence_payload1][media_player.playstation_vita-entry] From 3449863eee850a62fe5f4cc2e8b8ec67cbc5800f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 16 Jul 2025 15:49:02 +0200 Subject: [PATCH 1475/1664] Bump `gios` to version 6.1.2 (#148884) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 1782320a357..8c6765ece89 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], - "requirements": ["gios==6.1.1"] + "requirements": ["gios==6.1.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f89f00451de..09800ef9e94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f3345ae688..8dfe7a8edac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -890,7 +890,7 @@ georss-qld-bushfire-alert-client==0.8 getmac==0.9.5 # homeassistant.components.gios -gios==6.1.1 +gios==6.1.2 # homeassistant.components.glances glances-api==0.8.0 From 1734b316d517e999702b87e5fbbaf666cb9a2aae Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 16 Jul 2025 16:16:01 +0200 Subject: [PATCH 1476/1664] Return intent response from LLM chat log if available (#148522) --- .../components/anthropic/conversation.py | 12 +---- .../components/conversation/__init__.py | 2 + .../components/conversation/chat_log.py | 2 + homeassistant/components/conversation/util.py | 47 +++++++++++++++++++ .../conversation.py | 20 ++------ .../components/ollama/conversation.py | 14 +----- .../components/open_router/conversation.py | 10 +--- .../openai_conversation/conversation.py | 10 +--- homeassistant/helpers/llm.py | 21 +++++++-- tests/components/conversation/conftest.py | 26 +++++++++- .../components/conversation/test_chat_log.py | 22 --------- tests/components/conversation/test_util.py | 39 +++++++++++++++ .../test_conversation.py | 2 +- 13 files changed, 139 insertions(+), 88 deletions(-) create mode 100644 homeassistant/components/conversation/util.py create mode 100644 tests/components/conversation/test_util.py diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index 12c7917a30a..4eb40974b7a 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -6,7 +6,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry @@ -72,13 +71,4 @@ class AnthropicConversationEntity( await self._async_handle_chat_log(chat_log) - response_content = chat_log.content[-1] - if not isinstance(response_content, conversation.AssistantContent): - raise TypeError("Last message must be an assistant message") - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_content.content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index ec866604205..3435a7d2ed4 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -61,6 +61,7 @@ from .entity import ConversationEntity from .http import async_setup as async_setup_conversation_http from .models import AbstractConversationAgent, ConversationInput, ConversationResult from .trace import ConversationTraceEventType, async_conversation_trace_append +from .util import async_get_result_from_chat_log __all__ = [ "DOMAIN", @@ -83,6 +84,7 @@ __all__ = [ "async_converse", "async_get_agent_info", "async_get_chat_log", + "async_get_result_from_chat_log", "async_set_agent", "async_setup", "async_unset_agent", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 8d739b6267d..648a89e47f1 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -196,6 +196,7 @@ class ChatLog: extra_system_prompt: str | None = None llm_api: llm.APIInstance | None = None delta_listener: Callable[[ChatLog, dict], None] | None = None + llm_input_provided_index = 0 @property def continue_conversation(self) -> bool: @@ -496,6 +497,7 @@ class ChatLog: prompt = "\n".join(prompt_parts) + self.llm_input_provided_index = len(self.content) self.llm_api = llm_api self.extra_system_prompt = extra_system_prompt self.content[0] = SystemContent(content=prompt) diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py new file mode 100644 index 00000000000..04a5a420279 --- /dev/null +++ b/homeassistant/components/conversation/util.py @@ -0,0 +1,47 @@ +"""Utility functions for conversation integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import intent, llm + +from .chat_log import AssistantContent, ChatLog, ToolResultContent +from .models import ConversationInput, ConversationResult + +_LOGGER = logging.getLogger(__name__) + + +@callback +def async_get_result_from_chat_log( + user_input: ConversationInput, chat_log: ChatLog +) -> ConversationResult: + """Get the result from the chat log.""" + tool_results = [ + content.tool_result + for content in chat_log.content[chat_log.llm_input_provided_index :] + if isinstance(content, ToolResultContent) + and isinstance(content.tool_result, llm.IntentResponseDict) + ] + + if tool_results: + intent_response = tool_results[-1].original + else: + intent_response = intent.IntentResponse(language=user_input.language) + + if not isinstance((last_content := chat_log.content[-1]), AssistantContent): + _LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + last_content, + ) + raise HomeAssistantError("Unable to get response") + + intent_response.async_set_speech(last_content.content or "") + + return ConversationResult( + response=intent_response, + conversation_id=chat_log.conversation_id, + continue_conversation=chat_log.continue_conversation, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 3525fba3af5..d804073bfb4 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -8,12 +8,10 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_PROMPT, DOMAIN, LOGGER -from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity +from .const import CONF_PROMPT, DOMAIN +from .entity import GoogleGenerativeAILLMBaseEntity async def async_setup_entry( @@ -84,16 +82,4 @@ class GoogleGenerativeAIConversationEntity( await self._async_handle_chat_log(chat_log) - response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - LOGGER.error( - "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", - chat_log.content[-1], - ) - raise HomeAssistantError(ERROR_GETTING_RESPONSE) - response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index e0b64702cb4..cba8559e826 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -8,7 +8,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry @@ -84,15 +83,4 @@ class OllamaConversationEntity( await self._async_handle_chat_log(chat_log) - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - if not isinstance(chat_log.content[-1], conversation.AssistantContent): - raise TypeError( - f"Unexpected last message type: {type(chat_log.content[-1])}" - ) - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index 48fb1ec44cb..efc98835982 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, CONF_MODEL, MATCH_ALL from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import intent from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -131,11 +130,4 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): ) ) - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 25e89577ef3..803825c2810 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -6,7 +6,6 @@ from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry @@ -84,11 +83,4 @@ class OpenAIConversationEntity( await self._async_handle_chat_log(chat_log) - intent_response = intent.IntentResponse(language=user_input.language) - assert type(chat_log.content[-1]) is conversation.AssistantContent - intent_response.async_set_speech(chat_log.content[-1].content or "") - return conversation.ConversationResult( - response=intent_response, - conversation_id=chat_log.conversation_id, - continue_conversation=chat_log.continue_conversation, - ) + return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 784288375e9..1ff6b188214 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -315,10 +315,23 @@ class IntentTool(Tool): assistant=llm_context.assistant, device_id=llm_context.device_id, ) - response = intent_response.as_dict() - del response["language"] - del response["card"] - return response + return IntentResponseDict(intent_response) + + +class IntentResponseDict(dict): + """Dictionary to represent an intent response resulting from a tool call.""" + + def __init__(self, intent_response: Any) -> None: + """Initialize the dictionary.""" + if not isinstance(intent_response, intent.IntentResponse): + super().__init__(intent_response) + return + + result = intent_response.as_dict() + del result["language"] + del result["card"] + super().__init__(result) + self.original = intent_response class NamespacedTool(Tool): diff --git a/tests/components/conversation/conftest.py b/tests/components/conversation/conftest.py index 6575ab2ac98..8dfe879ee2b 100644 --- a/tests/components/conversation/conftest.py +++ b/tests/components/conversation/conftest.py @@ -1,13 +1,14 @@ """Conversation test helpers.""" -from unittest.mock import patch +from collections.abc import Generator +from unittest.mock import Mock, patch import pytest from homeassistant.components import conversation from homeassistant.components.shopping_list import intent as sl_intent from homeassistant.const import MATCH_ALL -from homeassistant.core import HomeAssistant +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from . import MockAgent @@ -15,6 +16,14 @@ from . import MockAgent from tests.common import MockConfigEntry +@pytest.fixture +def mock_ulid() -> Generator[Mock]: + """Mock the ulid library.""" + with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: + mock_ulid_now.return_value = "mock-ulid" + yield mock_ulid_now + + @pytest.fixture def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: """Mock agent that supports all languages.""" @@ -25,6 +34,19 @@ def mock_agent_support_all(hass: HomeAssistant) -> MockAgent: return agent +@pytest.fixture +def mock_conversation_input(hass: HomeAssistant) -> conversation.ConversationInput: + """Return a conversation input instance.""" + return conversation.ConversationInput( + text="Hello", + context=Context(), + conversation_id=None, + agent_id="mock-agent-id", + device_id=None, + language="en", + ) + + @pytest.fixture(autouse=True) def mock_shopping_list_io(): """Stub out the persistence.""" diff --git a/tests/components/conversation/test_chat_log.py b/tests/components/conversation/test_chat_log.py index 0e2a384f1da..811c045dd70 100644 --- a/tests/components/conversation/test_chat_log.py +++ b/tests/components/conversation/test_chat_log.py @@ -1,6 +1,5 @@ """Test the conversation session.""" -from collections.abc import Generator from dataclasses import asdict from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch @@ -26,27 +25,6 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -@pytest.fixture -def mock_conversation_input(hass: HomeAssistant) -> ConversationInput: - """Return a conversation input instance.""" - return ConversationInput( - text="Hello", - context=Context(), - conversation_id=None, - agent_id="mock-agent-id", - device_id=None, - language="en", - ) - - -@pytest.fixture -def mock_ulid() -> Generator[Mock]: - """Mock the ulid library.""" - with patch("homeassistant.helpers.chat_session.ulid_now") as mock_ulid_now: - mock_ulid_now.return_value = "mock-ulid" - yield mock_ulid_now - - async def test_cleanup( hass: HomeAssistant, mock_conversation_input: ConversationInput, diff --git a/tests/components/conversation/test_util.py b/tests/components/conversation/test_util.py new file mode 100644 index 00000000000..196de4ad2fb --- /dev/null +++ b/tests/components/conversation/test_util.py @@ -0,0 +1,39 @@ +"""Tests for conversation utility functions.""" + +from homeassistant.components import conversation +from homeassistant.core import HomeAssistant +from homeassistant.helpers import chat_session, intent, llm + + +async def test_async_get_result_from_chat_log( + hass: HomeAssistant, + mock_conversation_input: conversation.ConversationInput, +) -> None: + """Test getting result from chat log.""" + intent_response = intent.IntentResponse(language="en") + with ( + chat_session.async_get_chat_session(hass) as session, + conversation.async_get_chat_log( + hass, session, mock_conversation_input + ) as chat_log, + ): + chat_log.content.extend( + [ + conversation.ToolResultContent( + agent_id="mock-agent-id", + tool_call_id="mock-tool-call-id", + tool_name="mock-tool-name", + tool_result=llm.IntentResponseDict(intent_response), + ), + conversation.AssistantContent( + agent_id="mock-agent-id", + content="This is a response.", + ), + ] + ) + result = conversation.async_get_result_from_chat_log( + mock_conversation_input, chat_log + ) + # Original intent response is returned with speech set + assert result.response is intent_response + assert result.response.speech["plain"]["speech"] == "This is a response." diff --git a/tests/components/google_generative_ai_conversation/test_conversation.py b/tests/components/google_generative_ai_conversation/test_conversation.py index ff9694257f9..90f496b4b5b 100644 --- a/tests/components/google_generative_ai_conversation/test_conversation.py +++ b/tests/components/google_generative_ai_conversation/test_conversation.py @@ -359,7 +359,7 @@ async def test_empty_response( assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.error_code == "unknown", result assert result.response.as_dict()["speech"]["plain"]["speech"] == ( - ERROR_GETTING_RESPONSE + "Unable to get response" ) From aab6cd665f4dd9515e0c7187783c132802419415 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 16 Jul 2025 17:06:35 +0200 Subject: [PATCH 1477/1664] Fix flaky notify group test (#148895) --- tests/components/group/test_notify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index e3a01c05eca..49ad71f5b6b 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -199,7 +199,8 @@ async def test_send_message_with_data(hass: HomeAssistant, tmp_path: Path) -> No }, }, ), - ] + ], + any_order=True, ) From e2340314c69d754da3f0c1da822c3506abfa4a19 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Wed, 16 Jul 2025 17:40:35 +0200 Subject: [PATCH 1478/1664] Do not allow filters for services with no target in hassfest (#148869) --- script/hassfest/services.py | 153 +++++++++++++++++++----------------- 1 file changed, 83 insertions(+), 70 deletions(-) diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 70f0a63ca76..84d3aaefa88 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -43,104 +43,117 @@ def unique_field_validator(fields: Any) -> Any: return fields -CORE_INTEGRATION_FIELD_SCHEMA = vol.Schema( - { - vol.Optional("example"): exists, - vol.Optional("default"): exists, - vol.Optional("required"): bool, - vol.Optional("advanced"): bool, - vol.Optional(CONF_SELECTOR): selector.validate_selector, - vol.Optional("filter"): { - vol.Exclusive("attribute", "field_filter"): { - vol.Required(str): [vol.All(str, service.validate_attribute_option)], - }, - vol.Exclusive("supported_features", "field_filter"): [ - vol.All(str, service.validate_supported_feature) - ], +CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT = { + vol.Optional("description"): str, + vol.Optional("name"): str, +} + + +CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT = { + vol.Optional("example"): exists, + vol.Optional("default"): exists, + vol.Optional("required"): bool, + vol.Optional("advanced"): bool, + vol.Optional(CONF_SELECTOR): selector.validate_selector, +} + +FIELD_FILTER_SCHEMA_DICT = { + vol.Optional("filter"): { + vol.Exclusive("attribute", "field_filter"): { + vol.Required(str): [vol.All(str, service.validate_attribute_option)], }, + vol.Exclusive("supported_features", "field_filter"): [ + vol.All(str, service.validate_supported_feature) + ], } -) +} -CORE_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { + +def _field_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the field schema.""" + schema_dict = CORE_INTEGRATION_NOT_TARGETED_FIELD_SCHEMA_DICT.copy() + + # Filters are only allowed for targeted services because they rely on the presence + # of a `target` field to determine the scope of the service call. Non-targeted + # services do not have a `target` field, making filters inapplicable. + if targeted: + schema_dict |= FIELD_FILTER_SCHEMA_DICT + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) + + +def _section_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the section schema.""" + schema_dict = { vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CORE_INTEGRATION_FIELD_SCHEMA}), + vol.Required("fields"): vol.Schema( + { + str: _field_schema(targeted, custom), + } + ), } -) -CUSTOM_INTEGRATION_FIELD_SCHEMA = CORE_INTEGRATION_FIELD_SCHEMA.extend( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - } -) + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT -CUSTOM_INTEGRATION_SECTION_SCHEMA = vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("collapsed"): bool, - vol.Required("fields"): vol.Schema({str: CUSTOM_INTEGRATION_FIELD_SCHEMA}), + return vol.Schema(schema_dict) + + +def _service_schema(targeted: bool, custom: bool) -> vol.Schema: + """Return the service schema.""" + schema_dict = { + vol.Optional("fields"): vol.All( + vol.Schema( + { + str: vol.Any( + _field_schema(targeted, custom), + _section_schema(targeted, custom), + ), + } + ), + unique_field_validator, + ) } -) + + if targeted: + schema_dict[vol.Required("target")] = vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ) + + if custom: + schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT + + return vol.Schema(schema_dict) CORE_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CORE_INTEGRATION_FIELD_SCHEMA, - CORE_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=False), + _service_schema(targeted=False, custom=False), None, ) CUSTOM_INTEGRATION_SERVICE_SCHEMA = vol.Any( - vol.Schema( - { - vol.Optional("description"): str, - vol.Optional("name"): str, - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), - vol.Optional("fields"): vol.All( - vol.Schema( - { - str: vol.Any( - CUSTOM_INTEGRATION_FIELD_SCHEMA, - CUSTOM_INTEGRATION_SECTION_SCHEMA, - ) - } - ), - unique_field_validator, - ), - } - ), + _service_schema(targeted=True, custom=True), + _service_schema(targeted=False, custom=True), None, ) + CORE_INTEGRATION_SERVICES_SCHEMA = vol.Schema( { vol.Remove(vol.All(str, service.starts_with_dot)): object, cv.slug: CORE_INTEGRATION_SERVICE_SCHEMA, } ) + CUSTOM_INTEGRATION_SERVICES_SCHEMA = vol.Schema( {cv.slug: CUSTOM_INTEGRATION_SERVICE_SCHEMA} ) + VALIDATE_AS_CUSTOM_INTEGRATION = { # Adding translations would be a breaking change "foursquare", From a5f0f6c8b9b07eb641701540d11594b527f7bc6c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 18:23:38 +0200 Subject: [PATCH 1479/1664] Add prompt as constant and common translation key (#148896) --- homeassistant/components/anthropic/strings.json | 2 +- .../components/google_generative_ai_conversation/strings.json | 4 ++-- homeassistant/components/ollama/strings.json | 4 ++-- homeassistant/components/openai_conversation/strings.json | 2 +- homeassistant/const.py | 1 + homeassistant/strings.json | 1 + 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json index 098b4d5fa74..983260a3c95 100644 --- a/homeassistant/components/anthropic/strings.json +++ b/homeassistant/components/anthropic/strings.json @@ -29,7 +29,7 @@ "set_options": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "max_tokens": "Maximum tokens to return in response", "temperature": "Temperature", diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 5af1fe33ce4..11e7c75c8ba 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -34,7 +34,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "recommended": "Recommended model settings", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "temperature": "Temperature", "top_p": "Top P", @@ -72,7 +72,7 @@ "data": { "name": "[%key:common::config_flow::data::name%]", "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "chat_model": "[%key:common::generic::model%]", "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 4261b2286bf..87d2048a966 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -28,7 +28,7 @@ "data": { "model": "Model", "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "max_history": "Max history messages", "num_ctx": "Context window size", @@ -67,7 +67,7 @@ "data": { "model": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::model%]", "name": "[%key:common::config_flow::data::name%]", - "prompt": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::prompt%]", + "prompt": "[%key:common::config_flow::data::prompt%]", "max_history": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::max_history%]", "num_ctx": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::num_ctx%]", "keep_alive": "[%key:component::ollama::config_subentries::conversation::step::set_options::data::keep_alive%]", diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index fef955b4fa9..4446eff2c9e 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -28,7 +28,7 @@ "init": { "data": { "name": "[%key:common::config_flow::data::name%]", - "prompt": "Instructions", + "prompt": "[%key:common::config_flow::data::prompt%]", "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]", "recommended": "Recommended model settings" }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 6b4f16c316f..2daa6d91db2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -245,6 +245,7 @@ CONF_PLATFORM: Final = "platform" CONF_PORT: Final = "port" CONF_PREFIX: Final = "prefix" CONF_PROFILE_NAME: Final = "profile_name" +CONF_PROMPT: Final = "prompt" CONF_PROTOCOL: Final = "protocol" CONF_PROXY_SSL: Final = "proxy_ssl" CONF_QUOTE: Final = "quote" diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 80ced039e46..8e232498177 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -65,6 +65,7 @@ "path": "Path", "pin": "PIN code", "port": "Port", + "prompt": "Instructions", "ssl": "Uses an SSL certificate", "url": "URL", "usb_path": "USB device path", From fca05f6bcf4d15c0d531fce9d0d525b51f4d30cb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:34:28 +0200 Subject: [PATCH 1480/1664] Add snapshot tests for tuya dj category (#148897) --- tests/components/tuya/__init__.py | 4 + tests/components/tuya/conftest.py | 3 + .../tuya/fixtures/dj_smart_light_bulb.json | 458 ++++++++++++++++++ .../components/tuya/snapshots/test_light.ambr | 71 +++ 4 files changed, 536 insertions(+) create mode 100644 tests/components/tuya/fixtures/dj_smart_light_bulb.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 7f08f704fe5..c3d6c31924e 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -74,6 +74,10 @@ DEVICE_MOCKS = { Platform.SENSOR, Platform.SWITCH, ], + "dj_smart_light_bulb": [ + # https://github.com/home-assistant/core/pull/126242 + Platform.LIGHT + ], "dlq_earu_electric_eawcpt": [ # https://github.com/home-assistant/core/issues/102769 Platform.SENSOR, diff --git a/tests/components/tuya/conftest.py b/tests/components/tuya/conftest.py index 3d89e1d6f92..cac9359a8d3 100644 --- a/tests/components/tuya/conftest.py +++ b/tests/components/tuya/conftest.py @@ -180,4 +180,7 @@ async def mock_device(hass: HomeAssistant, mock_device_code: str) -> CustomerDev for key, value in details["status_range"].items() } device.status = details["status"] + for key, value in device.status.items(): + if device.status_range[key].type == "Json": + device.status[key] = json_dumps(value) return device diff --git a/tests/components/tuya/fixtures/dj_smart_light_bulb.json b/tests/components/tuya/fixtures/dj_smart_light_bulb.json new file mode 100644 index 00000000000..49854adc889 --- /dev/null +++ b/tests/components/tuya/fixtures/dj_smart_light_bulb.json @@ -0,0 +1,458 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "REDACTED", + "name": "Garage light", + "category": "dj", + "product_id": "mki13ie507rlry4r", + "product_name": "Smart Light Bulb", + "online": true, + "sub": false, + "time_zone": "-07:00", + "active_time": "2024-06-15T19:53:11+00:00", + "create_time": "2024-06-15T19:53:11+00:00", + "update_time": "2024-06-15T19:53:11+00:00", + "function": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status_range": { + "switch_led": { + "type": "Boolean", + "value": {} + }, + "work_mode": { + "type": "Enum", + "value": { + "range": ["white", "colour", "scene", "music"] + } + }, + "bright_value_v2": { + "type": "Integer", + "value": { + "min": 10, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + }, + "colour_data_v2": { + "type": "Json", + "value": { + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + }, + "scene_data_v2": { + "type": "Json", + "value": { + "scene_num": { + "min": 1, + "scale": 0, + "max": 8, + "step": 1 + }, + "scene_units": { + "unit_change_mode": { + "range": ["static", "jump", "gradient"] + }, + "unit_switch_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "unit_gradient_duration": { + "min": 0, + "scale": 0, + "max": 100, + "step": 1 + }, + "bright": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + } + } + } + }, + "countdown_1": { + "type": "Integer", + "value": { + "unit": "", + "min": 0, + "max": 86400, + "scale": 0, + "step": 1 + } + }, + "music_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + }, + "control_data": { + "type": "Json", + "value": { + "change_mode": { + "range": ["direct", "gradient"] + }, + "bright": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "temperature": { + "min": 0, + "scale": 0, + "unit": "", + "max": 1000, + "step": 1 + }, + "h": { + "min": 0, + "scale": 0, + "unit": "", + "max": 360, + "step": 1 + }, + "s": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + }, + "v": { + "min": 0, + "scale": 0, + "unit": "", + "max": 255, + "step": 1 + } + } + } + }, + "status": { + "switch_led": true, + "work_mode": "white", + "bright_value_v2": 546, + "colour_data_v2": { + "h": 243, + "s": 860, + "v": 541 + }, + "scene_data_v2": { + "scene_num": 1, + "scene_units": [ + { + "bright": 200, + "h": 0, + "s": 0, + "temperature": 1000, + "unit_change_mode": "static", + "unit_gradient_duration": 13, + "unit_switch_duration": 14, + "v": 0 + } + ] + }, + "countdown_1": 0, + "music_data": "", + "control_data": "" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_light.ambr b/tests/components/tuya/snapshots/test_light.ambr index 5b0afb289ac..c691aae2cc1 100644 --- a/tests/components/tuya/snapshots/test_light.ambr +++ b/tests/components/tuya/snapshots/test_light.ambr @@ -56,6 +56,77 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_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.garage_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': None, + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'tuya.REDACTEDswitch_led', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[dj_smart_light_bulb][light.garage_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 138, + 'color_mode': , + 'friendly_name': 'Garage light', + 'hs_color': tuple( + 243.0, + 86.0, + ), + 'rgb_color': tuple( + 47, + 36, + 255, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.148, + 0.055, + ), + }), + 'context': , + 'entity_id': 'light.garage_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_platform_setup_and_discovery[gyd_night_light][light.colorful_pir_night_light-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 58bb2fa327c413c44a68b3098bddbb3cb3a78381 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 16 Jul 2025 18:51:52 +0200 Subject: [PATCH 1481/1664] Bump python-open-router to 0.3.0 (#148900) --- homeassistant/components/open_router/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/open_router/manifest.json b/homeassistant/components/open_router/manifest.json index 64b7319a902..fab62e7971c 100644 --- a/homeassistant/components/open_router/manifest.json +++ b/homeassistant/components/open_router/manifest.json @@ -9,5 +9,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "bronze", - "requirements": ["openai==1.93.3", "python-open-router==0.2.0"] + "requirements": ["openai==1.93.3", "python-open-router==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 09800ef9e94..887e82a6c76 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2478,7 +2478,7 @@ python-mpd2==3.1.1 python-mystrom==2.4.0 # homeassistant.components.open_router -python-open-router==0.2.0 +python-open-router==0.3.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8dfe7a8edac..b19e7dcbdd3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2051,7 +2051,7 @@ python-mpd2==3.1.1 python-mystrom==2.4.0 # homeassistant.components.open_router -python-open-router==0.2.0 +python-open-router==0.3.0 # homeassistant.components.swiss_public_transport python-opendata-transport==0.5.0 From e8fca193355e82d77dc7c82316577759efb027b9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 16 Jul 2025 21:40:44 +0200 Subject: [PATCH 1482/1664] Fix flaky husqvarna_automower test with comprehensive race condition fix (#148911) Co-authored-by: Claude --- .../husqvarna_automower/calendar.py | 4 ++++ .../components/husqvarna_automower/entity.py | 5 ++++ .../husqvarna_automower/test_init.py | 23 ++++++++++--------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py index b4d3d2176af..ac7447bc3c0 100644 --- a/homeassistant/components/husqvarna_automower/calendar.py +++ b/homeassistant/components/husqvarna_automower/calendar.py @@ -70,6 +70,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): @property def event(self) -> CalendarEvent | None: """Return the current or next upcoming event.""" + if not self.available: + return None schedule = self.mower_attributes.calendar cursor = schedule.timeline.active_after(dt_util.now()) program_event = next(cursor, None) @@ -94,6 +96,8 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity): This is only called when opening the calendar in the UI. """ + if not self.available: + return [] schedule = self.mower_attributes.calendar cursor = schedule.timeline.overlapping( start_date, diff --git a/homeassistant/components/husqvarna_automower/entity.py b/homeassistant/components/husqvarna_automower/entity.py index 150a3d18d87..3ccb098262f 100644 --- a/homeassistant/components/husqvarna_automower/entity.py +++ b/homeassistant/components/husqvarna_automower/entity.py @@ -114,6 +114,11 @@ class AutomowerBaseEntity(CoordinatorEntity[AutomowerDataUpdateCoordinator]): """Get the mower attributes of the current mower.""" return self.coordinator.data[self.mower_id] + @property + def available(self) -> bool: + """Return True if the device is available.""" + return super().available and self.mower_id in self.coordinator.data + class AutomowerAvailableEntity(AutomowerBaseEntity): """Replies available when the mower is connected.""" diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index f54250a3336..d4921bf661d 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -312,8 +312,9 @@ async def test_coordinator_automatic_registry_cleanup( dr.async_entries_for_config_entry(device_registry, entry.entry_id) ) # Remove mower 2 and check if it worked - mower2 = values.pop("1234") - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower2 = values_copy.pop("1234") + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -327,8 +328,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 2 and check if it worked - values["1234"] = mower2 - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + values_copy["1234"] = mower2 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -342,8 +344,9 @@ async def test_coordinator_automatic_registry_cleanup( ) # Remove mower 1 and check if it worked - mower1 = values.pop(TEST_MOWER_ID) - mock_automower_client.get_status.return_value = values + values_copy = deepcopy(values) + mower1 = values_copy.pop(TEST_MOWER_ID) + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -357,11 +360,9 @@ async def test_coordinator_automatic_registry_cleanup( == current_devices - 1 ) # Add mower 1 and check if it worked - values[TEST_MOWER_ID] = mower1 - mock_automower_client.get_status.return_value = values - freezer.tick(SCAN_INTERVAL) - async_fire_time_changed(hass) - await hass.async_block_till_done() + values_copy = deepcopy(values) + values_copy[TEST_MOWER_ID] = mower1 + mock_automower_client.get_status.return_value = values_copy freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From 9d178ad5f1f94be776a3d78914e8b2f8e75cc1b0 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:45:22 +0200 Subject: [PATCH 1483/1664] Deprecate the usage of ContextVar for config_entry in coordinator (#138161) --- homeassistant/helpers/update_coordinator.py | 14 ++- tests/helpers/test_update_coordinator.py | 113 ++++++++++++++++++-- tests/test_config_entries.py | 4 + 3 files changed, 120 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index bd85391f98f..6b566797017 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -84,9 +84,19 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]): self.update_interval = update_interval self._shutdown_requested = False if config_entry is UNDEFINED: + # late import to avoid circular imports + from . import frame # noqa: PLC0415 + + # It is not planned to enforce this for custom integrations. + # see https://github.com/home-assistant/core/pull/138161#discussion_r1958184241 + frame.report_usage( + "relies on ContextVar, but should pass the config entry explicitly.", + core_behavior=frame.ReportBehavior.ERROR, + custom_integration_behavior=frame.ReportBehavior.LOG, + breaks_in_ha_version="2026.8", + ) + self.config_entry = config_entries.current_entry.get() - # This should be deprecated once all core integrations are updated - # to pass in the config entry explicitly. else: self.config_entry = config_entry self.always_update = always_update diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 5fd9f9e39fd..b4216a3fc6d 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -19,7 +19,7 @@ from homeassistant.exceptions import ( ConfigEntryError, ConfigEntryNotReady, ) -from homeassistant.helpers import update_coordinator +from homeassistant.helpers import frame, update_coordinator from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_fire_time_changed @@ -165,8 +165,6 @@ async def test_shutdown_on_entry_unload( ) -> None: """Test shutdown is requested on entry unload.""" entry = MockConfigEntry() - config_entries.current_entry.set(entry) - calls = 0 async def _refresh() -> int: @@ -177,6 +175,7 @@ async def test_shutdown_on_entry_unload( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=entry, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -206,6 +205,7 @@ async def test_shutdown_on_hass_stop( crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=_refresh, update_interval=DEFAULT_UPDATE_INTERVAL, @@ -843,6 +843,7 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: crd = update_coordinator.TimestampDataUpdateCoordinator[int]( hass, _LOGGER, + config_entry=None, name="test", update_method=refresh, update_interval=timedelta(seconds=10), @@ -865,39 +866,133 @@ async def test_timestamp_date_update_coordinator(hass: HomeAssistant) -> None: assert len(last_update_success_times) == 1 -async def test_config_entry(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "integration_frame_path", ["homeassistant/components/my_integration"] +) +@pytest.mark.usefixtures("mock_integration_frame") +async def test_config_entry( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: """Test behavior of coordinator.entry.""" entry = MockConfigEntry() - # Default without context should be None - crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") - assert crd.config_entry is None - # Explicit None is OK crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry is OK + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry different from ContextVar not recommended, but should work + another_entry = MockConfigEntry() + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=another_entry + ) + assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Default without context should log a warning + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + + # Default with context should log a warning + caplog.clear() + frame._REPORTED_INTEGRATIONS.clear() + config_entries.current_entry.set(entry) + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert ( + "Detected that integration 'my_integration' relies on ContextVar, " + "but should pass the config entry explicitly." + ) in caplog.text + assert crd.config_entry is entry + + +@pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) +@pytest.mark.usefixtures("hass", "mock_integration_frame") +async def test_config_entry_custom_integration( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test behavior of coordinator.entry for custom integrations.""" + entry = MockConfigEntry(domain="custom_integration") + + # Default without context should be None + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit None is OK + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=None + ) + assert crd.config_entry is None + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) + + # Explicit entry is OK + caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, _LOGGER, name="test", config_entry=entry + ) + assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") assert crd.config_entry is entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() + caplog.clear() crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) assert crd.config_entry is another_entry + assert ( + "Detected that integration 'my_integration' relies on ContextVar" + not in caplog.text + ) async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: @@ -920,7 +1015,7 @@ async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> self._unsub = None coordinator = update_coordinator.DataUpdateCoordinator[int]( - hass, _LOGGER, name="test" + hass, _LOGGER, config_entry=None, name="test" ) subscriber = Subscriber() subscriber.start_listen(coordinator) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index dc893e4c5fd..7fb632e18b5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4901,6 +4901,7 @@ async def test_setup_raise_entry_error_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -4941,6 +4942,7 @@ async def test_setup_not_raise_entry_error_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5020,6 +5022,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) @@ -5072,6 +5075,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update( hass, logging.getLogger(__name__), name="any", + config_entry=entry, update_method=_async_update_data, update_interval=timedelta(seconds=1000), ) From 5b41d5a7952ecd608820e753d0a6261a22041733 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Wed, 16 Jul 2025 21:50:29 +0200 Subject: [PATCH 1484/1664] Fix typo "barametric" in `rainmachine` (#148917) --- homeassistant/components/rainmachine/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 49731df5b6f..e8c54c94f84 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -240,8 +240,8 @@ "description": "Current weather condition code (WNUM)." }, "pressure": { - "name": "Barametric pressure", - "description": "Current barametric pressure (kPa)." + "name": "Barometric pressure", + "description": "Current barometric pressure (kPa)." }, "dewpoint": { "name": "Dew point", From a5c301db1be6b8f69da1d54619eb227f9a44660b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 16 Jul 2025 21:55:37 +0200 Subject: [PATCH 1485/1664] Add code review guidelines to exclude imports and formatting feedback (#148912) --- .github/copilot-instructions.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 603cf407081..7eba0203f7e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -45,6 +45,12 @@ rules: **When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules. +## Code Review Guidelines + +**When reviewing code, do NOT comment on:** +- **Missing imports** - We use static analysis tooling to catch that +- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions) + ## Python Requirements - **Compatibility**: Python 3.13+ From 83cd2dfef3765660a46859a1faf57ddbecbd9e96 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Wed, 16 Jul 2025 22:12:35 +0200 Subject: [PATCH 1486/1664] Bump aioautomower to 2.0.0 (#148846) --- homeassistant/components/husqvarna_automower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../husqvarna_automower/snapshots/test_diagnostics.ambr | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index fb717a5615f..d747bc00094 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==1.2.2"] + "requirements": ["aioautomower==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 887e82a6c76..9aac7e73049 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==1.2.2 +aioautomower==2.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b19e7dcbdd3..3339762dd58 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==1.2.2 +aioautomower==2.0.0 # homeassistant.components.azure_devops aioazuredevops==2.2.1 diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index c58a12ad007..170fbe7ad82 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -63,8 +63,6 @@ 'stay_out_zones': True, 'work_areas': True, }), - 'messages': list([ - ]), 'metadata': dict({ 'connected': True, 'status_dateteime': '2023-06-05T00:00:00+00:00', From 6dc2340c5ab5cb4ec51d22f99659baa88ce7e96f Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 17 Jul 2025 00:15:45 +0200 Subject: [PATCH 1487/1664] Fix docstring for WaitIntegrationOnboardingView (#148904) --- homeassistant/components/onboarding/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index a897d04562f..a89a98a7fcf 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -317,7 +317,7 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView): - """Get backup info view.""" + """View to wait for an integration.""" url = "/api/onboarding/integration/wait" name = "api:onboarding:integration:wait" From e32e06d7a0adf68cf84717006e3cce65ae5f13aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Thu, 17 Jul 2025 07:52:59 +0100 Subject: [PATCH 1488/1664] Fix Husqvarna Automower coordinator listener list mutating (#148926) --- .../components/husqvarna_automower/coordinator.py | 8 +++++++- tests/components/husqvarna_automower/test_init.py | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py index 342f6892b2e..7fc1e628e27 100644 --- a/homeassistant/components/husqvarna_automower/coordinator.py +++ b/homeassistant/components/husqvarna_automower/coordinator.py @@ -6,6 +6,7 @@ import asyncio from collections.abc import Callable from datetime import timedelta import logging +from typing import override from aioautomower.exceptions import ( ApiError, @@ -60,7 +61,12 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]): self._devices_last_update: set[str] = set() self._zones_last_update: dict[str, set[str]] = {} self._areas_last_update: dict[str, set[int]] = {} - self.async_add_listener(self._on_data_update) + + @override + @callback + def async_update_listeners(self) -> None: + self._on_data_update() + super().async_update_listeners() async def _async_update_data(self) -> MowerDictionary: """Subscribe for websocket and poll data from the API.""" diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index d4921bf661d..81874cea8a7 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -462,7 +462,13 @@ async def test_add_and_remove_work_area( poll_values[TEST_MOWER_ID].work_area_names.remove("Front lawn") del poll_values[TEST_MOWER_ID].work_area_dict[123456] del poll_values[TEST_MOWER_ID].work_areas[123456] - del poll_values[TEST_MOWER_ID].calendar.tasks[:2] + + poll_values[TEST_MOWER_ID].calendar.tasks = [ + task + for task in poll_values[TEST_MOWER_ID].calendar.tasks + if task.work_area_id not in [1, 123456] + ] + poll_values[TEST_MOWER_ID].mower.work_area_id = 654321 mock_automower_client.get_status.return_value = poll_values freezer.tick(SCAN_INTERVAL) From ae03fc22955b209350b0dfd28cbf5ce2bdbcbd1c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 17 Jul 2025 08:55:47 +0200 Subject: [PATCH 1489/1664] Fix missing unit of measurement in tuya numbers (#148924) --- homeassistant/components/tuya/number.py | 2 ++ tests/components/tuya/snapshots/test_number.ambr | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 68777d75a90..5aee426da8c 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -381,6 +381,8 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_native_max_value = self._number.max_scaled self._attr_native_min_value = self._number.min_scaled self._attr_native_step = self._number.step_scaled + if description.native_unit_of_measurement is None: + self._attr_native_unit_of_measurement = int_type.unit # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 125a0680de9..1b19d5827ab 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -95,7 +95,7 @@ 'supported_features': 0, 'translation_key': 'feed', 'unique_id': 'tuya.bfd0273e59494eb34esvrxmanual_feed', - 'unit_of_measurement': None, + 'unit_of_measurement': '', }) # --- # name: test_platform_setup_and_discovery[cwwsq_cleverio_pf100][number.cleverio_pf100_feed-state] @@ -106,6 +106,7 @@ 'min': 1.0, 'mode': , 'step': 1.0, + 'unit_of_measurement': '', }), 'context': , 'entity_id': 'number.cleverio_pf100_feed', @@ -152,7 +153,7 @@ 'supported_features': 0, 'translation_key': 'temp_correction', 'unique_id': 'tuya.bfb45cb8a9452fba66lexgtemp_correction', - 'unit_of_measurement': None, + 'unit_of_measurement': '℃', }) # --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-state] @@ -163,6 +164,7 @@ 'min': -9.9, 'mode': , 'step': 0.1, + 'unit_of_measurement': '℃', }), 'context': , 'entity_id': 'number.wifi_smart_gas_boiler_thermostat_temperature_correction', From 656822b39ceeb623dbbb40a251ecd57258c2ba30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joris=20Pelgr=C3=B6m?= Date: Thu, 17 Jul 2025 08:57:11 +0200 Subject: [PATCH 1490/1664] Bump letpot to 0.5.0 (#148922) --- homeassistant/components/letpot/__init__.py | 9 +++- .../components/letpot/binary_sensor.py | 4 +- .../components/letpot/coordinator.py | 13 ++--- homeassistant/components/letpot/entity.py | 5 +- homeassistant/components/letpot/manifest.json | 3 +- homeassistant/components/letpot/sensor.py | 8 ++- homeassistant/components/letpot/switch.py | 30 ++++++++--- homeassistant/components/letpot/time.py | 16 +++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/letpot/conftest.py | 52 ++++++++++++------- tests/components/letpot/test_init.py | 2 +- tests/components/letpot/test_switch.py | 5 +- tests/components/letpot/test_time.py | 5 +- 14 files changed, 105 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py index 50c73f949a3..4b84a023675 100644 --- a/homeassistant/components/letpot/__init__.py +++ b/homeassistant/components/letpot/__init__.py @@ -6,6 +6,7 @@ import asyncio from letpot.client import LetPotClient from letpot.converters import CONVERTERS +from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException from letpot.models import AuthenticationInfo @@ -68,8 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bo except LetPotException as exc: raise ConfigEntryNotReady from exc + device_client = LetPotDeviceClient(auth) + coordinators: list[LetPotDeviceCoordinator] = [ - LetPotDeviceCoordinator(hass, entry, auth, device) + LetPotDeviceCoordinator(hass, entry, device, device_client) for device in devices if any(converter.supports_type(device.device_type) for converter in CONVERTERS) ] @@ -92,5 +95,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> b """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): for coordinator in entry.runtime_data: - coordinator.device_client.disconnect() + await coordinator.device_client.unsubscribe( + coordinator.device.serial_number + ) return unload_ok diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py index bfc7a5ab4a7..e5939abc24d 100644 --- a/homeassistant/components/letpot/binary_sensor.py +++ b/homeassistant/components/letpot/binary_sensor.py @@ -58,7 +58,9 @@ BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.RUNNING, supported_fn=( lambda coordinator: DeviceFeature.PUMP_STATUS - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotBinarySensorEntityDescription( diff --git a/homeassistant/components/letpot/coordinator.py b/homeassistant/components/letpot/coordinator.py index 39e49348663..0ef2c563f38 100644 --- a/homeassistant/components/letpot/coordinator.py +++ b/homeassistant/components/letpot/coordinator.py @@ -8,7 +8,7 @@ import logging from letpot.deviceclient import LetPotDeviceClient from letpot.exceptions import LetPotAuthenticationException, LetPotException -from letpot.models import AuthenticationInfo, LetPotDevice, LetPotDeviceStatus +from letpot.models import LetPotDevice, LetPotDeviceStatus from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -34,8 +34,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): self, hass: HomeAssistant, config_entry: LetPotConfigEntry, - info: AuthenticationInfo, device: LetPotDevice, + device_client: LetPotDeviceClient, ) -> None: """Initialize coordinator.""" super().__init__( @@ -45,9 +45,8 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): name=f"LetPot {device.serial_number}", update_interval=timedelta(minutes=10), ) - self._info = info self.device = device - self.device_client = LetPotDeviceClient(info, device.serial_number) + self.device_client = device_client def _handle_status_update(self, status: LetPotDeviceStatus) -> None: """Distribute status update to entities.""" @@ -56,7 +55,9 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): async def _async_setup(self) -> None: """Set up subscription for coordinator.""" try: - await self.device_client.subscribe(self._handle_status_update) + await self.device_client.subscribe( + self.device.serial_number, self._handle_status_update + ) except LetPotAuthenticationException as exc: raise ConfigEntryAuthFailed from exc @@ -64,7 +65,7 @@ class LetPotDeviceCoordinator(DataUpdateCoordinator[LetPotDeviceStatus]): """Request an update from the device and wait for a status update or timeout.""" try: async with asyncio.timeout(REQUEST_UPDATE_TIMEOUT): - await self.device_client.get_current_status() + await self.device_client.get_current_status(self.device.serial_number) except LetPotException as exc: raise UpdateFailed(exc) from exc diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py index 5e2c46fee84..11d6a132a18 100644 --- a/homeassistant/components/letpot/entity.py +++ b/homeassistant/components/letpot/entity.py @@ -30,12 +30,13 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]): def __init__(self, coordinator: LetPotDeviceCoordinator) -> None: """Initialize a LetPot entity.""" super().__init__(coordinator) + info = coordinator.device_client.device_info(coordinator.device.serial_number) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.device.serial_number)}, name=coordinator.device.name, manufacturer="LetPot", - model=coordinator.device_client.device_model_name, - model_id=coordinator.device_client.device_model_code, + model=info.model_name, + model_id=info.model_code, serial_number=coordinator.device.serial_number, ) diff --git a/homeassistant/components/letpot/manifest.json b/homeassistant/components/letpot/manifest.json index d08b5f70a51..6ee6a309cac 100644 --- a/homeassistant/components/letpot/manifest.json +++ b/homeassistant/components/letpot/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/letpot", "integration_type": "hub", "iot_class": "cloud_push", + "loggers": ["letpot"], "quality_scale": "bronze", - "requirements": ["letpot==0.4.0"] + "requirements": ["letpot==0.5.0"] } diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py index b0b113eb063..841b8720616 100644 --- a/homeassistant/components/letpot/sensor.py +++ b/homeassistant/components/letpot/sensor.py @@ -50,7 +50,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.TEMPERATURE - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSensorEntityDescription( @@ -61,7 +63,9 @@ SENSORS: tuple[LetPotSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, supported_fn=( lambda coordinator: DeviceFeature.WATER_LEVEL - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), ) diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py index 0b00318c53b..d22bc85f116 100644 --- a/homeassistant/components/letpot/switch.py +++ b/homeassistant/components/letpot/switch.py @@ -25,7 +25,7 @@ class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescrip """Describes a LetPot switch entity.""" value_fn: Callable[[LetPotDeviceStatus], bool | None] - set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, bool], Coroutine[Any, Any, None]] SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( @@ -33,7 +33,9 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="alarm_sound", translation_key="alarm_sound", value_fn=lambda status: status.system_sound, - set_value_fn=lambda device_client, value: device_client.set_sound(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_sound(serial, value) + ), entity_category=EntityCategory.CONFIG, supported_fn=lambda coordinator: coordinator.data.system_sound is not None, ), @@ -41,25 +43,35 @@ SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = ( key="auto_mode", translation_key="auto_mode", value_fn=lambda status: status.water_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_water_mode(value), + set_value_fn=( + lambda device_client, serial, value: device_client.set_water_mode( + serial, value + ) + ), entity_category=EntityCategory.CONFIG, supported_fn=( lambda coordinator: DeviceFeature.PUMP_AUTO - in coordinator.device_client.device_features + in coordinator.device_client.device_info( + coordinator.device.serial_number + ).features ), ), LetPotSwitchEntityDescription( key="power", translation_key="power", value_fn=lambda status: status.system_on, - set_value_fn=lambda device_client, value: device_client.set_power(value), + set_value_fn=lambda device_client, serial, value: device_client.set_power( + serial, value + ), entity_category=EntityCategory.CONFIG, ), LetPotSwitchEntityDescription( key="pump_cycling", translation_key="pump_cycling", value_fn=lambda status: status.pump_mode == 1, - set_value_fn=lambda device_client, value: device_client.set_pump_mode(value), + set_value_fn=lambda device_client, serial, value: device_client.set_pump_mode( + serial, value + ), entity_category=EntityCategory.CONFIG, ), ) @@ -104,11 +116,13 @@ class LetPotSwitchEntity(LetPotEntity, SwitchEntity): @exception_handler async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - await self.entity_description.set_value_fn(self.coordinator.device_client, True) + await self.entity_description.set_value_fn( + self.coordinator.device_client, self.coordinator.device.serial_number, True + ) @exception_handler async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, False + self.coordinator.device_client, self.coordinator.device.serial_number, False ) diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py index bae61df6a28..87ce35f828d 100644 --- a/homeassistant/components/letpot/time.py +++ b/homeassistant/components/letpot/time.py @@ -26,7 +26,7 @@ class LetPotTimeEntityDescription(TimeEntityDescription): """Describes a LetPot time entity.""" value_fn: Callable[[LetPotDeviceStatus], time | None] - set_value_fn: Callable[[LetPotDeviceClient, time], Coroutine[Any, Any, None]] + set_value_fn: Callable[[LetPotDeviceClient, str, time], Coroutine[Any, Any, None]] TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( @@ -34,8 +34,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_end", translation_key="light_schedule_end", value_fn=lambda status: None if status is None else status.light_schedule_end, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=None, end=value + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=None, end=value + ) ), entity_category=EntityCategory.CONFIG, ), @@ -43,8 +45,10 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = ( key="light_schedule_start", translation_key="light_schedule_start", value_fn=lambda status: None if status is None else status.light_schedule_start, - set_value_fn=lambda deviceclient, value: deviceclient.set_light_schedule( - start=value, end=None + set_value_fn=( + lambda device_client, serial, value: device_client.set_light_schedule( + serial=serial, start=value, end=None + ) ), entity_category=EntityCategory.CONFIG, ), @@ -89,5 +93,5 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity): async def async_set_value(self, value: time) -> None: """Set the time.""" await self.entity_description.set_value_fn( - self.coordinator.device_client, value + self.coordinator.device_client, self.coordinator.device.serial_number, value ) diff --git a/requirements_all.txt b/requirements_all.txt index 9aac7e73049..9267aa3f2bd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1334,7 +1334,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.5.0 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3339762dd58..0b41f72e888 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1153,7 +1153,7 @@ led-ble==1.1.7 lektricowifi==0.1 # homeassistant.components.letpot -letpot==0.4.0 +letpot==0.5.0 # homeassistant.components.foscam libpyfoscamcgi==0.0.6 diff --git a/tests/components/letpot/conftest.py b/tests/components/letpot/conftest.py index 25974b2d78a..6d59f8bd2ef 100644 --- a/tests/components/letpot/conftest.py +++ b/tests/components/letpot/conftest.py @@ -3,7 +3,12 @@ from collections.abc import Callable, Generator from unittest.mock import AsyncMock, patch -from letpot.models import DeviceFeature, LetPotDevice, LetPotDeviceStatus +from letpot.models import ( + DeviceFeature, + LetPotDevice, + LetPotDeviceInfo, + LetPotDeviceStatus, +) import pytest from homeassistant.components.letpot.const import ( @@ -26,6 +31,16 @@ def device_type() -> str: return "LPH63" +def _mock_device_info(device_type: str) -> LetPotDeviceInfo: + """Return mock device info for the given type.""" + return LetPotDeviceInfo( + model=device_type, + model_name=f"LetPot {device_type}", + model_code=device_type, + features=_mock_device_features(device_type), + ) + + def _mock_device_features(device_type: str) -> DeviceFeature: """Return mock device feature support for the given type.""" if device_type == "LPH31": @@ -89,32 +104,33 @@ def mock_client(device_type: str) -> Generator[AsyncMock]: @pytest.fixture -def mock_device_client(device_type: str) -> Generator[AsyncMock]: +def mock_device_client() -> Generator[AsyncMock]: """Mock a LetPotDeviceClient.""" with patch( - "homeassistant.components.letpot.coordinator.LetPotDeviceClient", + "homeassistant.components.letpot.LetPotDeviceClient", autospec=True, ) as mock_device_client: device_client = mock_device_client.return_value - device_client.device_features = _mock_device_features(device_type) - device_client.device_model_code = device_type - device_client.device_model_name = f"LetPot {device_type}" - device_status = _mock_device_status(device_type) - subscribe_callbacks: list[Callable] = [] + subscribe_callbacks: dict[str, Callable] = {} - def subscribe_side_effect(callback: Callable) -> None: - subscribe_callbacks.append(callback) + def subscribe_side_effect(serial: str, callback: Callable) -> None: + subscribe_callbacks[serial] = callback - def status_side_effect() -> None: - # Deliver a status update to any subscribers, like the real client - for callback in subscribe_callbacks: - callback(device_status) + def request_status_side_effect(serial: str) -> None: + # Deliver a status update to the subscriber, like the real client + if (callback := subscribe_callbacks.get(serial)) is not None: + callback(_mock_device_status(serial[:5])) - device_client.get_current_status.side_effect = status_side_effect - device_client.get_current_status.return_value = device_status - device_client.last_status.return_value = device_status - device_client.request_status_update.side_effect = status_side_effect + def get_current_status_side_effect(serial: str) -> LetPotDeviceStatus: + request_status_side_effect(serial) + return _mock_device_status(serial[:5]) + + device_client.device_info.side_effect = lambda serial: _mock_device_info( + serial[:5] + ) + device_client.get_current_status.side_effect = get_current_status_side_effect + device_client.request_status_update.side_effect = request_status_side_effect device_client.subscribe.side_effect = subscribe_side_effect yield device_client diff --git a/tests/components/letpot/test_init.py b/tests/components/letpot/test_init.py index e3f78d87dc1..8357b4da67e 100644 --- a/tests/components/letpot/test_init.py +++ b/tests/components/letpot/test_init.py @@ -37,7 +37,7 @@ async def test_load_unload_config_entry( await hass.async_block_till_done() assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - mock_device_client.disconnect.assert_called_once() + mock_device_client.unsubscribe.assert_called_once() @pytest.mark.freeze_time("2025-02-15 00:00:00") diff --git a/tests/components/letpot/test_switch.py b/tests/components/letpot/test_switch.py index 7eeafd78291..b1b4b48b7bb 100644 --- a/tests/components/letpot/test_switch.py +++ b/tests/components/letpot/test_switch.py @@ -58,6 +58,7 @@ async def test_set_switch( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, service: str, parameter_value: bool, ) -> None: @@ -71,7 +72,9 @@ async def test_set_switch( target={"entity_id": "switch.garden_power"}, ) - mock_device_client.set_power.assert_awaited_once_with(parameter_value) + mock_device_client.set_power.assert_awaited_once_with( + f"{device_type}ABCD", parameter_value + ) @pytest.mark.parametrize( diff --git a/tests/components/letpot/test_time.py b/tests/components/letpot/test_time.py index dba51ce8497..5c84b6a0159 100644 --- a/tests/components/letpot/test_time.py +++ b/tests/components/letpot/test_time.py @@ -38,6 +38,7 @@ async def test_set_time( mock_config_entry: MockConfigEntry, mock_client: MagicMock, mock_device_client: MagicMock, + device_type: str, ) -> None: """Test setting the time entity.""" await setup_integration(hass, mock_config_entry) @@ -50,7 +51,9 @@ async def test_set_time( target={"entity_id": "time.garden_light_on"}, ) - mock_device_client.set_light_schedule.assert_awaited_once_with(time(7, 0), None) + mock_device_client.set_light_schedule.assert_awaited_once_with( + f"{device_type}ABCD", time(7, 0), None + ) @pytest.mark.parametrize( From 9def44dca472a9e36064e193c357dddf5d373e00 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 17 Jul 2025 08:58:53 +0200 Subject: [PATCH 1491/1664] Bump inexogy quality scale to platinum (#148908) --- .../components/discovergy/manifest.json | 2 +- .../components/discovergy/quality_scale.yaml | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 2f74928c19e..d3443eaefdf 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["pydiscovergy==3.0.2"] } diff --git a/homeassistant/components/discovergy/quality_scale.yaml b/homeassistant/components/discovergy/quality_scale.yaml index a8f140f258c..db49639b937 100644 --- a/homeassistant/components/discovergy/quality_scale.yaml +++ b/homeassistant/components/discovergy/quality_scale.yaml @@ -57,13 +57,16 @@ rules: status: exempt comment: | This integration cannot be discovered, it is a connecting to a cloud service. - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: + status: exempt + comment: | + The integration does not have any known limitations. + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done dynamic-devices: status: exempt comment: | From a0991134c46171765e336baced4980a7ab5c09f8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 17 Jul 2025 08:59:34 +0200 Subject: [PATCH 1492/1664] Rename tuya fixture file to match category (#148892) --- tests/components/tuya/__init__.py | 2 +- ...gbee_cover.json => cl_am43_corded_motor_zigbee_cover.json} | 0 tests/components/tuya/snapshots/test_cover.ambr | 4 ++-- tests/components/tuya/snapshots/test_select.ambr | 4 ++-- tests/components/tuya/test_cover.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) rename tests/components/tuya/fixtures/{am43_corded_motor_zigbee_cover.json => cl_am43_corded_motor_zigbee_cover.json} (100%) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index c3d6c31924e..5134410a293 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry DEVICE_MOCKS = { - "am43_corded_motor_zigbee_cover": [ + "cl_am43_corded_motor_zigbee_cover": [ # https://github.com/home-assistant/core/issues/71242 Platform.SELECT, Platform.COVER, diff --git a/tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json b/tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json similarity index 100% rename from tests/components/tuya/fixtures/am43_corded_motor_zigbee_cover.json rename to tests/components/tuya/fixtures/cl_am43_corded_motor_zigbee_cover.json diff --git a/tests/components/tuya/snapshots/test_cover.ambr b/tests/components/tuya/snapshots/test_cover.ambr index 1ab635919ca..6ae4781c7c1 100644 --- a/tests/components/tuya/snapshots/test_cover.ambr +++ b/tests/components/tuya/snapshots/test_cover.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -34,7 +34,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][cover.kitchen_blinds_curtain-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'current_position': 48, diff --git a/tests/components/tuya/snapshots/test_select.ambr b/tests/components/tuya/snapshots/test_select.ambr index a2d52a893c9..0f530184122 100644 --- a/tests/components/tuya/snapshots/test_select.ambr +++ b/tests/components/tuya/snapshots/test_select.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -39,7 +39,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_platform_setup_and_discovery[am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] +# name: test_platform_setup_and_discovery[cl_am43_corded_motor_zigbee_cover][select.kitchen_blinds_motor_mode-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Kitchen Blinds Motor mode', diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 4550ed9d6f4..3b190e46827 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -59,7 +59,7 @@ async def test_platform_setup_no_discovery( @pytest.mark.parametrize( "mock_device_code", - ["am43_corded_motor_zigbee_cover"], + ["cl_am43_corded_motor_zigbee_cover"], ) @pytest.mark.parametrize( ("percent_control", "percent_state"), From 5383ff96ef74bc95b17eb3d746504aaa55a720e3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 17 Jul 2025 09:00:44 +0200 Subject: [PATCH 1493/1664] Make sure gardena bluetooth mock unload if it mocks load (#148920) --- tests/components/gardena_bluetooth/conftest.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/components/gardena_bluetooth/conftest.py b/tests/components/gardena_bluetooth/conftest.py index d363e0e69f3..0f877fce7db 100644 --- a/tests/components/gardena_bluetooth/conftest.py +++ b/tests/components/gardena_bluetooth/conftest.py @@ -29,8 +29,18 @@ def mock_entry(): ) -@pytest.fixture -def mock_setup_entry() -> Generator[AsyncMock]: +@pytest.fixture(scope="module") +def mock_unload_entry() -> Generator[AsyncMock]: + """Override async_unload_entry.""" + with patch( + "homeassistant.components.gardena_bluetooth.async_unload_entry", + return_value=True, + ) as mock_unload_entry: + yield mock_unload_entry + + +@pytest.fixture(scope="module") +def mock_setup_entry(mock_unload_entry) -> Generator[AsyncMock]: """Override async_setup_entry.""" with patch( "homeassistant.components.gardena_bluetooth.async_setup_entry", From 3d278b626afa7c3414a24a24cb34780dd2ac1bd0 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 17 Jul 2025 09:19:44 +0200 Subject: [PATCH 1494/1664] Z-Wave JS: Add statistics sensors for channel 3 background RSSI (#148899) --- homeassistant/components/zwave_js/sensor.py | 19 +++++++++++++++++++ tests/components/zwave_js/test_sensor.py | 10 ++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index ac65b9e2749..f62e6e1a9f2 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -470,6 +470,23 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ state_class=SensorStateClass.MEASUREMENT, convert=convert_nested_attr, ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.average", + translation_key="average_background_rssi", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + convert=convert_nested_attr, + ), + ZWaveJSStatisticsSensorEntityDescription( + key="background_rssi.channel_3.current", + translation_key="current_background_rssi", + translation_placeholders={"channel": "3"}, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + convert=convert_nested_attr, + ), ] CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { @@ -488,6 +505,8 @@ CONTROLLER_STATISTICS_KEY_MAP: dict[str, str] = { "background_rssi.channel_1.current": "backgroundRSSI.channel1.current", "background_rssi.channel_2.average": "backgroundRSSI.channel2.average", "background_rssi.channel_2.current": "backgroundRSSI.channel2.current", + "background_rssi.channel_3.average": "backgroundRSSI.channel3.average", + "background_rssi.channel_3.current": "backgroundRSSI.channel3.current", } # Node statistics descriptions diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index ef77e22bbec..a005d374b31 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -800,8 +800,10 @@ CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { "average_background_rssi_channel_0": -2, "current_background_rssi_channel_1": -3, "average_background_rssi_channel_1": -4, - "current_background_rssi_channel_2": STATE_UNKNOWN, - "average_background_rssi_channel_2": STATE_UNKNOWN, + "current_background_rssi_channel_2": -5, + "average_background_rssi_channel_2": -6, + "current_background_rssi_channel_3": STATE_UNKNOWN, + "average_background_rssi_channel_3": STATE_UNKNOWN, } NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" # node statistics with initial state of 0 @@ -944,6 +946,10 @@ async def test_statistics_sensors_no_last_seen( "current": -3, "average": -4, }, + "channel2": { + "current": -5, + "average": -6, + }, "timestamp": 1681967176510, }, }, From 72d1c3cfc8dceb9cae3a250c1003e5bf9b4e5a7d Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Thu, 17 Jul 2025 08:47:54 +0100 Subject: [PATCH 1495/1664] Fix Tuya support for climate fan modes which use "windspeed" function (#148646) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/tuya/climate.py | 16 +++- tests/components/tuya/__init__.py | 4 + ...erenelife_slpac905wuk_air_conditioner.json | 80 +++++++++++++++++++ .../tuya/snapshots/test_climate.ambr | 75 +++++++++++++++++ tests/components/tuya/test_climate.py | 64 +++++++++++++++ 5 files changed, 236 insertions(+), 3 deletions(-) create mode 100644 tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index 734f6ba7f7a..d8907b0db9d 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.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 @@ -250,6 +250,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), dptype=DPType.ENUM, @@ -257,6 +258,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): ): self._attr_supported_features |= ClimateEntityFeature.FAN_MODE self._attr_fan_modes = enum_type.range + self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes if self.find_dpcode( @@ -304,7 +306,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - self._send_command([{"code": DPCode.FAN_SPEED_ENUM, "value": fan_mode}]) + if TYPE_CHECKING: + # We can rely on supported_features from __init__ + 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.""" @@ -460,7 +466,11 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity): @property def fan_mode(self) -> str | None: """Return fan mode.""" - return self.device.status.get(DPCode.FAN_SPEED_ENUM) + return ( + self.device.status.get(self._fan_mode_dp_code) + if self._fan_mode_dp_code + else None + ) @property def swing_mode(self) -> str: diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 5134410a293..2286cf016c3 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -107,6 +107,10 @@ DEVICE_MOCKS = { Platform.LIGHT, Platform.SWITCH, ], + "kt_serenelife_slpac905wuk_air_conditioner": [ + # https://github.com/home-assistant/core/pull/148646 + Platform.CLIMATE, + ], "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, diff --git a/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json b/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json new file mode 100644 index 00000000000..8fa2d7b0512 --- /dev/null +++ b/tests/components/tuya/fixtures/kt_serenelife_slpac905wuk_air_conditioner.json @@ -0,0 +1,80 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "mock_terminal_id", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "mock_device_id", + "name": "Air Conditioner", + "category": "kt", + "product_id": "5wnlzekkstwcdsvm", + "product_name": "\u79fb\u52a8\u7a7a\u8c03 YPK--\uff08\u53cc\u6a21+\u84dd\u7259\uff09\u4f4e\u529f\u8017", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-07-06T10:10:44+00:00", + "create_time": "2025-07-06T10:10:44+00:00", + "update_time": "2025-07-06T10:10:44+00:00", + "function": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status_range": { + "switch": { + "type": "Boolean", + "value": {} + }, + "temp_set": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": 16, + "max": 86, + "scale": 0, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "\u2103 \u2109", + "min": -7, + "max": 98, + "scale": 0, + "step": 1 + } + }, + "windspeed": { + "type": "Enum", + "value": { + "range": ["1", "2"] + } + } + }, + "status": { + "switch": false, + "temp_set": 23, + "temp_current": 22, + "windspeed": 1 + }, + "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 4360ef7f436..42fc10fef54 100644 --- a/tests/components/tuya/snapshots/test_climate.ambr +++ b/tests/components/tuya/snapshots/test_climate.ambr @@ -1,4 +1,79 @@ # serializer version: 1 +# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + '1', + '2', + ]), + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.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.air_conditioner', + '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.mock_device_id', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[kt_serenelife_slpac905wuk_air_conditioner][climate.air_conditioner-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 22.0, + 'fan_mode': 1, + 'fan_modes': list([ + '1', + '2', + ]), + 'friendly_name': 'Air Conditioner', + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 86.0, + 'min_temp': 16.0, + 'supported_features': , + 'target_temp_step': 1.0, + 'temperature': 23.0, + }), + 'context': , + 'entity_id': 'climate.air_conditioner', + '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/test_climate.py b/tests/components/tuya/test_climate.py index a5117983000..d564c027cd1 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -11,6 +11,7 @@ from tuya_sharing import CustomerDevice 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 @@ -55,3 +56,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", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_fan_mode_windspeed( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with windspeed.""" + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get("climate.air_conditioner") + assert state is not None, "climate.air_conditioner does not exist" + assert state.attributes["fan_mode"] == 1 + await hass.services.async_call( + Platform.CLIMATE, + "set_fan_mode", + { + "entity_id": "climate.air_conditioner", + "fan_mode": 2, + }, + ) + await hass.async_block_till_done() + mock_manager.send_commands.assert_called_once_with( + mock_device.id, [{"code": "windspeed", "value": "2"}] + ) + + +@pytest.mark.parametrize( + "mock_device_code", + ["kt_serenelife_slpac905wuk_air_conditioner"], +) +async def test_fan_mode_no_valid_code( + hass: HomeAssistant, + mock_manager: ManagerCompat, + mock_config_entry: MockConfigEntry, + mock_device: CustomerDevice, +) -> None: + """Test fan mode with no valid code.""" + # Remove windspeed DPCode to simulate a device with no valid fan mode + mock_device.function.pop("windspeed", None) + mock_device.status_range.pop("windspeed", None) + mock_device.status.pop("windspeed", None) + + await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) + + state = hass.states.get("climate.air_conditioner") + assert state is not None, "climate.air_conditioner does not exist" + assert state.attributes.get("fan_mode") is None + with pytest.raises(ServiceNotSupported): + await hass.services.async_call( + Platform.CLIMATE, + "set_fan_mode", + { + "entity_id": "climate.air_conditioner", + "fan_mode": 2, + }, + blocking=True, + ) From 79b8e74d8735afd8eef7ffda8222ce046836ee27 Mon Sep 17 00:00:00 2001 From: asafhas <121308170+asafhas@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:26:33 +0300 Subject: [PATCH 1496/1664] Add numbers configuration to Tuya alarm (#148907) --- homeassistant/components/tuya/const.py | 2 + homeassistant/components/tuya/number.py | 24 +++ homeassistant/components/tuya/strings.json | 9 + tests/components/tuya/__init__.py | 1 + .../tuya/snapshots/test_number.ambr | 177 ++++++++++++++++++ 5 files changed, 213 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index b8bb5ea483f..87f80755e8b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -98,6 +98,7 @@ class DPCode(StrEnum): AIR_QUALITY = "air_quality" AIR_QUALITY_INDEX = "air_quality_index" + ALARM_DELAY_TIME = "alarm_delay_time" ALARM_SWITCH = "alarm_switch" # Alarm switch ALARM_TIME = "alarm_time" # Alarm time ALARM_VOLUME = "alarm_volume" # Alarm volume @@ -176,6 +177,7 @@ class DPCode(StrEnum): DECIBEL_SWITCH = "decibel_switch" DEHUMIDITY_SET_ENUM = "dehumidify_set_enum" DEHUMIDITY_SET_VALUE = "dehumidify_set_value" + DELAY_SET = "delay_set" DISINFECTION = "disinfection" DO_NOT_DISTURB = "do_not_disturb" DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 5aee426da8c..415299307e3 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -170,6 +170,30 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Alarm Host + # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk + "mal": ( + NumberEntityDescription( + key=DPCode.DELAY_SET, + # This setting is called "Arm Delay" in the official Tuya app + translation_key="arm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_DELAY_TIME, + translation_key="alarm_delay", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.ALARM_TIME, + # This setting is called "Siren Duration" in the official Tuya app + translation_key="siren_duration", + device_class=NumberDeviceClass.DURATION, + entity_category=EntityCategory.CONFIG, + ), + ), # Sous Vide Cooker # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux "mzj": ( diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ee1df183f36..799d57547b2 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -222,6 +222,15 @@ }, "temp_correction": { "name": "Temperature correction" + }, + "arm_delay": { + "name": "Arm delay" + }, + "alarm_delay": { + "name": "Alarm delay" + }, + "siren_duration": { + "name": "Siren duration" } }, "select": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 2286cf016c3..1ce7e6c47dd 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -114,6 +114,7 @@ DEVICE_MOCKS = { "mal_alarm_host": [ # Alarm Host support Platform.ALARM_CONTROL_PANEL, + Platform.NUMBER, Platform.SWITCH, ], "mcs_door_sensor": [ diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 1b19d5827ab..1c8af00baff 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -116,6 +116,183 @@ 'state': '1.0', }) # --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Alarm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_delay', + 'unique_id': 'tuya.123123aba12312312dazubalarm_delay_time', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_alarm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Alarm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_alarm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Arm delay', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'arm_delay', + 'unique_id': 'tuya.123123aba12312312dazubdelay_set', + 'unit_of_measurement': 's', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_arm_delay-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Arm delay', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 's', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_arm_delay', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '15.0', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Siren duration', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'siren_duration', + 'unique_id': 'tuya.123123aba12312312dazubalarm_time', + 'unit_of_measurement': 'min', + }) +# --- +# name: test_platform_setup_and_discovery[mal_alarm_host][number.multifunction_alarm_siren_duration-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'Multifunction alarm Siren duration', + 'max': 999.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': 'min', + }), + 'context': , + 'entity_id': 'number.multifunction_alarm_siren_duration', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.0', + }) +# --- # name: test_platform_setup_and_discovery[wk_wifi_smart_gas_boiler_thermostat][number.wifi_smart_gas_boiler_thermostat_temperature_correction-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 0e6a1e324279ccc406176422333106ef2debe95b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Jul 2025 11:41:39 +0200 Subject: [PATCH 1497/1664] Improve integration sensor tests (#148938) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: G Johansson --- tests/components/integration/test_sensor.py | 101 +++++++++++++++----- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index ba4a6bdf198..3d5549d88bf 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -294,23 +294,35 @@ async def test_restore_state_failed(hass: HomeAssistant, extra_attributes) -> No assert state.state == STATE_UNKNOWN +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) @pytest.mark.parametrize("force_update", [False, True]) @pytest.mark.parametrize( "sequence", [ + # time, value, attributes, expected ( - (20, 10, 1.67), - (30, 30, 5.0), - (40, 5, 7.92), - (50, 5, 8.75), - (60, 0, 9.17), + (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, {}, 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), ), ], ) async def test_trapezoidal( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: """Test integration sensor state.""" config = { @@ -320,23 +332,29 @@ async def test_trapezoidal( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN 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, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() @@ -346,25 +364,35 @@ async def test_trapezoidal( 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", [ + # time, value, attributes, expected ( - (20, 10, 0.0), - (30, 30, 1.67), - (40, 5, 6.67), - (50, 5, 7.5), - (60, 0, 8.33), + (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), + ), + ( + (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), ), ], ) async def test_left( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with left Riemann method.""" config = { "sensor": { "platform": "integration", @@ -373,25 +401,31 @@ async def test_left( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # 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, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() @@ -401,25 +435,34 @@ async def test_left( 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", [ ( - (20, 10, 3.33), - (30, 30, 8.33), - (40, 5, 9.17), - (50, 5, 10.0), - (60, 0, 10.0), + (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, {}, 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), ), ], ) async def test_right( hass: HomeAssistant, - sequence: tuple[tuple[float, float, float], ...], + sequence: tuple[tuple[float, float, dict[str, Any], float], ...], force_update: bool, + extra_config: dict[str, Any], ) -> None: - """Test integration sensor state with left reimann method.""" + """Test integration sensor state with right Riemann method.""" config = { "sensor": { "platform": "integration", @@ -428,25 +471,31 @@ async def test_right( "source": "sensor.power", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN entity_id = config["sensor"]["source"] hass.states.async_set( entity_id, 0, {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} ) await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNKNOWN # 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, expected in sequence: + for time, value, extra_attributes, expected in sequence: freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, - {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT} | extra_attributes, force_update=force_update, ) await hass.async_block_till_done() From d72fb021c1bfa00b910c7c0c670c28ad4da22a49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Jul 2025 11:42:25 +0200 Subject: [PATCH 1498/1664] Improve statistics tests (#148937) --- tests/components/statistics/test_sensor.py | 99 ++++++++++++++-------- 1 file changed, 65 insertions(+), 34 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 21df0146ef5..1db4acf3ef8 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -54,6 +54,9 @@ VALUES_BINARY = ["on", "off", "on", "off", "on", "off", "on", "off", "on"] VALUES_NUMERIC = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6] VALUES_NUMERIC_LINEAR = [1, 2, 3, 4, 5, 6, 7, 8, 9] +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} + async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -249,7 +252,22 @@ async def test_sensor_defaults_binary(hass: HomeAssistant) -> None: assert "age_coverage_ratio" not in state.attributes -async def test_sensor_state_reported(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values", "attributes"), + [ + # Fires last reported events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A1, A1, A1, A1, A1, A1, A1, A1]), + # Fires state change events + ([18, 1, 1, 1, 1, 1, 1, 1, 9], [A1, A2, A1, A2, A1, A2, A1, A2, A1]), + ], +) +async def test_sensor_state_updated_reported( + hass: HomeAssistant, + values: list[float], + attributes: list[dict[str, Any]], + force_update: bool, +) -> None: """Test the behavior of the sensor with a sequence of identical values. Forced updates no longer make a difference, since the statistics are now reacting not @@ -258,7 +276,6 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: This fixes problems with time based averages and some other functions that behave differently when repeating values are reported. """ - repeating_values = [18, 0, 0, 0, 0, 0, 0, 0, 9] assert await async_setup_component( hass, "sensor", @@ -267,14 +284,7 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: { "platform": "statistics", "name": "test_normal", - "entity_id": "sensor.test_monitored_normal", - "state_characteristic": "mean", - "sampling_size": 20, - }, - { - "platform": "statistics", - "name": "test_force", - "entity_id": "sensor.test_monitored_force", + "entity_id": "sensor.test_monitored", "state_characteristic": "mean", "sampling_size": 20, }, @@ -283,27 +293,19 @@ async def test_sensor_state_reported(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value in repeating_values: + for value, attribute in zip(values, attributes, strict=True): hass.states.async_set( - "sensor.test_monitored_normal", + "sensor.test_monitored", str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - ) - hass.states.async_set( - "sensor.test_monitored_force", - str(value), - {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, - force_update=True, + {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS} | attribute, + force_update=force_update, ) await hass.async_block_till_done() - state_normal = hass.states.get("sensor.test_normal") - state_force = hass.states.get("sensor.test_force") - assert state_normal and state_force - assert state_normal.state == str(round(sum(repeating_values) / 9, 2)) - assert state_force.state == str(round(sum(repeating_values) / 9, 2)) - assert state_normal.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) - assert state_force.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) + state = hass.states.get("sensor.test_normal") + assert state + assert state.state == str(round(sum(values) / 9, 2)) + assert state.attributes.get("buffer_usage_ratio") == round(9 / 20, 2) async def test_sampling_boundaries_given(hass: HomeAssistant) -> None: @@ -1785,12 +1787,40 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) assert float(hass.states.get("sensor.test").state) == pytest.approx(4.5) -async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("force_update", [True, False]) +@pytest.mark.parametrize( + ("values_attributes_and_times", "expected_state"), + [ + ( + # Fires last reported events + [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], + "8.33", + ), + ( # Fires state change events + [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], + "8.33", + ), + ( + # Fires last reported events + [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], + "10.0", + ), + ( # Fires state change events + [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], + "10.0", + ), + ], +) +async def test_average_linear_unevenly_timed( + hass: HomeAssistant, + force_update: bool, + values_attributes_and_times: list[tuple[float, dict[str, Any], float]], + expected_state: str, +) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ - values_and_times = [[5.0, 2], [10.0, 1], [10.0, 1], [10.0, 2], [5.0, 1]] current_time = dt_util.utcnow() @@ -1814,22 +1844,23 @@ async def test_average_linear_unevenly_timed(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - for value_and_time in values_and_times: + for value, extra_attributes, time in values_attributes_and_times: hass.states.async_set( "sensor.test_monitored", - str(value_and_time[0]), - {ATTR_UNIT_OF_MEASUREMENT: DEGREE}, + str(value), + {ATTR_UNIT_OF_MEASUREMENT: DEGREE} | extra_attributes, + force_update=force_update, ) - current_time += timedelta(seconds=value_and_time[1]) + current_time += timedelta(seconds=time) freezer.move_to(current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test_sensor_average_linear") assert state is not None - assert state.state == "8.33", ( + assert state.state == expected_state, ( "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == 8.33" + f"assert {state.state} == {expected_state}" ) From 9373bb287c620ef1c1033ef27d0b2a14dcf6da03 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Thu, 17 Jul 2025 11:43:26 +0200 Subject: [PATCH 1499/1664] Huum - Introduce coordinator to support multiple platforms (#148889) Co-authored-by: Josef Zweck --- CODEOWNERS | 4 +- homeassistant/components/huum/__init__.py | 46 +++---- homeassistant/components/huum/climate.py | 53 ++++----- homeassistant/components/huum/config_flow.py | 4 +- homeassistant/components/huum/coordinator.py | 60 ++++++++++ homeassistant/components/huum/manifest.json | 2 +- tests/components/huum/__init__.py | 17 +++ tests/components/huum/conftest.py | 72 +++++++++++ .../huum/snapshots/test_climate.ambr | 68 +++++++++++ tests/components/huum/test_climate.py | 78 ++++++++++++ tests/components/huum/test_config_flow.py | 112 +++++++----------- tests/components/huum/test_init.py | 27 +++++ 12 files changed, 403 insertions(+), 140 deletions(-) create mode 100644 homeassistant/components/huum/coordinator.py create mode 100644 tests/components/huum/conftest.py create mode 100644 tests/components/huum/snapshots/test_climate.ambr create mode 100644 tests/components/huum/test_climate.py create mode 100644 tests/components/huum/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 05c17b5498d..f4f1d3b7a92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -684,8 +684,8 @@ build.json @home-assistant/supervisor /tests/components/husqvarna_automower/ @Thomas55555 /homeassistant/components/husqvarna_automower_ble/ @alistair23 /tests/components/husqvarna_automower_ble/ @alistair23 -/homeassistant/components/huum/ @frwickst -/tests/components/huum/ @frwickst +/homeassistant/components/huum/ @frwickst @vincentwolsink +/tests/components/huum/ @frwickst @vincentwolsink /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan diff --git a/homeassistant/components/huum/__init__.py b/homeassistant/components/huum/__init__.py index 75faf1923df..d2dd7ff4fa3 100644 --- a/homeassistant/components/huum/__init__.py +++ b/homeassistant/components/huum/__init__.py @@ -2,46 +2,28 @@ from __future__ import annotations -import logging - -from huum.exceptions import Forbidden, NotAuthenticated -from huum.huum import Huum - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) +from .const import PLATFORMS +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, config_entry: HuumConfigEntry) -> bool: """Set up Huum from a config entry.""" - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] + coordinator = HuumDataUpdateCoordinator( + hass=hass, + config_entry=config_entry, + ) - huum = Huum(username, password, session=async_get_clientsession(hass)) + await coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = coordinator - try: - await huum.status() - except (Forbidden, NotAuthenticated) as err: - _LOGGER.error("Could not log in to Huum with given credentials") - raise ConfigEntryNotReady( - "Could not log in to Huum with given credentials" - ) from err - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = huum - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: HuumConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index bbeb50a2b72..b0d36a56a46 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -7,38 +7,35 @@ from typing import Any from huum.const import SaunaStatus from huum.exceptions import SafetyException -from huum.huum import Huum -from huum.schemas import HuumStatusResponse from homeassistant.components.climate import ( ClimateEntity, ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: HuumConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the Huum sauna with config flow.""" - huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id] - - async_add_entities([HuumDevice(huum_handler, entry.entry_id)], True) + async_add_entities([HuumDevice(entry.runtime_data)]) -class HuumDevice(ClimateEntity): +class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -54,24 +51,22 @@ class HuumDevice(ClimateEntity): _attr_has_entity_name = True _attr_name = None - _target_temperature: int | None = None - _status: HuumStatusResponse | None = None - - def __init__(self, huum_handler: Huum, unique_id: str) -> None: + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: """Initialize the heater.""" - self._attr_unique_id = unique_id + super().__init__(coordinator) + + self._attr_unique_id = coordinator.config_entry.entry_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, unique_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, name="Huum sauna", manufacturer="Huum", + model="UKU WiFi", ) - self._huum_handler = huum_handler - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - if self._status and self._status.status == SaunaStatus.ONLINE_HEATING: + if self.coordinator.data.status == SaunaStatus.ONLINE_HEATING: return HVACMode.HEAT return HVACMode.OFF @@ -85,41 +80,33 @@ class HuumDevice(ClimateEntity): @property def current_temperature(self) -> int | None: """Return the current temperature.""" - if (status := self._status) is not None: - return status.temperature - return None + return self.coordinator.data.temperature @property def target_temperature(self) -> int: """Return the temperature we try to reach.""" - return self._target_temperature or int(self.min_temp) + return self.coordinator.data.target_temperature or int(self.min_temp) 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) elif hvac_mode == HVACMode.OFF: - await self._huum_handler.turn_off() + await self.coordinator.huum.turn_off() + await self.coordinator.async_refresh() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + if temperature is None or self.hvac_mode != HVACMode.HEAT: return - self._target_temperature = temperature - if self.hvac_mode == HVACMode.HEAT: - await self._turn_on(temperature) - - async def async_update(self) -> None: - """Get the latest status data.""" - self._status = await self._huum_handler.status() - if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT: - self._target_temperature = self._status.target_temperature + await self._turn_on(temperature) + await self.coordinator.async_refresh() async def _turn_on(self, temperature: int) -> None: try: - await self._huum_handler.turn_on(temperature) + await self.coordinator.huum.turn_on(temperature) except (ValueError, SafetyException) as err: _LOGGER.error(str(err)) raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err diff --git a/homeassistant/components/huum/config_flow.py b/homeassistant/components/huum/config_flow.py index 6a5fd96b99d..b6f7f883120 100644 --- a/homeassistant/components/huum/config_flow.py +++ b/homeassistant/components/huum/config_flow.py @@ -37,12 +37,12 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: - huum_handler = Huum( + huum = Huum( user_input[CONF_USERNAME], user_input[CONF_PASSWORD], session=async_get_clientsession(self.hass), ) - await huum_handler.status() + await huum.status() except (Forbidden, NotAuthenticated): # Most likely Forbidden as that is what is returned from `.status()` with bad creds _LOGGER.error("Could not log in to Huum with given credentials") diff --git a/homeassistant/components/huum/coordinator.py b/homeassistant/components/huum/coordinator.py new file mode 100644 index 00000000000..6580ca99da7 --- /dev/null +++ b/homeassistant/components/huum/coordinator.py @@ -0,0 +1,60 @@ +"""DataUpdateCoordinator for Huum.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from huum.exceptions import Forbidden, NotAuthenticated +from huum.huum import Huum +from huum.schemas import HuumStatusResponse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type HuumConfigEntry = ConfigEntry[HuumDataUpdateCoordinator] + +_LOGGER = logging.getLogger(__name__) +UPDATE_INTERVAL = timedelta(seconds=30) + + +class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]): + """Class to manage fetching data from the API.""" + + config_entry: HuumConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: HuumConfigEntry, + ) -> None: + """Initialize.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + config_entry=config_entry, + ) + + self.huum = Huum( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + session=async_get_clientsession(hass), + ) + + async def _async_update_data(self) -> HuumStatusResponse: + """Get the latest status data.""" + + try: + return await self.huum.status() + except (Forbidden, NotAuthenticated) as err: + _LOGGER.error("Could not log in to Huum with given credentials") + raise UpdateFailed( + "Could not log in to Huum with given credentials" + ) from err diff --git a/homeassistant/components/huum/manifest.json b/homeassistant/components/huum/manifest.json index 82b863e4e42..38001c58b35 100644 --- a/homeassistant/components/huum/manifest.json +++ b/homeassistant/components/huum/manifest.json @@ -1,7 +1,7 @@ { "domain": "huum", "name": "Huum", - "codeowners": ["@frwickst"], + "codeowners": ["@frwickst", "@vincentwolsink"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/huum", "iot_class": "cloud_polling", diff --git a/tests/components/huum/__init__.py b/tests/components/huum/__init__.py index 443cbd52c36..d280bab6a59 100644 --- a/tests/components/huum/__init__.py +++ b/tests/components/huum/__init__.py @@ -1 +1,18 @@ """Tests for the huum integration.""" + +from unittest.mock import patch + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_with_selected_platforms( + hass: HomeAssistant, entry: MockConfigEntry, platforms: list[Platform] +) -> None: + """Set up the Huum integration with the selected platforms.""" + entry.add_to_hass(hass) + with patch("homeassistant.components.huum.PLATFORMS", platforms): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/huum/conftest.py b/tests/components/huum/conftest.py new file mode 100644 index 00000000000..023abd4429e --- /dev/null +++ b/tests/components/huum/conftest.py @@ -0,0 +1,72 @@ +"""Configuration for Huum tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from huum.const import SaunaStatus +import pytest + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_huum() -> Generator[AsyncMock]: + """Mock data from the API.""" + huum = AsyncMock() + with ( + patch( + "homeassistant.components.huum.config_flow.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.status", + return_value=huum, + ), + patch( + "homeassistant.components.huum.coordinator.Huum.turn_on", + return_value=huum, + ) as turn_on, + ): + huum.status = SaunaStatus.ONLINE_NOT_HEATING + huum.door_closed = True + huum.temperature = 30 + huum.sauna_name = 123456 + huum.target_temperature = 80 + huum.light = 1 + huum.humidity = 5 + huum.sauna_config.child_lock = "OFF" + huum.sauna_config.max_heating_time = 3 + huum.sauna_config.min_heating_time = 0 + huum.sauna_config.max_temp = 110 + huum.sauna_config.min_temp = 40 + huum.sauna_config.max_timer = 0 + huum.sauna_config.min_timer = 0 + huum.turn_on = turn_on + + yield huum + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.huum.async_setup_entry", return_value=True + ) as setup_entry_mock: + yield setup_entry_mock + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: "huum@sauna.org", + CONF_PASSWORD: "ukuuku", + }, + unique_id="123456", + entry_id="AABBCC112233", + ) diff --git a/tests/components/huum/snapshots/test_climate.ambr b/tests/components/huum/snapshots/test_climate.ambr new file mode 100644 index 00000000000..f18fd279f25 --- /dev/null +++ b/tests/components/huum/snapshots/test_climate.ambr @@ -0,0 +1,68 @@ +# serializer version: 1 +# name: test_climate_entity[climate.huum_sauna-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + ]), + 'max_temp': 110, + 'min_temp': 40, + 'target_temp_step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.huum_sauna', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:radiator-off', + 'original_name': None, + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'AABBCC112233', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entity[climate.huum_sauna-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 30, + 'friendly_name': 'Huum sauna', + 'hvac_modes': list([ + , + , + ]), + 'icon': 'mdi:radiator-off', + 'max_temp': 110, + 'min_temp': 40, + 'supported_features': , + 'target_temp_step': 1, + 'temperature': 80, + }), + 'context': , + 'entity_id': 'climate.huum_sauna', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/test_climate.py b/tests/components/huum/test_climate.py new file mode 100644 index 00000000000..ca7fcf81185 --- /dev/null +++ b/tests/components/huum/test_climate.py @@ -0,0 +1,78 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from huum.const import SaunaStatus +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_HVAC_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_TEMPERATURE, + HVACMode, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, 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 = "climate.huum_sauna" + + +async def test_climate_entity( + 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.CLIMATE]) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_set_hvac_mode( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting HVAC mode.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.HEAT + + mock_huum.turn_on.assert_called_once() + + +async def test_set_temperature( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test setting the temperature.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + mock_huum.status = SaunaStatus.ONLINE_HEATING + await hass.services.async_call( + Platform.CLIMATE, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 60, + }, + blocking=True, + ) + + mock_huum.turn_on.assert_called_once_with(60) diff --git a/tests/components/huum/test_config_flow.py b/tests/components/huum/test_config_flow.py index 9917f71fc08..d59eac51207 100644 --- a/tests/components/huum/test_config_flow.py +++ b/tests/components/huum/test_config_flow.py @@ -1,6 +1,6 @@ """Test the huum config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from huum.exceptions import Forbidden import pytest @@ -13,11 +13,13 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry -TEST_USERNAME = "test-username" -TEST_PASSWORD = "test-password" +TEST_USERNAME = "huum@sauna.org" +TEST_PASSWORD = "ukuuku" -async def test_form(hass: HomeAssistant) -> None: +async def test_form( + hass: HomeAssistant, mock_huum: AsyncMock, mock_setup_entry: AsyncMock +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -26,24 +28,14 @@ async def test_form(hass: HomeAssistant) -> None: assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == TEST_USERNAME @@ -54,42 +46,28 @@ async def test_form(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: +async def test_signup_flow_already_set_up( + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: """Test that we handle already existing entities with same id.""" - mock_config_entry = MockConfigEntry( - title="Huum Sauna", - domain=DOMAIN, - unique_id=TEST_USERNAME, - data={ - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) mock_config_entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - await hass.async_block_till_done() - assert result2["type"] is FlowResultType.ABORT + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + assert result2["type"] is FlowResultType.ABORT @pytest.mark.parametrize( @@ -103,7 +81,11 @@ async def test_signup_flow_already_set_up(hass: HomeAssistant) -> None: ], ) async def test_huum_errors( - hass: HomeAssistant, raises: Exception, error_base: str + hass: HomeAssistant, + mock_huum: AsyncMock, + mock_setup_entry: AsyncMock, + raises: Exception, + error_base: str, ) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( @@ -125,21 +107,11 @@ async def test_huum_errors( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error_base} - with ( - patch( - "homeassistant.components.huum.config_flow.Huum.status", - return_value=True, - ), - patch( - "homeassistant.components.huum.async_setup_entry", - return_value=True, - ), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, - ) - assert result2["type"] is FlowResultType.CREATE_ENTRY + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/huum/test_init.py b/tests/components/huum/test_init.py new file mode 100644 index 00000000000..fac5fa875ee --- /dev/null +++ b/tests/components/huum/test_init.py @@ -0,0 +1,27 @@ +"""Tests for the Huum __init__.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.huum.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from . import setup_with_selected_platforms + +from tests.common import MockConfigEntry + + +async def test_loading_and_unloading_config_entry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_huum: AsyncMock +) -> None: + """Test loading and unloading a config entry.""" + await setup_with_selected_platforms(hass, mock_config_entry, [Platform.CLIMATE]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + 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 From ee35fc495d45d00895a666cb7b637aba1ac7883d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 17 Jul 2025 11:44:37 +0200 Subject: [PATCH 1500/1664] Improve derivative sensor tests (#148941) --- tests/components/derivative/test_sensor.py | 117 +++++++++++++++------ 1 file changed, 84 insertions(+), 33 deletions(-) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 10092e30ca0..ee458ea54cd 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -27,8 +27,25 @@ from tests.common import ( mock_restore_cache_with_extra_data, ) +A1 = {"attr": "value1"} +A2 = {"attr": "value2"} -async def test_state(hass: HomeAssistant) -> None: + +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2], + ], +) +async def test_state( + hass: HomeAssistant, + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state.""" config = { "sensor": { @@ -45,12 +62,13 @@ async def test_state(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + for extra_attributes in attributes: + hass.states.async_set( + entity_id, 1, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -61,7 +79,24 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("unit_of_measurement") == "kW" -async def test_no_change(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1, A1, A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2, A1, A2], + ], +) +async def test_no_change( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test derivative sensor state updated when source sensor doesn't change.""" config = { "sensor": { @@ -71,6 +106,7 @@ async def test_no_change(hass: HomeAssistant) -> None: "unit": "kW", "round": 2, } + | extra_config } assert await async_setup_component(hass, "sensor", config) @@ -78,20 +114,13 @@ async def test_no_change(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() with freeze_time(base) as freezer: - hass.states.async_set(entity_id, 0, {}) - await hass.async_block_till_done() + for value, extra_attributes in zip([0, 1, 1, 1], attributes, strict=True): + hass.states.async_set( + entity_id, value, extra_attributes, force_update=force_update + ) + await hass.async_block_till_done() - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() - - freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) - hass.states.async_set(entity_id, 1, {}) - await hass.async_block_till_done() + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) state = hass.states.get("sensor.derivative") assert state is not None @@ -138,7 +167,7 @@ async def setup_tests( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -213,7 +242,24 @@ async def test_dataSet6(hass: HomeAssistant) -> None: await setup_tests(hass, {}, times=[0, 60], values=[0, 1 / 60], expected_state=1) -async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: +# Test unchanged states work both with and without max_sub_interval +@pytest.mark.parametrize("extra_config", [{}, {"max_sub_interval": {"minutes": 9999}}]) +@pytest.mark.parametrize("force_update", [False, True]) +@pytest.mark.parametrize( + "attributes", + [ + # Same attributes, fires state report + [A1, A1] * 10 + [A1], + # Changing attributes, fires state change with bumped last_updated + [A1, A2] * 10 + [A1], + ], +) +async def test_data_moving_average_with_zeroes( + hass: HomeAssistant, + extra_config: dict[str, Any], + force_update: bool, + attributes: list[dict[str, Any]], +) -> None: """Test that zeroes are properly handled within the time window.""" # We simulate the following situation: # The temperature rises 1 °C per minute for 10 minutes long. Then, it @@ -235,16 +281,21 @@ async def test_data_moving_average_with_zeroes(hass: HomeAssistant) -> None: "time_window": {"seconds": time_window}, "unit_time": UnitOfTime.MINUTES, "round": 1, - }, + } + | extra_config, ) base = dt_util.utcnow() with freeze_time(base) as freezer: last_derivative = 0 - for time, value in zip(times, temperature_values, strict=True): + for time, value, extra_attributes in zip( + times, temperature_values, attributes, strict=True + ): now = base + timedelta(seconds=time) freezer.move_to(now) - hass.states.async_set(entity_id, value, {}) + 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") @@ -273,7 +324,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N for temperature in range(30): temperature_values += [temperature] * 2 # two values per minute time_window = 600 - times = list(range(0, 1800 + 30, 30)) + times = list(range(0, 1800, 30)) config, entity_id = await _setup_sensor( hass, @@ -286,7 +337,7 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -330,7 +381,7 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -368,7 +419,7 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: base = dt_util.utcnow() previous = 0 with freeze_time(base) as freezer: - for time, value in zip(times, temperature_values, strict=False): + for time, value in zip(times, temperature_values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}) @@ -506,7 +557,7 @@ async def test_sub_intervals_with_time_window(hass: HomeAssistant) -> None: base = dt_util.utcnow() with freeze_time(base) as freezer: last_state_change = None - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): now = base + timedelta(seconds=time) freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) @@ -636,7 +687,7 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: actual_times = [] actual_values = [] with freeze_time(base_time) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): current_time = base_time + timedelta(seconds=time) freezer.move_to(current_time) hass.states.async_set( @@ -724,7 +775,7 @@ async def test_unavailable( # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value, expect in zip(times, values, expected_state, strict=False): + for time, value, expect in zip(times, values, expected_state, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() @@ -759,7 +810,7 @@ async def test_unavailable_2( base = dt_util.utcnow() with freeze_time(base) as freezer: - for time, value in zip(times, values, strict=False): + for time, value in zip(times, values, strict=True): freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}) await hass.async_block_till_done() From 9df97fb2e20f896d48fe4b209752ffc5f15aab40 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 17 Jul 2025 12:31:55 +0200 Subject: [PATCH 1501/1664] Add correct labels for dependabot PRs (#148944) --- .github/dependabot.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a394d7dcbba..f9bfa9b406d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,3 +6,6 @@ updates: interval: daily time: "06:00" open-pull-requests-limit: 10 + labels: + - dependency + - github_actions From b33a556ca5699bdb5b9221558c689f7d9e934ab3 Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 17 Jul 2025 15:20:03 +0200 Subject: [PATCH 1502/1664] Bump zwave-js-server-python to 0.66.0 (#148939) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/zwave_js/manifest.json | 2 +- homeassistant/components/zwave_js/triggers/event.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 4 ++-- tests/components/zwave_js/test_repairs.py | 2 +- tests/components/zwave_js/test_sensor.py | 4 ++-- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 93d585d72a2..4c9ef784077 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.65.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.66.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 8d0ccf60fdf..52c24055052 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -5,7 +5,7 @@ from __future__ import annotations from collections.abc import Callable import functools -from pydantic.v1 import ValidationError +from pydantic import ValidationError import voluptuous as vol from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver @@ -78,7 +78,7 @@ def validate_event_data(obj: dict) -> dict: except ValidationError as exc: # Filter out required field errors if keys can be missing, and if there are # still errors, raise an exception - if [error for error in exc.errors() if error["type"] != "value_error.missing"]: + if [error for error in exc.errors() if error["type"] != "missing"]: raise vol.MultipleInvalid from exc return obj diff --git a/requirements_all.txt b/requirements_all.txt index 9267aa3f2bd..0203edd6aa5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3209,7 +3209,7 @@ ziggo-mediabox-xl==1.1.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.65.0 +zwave-js-server-python==0.66.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b41f72e888..bc30c59da4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2644,7 +2644,7 @@ zeversolar==0.3.2 zha==0.0.62 # homeassistant.components.zwave_js -zwave-js-server-python==0.65.0 +zwave-js-server-python==0.66.0 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index bac0162ba74..6359f4bf5e7 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2520,7 +2520,7 @@ async def test_subscribe_rebuild_routes_progress( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) @@ -2564,7 +2564,7 @@ async def test_subscribe_rebuild_routes_progress_initial_value( { "source": "controller", "event": "rebuild routes progress", - "progress": {67: "pending"}, + "progress": {"67": "pending"}, }, ) client.driver.controller.receive_event(event) diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index d8c3de92b3b..d783e3deaba 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -34,7 +34,7 @@ async def _trigger_repair_issue( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) with patch( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index a005d374b31..140d584f76f 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -247,7 +247,7 @@ async def test_invalid_multilevel_sensor_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) @@ -610,7 +610,7 @@ async def test_invalid_meter_scale( "source": "controller", "event": "node added", "node": node_state, - "result": "", + "result": {}, }, ) client.driver.controller.receive_event(event) From 40cabc8d7059809e8f4983e7f069ca2d85099785 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 17 Jul 2025 07:27:41 -0700 Subject: [PATCH 1503/1664] Validate min/max for input_text config (#148909) --- .../components/input_text/__init__.py | 17 +++++++++++---- tests/components/input_text/test_init.py | 21 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 998bf35cd82..4928b4325d1 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_MODE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, + MAX_LENGTH_STATE_STATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -51,8 +52,12 @@ STORAGE_VERSION = 1 STORAGE_FIELDS: VolDictType = { vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL, ""): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, @@ -84,8 +89,12 @@ CONFIG_SCHEMA = vol.Schema( lambda value: value or {}, { vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.All( + vol.Coerce(int), vol.Range(0, MAX_LENGTH_STATE_STATE) + ), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.All( + vol.Coerce(int), vol.Range(1, MAX_LENGTH_STATE_STATE) + ), vol.Optional(CONF_INITIAL): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py index 2ca1d39a983..c0c18a5153c 100644 --- a/tests/components/input_text/test_init.py +++ b/tests/components/input_text/test_init.py @@ -81,16 +81,21 @@ async def async_set_value(hass: HomeAssistant, entity_id: str, value: str) -> No ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, - {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 51, "max": 50}}, + {"test_1": {"min": -1, "max": 100}}, + {"test_1": {"min": 0, "max": 256}}, + {"test_1": {"min": 0, "max": 3, "initial": "aaaaa"}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant) -> None: From 17920b6ec312f9c7c312a8133519cb6fe4a2a3dd Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Thu, 17 Jul 2025 16:34:15 +0200 Subject: [PATCH 1504/1664] Use climate min/max temp from sauna configuration in Huum (#148955) --- homeassistant/components/huum/climate.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index b0d36a56a46..c82fd2c91a5 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -46,8 +46,6 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_max_temp = 110 - _attr_min_temp = 40 _attr_has_entity_name = True _attr_name = None @@ -63,6 +61,16 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): model="UKU WiFi", ) + @property + def min_temp(self) -> int: + """Return configured minimal temperature.""" + return self.coordinator.data.sauna_config.min_temp + + @property + def max_temp(self) -> int: + """Return configured maximum temperature.""" + return self.coordinator.data.sauna_config.max_temp + @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" From a96e38871f4c197c147a229a4758776e21c8fe8e Mon Sep 17 00:00:00 2001 From: AlCalzone Date: Thu, 17 Jul 2025 17:49:34 +0200 Subject: [PATCH 1505/1664] Z-Wave JS: Simplify strings for RSSI sensors (#148936) --- homeassistant/components/zwave_js/sensor.py | 18 +++++++------- .../components/zwave_js/strings.json | 16 ++++++------- tests/components/zwave_js/test_sensor.py | 24 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index f62e6e1a9f2..df0a701bf15 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -421,7 +421,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -429,7 +429,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_0.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "0"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -438,7 +438,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -446,7 +446,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_1.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "1"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -455,7 +455,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -463,7 +463,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_2.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "2"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -472,7 +472,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_3.average", - translation_key="average_background_rssi", + translation_key="avg_signal_noise", translation_placeholders={"channel": "3"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -480,7 +480,7 @@ ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="background_rssi.channel_3.current", - translation_key="current_background_rssi", + translation_key="signal_noise", translation_placeholders={"channel": "3"}, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, @@ -549,7 +549,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ ), ZWaveJSStatisticsSensorEntityDescription( key="rssi", - translation_key="rssi", + translation_key="signal_strength", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 63dad248246..7f59e640ef8 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -199,8 +199,8 @@ } }, "sensor": { - "average_background_rssi": { - "name": "Average background RSSI (channel {channel})" + "avg_signal_noise": { + "name": "Avg. signal noise (channel {channel})" }, "can": { "name": "Collisions" @@ -216,9 +216,6 @@ "unresponsive": "Unresponsive" } }, - "current_background_rssi": { - "name": "Current background RSSI (channel {channel})" - }, "last_seen": { "name": "Last seen" }, @@ -238,12 +235,15 @@ "unknown": "Unknown" } }, - "rssi": { - "name": "RSSI" - }, "rtt": { "name": "Round trip time" }, + "signal_noise": { + "name": "Signal noise (channel {channel})" + }, + "signal_strength": { + "name": "Signal strength" + }, "successful_commands": { "name": "Successful commands ({direction})" }, diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 140d584f76f..42e2108be89 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -796,14 +796,14 @@ CONTROLLER_STATISTICS_SUFFIXES = { } # controller statistics with initial state of unknown CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { - "current_background_rssi_channel_0": -1, - "average_background_rssi_channel_0": -2, - "current_background_rssi_channel_1": -3, - "average_background_rssi_channel_1": -4, - "current_background_rssi_channel_2": -5, - "average_background_rssi_channel_2": -6, - "current_background_rssi_channel_3": STATE_UNKNOWN, - "average_background_rssi_channel_3": STATE_UNKNOWN, + "signal_noise_channel_0": -1, + "avg_signal_noise_channel_0": -2, + "signal_noise_channel_1": -3, + "avg_signal_noise_channel_1": -4, + "signal_noise_channel_2": -5, + "avg_signal_noise_channel_2": -6, + "signal_noise_channel_3": STATE_UNKNOWN, + "avg_signal_noise_channel_3": STATE_UNKNOWN, } NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" # node statistics with initial state of 0 @@ -817,7 +817,7 @@ NODE_STATISTICS_SUFFIXES = { # node statistics with initial state of unknown NODE_STATISTICS_SUFFIXES_UNKNOWN = { "round_trip_time": 6, - "rssi": 7, + "signal_strength": 7, } @@ -887,7 +887,7 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert entry.disabled assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION @@ -913,12 +913,12 @@ async def test_statistics_sensors_no_last_seen( ): for suffix_key in suffixes: entry = entity_registry.async_get(f"{prefix}{suffix_key}") - assert entry + assert entry, f"Entity {prefix}{suffix_key} not found" assert not entry.disabled assert entry.disabled_by is None state = hass.states.get(entry.entity_id) - assert state + assert state, f"State for {entry.entity_id} not found" assert state.state == initial_state # Fire statistics updated for controller From fb13c8f4f2754eecfda48cb6df7d3874b016929a Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 17 Jul 2025 19:34:58 +0200 Subject: [PATCH 1506/1664] Update arcam to 1.8.2 (#148956) --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index 41396eca5d6..eb8764e1596 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", "iot_class": "local_polling", "loggers": ["arcam"], - "requirements": ["arcam-fmj==1.8.1"], + "requirements": ["arcam-fmj==1.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 0203edd6aa5..9f33d8a6dc5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -513,7 +513,7 @@ aqualogic==2.6 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.arris_tg2492lg arris-tg2492lg==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc30c59da4e..6ac01ad70b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -483,7 +483,7 @@ apsystems-ez1==2.7.0 aranet4==2.5.1 # homeassistant.components.arcam_fmj -arcam-fmj==1.8.1 +arcam-fmj==1.8.2 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms From 9802441feae751ea958bc2ee55bbd0753d358287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Thu, 17 Jul 2025 18:47:00 +0100 Subject: [PATCH 1507/1664] Bump hass-nabucasa from 0.106.0 to 0.107.1 (#148949) --- homeassistant/components/cloud/client.py | 3 ++- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/components/cloud/strings.json | 4 ++++ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/snapshots/test_http_api.ambr | 2 +- tests/components/cloud/test_http_api.py | 2 +- 10 files changed, 14 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index a857185f07f..e15ea92dece 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -40,10 +40,11 @@ from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) VALID_REPAIR_TRANSLATION_KEYS = { + "connection_error", "no_subscription", - "warn_bad_custom_domain_configuration", "reset_bad_custom_domain_configuration", "subscription_expired", + "warn_bad_custom_domain_configuration", } diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 7c64100873c..642bece1b8e 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.106.0"], + "requirements": ["hass-nabucasa==0.107.1"], "single_config_entry": true } diff --git a/homeassistant/components/cloud/strings.json b/homeassistant/components/cloud/strings.json index e7d219ff69e..193d9e3f948 100644 --- a/homeassistant/components/cloud/strings.json +++ b/homeassistant/components/cloud/strings.json @@ -62,6 +62,10 @@ } } }, + "connection_error": { + "title": "No connection", + "description": "You do not have a connection to Home Assistant Cloud. Check your network." + }, "no_subscription": { "title": "No subscription detected", "description": "You do not have a Home Assistant Cloud subscription. Subscribe at {account_url}." diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f56c44d494a..ecbb7035ea9 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.106.0 +hass-nabucasa==0.107.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250702.2 diff --git a/pyproject.toml b/pyproject.toml index 6946993e6af..3b0994ff2cf 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.106.0", + "hass-nabucasa==0.107.1", # 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 896ff44a3c7..ed9c100fd3a 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.106.0 +hass-nabucasa==0.107.1 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 9f33d8a6dc5..6e1d211b75b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.106.0 +hass-nabucasa==0.107.1 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ac01ad70b0..c420331c46a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.106.0 +hass-nabucasa==0.107.1 # homeassistant.components.assist_satellite # homeassistant.components.conversation diff --git a/tests/components/cloud/snapshots/test_http_api.ambr b/tests/components/cloud/snapshots/test_http_api.ambr index c67691dfa1a..52c544dc541 100644 --- a/tests/components/cloud/snapshots/test_http_api.ambr +++ b/tests/components/cloud/snapshots/test_http_api.ambr @@ -37,7 +37,7 @@ google_enabled | False cloud_ice_servers_enabled | True remote_server | us-west-1 - certificate_status | CertificateStatus.READY + certificate_status | ready instance_id | 12345678901234567890 can_reach_cert_server | Exception: Unexpected exception can_reach_cloud_auth | Failed: unreachable diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 84630bc0320..f125a5cbdae 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1050,7 +1050,7 @@ async def test_websocket_subscription_not_logged_in( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.cloud_api.async_subscription_info", + "hass_nabucasa.payments_api.PaymentsApi.subscription_info", return_value={"return": "value"}, ): await client.send_json({"id": 5, "type": "cloud/subscription"}) From 0d819f2389cdc04fe8cc0f3fd11112d833b3e8a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 20:30:40 +0200 Subject: [PATCH 1508/1664] Refactor WAQI tests (#148968) --- homeassistant/components/waqi/config_flow.py | 90 ++- tests/components/waqi/__init__.py | 12 + tests/components/waqi/conftest.py | 31 +- .../waqi/snapshots/test_sensor.ambr | 666 +++++++++++++++--- tests/components/waqi/test_config_flow.py | 222 ++---- tests/components/waqi/test_init.py | 24 + tests/components/waqi/test_sensor.py | 48 +- 7 files changed, 735 insertions(+), 358 deletions(-) create mode 100644 tests/components/waqi/test_init.py diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index 51ba801c92e..8ed2dcd8425 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -66,24 +66,22 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle the initial step.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(user_input[CONF_API_KEY]) - try: - await waqi_client.get_by_ip() - except WAQIAuthenticationError: - errors["base"] = "invalid_auth" - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - self.data = user_input - if user_input[CONF_METHOD] == CONF_MAP: - return await self.async_step_map() - return await self.async_step_station_number() + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(user_input[CONF_API_KEY]) + try: + await client.get_by_ip() + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", @@ -107,22 +105,20 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via map.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - try: - measuring_station = await waqi_client.get_by_coordinates( - user_input[CONF_LOCATION][CONF_LATITUDE], - user_input[CONF_LOCATION][CONF_LONGITUDE], - ) - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return await self._async_create_entry(measuring_station) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await client.get_by_coordinates( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + ) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_MAP, data_schema=self.add_suggested_values_to_schema( @@ -149,21 +145,19 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Add measuring station via station number.""" errors: dict[str, str] = {} if user_input is not None: - async with WAQIClient( - session=async_get_clientsession(self.hass) - ) as waqi_client: - waqi_client.authenticate(self.data[CONF_API_KEY]) - station_number = user_input[CONF_STATION_NUMBER] - measuring_station, errors = await get_by_station_number( - waqi_client, abs(station_number) + client = WAQIClient(session=async_get_clientsession(self.hass)) + client.authenticate(self.data[CONF_API_KEY]) + station_number = user_input[CONF_STATION_NUMBER] + measuring_station, errors = await get_by_station_number( + client, abs(station_number) + ) + if not measuring_station: + measuring_station, _ = await get_by_station_number( + client, + abs(station_number) - station_number - station_number, ) - if not measuring_station: - measuring_station, _ = await get_by_station_number( - waqi_client, - abs(station_number) - station_number - station_number, - ) - if measuring_station: - return await self._async_create_entry(measuring_station) + if measuring_station: + return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_STATION_NUMBER, data_schema=vol.Schema( diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py index b6f36680ee3..be808875df8 100644 --- a/tests/components/waqi/__init__.py +++ b/tests/components/waqi/__init__.py @@ -1 +1,13 @@ """Tests for the World Air Quality Index (WAQI) 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/waqi/conftest.py b/tests/components/waqi/conftest.py index 75709d4f56e..bb64fdef097 100644 --- a/tests/components/waqi/conftest.py +++ b/tests/components/waqi/conftest.py @@ -1,14 +1,16 @@ """Common fixtures for the World Air Quality Index (WAQI) tests.""" -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from unittest.mock import AsyncMock, patch +from aiowaqi import WAQIAirQuality import pytest from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_load_json_object_fixture @pytest.fixture @@ -29,3 +31,28 @@ def mock_config_entry() -> MockConfigEntry: title="de Jongweg, Utrecht", data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, ) + + +@pytest.fixture +async def mock_waqi(hass: HomeAssistant) -> AsyncGenerator[AsyncMock]: + """Mock WAQI client.""" + with ( + patch( + "homeassistant.components.waqi.WAQIClient", + autospec=True, + ) as mock_waqi, + patch( + "homeassistant.components.waqi.config_flow.WAQIClient", + new=mock_waqi, + ), + ): + client = mock_waqi.return_value + air_quality = WAQIAirQuality.from_dict( + await async_load_json_object_fixture( + hass, "air_quality_sensor.json", DOMAIN + ) + ) + client.get_by_station_number.return_value = air_quality + client.get_by_ip.return_value = air_quality + client.get_by_coordinates.return_value = air_quality + yield client diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 08e58a74524..d0c46346b2e 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -1,5 +1,42 @@ # serializer version: 1 -# name: test_sensor +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-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.de_jongweg_utrecht_air_quality_index', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air quality index', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_air_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_air_quality_index-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -15,39 +52,104 @@ 'state': '29', }) # --- -# name: test_sensor.1 +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-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.de_jongweg_utrecht_carbon_monoxide', + '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': 'Carbon monoxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'carbon_monoxide', + 'unique_id': '4584_carbon_monoxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_carbon_monoxide-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'device_class': 'humidity', - 'friendly_name': 'de Jongweg, Utrecht Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '80', - }) -# --- -# name: test_sensor.10 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', + 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '80', + 'state': '2.3', }) # --- -# name: test_sensor.11 +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'co', + 'no2', + 'o3', + 'so2', + 'pm10', + 'pm25', + 'neph', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.de_jongweg_utrecht_dominant_pollutant', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Dominant pollutant', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dominant_pollutant', + 'unique_id': '4584_dominant_pollutant', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_dominant_pollutant-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -71,7 +173,309 @@ 'state': 'o3', }) # --- -# name: test_sensor.2 +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_humidity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Humidity', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_humidity', + 'unit_of_measurement': '%', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_humidity-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'humidity', + 'friendly_name': 'de Jongweg, Utrecht Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_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.de_jongweg_utrecht_nitrogen_dioxide', + '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': 'Nitrogen dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'nitrogen_dioxide', + 'unique_id': '4584_nitrogen_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_nitrogen_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-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.de_jongweg_utrecht_ozone', + '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': 'Ozone', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ozone', + 'unique_id': '4584_ozone', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_ozone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Ozone', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '29.4', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-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.de_jongweg_utrecht_pm10', + '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': 'PM10', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm10', + 'unique_id': '4584_pm10', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm10-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM10', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_pm2_5', + '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': 'PM2.5', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'pm25', + 'unique_id': '4584_pm25', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pm2_5-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM2.5', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '17', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_pressure', + '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': 'Pressure', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_pressure', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_pressure-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -88,7 +492,99 @@ 'state': '1008.8', }) # --- -# name: test_sensor.3 +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_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.de_jongweg_utrecht_sulphur_dioxide', + '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': 'Sulphur dioxide', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sulphur_dioxide', + 'unique_id': '4584_sulphur_dioxide', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_sulphur_dioxide-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_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.de_jongweg_utrecht_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': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '4584_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_temperature-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', @@ -105,93 +601,55 @@ 'state': '16', }) # --- -# name: test_sensor.4 +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-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.de_jongweg_utrecht_visibility_using_nephelometry', + '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': 'Visibility using nephelometry', + 'platform': 'waqi', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'neph', + 'unique_id': '4584_neph', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensor[sensor.de_jongweg_utrecht_visibility_using_nephelometry-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', + 'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry', 'state_class': , }), 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.5 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.6 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Ozone', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_ozone', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '29.4', - }) -# --- -# name: test_sensor.7 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2.3', - }) -# --- -# name: test_sensor.8 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM10', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm10', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '12', - }) -# --- -# name: test_sensor.9 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', - 'friendly_name': 'de Jongweg, Utrecht PM2.5', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '17', + 'state': '80', }) # --- diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index a3fa47abc67..03759f96ff5 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,15 +1,14 @@ """Test the World Air Quality Index (WAQI) config flow.""" -import json from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +from aiowaqi import WAQIAuthenticationError, WAQIConnectionError import pytest -from homeassistant import config_entries from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -20,10 +19,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import async_load_fixture - -pytestmark = pytest.mark.usefixtures("mock_setup_entry") - @pytest.mark.parametrize( ("method", "payload"), @@ -45,63 +40,28 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") async def test_full_map_flow( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" @@ -109,6 +69,7 @@ async def test_full_map_flow( CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584, } + assert result["result"].unique_id == "4584" assert len(mock_setup_entry.mock_calls) == 1 @@ -121,73 +82,43 @@ async def test_full_map_flow( ], ) async def test_flow_errors( - hass: HomeAssistant, exception: Exception, error: str + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, + exception: Exception, + error: str, ) -> None: """Test we handle errors during configuration.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - side_effect=exception, - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, - ) - await hass.async_block_till_done() + mock_waqi.get_by_ip.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "map" - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - }, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ) assert result["type"] is FlowResultType.CREATE_ENTRY @@ -232,6 +163,7 @@ async def test_flow_errors( async def test_error_in_second_step( hass: HomeAssistant, mock_setup_entry: AsyncMock, + mock_waqi: AsyncMock, method: str, payload: dict[str, Any], exception: Exception, @@ -239,74 +171,36 @@ async def test_error_in_second_step( ) -> None: """Test we get the form.""" 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 - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_API_KEY: "asd", CONF_METHOD: method}, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == method - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch("aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception), - patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = exception + mock_waqi.get_by_station_number.side_effect = exception + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": error} - with ( - patch( - "aiowaqi.WAQIClient.authenticate", - ), - patch( - "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - payload, - ) - await hass.async_block_till_done() + mock_waqi.get_by_coordinates.side_effect = None + mock_waqi.get_by_station_number.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "de Jongweg, Utrecht" diff --git a/tests/components/waqi/test_init.py b/tests/components/waqi/test_init.py new file mode 100644 index 00000000000..7e4487f8ad2 --- /dev/null +++ b/tests/components/waqi/test_init.py @@ -0,0 +1,24 @@ +"""Test the World Air Quality Index (WAQI) initialization.""" + +from unittest.mock import AsyncMock + +from aiowaqi import WAQIError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_failed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_waqi: AsyncMock, +) -> None: + """Test setup failure due to API error.""" + mock_waqi.get_by_station_number.side_effect = WAQIError("API error") + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7cd045604c8..d6e14d2dd54 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -1,59 +1,27 @@ """Test the World Air Quality Index (WAQI) sensor.""" -import json -from unittest.mock import patch +from unittest.mock import AsyncMock -from aiowaqi import WAQIAirQuality, WAQIError import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.waqi.const import DOMAIN -from homeassistant.components.waqi.sensor import SENSORS -from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, async_load_fixture +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry, + mock_waqi: AsyncMock, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion, ) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.from_dict( - json.loads( - await async_load_fixture(hass, "air_quality_sensor.json", DOMAIN) - ) - ), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - for sensor in SENSORS: - entity_id = entity_registry.async_get_entity_id( - SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" - ) - assert hass.states.get(entity_id) == snapshot + """Test the World Air Quality Index (WAQI) sensor.""" + await setup_integration(hass, mock_config_entry) - -async def test_updating_failed( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test failed update.""" - mock_config_entry.add_to_hass(hass) - with patch( - "aiowaqi.WAQIClient.get_by_station_number", - side_effect=WAQIError(), - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From 3b6eb045c67b095eca7cd2ddfeb8e75cee8bd442 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Thu, 17 Jul 2025 21:19:47 +0200 Subject: [PATCH 1509/1664] Bump async-upnp-client to 0.45.0 (#148961) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 119d1d31d52..eac8ddcf713 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 0289d5100d6..4a73bf779e0 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", - "requirements": ["async-upnp-client==0.44.0"], + "requirements": ["async-upnp-client==0.45.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index a2ab8e6e466..1b927757a39 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -40,7 +40,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.7.2", "wakeonlan==3.1.0", - "async-upnp-client==0.44.0" + "async-upnp-client==0.45.0" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 93943b0a9ea..2471e45b4e0 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.44.0"] + "requirements": ["async-upnp-client==0.45.0"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 62ee4ede7d9..825c5774c1d 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"], + "requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 07970cb25ca..d65ebb3a25a 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -16,7 +16,7 @@ }, "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], - "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"], + "requirements": ["yeelight==0.7.16", "async-upnp-client==0.45.0"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ecbb7035ea9..d26705842e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -13,7 +13,7 @@ aiozoneinfo==0.2.3 annotatedyaml==0.4.5 astral==2.2 async-interrupt==1.2.2 -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 atomicwrites-homeassistant==1.4.1 attrs==25.3.0 audioop-lts==0.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 6e1d211b75b..dce942e705c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -527,7 +527,7 @@ asmog==0.0.6 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c420331c46a..b88311f6169 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -491,7 +491,7 @@ arcam-fmj==1.8.2 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.44.0 +async-upnp-client==0.45.0 # homeassistant.components.arve asyncarve==0.1.1 From 29afa891ecfb463e1ca73d14d60ceb3eb9dc6323 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 17 Jul 2025 23:06:47 +0200 Subject: [PATCH 1510/1664] Add YAML and discovery info export feature for MQTT device subentries (#141896) Co-authored-by: Norbert Rittel --- homeassistant/components/mqtt/config_flow.py | 128 ++++++++++++- homeassistant/components/mqtt/entity.py | 76 +++++++- homeassistant/components/mqtt/repairs.py | 74 ++++++++ homeassistant/components/mqtt/strings.json | 59 +++++- tests/components/mqtt/test_config_flow.py | 100 +++++++++++ tests/components/mqtt/test_repairs.py | 179 +++++++++++++++++++ 6 files changed, 608 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/mqtt/repairs.py create mode 100644 tests/components/mqtt/test_repairs.py diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index a3cf2d1d12f..52f00c82c27 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass from enum import IntEnum +import json import logging import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError @@ -24,6 +25,7 @@ from cryptography.hazmat.primitives.serialization import ( ) from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate import voluptuous as vol +import yaml from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.button import ButtonDeviceClass @@ -78,6 +80,7 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_STATE_TEMPLATE, + CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -321,6 +324,10 @@ SET_CLIENT_CERT = "set_client_cert" BOOLEAN_SELECTOR = BooleanSelector() TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) +TEXT_SELECTOR_READ_ONLY = TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, read_only=True) +) +URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL)) PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) PORT_SELECTOR = vol.All( NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)), @@ -400,6 +407,7 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( ) ) TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) +TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True)) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( { @@ -556,6 +564,8 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector( ) ) +EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY} + @callback def validate_cover_platform_config( @@ -3102,8 +3112,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): menu_options.append("delete_entity") menu_options.extend(["device", "availability"]) self._async_update_component_data_defaults() - if self._subentry_data != self._get_reconfigure_subentry().data: - menu_options.append("save_changes") + menu_options.append( + "save_changes" + if self._subentry_data != self._get_reconfigure_subentry().data + else "export" + ) return self.async_show_menu( step_id="summary_menu", menu_options=menu_options, @@ -3145,6 +3158,117 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): title=self._subentry_data[CONF_DEVICE][CONF_NAME], ) + async def async_step_export( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML or discovery payload.""" + return self.async_show_menu( + step_id="export", + menu_options=["export_yaml", "export_discovery"], + ) + + async def async_step_export_yaml( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config as YAML.""" + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + mqtt_yaml_config_base: dict[str, list[dict[str, dict[str, Any]]]] = {DOMAIN: []} + mqtt_yaml_config = mqtt_yaml_config_base[DOMAIN] + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config[CONF_DEVICE] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + platform = component_config.pop(CONF_PLATFORM) + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + mqtt_yaml_config.append({platform: component_config}) + + yaml_config = yaml.dump(mqtt_yaml_config_base) + data_schema = vol.Schema( + { + vol.Optional("yaml"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={"yaml": yaml_config}, + ) + return self.async_show_form( + step_id="export_yaml", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + + async def async_step_export_discovery( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Export the MQTT device config dor MQTT discovery.""" + + if user_input is not None: + return await self.async_step_summary_menu() + + subentry = self._get_reconfigure_subentry() + discovery_topic = f"homeassistant/device/{subentry.subentry_id}/config" + discovery_payload: dict[str, Any] = {} + discovery_payload.update(self._subentry_data.get("availability", {})) + discovery_payload["dev"] = { + key: value + for key, value in self._subentry_data["device"].items() + if key != "mqtt_settings" + } | {"identifiers": [subentry.subentry_id]} + discovery_payload["o"] = {"name": "MQTT subentry export"} + discovery_payload["cmps"] = {} + + for component_id, component_data in self._subentry_data["components"].items(): + component_config: dict[str, Any] = component_data.copy() + component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}" + component_config.update(self._subentry_data.get("availability", {})) + component_config.update( + self._subentry_data["device"].get("mqtt_settings", {}).copy() + ) + for field in EXCLUDE_FROM_CONFIG_IF_NONE: + if field in component_config and component_config[field] is None: + component_config.pop(field) + discovery_payload["cmps"][component_id] = component_config + + data_schema = vol.Schema( + { + vol.Optional("discovery_topic"): TEXT_SELECTOR_READ_ONLY, + vol.Optional("discovery_payload"): TEMPLATE_SELECTOR_READ_ONLY, + } + ) + data_schema = self.add_suggested_values_to_schema( + data_schema=data_schema, + suggested_values={ + "discovery_topic": discovery_topic, + "discovery_payload": json.dumps(discovery_payload, indent=2), + }, + ) + return self.async_show_form( + step_id="export_discovery", + last_step=False, + data_schema=data_schema, + description_placeholders={ + "url": "https://www.home-assistant.io/integrations/mqtt/" + }, + ) + @callback def async_is_pem_data(data: bytes) -> bool: diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index f1594a7b034..f0e7f915551 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -247,6 +247,58 @@ def async_setup_entity_entry_helper( """Set up entity creation dynamically through MQTT discovery.""" mqtt_data = hass.data[DATA_MQTT] + @callback + def _async_migrate_subentry( + config: dict[str, Any], raw_config: dict[str, Any], migration_type: str + ) -> bool: + """Start a repair flow to allow migration of MQTT device subentries. + + If a YAML config or discovery is detected using the ID + of an existing mqtt subentry, and exported configuration is detected, + and a repair flow is offered to migrate the subentry. + """ + if ( + CONF_DEVICE in config + and CONF_IDENTIFIERS in config[CONF_DEVICE] + and config[CONF_DEVICE][CONF_IDENTIFIERS] + and (subentry_id := config[CONF_DEVICE][CONF_IDENTIFIERS][0]) + in entry.subentries + ): + name: str = config[CONF_DEVICE].get(CONF_NAME, "-") + if migration_type == "subentry_migration_yaml": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to YAML config: %s", + subentry_id, + raw_config, + ) + elif migration_type == "subentry_migration_discovery": + _LOGGER.info( + "Starting migration repair flow for MQTT subentry %s " + "for migration to configuration via MQTT discovery: %s", + subentry_id, + raw_config, + ) + async_create_issue( + hass, + DOMAIN, + subentry_id, + issue_domain=DOMAIN, + is_fixable=True, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(domain), + data={ + "entry_id": entry.entry_id, + "subentry_id": subentry_id, + "name": name, + }, + translation_placeholders={"name": name}, + translation_key=migration_type, + ) + return True + + return False + @callback def _async_setup_entity_entry_from_discovery( discovery_payload: MQTTDiscoveryPayload, @@ -263,9 +315,22 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None - async_add_entities( - [entity_class(hass, config, entry, discovery_payload.discovery_data)] - ) + if _async_migrate_subentry( + config, discovery_payload, "subentry_migration_discovery" + ): + _handle_discovery_failure(hass, discovery_payload) + _LOGGER.debug( + "MQTT discovery skipped, as device exists in subentry, " + "and repair flow must be completed first" + ) + else: + async_add_entities( + [ + entity_class( + hass, config, entry, discovery_payload.discovery_data + ) + ] + ) except vol.Invalid as err: _handle_discovery_failure(hass, discovery_payload) async_handle_schema_error(discovery_payload, err) @@ -346,6 +411,11 @@ def async_setup_entity_entry_helper( entity_class = schema_class_mapping[config[CONF_SCHEMA]] if TYPE_CHECKING: assert entity_class is not None + if _async_migrate_subentry( + config, yaml_config, "subentry_migration_yaml" + ): + continue + entities.append(entity_class(hass, config, entry, None)) except vol.Invalid as exc: error = str(exc) diff --git a/homeassistant/components/mqtt/repairs.py b/homeassistant/components/mqtt/repairs.py new file mode 100644 index 00000000000..6a002904f11 --- /dev/null +++ b/homeassistant/components/mqtt/repairs.py @@ -0,0 +1,74 @@ +"""Repairs for MQTT.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.repairs import RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import DOMAIN + + +class MQTTDeviceEntryMigration(RepairsFlow): + """Handler to remove subentry for migrated MQTT device.""" + + def __init__(self, entry_id: str, subentry_id: str, name: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + self.subentry_id = subentry_id + self.name = name + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + if user_input is not None: + device_registry = dr.async_get(self.hass) + subentry_device = device_registry.async_get_device( + identifiers={(DOMAIN, self.subentry_id)} + ) + entry = self.hass.config_entries.async_get_entry(self.entry_id) + if TYPE_CHECKING: + assert entry is not None + assert subentry_device is not None + self.hass.config_entries.async_remove_subentry(entry, self.subentry_id) + return self.async_create_entry(data={}) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders={"name": self.name}, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if TYPE_CHECKING: + assert data is not None + entry_id = data["entry_id"] + subentry_id = data["subentry_id"] + name = data["name"] + if TYPE_CHECKING: + assert isinstance(entry_id, str) + assert isinstance(subentry_id, str) + assert isinstance(name, str) + return MQTTDeviceEntryMigration( + entry_id=entry_id, + subentry_id=subentry_id, + name=name, + ) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 96b5bd15d28..1315463ebcf 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -3,6 +3,28 @@ "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + }, + "subentry_migration_discovery": { + "title": "MQTT device \"{name}\" subentry migration to MQTT discovery", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_discovery::title%]", + "description": "Exported MQTT device \"{name}\" identified via MQTT discovery. Select **Submit** to confirm that the MQTT device is to be migrated to the main MQTT configuration, and to remove the existing MQTT device subentry. Make sure that the discovery is retained at the MQTT broker, or is resent after the subentry is removed, so that the MQTT device will be set up correctly. As an alternative you can change the device identifiers and entity unique ID-s in your MQTT discovery configuration payload, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } + }, + "subentry_migration_yaml": { + "title": "MQTT device \"{name}\" subentry migration to YAML", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::mqtt::issues::subentry_migration_yaml::title%]", + "description": "Exported MQTT device \"{name}\" identified in YAML configuration. Select **Submit** to confirm that the MQTT device is to be migrated to main MQTT config entry, and to remove the existing MQTT device subentry. As an alternative you can change the device identifiers and entity unique ID-s in your configuration.yaml file, and cancel this repair if you want to keep the MQTT device subentry." + } + } + } } }, "config": { @@ -107,10 +129,10 @@ "config_subentries": { "device": { "initiate_flow": { - "user": "Add MQTT Device", - "reconfigure": "Reconfigure MQTT Device" + "user": "Add MQTT device", + "reconfigure": "Reconfigure MQTT device" }, - "entry_type": "MQTT Device", + "entry_type": "MQTT device", "step": { "availability": { "title": "Availability options", @@ -175,6 +197,7 @@ "delete_entity": "Delete an entity", "availability": "Configure availability", "device": "Update device properties", + "export": "Export MQTT device configuration", "save_changes": "Save changes" } }, @@ -627,6 +650,36 @@ } } } + }, + "export": { + "title": "Export MQTT device config", + "description": "An export allows you to migrate the MQTT device configuration to YAML-based configuration or MQTT discovery. The configuration export can also be helpful for troubleshooting.", + "menu_options": { + "export_discovery": "Export MQTT discovery information", + "export_yaml": "Export to YAML configuration" + } + }, + "export_yaml": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "You can copy the configuration below and place it your configuration.yaml file. Home Assistant will detect if the setup of the MQTT device was tried via YAML instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "yaml": "Copy the YAML configuration below:" + }, + "data_description": { + "yaml": "Place YAML configuration in your [configuration.yaml]({url}#yaml-configuration-listed-per-item)." + } + }, + "export_discovery": { + "title": "[%key:component::mqtt::config_subentries::device::step::export::title%]", + "description": "To allow setup via MQTT [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic. Copy the information from the fields below. Home Assistant will detect if the setup of the MQTT device was tried via MQTT discovery instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.", + "data": { + "discovery_topic": "Discovery topic", + "discovery_payload": "Discovery payload:" + }, + "data_description": { + "discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.", + "discovery_payload": "The JSON [discovery payload]({url}#discovery-discovery-payload) that contains information about the MQTT device." + } } }, "abort": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 77c74001939..ce0a0c44a79 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -3344,6 +3344,7 @@ async def test_subentry_reconfigure_remove_entity( "delete_entity", "device", "availability", + "export", ] # assert we can delete an entity @@ -3465,6 +3466,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( "delete_entity", "device", "availability", + "export", ] # assert we can update an entity @@ -3683,6 +3685,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3823,6 +3826,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -3953,6 +3957,7 @@ async def test_subentry_reconfigure_add_entity( "update_entity", "device", "availability", + "export", ] # assert we can update the entity, there is no select step @@ -4058,6 +4063,7 @@ async def test_subentry_reconfigure_update_device_properties( "delete_entity", "device", "availability", + "export", ] # assert we can update the device properties @@ -4214,6 +4220,100 @@ async def test_subentry_reconfigure_availablity( } +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "field_suggestions"), + [ + ("export_yaml", {"yaml": "identifiers:\n - {}\n"}), + ( + "export_discovery", + { + "discovery_topic": "homeassistant/device/{}/config", + "discovery_payload": '"identifiers": [\n "{}"\n', + }, + ), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + flow_step: str, + field_suggestions: dict[str, str], +) -> None: + """Test the subentry ConfigFlow reconfigure export feature.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Assert the export is correct + for field in result["data_schema"].schema: + assert ( + field_suggestions[field].format(subentry_id) + in field.description["suggested_value"] + ) + + # Back to summary menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + async def test_subentry_configflow_section_feature( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_repairs.py b/tests/components/mqtt/test_repairs.py new file mode 100644 index 00000000000..bc7b9dd4294 --- /dev/null +++ b/tests/components/mqtt/test_repairs.py @@ -0,0 +1,179 @@ +"""Test repairs for MQTT.""" + +from collections.abc import Coroutine +from copy import deepcopy +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components import mqtt +from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData +from homeassistant.const import SERVICE_RELOAD +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.util.yaml import parse_yaml + +from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message + +from tests.common import MockConfigEntry, async_capture_events +from tests.components.repairs import ( + async_process_repairs_platforms, + process_repair_fix_flow, + start_repair_fix_flow, +) +from tests.conftest import ClientSessionGenerator +from tests.typing import MqttMockHAClientGenerator + + +async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + with patch( + "homeassistant.config.load_yaml_config_file", + return_value=parse_yaml(config["yaml"]), + ): + await hass.services.async_call( + mqtt.DOMAIN, + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + +async def help_setup_discovery(hass: HomeAssistant, config: dict[str, str]) -> None: + """Help to set up an exported MQTT device via YAML.""" + async_fire_mqtt_message( + hass, config["discovery_topic"], config["discovery_payload"] + ) + await hass.async_block_till_done(wait_background_tasks=True) + + +@pytest.mark.parametrize( + "mqtt_config_subentries_data", + [ + ( + ConfigSubentryData( + data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + subentry_type="device", + title="Mock subentry", + ), + ) + ], +) +@pytest.mark.parametrize( + ("flow_step", "setup_helper", "translation_key"), + [ + ("export_yaml", help_setup_yaml, "subentry_migration_yaml"), + ("export_discovery", help_setup_discovery, "subentry_migration_discovery"), + ], +) +async def test_subentry_reconfigure_export_settings( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + hass_client: ClientSessionGenerator, + flow_step: str, + setup_helper: Coroutine[Any, Any, None], + translation_key: str, +) -> None: + """Test the subentry ConfigFlow YAML export with migration to YAML.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow(hass, subentry_id) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # assert we entity for all subentry components + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 2 + + # assert menu options, we have the option to export + assert result["menu_options"] == [ + "entity", + "update_entity", + "delete_entity", + "device", + "availability", + "export", + ] + + # Open export menu + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "export"}, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "export" + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": flow_step}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == flow_step + assert result["description_placeholders"] == { + "url": "https://www.home-assistant.io/integrations/mqtt/" + } + + # Copy the exported config suggested values for an export + suggested_values_from_schema = { + field: field.description["suggested_value"] + for field in result["data_schema"].schema + } + # Try to set up the exported config with a changed device name + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + + # Assert the subentry device was not effected by the exported configs + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id} + assert device is not None + + # Assert a repair flow was created + # This happens when the exported device identifier was detected + # The subentry ID is used as device identifier + assert len(events) == 1 + issue_id = events[0].data["issue_id"] + issue_registry = ir.async_get(hass) + repair_issue = issue_registry.async_get_issue(mqtt.DOMAIN, issue_id) + assert repair_issue.translation_key == translation_key + + await async_process_repairs_platforms(hass) + client = await hass_client() + + data = await start_repair_fix_flow(client, mqtt.DOMAIN, issue_id) + + flow_id = data["flow_id"] + assert data["description_placeholders"] == {"name": "Milk notifier"} + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, flow_id) + assert data["type"] == "create_entry" + + # Assert the subentry is removed and no other entity has linked the device + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is None + + await hass.async_block_till_done(wait_background_tasks=True) + assert len(config_entry.subentries) == 0 + + # Try to set up the exported config again + events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) + await setup_helper(hass, suggested_values_from_schema) + assert len(events) == 0 + + # The MQTT device was now set up from the new source + await hass.async_block_till_done(wait_background_tasks=True) + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device.config_entries_subentries[config_entry.entry_id] == {None} + assert device is not None From c0744537635901c8802a5fc6137e0337d9de8ad9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:13:46 +0200 Subject: [PATCH 1511/1664] Remove obsolete variables in WAQI (#148975) --- homeassistant/components/waqi/sensor.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 59daf60392e..7f249b059a3 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -import logging from aiowaqi import WAQIAirQuality from aiowaqi.models import Pollutant @@ -26,17 +25,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import WAQIDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) - -ATTR_DOMINENTPOL = "dominentpol" -ATTR_HUMIDITY = "humidity" -ATTR_NITROGEN_DIOXIDE = "nitrogen_dioxide" -ATTR_OZONE = "ozone" -ATTR_PM10 = "pm_10" -ATTR_PM2_5 = "pm_2_5" -ATTR_PRESSURE = "pressure" -ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" - @dataclass(frozen=True, kw_only=True) class WAQISensorEntityDescription(SensorEntityDescription): From aacaa9a20f6d97bcd64d8e9e0a44b75cd7e38cb1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:14:19 +0200 Subject: [PATCH 1512/1664] Pass Syncthru entry to coordinator (#148974) --- homeassistant/components/syncthru/coordinator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py index 0b96b354436..27239a5a520 100644 --- a/homeassistant/components/syncthru/coordinator.py +++ b/homeassistant/components/syncthru/coordinator.py @@ -28,6 +28,7 @@ class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]): hass, _LOGGER, name=DOMAIN, + config_entry=entry, update_interval=timedelta(seconds=30), ) self.syncthru = SyncThru( From 3c87a3e892511d31da99e4c38d26c9bb9befbc34 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Thu, 17 Jul 2025 17:19:45 -0400 Subject: [PATCH 1513/1664] Add a preview to template config flow for alarm control panel, image, and select platforms (#148441) --- .../template/alarm_control_panel.py | 12 ++++++++++++ .../components/template/config_flow.py | 19 ++++++++++++++----- homeassistant/components/template/select.py | 9 +++++++++ .../template/test_alarm_control_panel.py | 19 ++++++++++++++++++- tests/components/template/test_select.py | 19 ++++++++++++++++++- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 97896e08a68..cd70a7d44e0 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -206,6 +206,18 @@ async def async_setup_platform( ) +@callback +def async_create_preview_alarm_control_panel( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> StateAlarmControlPanelEntity: + """Create a preview alarm control panel.""" + updated_config = rewrite_options_to_modern_conf(config) + validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA( + updated_config | {CONF_NAME: name} + ) + return StateAlarmControlPanelEntity(hass, validated_config, None) + + class AbstractTemplateAlarmControlPanel( AbstractTemplateEntity, AlarmControlPanelEntity, RestoreEntity ): diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index e6cc377bc26..d6fc5768f81 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -50,6 +50,7 @@ from .alarm_control_panel import ( CONF_DISARM_ACTION, CONF_TRIGGER_ACTION, TemplateCodeFormat, + 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 @@ -63,7 +64,7 @@ from .number import ( DEFAULT_STEP, async_create_preview_number, ) -from .select import CONF_OPTIONS, CONF_SELECT_OPTION +from .select import CONF_OPTIONS, CONF_SELECT_OPTION, async_create_preview_select from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch from .template_entity import TemplateEntity @@ -319,6 +320,7 @@ CONFIG_FLOW = { "user": SchemaFlowMenuStep(TEMPLATE_TYPES), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( config_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -332,6 +334,7 @@ CONFIG_FLOW = { ), Platform.IMAGE: SchemaFlowFormStep( config_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), Platform.NUMBER: SchemaFlowFormStep( @@ -341,6 +344,7 @@ CONFIG_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( config_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -360,6 +364,7 @@ OPTIONS_FLOW = { "init": SchemaFlowFormStep(next_step=choose_options_step), Platform.ALARM_CONTROL_PANEL: SchemaFlowFormStep( options_schema(Platform.ALARM_CONTROL_PANEL), + preview="template", validate_user_input=validate_user_input(Platform.ALARM_CONTROL_PANEL), ), Platform.BINARY_SENSOR: SchemaFlowFormStep( @@ -373,6 +378,7 @@ OPTIONS_FLOW = { ), Platform.IMAGE: SchemaFlowFormStep( options_schema(Platform.IMAGE), + preview="template", validate_user_input=validate_user_input(Platform.IMAGE), ), Platform.NUMBER: SchemaFlowFormStep( @@ -382,6 +388,7 @@ OPTIONS_FLOW = { ), Platform.SELECT: SchemaFlowFormStep( options_schema(Platform.SELECT), + preview="template", validate_user_input=validate_user_input(Platform.SELECT), ), Platform.SENSOR: SchemaFlowFormStep( @@ -400,10 +407,12 @@ CREATE_PREVIEW_ENTITY: dict[ str, Callable[[HomeAssistant, str, dict[str, Any]], TemplateEntity], ] = { - "binary_sensor": async_create_preview_binary_sensor, - "number": async_create_preview_number, - "sensor": async_create_preview_sensor, - "switch": async_create_preview_switch, + Platform.ALARM_CONTROL_PANEL: async_create_preview_alarm_control_panel, + Platform.BINARY_SENSOR: async_create_preview_binary_sensor, + Platform.NUMBER: async_create_preview_number, + Platform.SELECT: async_create_preview_select, + Platform.SENSOR: async_create_preview_sensor, + Platform.SWITCH: async_create_preview_switch, } diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index d5abf7033a9..4273af6db28 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -90,6 +90,15 @@ async def async_setup_entry( async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) +@callback +def async_create_preview_select( + hass: HomeAssistant, name: str, config: dict[str, Any] +) -> TemplateSelect: + """Create a preview select.""" + validated_config = SELECT_CONFIG_SCHEMA(config | {CONF_NAME: name}) + return TemplateSelect(hass, validated_config, None) + + class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): """Representation of a template select features.""" diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py index 1984b4ea2af..06d678edcab 100644 --- a/tests/components/template/test_alarm_control_panel.py +++ b/tests/components/template/test_alarm_control_panel.py @@ -23,9 +23,10 @@ from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, mock_restore_cache +from tests.conftest import WebSocketGenerator TEST_OBJECT_ID = "test_template_panel" TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}" @@ -915,3 +916,19 @@ async def test_device_id( template_entity = entity_registry.async_get("alarm_control_panel.my_template") assert template_entity is not None assert template_entity.device_id == device_entry.id + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + ALARM_DOMAIN, + {"name": "My template", "state": "{{ 'disarmed' }}"}, + ) + + assert state["state"] == AlarmControlPanelState.DISARMED diff --git a/tests/components/template/test_select.py b/tests/components/template/test_select.py index 6971d41750d..f613fa865a6 100644 --- a/tests/components/template/test_select.py +++ b/tests/components/template/test_select.py @@ -35,9 +35,10 @@ from homeassistant.core import Context, HomeAssistant, ServiceCall from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ConfigurationStyle +from .conftest import ConfigurationStyle, async_get_flow_preview_state from tests.common import MockConfigEntry, assert_setup_component, async_capture_events +from tests.conftest import WebSocketGenerator _TEST_OBJECT_ID = "template_select" _TEST_SELECT = f"select.{_TEST_OBJECT_ID}" @@ -645,3 +646,19 @@ async def test_availability(hass: HomeAssistant) -> None: state = hass.states.get(_TEST_SELECT) assert state.state == "yes" + + +async def test_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test the config flow preview.""" + + state = await async_get_flow_preview_state( + hass, + hass_ws_client, + select.DOMAIN, + {"name": "My template", **TEST_OPTIONS}, + ) + + assert state["state"] == "test" From 37a154b1dfb7d78f890e371868853cdd46339058 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:22:30 +0200 Subject: [PATCH 1514/1664] Migrate WAQI to runtime data (#148977) --- homeassistant/components/waqi/__init__.py | 15 +++++---------- homeassistant/components/waqi/coordinator.py | 6 ++++-- homeassistant/components/waqi/sensor.py | 7 +++---- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 9821b5435d9..7b1243ed905 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -4,18 +4,16 @@ from __future__ import annotations from aiowaqi import WAQIClient -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" client = WAQIClient(session=async_get_clientsession(hass)) @@ -23,16 +21,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client) await waqi_coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator + entry.runtime_data = waqi_coordinator 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, entry: WAQIConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py index 86f553a86cd..f40df4a1b89 100644 --- a/homeassistant/components/waqi/coordinator.py +++ b/homeassistant/components/waqi/coordinator.py @@ -12,14 +12,16 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER +type WAQIConfigEntry = ConfigEntry[WAQIDataUpdateCoordinator] + class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): """The WAQI Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: WAQIConfigEntry def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, client: WAQIClient + self, hass: HomeAssistant, config_entry: WAQIConfigEntry, client: WAQIClient ) -> None: """Initialize the WAQI data coordinator.""" super().__init__( diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 7f249b059a3..c887d893c08 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -23,7 +22,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import WAQIDataUpdateCoordinator +from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator @dataclass(frozen=True, kw_only=True) @@ -127,11 +126,11 @@ SENSORS: list[WAQISensorEntityDescription] = [ async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WAQIConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up the WAQI sensor.""" - coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( WaqiSensor(coordinator, sensor) for sensor in SENSORS From 0ff0902ccf00c46c9df09aae6c7ccc296b6156b1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 17 Jul 2025 23:36:18 +0200 Subject: [PATCH 1515/1664] Add icons to WAQI (#148976) --- homeassistant/components/waqi/icons.json | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 homeassistant/components/waqi/icons.json diff --git a/homeassistant/components/waqi/icons.json b/homeassistant/components/waqi/icons.json new file mode 100644 index 00000000000..545e49fd54e --- /dev/null +++ b/homeassistant/components/waqi/icons.json @@ -0,0 +1,39 @@ +{ + "entity": { + "sensor": { + "carbon_monoxide": { + "default": "mdi:molecule-co" + }, + "nitrogen_dioxide": { + "default": "mdi:molecule" + }, + "ozone": { + "default": "mdi:molecule" + }, + "sulphur_dioxide": { + "default": "mdi:molecule" + }, + "pm10": { + "default": "mdi:molecule" + }, + "pm25": { + "default": "mdi:molecule" + }, + "neph": { + "default": "mdi:eye" + }, + "dominant_pollutant": { + "default": "mdi:molecule", + "state": { + "co": "mdi:molecule-co", + "neph": "mdi:eye", + "no2": "mdi:molecule", + "o3": "mdi:molecule", + "so2": "mdi:molecule", + "pm10": "mdi:molecule", + "pm25": "mdi:molecule" + } + } + } + } +} From 6b959f42f61a8b005b3340adbae47f7a90101eae Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Fri, 18 Jul 2025 00:06:51 +0200 Subject: [PATCH 1516/1664] Introduce base entity for supporting multiple platforms in Huum (#148957) --- homeassistant/components/huum/climate.py | 13 ++----------- homeassistant/components/huum/entity.py | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/huum/entity.py diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py index c82fd2c91a5..6a50137f0a7 100644 --- a/homeassistant/components/huum/climate.py +++ b/homeassistant/components/huum/climate.py @@ -16,12 +16,10 @@ from homeassistant.components.climate import ( from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity _LOGGER = logging.getLogger(__name__) @@ -35,7 +33,7 @@ async def async_setup_entry( async_add_entities([HuumDevice(entry.runtime_data)]) -class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): +class HuumDevice(HuumBaseEntity, ClimateEntity): """Representation of a heater.""" _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] @@ -46,7 +44,6 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): ) _attr_target_temperature_step = PRECISION_WHOLE _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True _attr_name = None def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: @@ -54,12 +51,6 @@ class HuumDevice(CoordinatorEntity[HuumDataUpdateCoordinator], ClimateEntity): super().__init__(coordinator) self._attr_unique_id = coordinator.config_entry.entry_id - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - name="Huum sauna", - manufacturer="Huum", - model="UKU WiFi", - ) @property def min_temp(self) -> int: diff --git a/homeassistant/components/huum/entity.py b/homeassistant/components/huum/entity.py new file mode 100644 index 00000000000..cd30119f6fe --- /dev/null +++ b/homeassistant/components/huum/entity.py @@ -0,0 +1,24 @@ +"""Define Huum Base entity.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import HuumDataUpdateCoordinator + + +class HuumBaseEntity(CoordinatorEntity[HuumDataUpdateCoordinator]): + """Huum base Entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + name="Huum sauna", + manufacturer="Huum", + model="UKU WiFi", + ) From 073ea813f0a36da5a82180e4a21c24f2a262749a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 18 Jul 2025 00:08:45 +0200 Subject: [PATCH 1517/1664] Update aioairzone-cloud to v0.6.15 (#148947) --- 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 3a494aa361e..8694d3d06d9 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.14"] + "requirements": ["aioairzone-cloud==0.6.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index dce942e705c..85da7a1f7b1 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.14 +aioairzone-cloud==0.6.15 # homeassistant.components.airzone aioairzone==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b88311f6169..5377eb55c3a 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.14 +aioairzone-cloud==0.6.15 # homeassistant.components.airzone aioairzone==1.0.0 From 50688bbd69cfe8d9e373ccaba8aa305dd95932f9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 18 Jul 2025 05:49:27 +0200 Subject: [PATCH 1518/1664] Add support for calling tools in Open Router (#148881) --- .../components/open_router/config_flow.py | 30 +++- homeassistant/components/open_router/const.py | 12 ++ .../components/open_router/conversation.py | 142 +++++++++++++++--- .../components/open_router/strings.json | 8 +- tests/components/open_router/conftest.py | 30 +++- .../snapshots/test_conversation.ambr | 140 +++++++++++++++++ .../open_router/test_config_flow.py | 66 ++++++-- .../open_router/test_conversation.py | 120 ++++++++++++++- 8 files changed, 497 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/open_router/config_flow.py b/homeassistant/components/open_router/config_flow.py index 48d37d79cc6..e228492e3a1 100644 --- a/homeassistant/components/open_router/config_flow.py +++ b/homeassistant/components/open_router/config_flow.py @@ -16,8 +16,9 @@ from homeassistant.config_entries import ( ConfigSubentryFlow, SubentryFlowResult, ) -from homeassistant.const import CONF_API_KEY, CONF_MODEL +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 ( @@ -25,9 +26,10 @@ from homeassistant.helpers.selector import ( SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TemplateSelector, ) -from .const import DOMAIN +from .const import CONF_PROMPT, DOMAIN, RECOMMENDED_CONVERSATION_OPTIONS _LOGGER = logging.getLogger(__name__) @@ -90,6 +92,8 @@ class ConversationFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """User flow to create a sensor subentry.""" if user_input is not None: + 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 ) @@ -99,11 +103,17 @@ class ConversationFlowHandler(ConfigSubentryFlow): api_key=entry.data[CONF_API_KEY], http_client=get_async_client(self.hass), ) + hass_apis: list[SelectOptionDict] = [ + SelectOptionDict( + label=api.name, + value=api.id, + ) + 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( @@ -113,6 +123,20 @@ class ConversationFlowHandler(ConfigSubentryFlow): options=options, mode=SelectSelectorMode.DROPDOWN, sort=True ), ), + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": RECOMMENDED_CONVERSATION_OPTIONS[ + CONF_PROMPT + ] + }, + ): TemplateSelector(), + vol.Optional( + CONF_LLM_HASS_API, + default=RECOMMENDED_CONVERSATION_OPTIONS[CONF_LLM_HASS_API], + ): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), } ), ) diff --git a/homeassistant/components/open_router/const.py b/homeassistant/components/open_router/const.py index e357f28d6d5..9fbce10da4e 100644 --- a/homeassistant/components/open_router/const.py +++ b/homeassistant/components/open_router/const.py @@ -2,5 +2,17 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "open_router" LOGGER = logging.getLogger(__package__) + +CONF_PROMPT = "prompt" +CONF_RECOMMENDED = "recommended" + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} diff --git a/homeassistant/components/open_router/conversation.py b/homeassistant/components/open_router/conversation.py index efc98835982..06196565aad 100644 --- a/homeassistant/components/open_router/conversation.py +++ b/homeassistant/components/open_router/conversation.py @@ -1,25 +1,39 @@ """Conversation support for OpenRouter.""" -from typing import Literal +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_LLM_HASS_API, CONF_MODEL, 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 DOMAIN, LOGGER +from .const import CONF_PROMPT, DOMAIN, LOGGER + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( @@ -35,13 +49,31 @@ 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 None + 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: @@ -51,13 +83,55 @@ def _convert_content_to_chat_message( return ChatCompletionUserMessageParam(role="user", content=content.content) if role == "assistant": - return ChatCompletionAssistantMessageParam( - role="assistant", content=content.content + 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): """OpenRouter conversation agent.""" @@ -75,6 +149,10 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): name=subentry.title, entry_type=DeviceEntryType.SERVICE, ) + if self.subentry.data.get(CONF_LLM_HASS_API): + self._attr_supported_features = ( + conversation.ConversationEntityFeature.CONTROL + ) @property def supported_languages(self) -> list[str] | Literal["*"]: @@ -93,12 +171,19 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): await chat_log.async_provide_llm_data( user_input.as_llm_context(DOMAIN), options.get(CONF_LLM_HASS_API), - None, + options.get(CONF_PROMPT), user_input.extra_system_prompt, ) 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 @@ -107,27 +192,34 @@ class OpenRouterConversationEntity(conversation.ConversationEntity): client = self.entry.runtime_data - try: - result = await client.chat.completions.create( - model=self.model, - messages=messages, - 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 + 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 + result_message = result.choices[0].message - chat_log.async_add_assistant_content_without_tools( - conversation.AssistantContent( - agent_id=user_input.agent_id, - content=result_message.content, + 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 return conversation.async_get_result_from_chat_log(user_input, chat_log) diff --git a/homeassistant/components/open_router/strings.json b/homeassistant/components/open_router/strings.json index 93936b4d92b..6e6674dac06 100644 --- a/homeassistant/components/open_router/strings.json +++ b/homeassistant/components/open_router/strings.json @@ -24,7 +24,13 @@ "user": { "description": "Configure the new conversation agent", "data": { - "model": "Model" + "model": "Model", + "prompt": "Instructions", + "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" + }, + "data_description": { + "model": "The model to use for the conversation agent", + "prompt": "Instruct how the LLM should respond. This can be a template." } } }, diff --git a/tests/components/open_router/conftest.py b/tests/components/open_router/conftest.py index e2e0fbb2c37..ca679c2ebef 100644 --- a/tests/components/open_router/conftest.py +++ b/tests/components/open_router/conftest.py @@ -2,6 +2,7 @@ from collections.abc import AsyncGenerator, Generator from dataclasses import dataclass +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from openai.types import CompletionUsage @@ -9,10 +10,11 @@ from openai.types.chat import ChatCompletion, ChatCompletionMessage from openai.types.chat.chat_completion import Choice import pytest -from homeassistant.components.open_router.const import DOMAIN +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN from homeassistant.config_entries import ConfigSubentryData -from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant +from homeassistant.helpers import llm from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -29,7 +31,27 @@ def mock_setup_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: +def enable_assist() -> bool: + """Mock conversation subentry data.""" + return False + + +@pytest.fixture +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_PROMPT: "You are a helpful assistant.", + } + if enable_assist: + res[CONF_LLM_HASS_API] = [llm.LLM_API_ASSIST] + return res + + +@pytest.fixture +def mock_config_entry( + hass: HomeAssistant, conversation_subentry_data: dict[str, Any] +) -> MockConfigEntry: """Mock a config entry.""" return MockConfigEntry( title="OpenRouter", @@ -39,7 +61,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: }, subentries_data=[ ConfigSubentryData( - data={CONF_MODEL: "gpt-3.5-turbo"}, + data=conversation_subentry_data, subentry_id="ABCDEF", subentry_type="conversation", title="GPT-3.5 Turbo", diff --git a/tests/components/open_router/snapshots/test_conversation.ambr b/tests/components/open_router/snapshots/test_conversation.ambr index 90f9097e854..d119c2f6aa5 100644 --- a/tests/components/open_router/snapshots/test_conversation.ambr +++ b/tests/components/open_router/snapshots/test_conversation.ambr @@ -1,4 +1,108 @@ # serializer version: 1 +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'conversation', + 'entity_category': None, + 'entity_id': 'conversation.gpt_3_5_turbo', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'conversation': dict({ + 'should_expose': False, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'open_router', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEF', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[no_assist][conversation.gpt_3_5_turbo-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'GPT-3.5 Turbo', + 'supported_features': , + }), + 'context': , + 'entity_id': 'conversation.gpt_3_5_turbo', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_default_prompt list([ dict({ @@ -14,3 +118,39 @@ }), ]) # --- +# name: test_function_call[True] + list([ + dict({ + 'attachments': None, + 'content': 'Please call the test function', + 'role': 'user', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': None, + 'role': 'assistant', + 'tool_calls': list([ + dict({ + 'id': 'call_call_1', + 'tool_args': dict({ + 'param1': 'call1', + }), + 'tool_name': 'test_tool', + }), + ]), + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'role': 'tool_result', + 'tool_call_id': 'call_call_1', + 'tool_name': 'test_tool', + 'tool_result': 'value1', + }), + dict({ + 'agent_id': 'conversation.gpt_3_5_turbo', + 'content': 'I have successfully called the function', + 'role': 'assistant', + 'tool_calls': None, + }), + ]) +# --- diff --git a/tests/components/open_router/test_config_flow.py b/tests/components/open_router/test_config_flow.py index 6be258dca38..5e7a67d4a2b 100644 --- a/tests/components/open_router/test_config_flow.py +++ b/tests/components/open_router/test_config_flow.py @@ -5,9 +5,9 @@ from unittest.mock import AsyncMock import pytest from python_open_router import OpenRouterError -from homeassistant.components.open_router.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigSubentry -from homeassistant.const import CONF_API_KEY, CONF_MODEL +from homeassistant.components.open_router.const import CONF_PROMPT, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_MODEL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -129,18 +129,56 @@ async def test_create_conversation_agent( result = await hass.config_entries.subentries.async_configure( result["flow_id"], - {CONF_MODEL: "gpt-3.5-turbo"}, + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + }, ) assert result["type"] is FlowResultType.CREATE_ENTRY - subentry_id = list(mock_config_entry.subentries)[0] - assert ( - ConfigSubentry( - data={CONF_MODEL: "gpt-3.5-turbo"}, - subentry_id=subentry_id, - subentry_type="conversation", - title="GPT-3.5 Turbo", - unique_id=None, - ) - in mock_config_entry.subentries.values() + assert result["data"] == { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: ["assist"], + } + + +async def test_create_conversation_agent_no_control( + hass: HomeAssistant, + mock_open_router_client: AsyncMock, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating a conversation agent without control over the LLM API.""" + + mock_config_entry.add_to_hass(hass) + + await setup_integration(hass, mock_config_entry) + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "conversation"), + context={"source": SOURCE_USER}, ) + assert result["type"] is FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "user" + + assert result["data_schema"].schema["model"].config["options"] == [ + {"value": "gpt-3.5-turbo", "label": "GPT-3.5 Turbo"}, + ] + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_MODEL: "gpt-3.5-turbo", + CONF_PROMPT: "you are an assistant", + CONF_LLM_HASS_API: [], + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_MODEL: "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 043dae2ff30..84742191efd 100644 --- a/tests/components/open_router/test_conversation.py +++ b/tests/components/open_router/test_conversation.py @@ -3,16 +3,24 @@ from unittest.mock import AsyncMock from freezegun import freeze_time +from openai.types import CompletionUsage +from openai.types.chat import ( + ChatCompletion, + ChatCompletionMessage, + ChatCompletionMessageToolCall, +) +from openai.types.chat.chat_completion import Choice +from openai.types.chat.chat_completion_message_tool_call import Function import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import conversation from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import area_registry as ar, device_registry as dr, intent +from homeassistant.helpers import entity_registry as er, intent from . import setup_integration -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, snapshot_platform from tests.components.conversation import MockChatLog, mock_chat_log # noqa: F401 @@ -23,11 +31,23 @@ def freeze_the_time(): yield +@pytest.mark.parametrize("enable_assist", [True, False], ids=["assist", "no_assist"]) +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + async def test_default_prompt( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, mock_openai_client: AsyncMock, mock_chat_log: MockChatLog, # noqa: F811 @@ -50,3 +70,95 @@ async def test_default_prompt( "HTTP-Referer": "https://www.home-assistant.io/integrations/open_router", "X-Title": "Home Assistant", } + + +@pytest.mark.parametrize("enable_assist", [True]) +async def test_function_call( + hass: HomeAssistant, + mock_chat_log: MockChatLog, # noqa: F811 + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + mock_openai_client: AsyncMock, +) -> None: + """Test function call from the assistant.""" + await setup_integration(hass, mock_config_entry) + + mock_chat_log.mock_tool_results( + { + "call_call_1": "value1", + "call_call_2": "value2", + } + ) + + async def completion_result(*args, messages, **kwargs): + for message in messages: + role = message["role"] if isinstance(message, dict) else message.role + if role == "tool": + return ChatCompletion( + id="chatcmpl-1234567890ZYXWVUTSRQPONMLKJIH", + choices=[ + Choice( + finish_reason="stop", + index=0, + message=ChatCompletionMessage( + content="I have successfully called the function", + role="assistant", + function_call=None, + tool_calls=None, + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + return ChatCompletion( + id="chatcmpl-1234567890ABCDEFGHIJKLMNOPQRS", + choices=[ + Choice( + finish_reason="tool_calls", + index=0, + message=ChatCompletionMessage( + content=None, + role="assistant", + function_call=None, + tool_calls=[ + ChatCompletionMessageToolCall( + id="call_call_1", + function=Function( + arguments='{"param1":"call1"}', + name="test_tool", + ), + type="function", + ) + ], + ), + ) + ], + created=1700000000, + model="gpt-4-1106-preview", + object="chat.completion", + system_fingerprint=None, + usage=CompletionUsage( + completion_tokens=9, prompt_tokens=8, total_tokens=17 + ), + ) + + mock_openai_client.chat.completions.create = completion_result + + result = await conversation.async_converse( + hass, + "Please call the test function", + mock_chat_log.conversation_id, + Context(), + agent_id="conversation.gpt_3_5_turbo", + ) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + # Don't test the prompt, as it's not deterministic + assert mock_chat_log.content[1:] == snapshot From 414057d455a48fcebbbeadce16a5ecc45bf82bb9 Mon Sep 17 00:00:00 2001 From: Manu <4445816+tr4nt0r@users.noreply.github.com> Date: Fri, 18 Jul 2025 08:33:30 +0200 Subject: [PATCH 1519/1664] Add image platform to PlayStation Network (#148928) --- .../playstation_network/__init__.py | 1 + .../components/playstation_network/helpers.py | 6 +- .../components/playstation_network/icons.json | 8 ++ .../components/playstation_network/image.py | 105 ++++++++++++++++++ .../playstation_network/strings.json | 8 ++ .../playstation_network/conftest.py | 3 + .../snapshots/test_diagnostics.ambr | 3 + .../playstation_network/test_image.py | 96 ++++++++++++++++ 8 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/playstation_network/image.py create mode 100644 tests/components/playstation_network/test_image.py diff --git a/homeassistant/components/playstation_network/__init__.py b/homeassistant/components/playstation_network/__init__.py index e5b98d00726..be0eae961e0 100644 --- a/homeassistant/components/playstation_network/__init__.py +++ b/homeassistant/components/playstation_network/__init__.py @@ -16,6 +16,7 @@ from .helpers import PlaystationNetwork PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, + Platform.IMAGE, Platform.MEDIA_PLAYER, Platform.SENSOR, ] diff --git a/homeassistant/components/playstation_network/helpers.py b/homeassistant/components/playstation_network/helpers.py index debe7a338e2..f7f6143e94f 100644 --- a/homeassistant/components/playstation_network/helpers.py +++ b/homeassistant/components/playstation_network/helpers.py @@ -43,11 +43,14 @@ class PlaystationNetworkData: registered_platforms: set[PlatformType] = field(default_factory=set) trophy_summary: TrophySummary | None = None profile: dict[str, Any] = field(default_factory=dict) + shareable_profile_link: dict[str, str] = field(default_factory=dict) class PlaystationNetwork: """Helper Class to return playstation network data in an easy to use structure.""" + shareable_profile_link: dict[str, str] + def __init__(self, hass: HomeAssistant, npsso: str) -> None: """Initialize the class with the npsso token.""" rate = Rate(300, Duration.MINUTE * 15) @@ -63,6 +66,7 @@ class PlaystationNetwork: """Setup PSN.""" 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()) async def async_setup(self) -> None: @@ -100,7 +104,7 @@ class PlaystationNetwork: data = await self.hass.async_add_executor_job(self.retrieve_psn_data) data.username = self.user.online_id data.account_id = self.user.account_id - + data.shareable_profile_link = self.shareable_profile_link data.availability = data.presence["basicPresence"]["availability"] session = SessionData() diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index 2742ab1c989..2ea09823ca4 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -43,6 +43,14 @@ "offline": "mdi:account-off-outline" } } + }, + "image": { + "share_profile": { + "default": "mdi:share-variant" + }, + "avatar": { + "default": "mdi:account-circle" + } } } } diff --git a/homeassistant/components/playstation_network/image.py b/homeassistant/components/playstation_network/image.py new file mode 100644 index 00000000000..8f9d19e3a55 --- /dev/null +++ b/homeassistant/components/playstation_network/image.py @@ -0,0 +1,105 @@ +"""Image platform for PlayStation Network.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum + +from homeassistant.components.image import ImageEntity, ImageEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util import dt as dt_util + +from .coordinator import ( + PlaystationNetworkConfigEntry, + PlaystationNetworkData, + PlaystationNetworkUserDataCoordinator, +) +from .entity import PlaystationNetworkServiceEntity + +PARALLEL_UPDATES = 0 + + +class PlaystationNetworkImage(StrEnum): + """PlayStation Network images.""" + + AVATAR = "avatar" + SHARE_PROFILE = "share_profile" + + +@dataclass(kw_only=True, frozen=True) +class PlaystationNetworkImageEntityDescription(ImageEntityDescription): + """Image entity description.""" + + image_url_fn: Callable[[PlaystationNetworkData], str | None] + + +IMAGE_DESCRIPTIONS: tuple[PlaystationNetworkImageEntityDescription, ...] = ( + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.SHARE_PROFILE, + translation_key=PlaystationNetworkImage.SHARE_PROFILE, + image_url_fn=lambda data: data.shareable_profile_link["shareImageUrl"], + ), + PlaystationNetworkImageEntityDescription( + key=PlaystationNetworkImage.AVATAR, + translation_key=PlaystationNetworkImage.AVATAR, + image_url_fn=( + lambda data: next( + ( + pic.get("url") + for pic in data.profile["avatars"] + if pic.get("size") == "xl" + ), + None, + ) + ), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PlaystationNetworkConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up image platform.""" + + coordinator = config_entry.runtime_data.user_data + + async_add_entities( + [ + PlaystationNetworkImageEntity(hass, coordinator, description) + for description in IMAGE_DESCRIPTIONS + ] + ) + + +class PlaystationNetworkImageEntity(PlaystationNetworkServiceEntity, ImageEntity): + """An image entity.""" + + entity_description: PlaystationNetworkImageEntityDescription + + def __init__( + self, + hass: HomeAssistant, + coordinator: PlaystationNetworkUserDataCoordinator, + entity_description: PlaystationNetworkImageEntityDescription, + ) -> None: + """Initialize the image entity.""" + super().__init__(coordinator, entity_description) + ImageEntity.__init__(self, hass) + + self._attr_image_url = self.entity_description.image_url_fn(coordinator.data) + self._attr_image_last_updated = dt_util.utcnow() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + url = self.entity_description.image_url_fn(self.coordinator.data) + + if url != self._attr_image_url: + self._attr_image_url = url + self._cached_image = None + self._attr_image_last_updated = dt_util.utcnow() + + super()._handle_coordinator_update() diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index 360687f97c8..aaefdf51506 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -96,6 +96,14 @@ "busy": "Away" } } + }, + "image": { + "share_profile": { + "name": "Share profile" + }, + "avatar": { + "name": "Avatar" + } } } } diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 5f6f3436699..77ec2377932 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -156,6 +156,9 @@ 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" + } yield client diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index ebf8d9e927f..0b7aa63fc03 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -71,6 +71,9 @@ 'PS5', 'PSVITA', ]), + 'shareable_profile_link': dict({ + 'shareImageUrl': 'https://xxxxx.cloudfront.net/profile-testuser?Expires=1753304493', + }), 'trophy_summary': dict({ 'account_id': '**REDACTED**', 'earned_trophies': dict({ diff --git a/tests/components/playstation_network/test_image.py b/tests/components/playstation_network/test_image.py new file mode 100644 index 00000000000..0dc52646d9e --- /dev/null +++ b/tests/components/playstation_network/test_image.py @@ -0,0 +1,96 @@ +"""Test the PlayStation Network image platform.""" + +from collections.abc import Generator +from datetime import timedelta +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +import respx + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def image_only() -> Generator[None]: + """Enable only the image platform.""" + with patch( + "homeassistant.components.playstation_network.PLATFORMS", + [Platform.IMAGE], + ): + yield + + +@respx.mock +@pytest.mark.usefixtures("mock_psnawpapi") +async def test_image_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, + mock_psnawpapi: MagicMock, +) -> None: + """Test image platform.""" + freezer.move_to("2025-06-16T00:00:00-00:00") + + respx.get( + "http://static-resource.np.community.playstation.net/avatar_xl/WWS_A/UP90001312L24_DD96EB6A4FF5FE883C09_XL.png" + ).respond(status_code=HTTPStatus.OK, content_type="image/png", content=b"Test") + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:00+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test" + assert resp.content_type == "image/png" + assert resp.content_length == 4 + + ava = "https://static-resource.np.community.playstation.net/avatar_m/WWS_E/E0011_m.png" + profile = mock_psnawpapi.user.return_value.profile.return_value + profile["avatars"] = [{"size": "xl", "url": ava}] + mock_psnawpapi.user.return_value.profile.return_value = profile + respx.get(ava).respond( + status_code=HTTPStatus.OK, content_type="image/png", content=b"Test2" + ) + + freezer.tick(timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert (state := hass.states.get("image.testuser_avatar")) + assert state.state == "2025-06-16T00:00:30+00:00" + + access_token = state.attributes["access_token"] + assert ( + state.attributes["entity_picture"] + == f"/api/image_proxy/image.testuser_avatar?token={access_token}" + ) + + client = await hass_client() + resp = await client.get(state.attributes["entity_picture"]) + assert resp.status == HTTPStatus.OK + body = await resp.read() + assert body == b"Test2" + assert resp.content_type == "image/png" + assert resp.content_length == 5 From 57c024449c97375e95c67e2c9b8c5813d0e8af5e Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Fri, 18 Jul 2025 00:02:44 -0700 Subject: [PATCH 1520/1664] Fix broken invalid_config tests (#148965) --- tests/components/counter/test_init.py | 10 ++++++---- tests/components/input_boolean/test_init.py | 10 ++++++---- tests/components/input_button/test_init.py | 10 ++++++---- tests/components/input_number/test_init.py | 17 ++++++++++------- tests/components/input_select/test_init.py | 15 ++++++++------- tests/components/schedule/test_init.py | 11 +++-------- tests/components/timer/test_init.py | 7 +++---- 7 files changed, 42 insertions(+), 38 deletions(-) diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index ef2caf2eab1..c5595d7fcbe 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -73,12 +73,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py index b2e99836477..b82bbe59203 100644 --- a/tests/components/input_boolean/test_init.py +++ b/tests/components/input_boolean/test_init.py @@ -54,12 +54,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_methods(hass: HomeAssistant) -> None: diff --git a/tests/components/input_button/test_init.py b/tests/components/input_button/test_init.py index e59d0543751..78cfd0a3d8b 100644 --- a/tests/components/input_button/test_init.py +++ b/tests/components/input_button/test_init.py @@ -47,12 +47,14 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "invalid_config", + [None, 1, {"name with space": None}], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 8ea1c2e25b6..94166a8ab7e 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -98,16 +98,19 @@ async def decrement(hass: HomeAssistant, entity_id: str) -> None: ) -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"test_1": {"min": 50, "max": 50}}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + {"test_1": {"min": 0, "max": 10, "initial": 11}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_set_value(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py index 153d8ed848d..c53e105bd09 100644 --- a/tests/components/input_select/test_init.py +++ b/tests/components/input_select/test_init.py @@ -70,17 +70,18 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: - """Test config.""" - invalid_configs = [ +@pytest.mark.parametrize( + "invalid_config", + [ None, - {}, {"name with space": None}, {"bad_initial": {"options": [1, 2], "initial": 3}}, - ] + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_select_option(hass: HomeAssistant) -> None: diff --git a/tests/components/schedule/test_init.py b/tests/components/schedule/test_init.py index fef2ff745cd..6fd6314c6bb 100644 --- a/tests/components/schedule/test_init.py +++ b/tests/components/schedule/test_init.py @@ -131,16 +131,11 @@ def schedule_setup( return _schedule_setup -async def test_invalid_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, {"name with space": None}]) +async def test_invalid_config(hass: HomeAssistant, invalid_config) -> None: """Test invalid configs.""" - invalid_configs = [ - None, - {}, - {"name with space": None}, - ] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) @pytest.mark.parametrize( diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 6e68b354087..d2db9b094f5 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -92,12 +92,11 @@ def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): return _storage -async def test_config(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("invalid_config", [None, 1, {"name with space": None}]) +async def test_config(hass: HomeAssistant, invalid_config) -> None: """Test config.""" - invalid_configs = [None, 1, {}, {"name with space": None}] - for cfg in invalid_configs: - assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) async def test_config_options(hass: HomeAssistant) -> None: From 39d323186fedb7617502d0ab45e27a5b602770d9 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Fri, 18 Jul 2025 10:53:47 +0300 Subject: [PATCH 1521/1664] Disable "last seen" Z-Wave entity by default (#148987) --- homeassistant/components/zwave_js/sensor.py | 2 +- tests/components/zwave_js/test_init.py | 8 ++++---- tests/components/zwave_js/test_sensor.py | 15 ++++++++++++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index df0a701bf15..2efb8c8e67c 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -558,7 +558,7 @@ ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ key="last_seen", translation_key="last_seen", device_class=SensorDeviceClass.TIMESTAMP, - entity_registry_enabled_default=True, + entity_registry_enabled_default=False, ), ] diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 324a0f14941..930f27e73f0 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -514,8 +514,8 @@ async def test_on_node_added_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_ready( @@ -631,8 +631,8 @@ async def test_existing_node_not_ready( assert len(device.identifiers) == 1 entities = er.async_entries_for_device(entity_registry, device.id) - # the only entities are the node status sensor, last_seen sensor, and ping button - assert len(entities) == 3 + # the only entities are the node status sensor, and ping button + assert len(entities) == 2 async def test_existing_node_not_replaced_when_not_ready( diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index 42e2108be89..c7b41449d43 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -869,7 +869,7 @@ async def test_statistics_sensors_migration( ) -async def test_statistics_sensors_no_last_seen( +async def test_statistics_sensors( hass: HomeAssistant, entity_registry: er.EntityRegistry, zp3111, @@ -877,7 +877,7 @@ async def test_statistics_sensors_no_last_seen( integration, caplog: pytest.LogCaptureFixture, ) -> None: - """Test all statistics sensors but last seen which is enabled by default.""" + """Test statistics sensors.""" for prefix, suffixes in ( (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), @@ -1029,7 +1029,16 @@ async def test_last_seen_statistics_sensors( entity_id = f"{NODE_STATISTICS_ENTITY_PREFIX}last_seen" entry = entity_registry.async_get(entity_id) assert entry - assert not entry.disabled + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + assert hass.states.get(entity_id) is None # disabled by default + + entity_registry.async_update_entity(entity_id, disabled_by=None) + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + ) + await hass.async_block_till_done() state = hass.states.get(entity_id) assert state From 43a30fad96c89e694ce09b59c902caa5f4ebfff6 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:19:33 +0200 Subject: [PATCH 1522/1664] Home Assistant Cloud: fix capitalization (#148992) --- homeassistant/components/cloud/http_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 998f3fcd5bc..49e4af9e3e5 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -71,7 +71,7 @@ _CLOUD_ERRORS: dict[ ] = { TimeoutError: ( HTTPStatus.BAD_GATEWAY, - "Unable to reach the Home Assistant cloud.", + "Unable to reach the Home Assistant Cloud.", ), aiohttp.ClientError: ( HTTPStatus.INTERNAL_SERVER_ERROR, From a96e31f1d8c63c96173ed7fffe40baa69fc0c651 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jul 2025 00:48:09 -1000 Subject: [PATCH 1523/1664] Bump PySwitchbot to 0.68.2 (#148996) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 5ef7eec9976..22168c21f97 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.68.1"] + "requirements": ["PySwitchbot==0.68.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 85da7a1f7b1..8a44f24c055 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.1 +PySwitchbot==0.68.2 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5377eb55c3a..16c620ff6db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.68.1 +PySwitchbot==0.68.2 # homeassistant.components.syncthru PySyncThru==0.8.0 From 75c803e3767b61c50ce324be8e89cdf724b74825 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:48:39 +0200 Subject: [PATCH 1524/1664] Update pysmarlaapi to 0.9.1 (#149001) --- homeassistant/components/smarla/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/smarla/manifest.json b/homeassistant/components/smarla/manifest.json index 8f7786bdf72..e2e9e08dcab 100644 --- a/homeassistant/components/smarla/manifest.json +++ b/homeassistant/components/smarla/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["pysmarlaapi", "pysignalr"], "quality_scale": "bronze", - "requirements": ["pysmarlaapi==0.9.0"] + "requirements": ["pysmarlaapi==0.9.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8a44f24c055..2893a2960cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2346,7 +2346,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.1 # homeassistant.components.smartthings pysmartthings==3.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16c620ff6db..291c5e46a67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1949,7 +1949,7 @@ pysma==0.7.5 pysmappee==0.2.29 # homeassistant.components.smarla -pysmarlaapi==0.9.0 +pysmarlaapi==0.9.1 # homeassistant.components.smartthings pysmartthings==3.2.8 From ec544b0430f97f10af6c7c68c06e4a865ce528bb Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Fri, 18 Jul 2025 12:49:50 +0200 Subject: [PATCH 1525/1664] Mark entities as unavailable when they don't have a value in Husqvarna Automower (#148563) --- homeassistant/components/husqvarna_automower/sensor.py | 5 +++++ tests/components/husqvarna_automower/test_sensor.py | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py index 72f65320efd..0ff72271cb9 100644 --- a/homeassistant/components/husqvarna_automower/sensor.py +++ b/homeassistant/components/husqvarna_automower/sensor.py @@ -541,6 +541,11 @@ class AutomowerSensorEntity(AutomowerBaseEntity, SensorEntity): """Return the state attributes.""" return self.entity_description.extra_state_attributes_fn(self.mower_attributes) + @property + def available(self) -> bool: + """Return the available attribute of the entity.""" + return super().available and self.native_value is not None + class WorkAreaSensorEntity(WorkAreaAvailableEntity, SensorEntity): """Defining the Work area sensors with WorkAreaSensorEntityDescription.""" diff --git a/tests/components/husqvarna_automower/test_sensor.py b/tests/components/husqvarna_automower/test_sensor.py index b1029f5919b..d756b1b2ffa 100644 --- a/tests/components/husqvarna_automower/test_sensor.py +++ b/tests/components/husqvarna_automower/test_sensor.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.husqvarna_automower.coordinator import SCAN_INTERVAL -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -39,7 +39,7 @@ async def test_sensor_unknown_states( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_mode") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_cutting_blade_usage_time_sensor( @@ -78,7 +78,7 @@ async def test_next_start_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get("sensor.test_mower_1_next_start") - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE async def test_work_area_sensor( From 17034f4d6a60afb3063df889cc7fc9e63db2ce9e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 18 Jul 2025 13:15:55 +0200 Subject: [PATCH 1526/1664] Update frontend to 20250702.3 (#148994) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index a7582ebc5e2..791acf8a39c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250702.2"] + "requirements": ["home-assistant-frontend==20250702.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d26705842e2..f5f72d1c4c3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.1 hass-nabucasa==0.107.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2893a2960cb..7da952c2f01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 291c5e46a67..c5ecaff0718 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.2 +home-assistant-frontend==20250702.3 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 277241c4d3fc2bacd69343c92f5bda13ec8e6d5f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 18 Jul 2025 13:49:12 +0200 Subject: [PATCH 1527/1664] Adjust ManualTriggerSensorEntity to handle timestamp device classes (#145909) --- .../components/command_line/sensor.py | 13 +------ homeassistant/components/rest/sensor.py | 16 +------- homeassistant/components/scrape/sensor.py | 15 +------ homeassistant/components/snmp/sensor.py | 2 +- homeassistant/components/sql/sensor.py | 3 +- .../helpers/trigger_template_entity.py | 19 +++++++++ tests/helpers/test_trigger_template_entity.py | 39 +++++++++++++++++++ 7 files changed, 65 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index dfc31b4581b..234241fdeab 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -10,8 +10,6 @@ from typing import Any from jsonpath import jsonpath -from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -188,16 +186,7 @@ class CommandSensor(ManualTriggerSensorEntity): self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - elif value is not None: - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 9df10197a1a..3db44b0e5d2 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, - SensorDeviceClass, ) -from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, @@ -181,18 +179,6 @@ class RestSensor(ManualTriggerSensorEntity, RestEntity): self.entity_id, variables, None ) - if value is None or self.device_class not in ( - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - ): - self._attr_native_value = value - self._process_manual_data(variables) - self.async_write_ha_state() - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) - + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) self.async_write_ha_state() diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 80d53a2c8b1..3e7f416166b 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -7,8 +7,7 @@ from typing import Any, cast import voluptuous as vol -from homeassistant.components.sensor import CONF_STATE_CLASS, SensorDeviceClass -from homeassistant.components.sensor.helpers import async_parse_date_datetime +from homeassistant.components.sensor import CONF_STATE_CLASS from homeassistant.const import ( CONF_ATTRIBUTE, CONF_DEVICE_CLASS, @@ -218,17 +217,7 @@ class ScrapeSensor(CoordinatorEntity[ScrapeCoordinator], ManualTriggerSensorEnti self.entity_id, variables, None ) - if self.device_class not in { - SensorDeviceClass.DATE, - SensorDeviceClass.TIMESTAMP, - }: - self._attr_native_value = value - self._process_manual_data(variables) - return - - self._attr_native_value = async_parse_date_datetime( - value, self.entity_id, self.device_class - ) + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) @property diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index 3574affaccd..46e0dc83050 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -217,7 +217,7 @@ class SnmpSensor(ManualTriggerSensorEntity): self.entity_id, variables, STATE_UNKNOWN ) - self._attr_native_value = value + self._set_native_value_with_possible_timestamp(value) self._process_manual_data(variables) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index b86a33db7ab..8c0ba81d6d2 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -401,9 +401,10 @@ class SQLSensor(ManualTriggerSensorEntity): if data is not None and self._template is not None: variables = self._template_variables_with_value(data) if self._render_availability_template(variables): - self._attr_native_value = self._template.async_render_as_value_template( + _value = self._template.async_render_as_value_template( self.entity_id, variables, None ) + self._set_native_value_with_possible_timestamp(_value) self._process_manual_data(variables) else: self._attr_native_value = data diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index bf7598eb024..d8ebab8b83e 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -13,8 +13,10 @@ from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, STATE_CLASSES_SCHEMA, + SensorDeviceClass, SensorEntity, ) +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, @@ -389,3 +391,20 @@ class ManualTriggerSensorEntity(ManualTriggerEntity, SensorEntity): ManualTriggerEntity.__init__(self, hass, config) self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) self._attr_state_class = config.get(CONF_STATE_CLASS) + + @callback + def _set_native_value_with_possible_timestamp(self, value: Any) -> None: + """Set native value with possible timestamp. + + If self.device_class is `date` or `timestamp`, + it will try to parse the value to a date/datetime object. + """ + if self.device_class not in ( + SensorDeviceClass.DATE, + SensorDeviceClass.TIMESTAMP, + ): + self._attr_native_value = value + elif value is not None: + self._attr_native_value = async_parse_date_datetime( + value, self.entity_id, self.device_class + ) diff --git a/tests/helpers/test_trigger_template_entity.py b/tests/helpers/test_trigger_template_entity.py index 8389218054d..fcfdd249d75 100644 --- a/tests/helpers/test_trigger_template_entity.py +++ b/tests/helpers/test_trigger_template_entity.py @@ -4,7 +4,10 @@ from typing import Any import pytest +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_ICON, CONF_NAME, CONF_STATE, @@ -20,6 +23,7 @@ from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, CONF_PICTURE, ManualTriggerEntity, + ManualTriggerSensorEntity, ValueTemplate, ) @@ -288,3 +292,38 @@ async def test_trigger_template_complex(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert entity.some_other_key == {"test_key": "test_data"} + + +async def test_manual_trigger_sensor_entity_with_date( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test manual trigger template entity when availability template isn't used.""" + config = { + CONF_NAME: template.Template("test_entity", hass), + CONF_STATE: template.Template("{{ as_datetime(value) }}", hass), + CONF_DEVICE_CLASS: SensorDeviceClass.TIMESTAMP, + } + + class TestEntity(ManualTriggerSensorEntity): + """Test entity class.""" + + extra_template_keys = (CONF_STATE,) + + @property + def state(self) -> bool | None: + """Return extra attributes.""" + return "2025-01-01T00:00:00+00:00" + + entity = TestEntity(hass, config) + entity.entity_id = "test.entity" + variables = entity._template_variables_with_value("2025-01-01T00:00:00+00:00") + assert entity._render_availability_template(variables) is True + assert entity.available is True + entity._set_native_value_with_possible_timestamp(entity.state) + await hass.async_block_till_done() + + assert entity.native_value == async_parse_date_datetime( + "2025-01-01T00:00:00+00:00", entity.entity_id, entity.device_class + ) + assert entity.state == "2025-01-01T00:00:00+00:00" + assert entity.device_class == SensorDeviceClass.TIMESTAMP From 1743766d170c09d1490652413edec89104df7808 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 18 Jul 2025 13:53:30 +0200 Subject: [PATCH 1528/1664] Add last_reported to state reported event data (#148932) --- homeassistant/components/derivative/sensor.py | 37 +++++++----- .../components/integration/sensor.py | 38 ++++++++---- homeassistant/components/statistics/sensor.py | 23 ++++--- homeassistant/core.py | 60 +++++++++++++++---- 4 files changed, 109 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ab4feabc4ee..da35975c193 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -320,7 +320,12 @@ class DerivativeSensor(RestoreSensor, SensorEntity): # changed state, then we know it will still be zero. return schedule_max_sub_interval_exceeded(new_state) - calc_derivative(new_state, new_state.state, event.data["old_last_reported"]) + calc_derivative( + new_state, + new_state.state, + event.data["last_reported"], + event.data["old_last_reported"], + ) @callback def on_state_changed(event: Event[EventStateChangedData]) -> None: @@ -334,19 +339,27 @@ class DerivativeSensor(RestoreSensor, SensorEntity): schedule_max_sub_interval_exceeded(new_state) old_state = event.data["old_state"] if old_state is not None: - calc_derivative(new_state, old_state.state, old_state.last_reported) + calc_derivative( + new_state, + old_state.state, + new_state.last_updated, + old_state.last_reported, + ) else: # On first state change from none, update availability self.async_write_ha_state() def calc_derivative( - new_state: State, old_value: str, old_last_reported: datetime + new_state: State, + old_value: str, + new_timestamp: datetime, + old_timestamp: datetime, ) -> None: """Handle the sensor state changes.""" if not _is_decimal_state(old_value): if self._last_valid_state_time: old_value = self._last_valid_state_time[0] - old_last_reported = self._last_valid_state_time[1] + old_timestamp = self._last_valid_state_time[1] else: # Sensor becomes valid for the first time, just keep the restored value self.async_write_ha_state() @@ -358,12 +371,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): "" if unit is None else unit ) - self._prune_state_list(new_state.last_reported) + self._prune_state_list(new_timestamp) try: - elapsed_time = ( - new_state.last_reported - old_last_reported - ).total_seconds() + elapsed_time = (new_timestamp - old_timestamp).total_seconds() delta_value = Decimal(new_state.state) - Decimal(old_value) new_derivative = ( delta_value @@ -392,12 +403,10 @@ class DerivativeSensor(RestoreSensor, SensorEntity): return # add latest derivative to the window list - self._state_list.append( - (old_last_reported, new_state.last_reported, new_derivative) - ) + self._state_list.append((old_timestamp, new_timestamp, new_derivative)) self._last_valid_state_time = ( new_state.state, - new_state.last_reported, + new_timestamp, ) # If outside of time window just report derivative (is the same as modeling it in the window), @@ -405,9 +414,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity): if elapsed_time > self._time_window: derivative = new_derivative else: - derivative = self._calc_derivative_from_state_list( - new_state.last_reported - ) + derivative = self._calc_derivative_from_state_list(new_timestamp) self._write_native_value(derivative) source_state = self.hass.states.get(self._sensor_source_id) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 25181ac6149..49a032899be 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -463,7 +463,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state update when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -472,13 +472,17 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report when sub interval is configured.""" self._integrate_on_state_update_with_max_sub_interval( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) @callback def _integrate_on_state_update_with_max_sub_interval( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -489,7 +493,9 @@ class IntegrationSensor(RestoreSensor): """ self._cancel_max_sub_interval_exceeded_callback() try: - self._integrate_on_state_change(old_last_reported, old_state, new_state) + self._integrate_on_state_change( + old_timestamp, new_timestamp, old_state, new_state + ) self._last_integration_trigger = _IntegrationTrigger.StateEvent self._last_integration_time = datetime.now(tz=UTC) finally: @@ -503,7 +509,7 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state change.""" return self._integrate_on_state_change( - None, event.data["old_state"], event.data["new_state"] + None, None, event.data["old_state"], event.data["new_state"] ) @callback @@ -512,12 +518,16 @@ class IntegrationSensor(RestoreSensor): ) -> None: """Handle sensor state report.""" return self._integrate_on_state_change( - event.data["old_last_reported"], None, event.data["new_state"] + event.data["old_last_reported"], + event.data["last_reported"], + None, + event.data["new_state"], ) def _integrate_on_state_change( self, - old_last_reported: datetime | None, + old_timestamp: datetime | None, + new_timestamp: datetime | None, old_state: State | None, new_state: State | None, ) -> None: @@ -531,16 +541,17 @@ class IntegrationSensor(RestoreSensor): if old_state: # state has changed, we recover old_state from the event + new_timestamp = new_state.last_updated old_state_state = old_state.state - old_last_reported = old_state.last_reported + old_timestamp = old_state.last_reported else: - # event state reported without any state change + # first state or event state reported without any state change old_state_state = new_state.state self._attr_available = True self._derive_and_set_attributes_from_state(new_state) - if old_last_reported is None and old_state is None: + if old_timestamp is None and old_state is None: self.async_write_ha_state() return @@ -551,11 +562,12 @@ class IntegrationSensor(RestoreSensor): return if TYPE_CHECKING: - assert old_last_reported is not None + assert new_timestamp is not None + assert old_timestamp is not None elapsed_seconds = Decimal( - (new_state.last_reported - old_last_reported).total_seconds() + (new_timestamp - old_timestamp).total_seconds() if self._last_integration_trigger == _IntegrationTrigger.StateEvent - else (new_state.last_reported - self._last_integration_time).total_seconds() + else (new_timestamp - self._last_integration_time).total_seconds() ) area = self._method.calculate_area_with_two_states(elapsed_seconds, *states) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 8129a000b91..14471ab16ee 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -727,12 +727,11 @@ class StatisticsSensor(SensorEntity): def _async_handle_new_state( self, - reported_state: State | None, + reported_state: State, + timestamp: float, ) -> None: """Handle the sensor state changes.""" - if (new_state := reported_state) is None: - return - self._add_state_to_queue(new_state) + self._add_state_to_queue(reported_state, timestamp) self._async_purge_update_and_schedule() if self._preview_callback: @@ -747,14 +746,18 @@ class StatisticsSensor(SensorEntity): self, event: Event[EventStateChangedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + if (new_state := event.data["new_state"]) is None: + return + self._async_handle_new_state(new_state, new_state.last_updated_timestamp) @callback def _async_stats_sensor_state_report_listener( self, event: Event[EventStateReportedData], ) -> None: - self._async_handle_new_state(event.data["new_state"]) + self._async_handle_new_state( + event.data["new_state"], event.data["last_reported"].timestamp() + ) async def _async_stats_sensor_startup(self) -> None: """Add listener and get recorded state. @@ -785,7 +788,9 @@ class StatisticsSensor(SensorEntity): """Register callbacks.""" await self._async_stats_sensor_startup() - def _add_state_to_queue(self, new_state: State) -> None: + def _add_state_to_queue( + self, new_state: State, last_reported_timestamp: float + ) -> None: """Add the state to the queue.""" # Attention: it is not safe to store the new_state object, @@ -805,7 +810,7 @@ class StatisticsSensor(SensorEntity): self.states.append(new_state.state == "on") else: self.states.append(float(new_state.state)) - self.ages.append(new_state.last_reported_timestamp) + self.ages.append(last_reported_timestamp) self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = True except ValueError: self._attr_extra_state_attributes[STAT_SOURCE_VALUE_VALID] = False @@ -1062,7 +1067,7 @@ class StatisticsSensor(SensorEntity): self._fetch_states_from_database ): for state in reversed(states): - self._add_state_to_queue(state) + self._add_state_to_queue(state, state.last_reported_timestamp) self._calculate_state_attributes(state) self._async_purge_update_and_schedule() diff --git a/homeassistant/core.py b/homeassistant/core.py index 8ffabf56171..299a7d32306 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -157,7 +157,6 @@ class EventStateEventData(TypedDict): """Base class for EVENT_STATE_CHANGED and EVENT_STATE_REPORTED data.""" entity_id: str - new_state: State | None class EventStateChangedData(EventStateEventData): @@ -166,6 +165,7 @@ class EventStateChangedData(EventStateEventData): A state changed event is fired when on state write the state is changed. """ + new_state: State | None old_state: State | None @@ -175,6 +175,8 @@ class EventStateReportedData(EventStateEventData): A state reported event is fired when on state write the state is unchanged. """ + last_reported: datetime.datetime + new_state: State old_last_reported: datetime.datetime @@ -1749,18 +1751,38 @@ class CompressedState(TypedDict): class State: - """Object to represent a state within the state machine. + """Object to represent a state within the state machine.""" - entity_id: the entity that is represented. - state: the state of the entity - attributes: extra information on entity and state - last_changed: last time the state was changed. - last_reported: last time the state was reported. - last_updated: last time the state or attributes were changed. - context: Context in which it was created - domain: Domain of this state. - object_id: Object id of this state. + entity_id: str + """The entity that is represented by the state.""" + domain: str + """Domain of the entity that is represented by the state.""" + object_id: str + """object_id: Object id of this state.""" + state: str + """The state of the entity.""" + attributes: ReadOnlyDict[str, Any] + """Extra information on entity and state""" + last_changed: datetime.datetime + """Last time the state was changed.""" + last_reported: datetime.datetime + """Last time the state was reported. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported attribute of the old + state will not be modified and can safely be used. The last_reported attribute + of the new state may be modified and the last_updated attribute should be used + instead. + + When handling a state report event, the last_reported attribute may be + modified and last_reported from the event data should be used instead. """ + last_updated: datetime.datetime + """Last time the state or attributes were changed.""" + context: Context + """Context in which the state was created.""" __slots__ = ( "_cache", @@ -1841,7 +1863,20 @@ class State: @under_cached_property def last_reported_timestamp(self) -> float: - """Timestamp of last report.""" + """Timestamp of last report. + + Note: When the state is set and neither the state nor attributes are + changed, the existing state will be mutated with an updated last_reported. + + When handling a state change event, the last_reported_timestamp attribute + of the old state will not be modified and can safely be used. The + last_reported_timestamp attribute of the new state may be modified and the + last_updated_timestamp attribute should be used instead. + + When handling a state report event, the last_reported_timestamp attribute may + be modified and last_reported from the event data should be used instead. + """ + return self.last_reported.timestamp() @under_cached_property @@ -2340,6 +2375,7 @@ class StateMachine: EVENT_STATE_REPORTED, { "entity_id": entity_id, + "last_reported": now, "old_last_reported": old_last_reported, "new_state": old_state, }, From 29d0d6cd43a01ab09ccdbdc6b59e8c3aebcd6d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Fri, 18 Jul 2025 14:32:16 +0100 Subject: [PATCH 1529/1664] Add top-level target support to trigger schema (#148894) --- script/hassfest/triggers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index ff6654f2789..8efaab47050 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -38,6 +38,9 @@ FIELD_SCHEMA = vol.Schema( TRIGGER_SCHEMA = vol.Any( vol.Schema( { + vol.Optional("target"): vol.Any( + selector.TargetSelector.CONFIG_SCHEMA, None + ), vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ), From 3b89b2cbbe062432a41694546ea94398ba8c1a87 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 18 Jul 2025 16:35:38 +0300 Subject: [PATCH 1530/1664] Bump aioamazondevices to 3.5.0 (#149011) --- 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 25ad75d0d00..9a98be052be 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.2.10"] + "requirements": ["aioamazondevices==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7da952c2f01..ca38d1b2743 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aioairzone-cloud==0.6.15 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5ecaff0718..e4590e45908 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aioairzone-cloud==0.6.15 aioairzone==1.0.0 # homeassistant.components.alexa_devices -aioamazondevices==3.2.10 +aioamazondevices==3.5.0 # homeassistant.components.ambient_network # homeassistant.components.ambient_station From 109663f1777d6dada2d98264fd1a874b755edf27 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 18 Jul 2025 15:36:17 +0200 Subject: [PATCH 1531/1664] Bump `imgw_pib` to version 1.4.2 (#149009) --- 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 e2032b6d51a..7b7c66a953d 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.1"] + "requirements": ["imgw_pib==1.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ca38d1b2743..6005c7a0dff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1234,7 +1234,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.1 +imgw_pib==1.4.2 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4590e45908..9487ea55ce7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1068,7 +1068,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.3.12 # homeassistant.components.imgw_pib -imgw_pib==1.4.1 +imgw_pib==1.4.2 # homeassistant.components.incomfort incomfort-client==0.6.9 From 353b573707814156ed28415755e6b266d8d71f64 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 18 Jul 2025 15:43:43 +0200 Subject: [PATCH 1532/1664] Update bluecurrent-api to 1.2.4 (#149005) --- homeassistant/components/blue_current/manifest.json | 2 +- pyproject.toml | 4 ---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/blue_current/manifest.json b/homeassistant/components/blue_current/manifest.json index e813b08131c..84604c62951 100644 --- a/homeassistant/components/blue_current/manifest.json +++ b/homeassistant/components/blue_current/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/blue_current", "iot_class": "cloud_push", "loggers": ["bluecurrent_api"], - "requirements": ["bluecurrent-api==1.2.3"] + "requirements": ["bluecurrent-api==1.2.4"] } diff --git a/pyproject.toml b/pyproject.toml index 3b0994ff2cf..6c732066e41 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -589,10 +589,6 @@ filterwarnings = [ # -- Websockets 14.1 # https://websockets.readthedocs.io/en/stable/howto/upgrade.html "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy", - # https://github.com/bluecurrent/HomeAssistantAPI/pull/19 - >=1.2.4 - "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket", - "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket", # https://github.com/graphql-python/gql/pull/543 - >=4.0.0a0 "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base", diff --git a/requirements_all.txt b/requirements_all.txt index 6005c7a0dff..48a5e2a17c1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -634,7 +634,7 @@ blinkpy==0.23.0 blockchain==1.4.4 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.2.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9487ea55ce7..202d6826562 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -565,7 +565,7 @@ blebox-uniapi==2.5.0 blinkpy==0.23.0 # homeassistant.components.blue_current -bluecurrent-api==1.2.3 +bluecurrent-api==1.2.4 # homeassistant.components.bluemaestro bluemaestro-ble==0.4.1 From 4c99fe9ae5376dc177a47e6e899a4371e99e2485 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 18 Jul 2025 18:57:03 +0200 Subject: [PATCH 1533/1664] Ignore MQTT sensor unit of measurement if it is an empty string (#149006) --- homeassistant/components/mqtt/sensor.py | 6 ++++ tests/components/mqtt/test_sensor.py | 39 +++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 783a0b30b14..83679894d71 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -98,6 +98,12 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT f"together with state class `{state_class}`" ) + unit_of_measurement: str | None + if ( + unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT) + ) is not None and not unit_of_measurement.strip(): + config.pop(CONF_UNIT_OF_MEASUREMENT) + # Only allow `options` to be set for `enum` sensors # to limit the possible sensor values if (options := config.get(CONF_OPTIONS)) is not None: diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 997c014cd13..16f0c9f22bc 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -924,6 +924,30 @@ async def test_invalid_unit_of_measurement( "device_class": None, "unit_of_measurement": None, }, + { + "name": "Test 4", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": "", + }, + { + "name": "Test 5", + "state_topic": "test-topic", + "device_class": "ph", + "unit_of_measurement": " ", + }, + { + "name": "Test 6", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": "", + }, + { + "name": "Test 7", + "state_topic": "test-topic", + "device_class": None, + "unit_of_measurement": " ", + }, ] } } @@ -936,10 +960,25 @@ async def test_valid_device_class_and_uom( await mqtt_mock_entry() state = hass.states.get("sensor.test_1") + assert state is not None assert state.attributes["device_class"] == "temperature" state = hass.states.get("sensor.test_2") + assert state is not None assert "device_class" not in state.attributes state = hass.states.get("sensor.test_3") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_4") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_5") + assert state is not None + assert state.attributes["device_class"] == "ph" + state = hass.states.get("sensor.test_6") + assert state is not None + assert "device_class" not in state.attributes + state = hass.states.get("sensor.test_7") + assert state is not None assert "device_class" not in state.attributes From 916b4368dd2eaa7d407565db31a89147617003cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 18 Jul 2025 07:30:34 -1000 Subject: [PATCH 1534/1664] Bump aioesphomeapi to 36.0.1 (#148991) --- .../components/esphome/entry_data.py | 18 +------- .../components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../esphome/test_alarm_control_panel.py | 3 -- .../esphome/test_assist_satellite.py | 4 -- .../components/esphome/test_binary_sensor.py | 8 ---- tests/components/esphome/test_button.py | 1 - tests/components/esphome/test_camera.py | 6 --- tests/components/esphome/test_climate.py | 7 --- tests/components/esphome/test_cover.py | 2 - tests/components/esphome/test_date.py | 2 - tests/components/esphome/test_datetime.py | 2 - tests/components/esphome/test_entity.py | 46 ++----------------- tests/components/esphome/test_entry_data.py | 44 ------------------ tests/components/esphome/test_event.py | 1 - tests/components/esphome/test_fan.py | 3 -- tests/components/esphome/test_light.py | 20 -------- tests/components/esphome/test_lock.py | 3 -- tests/components/esphome/test_media_player.py | 4 -- tests/components/esphome/test_number.py | 4 -- tests/components/esphome/test_repairs.py | 1 - tests/components/esphome/test_select.py | 1 - tests/components/esphome/test_sensor.py | 14 ------ tests/components/esphome/test_switch.py | 3 -- tests/components/esphome/test_text.py | 3 -- tests/components/esphome/test_time.py | 2 - tests/components/esphome/test_update.py | 3 -- tests/components/esphome/test_valve.py | 2 - 29 files changed, 8 insertions(+), 205 deletions(-) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index dddbb598a57..eddd4d523c9 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -295,23 +295,7 @@ class RuntimeEntryData: needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) - ent_reg = er.async_get(hass) - registry_get_entity = ent_reg.async_get_entity_id - for info in infos: - platform = INFO_TYPE_TO_PLATFORM[type(info)] - needed_platforms.add(platform) - # If the unique id is in the old format, migrate it - # except if they downgraded and upgraded, there might be a duplicate - # so we want to keep the one that was already there. - if ( - (old_unique_id := info.unique_id) - and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) - and (new_unique_id := build_device_unique_id(mac, info)) - != old_unique_id - and not registry_get_entity(platform, DOMAIN, new_unique_id) - ): - ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) - + needed_platforms.update(INFO_TYPE_TO_PLATFORM[type(info)] for info in infos) await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Make a dict of the EntityInfo by type and send diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index c88fa7246fe..903aaea9980 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==35.0.0", + "aioesphomeapi==36.0.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 48a5e2a17c1..03019fcc39e 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==35.0.0 +aioesphomeapi==36.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 202d6826562..0042ef7aa34 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==35.0.0 +aioesphomeapi==36.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_alarm_control_panel.py b/tests/components/esphome/test_alarm_control_panel.py index e06b88432a9..ff16731b44e 100644 --- a/tests/components/esphome/test_alarm_control_panel.py +++ b/tests/components/esphome/test_alarm_control_panel.py @@ -40,7 +40,6 @@ async def test_generic_alarm_control_panel_requires_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -173,7 +172,6 @@ async def test_generic_alarm_control_panel_no_code( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME @@ -219,7 +217,6 @@ async def test_generic_alarm_control_panel_missing_state( object_id="myalarm_control_panel", key=1, name="my alarm_control_panel", - unique_id="my_alarm_control_panel", supported_features=EspHomeACPFeatures.ARM_AWAY | EspHomeACPFeatures.ARM_CUSTOM_BYPASS | EspHomeACPFeatures.ARM_HOME diff --git a/tests/components/esphome/test_assist_satellite.py b/tests/components/esphome/test_assist_satellite.py index bfcc35b2e6a..2fdf53dc5ea 100644 --- a/tests/components/esphome/test_assist_satellite.py +++ b/tests/components/esphome/test_assist_satellite.py @@ -953,7 +953,6 @@ async def test_tts_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1020,7 +1019,6 @@ async def test_tts_minimal_format_from_media_player( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1156,7 +1154,6 @@ async def test_announce_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -1437,7 +1434,6 @@ async def test_start_conversation_media_id( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( diff --git a/tests/components/esphome/test_binary_sensor.py b/tests/components/esphome/test_binary_sensor.py index d6e94e61766..0e3bcc5a115 100644 --- a/tests/components/esphome/test_binary_sensor.py +++ b/tests/components/esphome/test_binary_sensor.py @@ -24,7 +24,6 @@ async def test_binary_sensor_generic_entity( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] esphome_state, hass_state = binary_state @@ -52,7 +51,6 @@ async def test_status_binary_sensor( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] @@ -80,7 +78,6 @@ async def test_binary_sensor_missing_state( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [BinarySensorState(key=1, state=True, missing_state=True)] @@ -107,7 +104,6 @@ async def test_binary_sensor_has_state_false( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -152,14 +148,12 @@ async def test_binary_sensors_same_key_different_device_id( object_id="sensor", key=1, name="Motion", - unique_id="motion_1", device_id=11111111, ), BinarySensorInfo( object_id="sensor", key=1, name="Motion", - unique_id="motion_2", device_id=22222222, ), ] @@ -235,14 +229,12 @@ async def test_binary_sensor_main_and_sub_device_same_key( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_1", device_id=0, # Main device ), BinarySensorInfo( object_id="sub_sensor", key=1, name="Sub Sensor", - unique_id="sub_1", device_id=11111111, ), ] diff --git a/tests/components/esphome/test_button.py b/tests/components/esphome/test_button.py index 3cedc3526d4..b85dd04e6b7 100644 --- a/tests/components/esphome/test_button.py +++ b/tests/components/esphome/test_button.py @@ -18,7 +18,6 @@ async def test_button_generic_entity( object_id="mybutton", key=1, name="my button", - unique_id="my_button", ) ] states = [] diff --git a/tests/components/esphome/test_camera.py b/tests/components/esphome/test_camera.py index e29eed16d9f..2f3966fe1f6 100644 --- a/tests/components/esphome/test_camera.py +++ b/tests/components/esphome/test_camera.py @@ -30,7 +30,6 @@ async def test_camera_single_image( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -75,7 +74,6 @@ async def test_camera_single_image_unavailable_before_requested( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -113,7 +111,6 @@ async def test_camera_single_image_unavailable_during_request( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -155,7 +152,6 @@ async def test_camera_stream( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -212,7 +208,6 @@ async def test_camera_stream_unavailable( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] @@ -249,7 +244,6 @@ async def test_camera_stream_with_disconnection( object_id="mycamera", key=1, name="my camera", - unique_id="my_camera", ) ] states = [] diff --git a/tests/components/esphome/test_climate.py b/tests/components/esphome/test_climate.py index 5c907eef3b1..c574764e3c9 100644 --- a/tests/components/esphome/test_climate.py +++ b/tests/components/esphome/test_climate.py @@ -58,7 +58,6 @@ async def test_climate_entity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_action=True, visual_min_temperature=10.0, @@ -110,7 +109,6 @@ async def test_climate_entity_with_step_and_two_point( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, visual_target_temperature_step=2, @@ -187,7 +185,6 @@ async def test_climate_entity_with_step_and_target_temp( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -345,7 +342,6 @@ async def test_climate_entity_with_humidity( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -409,7 +405,6 @@ async def test_climate_entity_with_inf_value( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, supports_two_point_target_temperature=True, supports_action=True, @@ -465,7 +460,6 @@ async def test_climate_entity_attributes( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=True, visual_target_temperature_step=2, visual_current_temperature_step=2, @@ -520,7 +514,6 @@ async def test_climate_entity_attribute_current_temperature_unsupported( object_id="myclimate", key=1, name="my climate", - unique_id="my_climate", supports_current_temperature=False, ) ] diff --git a/tests/components/esphome/test_cover.py b/tests/components/esphome/test_cover.py index 93524905f6b..d7b92e490fe 100644 --- a/tests/components/esphome/test_cover.py +++ b/tests/components/esphome/test_cover.py @@ -41,7 +41,6 @@ async def test_cover_entity( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=True, supports_tilt=True, supports_stop=True, @@ -169,7 +168,6 @@ async def test_cover_entity_without_position( object_id="mycover", key=1, name="my cover", - unique_id="my_cover", supports_position=False, supports_tilt=False, supports_stop=False, diff --git a/tests/components/esphome/test_date.py b/tests/components/esphome/test_date.py index 387838e0b23..9e555eb98c2 100644 --- a/tests/components/esphome/test_date.py +++ b/tests/components/esphome/test_date.py @@ -26,7 +26,6 @@ async def test_generic_date_entity( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, year=2024, month=12, day=31)] @@ -62,7 +61,6 @@ async def test_generic_date_missing_state( object_id="mydate", key=1, name="my date", - unique_id="my_date", ) ] states = [DateState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_datetime.py b/tests/components/esphome/test_datetime.py index 6fcfe7ed947..940fae5cfef 100644 --- a/tests/components/esphome/test_datetime.py +++ b/tests/components/esphome/test_datetime.py @@ -26,7 +26,6 @@ async def test_generic_datetime_entity( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, epoch_seconds=1713270896)] @@ -65,7 +64,6 @@ async def test_generic_datetime_missing_state( object_id="mydatetime", key=1, name="my datetime", - unique_id="my_datetime", ) ] states = [DateTimeState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_entity.py b/tests/components/esphome/test_entity.py index f364e1f528f..9b3c08bb77d 100644 --- a/tests/components/esphome/test_entity.py +++ b/tests/components/esphome/test_entity.py @@ -51,13 +51,11 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -100,7 +98,6 @@ async def test_entities_removed( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -140,13 +137,11 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), BinarySensorInfo( object_id="mybinary_sensor_to_be_removed", key=2, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -214,7 +209,6 @@ async def test_entities_removed_after_reload( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] mock_device.client.list_entities_services = AsyncMock( @@ -267,7 +261,6 @@ async def test_entities_for_entire_platform_removed( object_id="mybinary_sensor_to_be_removed", key=1, name="my binary_sensor to be removed", - unique_id="mybinary_sensor_to_be_removed", ), ] states = [ @@ -325,7 +318,6 @@ async def test_entity_info_object_ids( object_id="object_id_is_used", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ) ] states = [] @@ -350,13 +342,11 @@ async def test_deep_sleep_device( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), SensorInfo( object_id="my_sensor", key=3, name="my sensor", - unique_id="my_sensor", ), ] states = [ @@ -456,7 +446,6 @@ async def test_esphome_device_without_friendly_name( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", ), ] states = [ @@ -486,7 +475,6 @@ async def test_entity_without_name_device_with_friendly_name( object_id="mybinary_sensor", key=1, name="", - unique_id="my_binary_sensor", ), ] states = [ @@ -519,7 +507,6 @@ async def test_entity_id_preserved_on_upgrade( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -560,7 +547,6 @@ async def test_entity_id_preserved_on_upgrade_old_format_entity_id( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -601,7 +587,6 @@ async def test_entity_id_preserved_on_upgrade_when_in_storage( object_id="my", key=1, name="my", - unique_id="binary_sensor_my", ), ] states = [ @@ -660,7 +645,6 @@ async def test_deep_sleep_added_after_setup( object_id="test", key=1, name="test", - unique_id="test", ), ], states=[ @@ -732,7 +716,6 @@ async def test_entity_assignment_to_sub_device( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_sensor", device_id=0, ), # Entity for sub device 1 @@ -740,7 +723,6 @@ async def test_entity_assignment_to_sub_device( object_id="motion", key=2, name="Motion", - unique_id="motion", device_id=11111111, ), # Entity for sub device 2 @@ -748,7 +730,6 @@ async def test_entity_assignment_to_sub_device( object_id="door", key=3, name="Door", - unique_id="door", device_id=22222222, ), ] @@ -932,7 +913,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", # device_id omitted - entity belongs to main device ), ] @@ -964,7 +944,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", device_id=11111111, # Now on sub device 1 ), ] @@ -993,7 +972,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", device_id=22222222, # Now on sub device 2 ), ] @@ -1020,7 +998,6 @@ async def test_entity_switches_between_devices( object_id="sensor", key=1, name="Test Sensor", - unique_id="sensor", # device_id omitted - back to main device ), ] @@ -1063,7 +1040,6 @@ async def test_entity_id_uses_sub_device_name( object_id="main_sensor", key=1, name="Main Sensor", - unique_id="main_sensor", device_id=0, ), # Entity for sub device 1 @@ -1071,7 +1047,6 @@ async def test_entity_id_uses_sub_device_name( object_id="motion", key=2, name="Motion", - unique_id="motion", device_id=11111111, ), # Entity for sub device 2 @@ -1079,7 +1054,6 @@ async def test_entity_id_uses_sub_device_name( object_id="door", key=3, name="Door", - unique_id="door", device_id=22222222, ), # Entity without name on sub device @@ -1087,7 +1061,6 @@ async def test_entity_id_uses_sub_device_name( object_id="sensor_no_name", key=4, name="", - unique_id="sensor_no_name", device_id=11111111, ), ] @@ -1147,7 +1120,6 @@ async def test_entity_id_with_empty_sub_device_name( object_id="sensor", key=1, name="Sensor", - unique_id="sensor", device_id=11111111, ), ] @@ -1187,8 +1159,7 @@ async def test_unique_id_migration_when_entity_moves_between_devices( BinarySensorInfo( object_id="temperature", key=1, - name="Temperature", - unique_id="unused", # This field is not used by the integration + name="Temperature", # This field is not used by the integration device_id=0, # Main device ), ] @@ -1250,8 +1221,7 @@ async def test_unique_id_migration_when_entity_moves_between_devices( BinarySensorInfo( object_id="temperature", # Same object_id key=1, # Same key - this is what identifies the entity - name="Temperature", - unique_id="unused", # This field is not used + name="Temperature", # This field is not used device_id=22222222, # Now on sub-device ), ] @@ -1312,7 +1282,6 @@ async def test_unique_id_migration_sub_device_to_main_device( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=22222222, # On sub-device ), ] @@ -1347,7 +1316,6 @@ async def test_unique_id_migration_sub_device_to_main_device( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=0, # Now on main device ), ] @@ -1407,7 +1375,6 @@ async def test_unique_id_migration_between_sub_devices( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=22222222, # On kitchen_controller ), ] @@ -1442,7 +1409,6 @@ async def test_unique_id_migration_between_sub_devices( object_id="temperature", key=1, name="Temperature", - unique_id="unused", device_id=33333333, # Now on bedroom_controller ), ] @@ -1501,7 +1467,6 @@ async def test_entity_device_id_rename_in_yaml( object_id="sensor", key=1, name="Sensor", - unique_id="unused", device_id=11111111, ), ] @@ -1563,7 +1528,6 @@ async def test_entity_device_id_rename_in_yaml( object_id="sensor", # Same object_id key=1, # Same key name="Sensor", - unique_id="unused", device_id=99999999, # New device_id after rename ), ] @@ -1636,8 +1600,7 @@ async def test_entity_with_unicode_name( BinarySensorInfo( object_id=sanitized_object_id, # ESPHome sends the sanitized version key=1, - name=unicode_name, # But also sends the original Unicode name - unique_id="unicode_sensor", + name=unicode_name, # But also sends the original Unicode name, ) ] states = [BinarySensorState(key=1, state=True)] @@ -1677,8 +1640,7 @@ async def test_entity_without_name_uses_device_name_only( BinarySensorInfo( object_id="some_sanitized_id", key=1, - name="", # Empty name - unique_id="no_name_sensor", + name="", # Empty name, ) ] states = [BinarySensorState(key=1, state=True)] diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py index 886e5317462..044c3c7a8f1 100644 --- a/tests/components/esphome/test_entry_data.py +++ b/tests/components/esphome/test_entry_data.py @@ -15,49 +15,6 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockGenericDeviceEntryType -async def test_migrate_entity_unique_id( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - mock_client: APIClient, - mock_generic_device_entry: MockGenericDeviceEntryType, -) -> None: - """Test a generic sensor entity unique id migration.""" - entity_registry.async_get_or_create( - "sensor", - "esphome", - "my_sensor", - suggested_object_id="old_sensor", - disabled_by=None, - ) - entity_info = [ - SensorInfo( - object_id="mysensor", - key=1, - name="my sensor", - unique_id="my_sensor", - entity_category=ESPHomeEntityCategory.DIAGNOSTIC, - icon="mdi:leaf", - ) - ] - states = [SensorState(key=1, state=50)] - user_service = [] - await mock_generic_device_entry( - mock_client=mock_client, - entity_info=entity_info, - user_service=user_service, - states=states, - ) - state = hass.states.get("sensor.old_sensor") - assert state is not None - assert state.state == "50" - entry = entity_registry.async_get("sensor.old_sensor") - assert entry is not None - assert entity_registry.async_get_entity_id("sensor", "esphome", "my_sensor") is None - # Note that ESPHome includes the EntityInfo type in the unique id - # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) - assert entry.unique_id == "11:22:33:44:55:AA-sensor-mysensor" - - async def test_migrate_entity_unique_id_downgrade_upgrade( hass: HomeAssistant, entity_registry: er.EntityRegistry, @@ -84,7 +41,6 @@ async def test_migrate_entity_unique_id_downgrade_upgrade( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) diff --git a/tests/components/esphome/test_event.py b/tests/components/esphome/test_event.py index 2756aa6d251..3cff3184bf1 100644 --- a/tests/components/esphome/test_event.py +++ b/tests/components/esphome/test_event.py @@ -20,7 +20,6 @@ async def test_generic_event_entity( object_id="myevent", key=1, name="my event", - unique_id="my_event", event_types=["type1", "type2"], device_class=EventDeviceClass.BUTTON, ) diff --git a/tests/components/esphome/test_fan.py b/tests/components/esphome/test_fan.py index a33be1a6fca..763e95d3e6f 100644 --- a/tests/components/esphome/test_fan.py +++ b/tests/components/esphome/test_fan.py @@ -44,7 +44,6 @@ async def test_fan_entity_with_all_features_old_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=True, supports_speed=True, supports_oscillation=True, @@ -147,7 +146,6 @@ async def test_fan_entity_with_all_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supported_speed_count=4, supports_direction=True, supports_speed=True, @@ -317,7 +315,6 @@ async def test_fan_entity_with_no_features_new_api( object_id="myfan", key=1, name="my fan", - unique_id="my_fan", supports_direction=False, supports_speed=False, supports_oscillation=False, diff --git a/tests/components/esphome/test_light.py b/tests/components/esphome/test_light.py index 4377a714b17..bf602a6fa84 100644 --- a/tests/components/esphome/test_light.py +++ b/tests/components/esphome/test_light.py @@ -56,7 +56,6 @@ async def test_light_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF], @@ -98,7 +97,6 @@ async def test_light_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS], @@ -226,7 +224,6 @@ async def test_light_legacy_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[LightColorCapability.BRIGHTNESS, 2], @@ -282,7 +279,6 @@ async def test_light_brightness_on_off( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ESPColorMode.ON_OFF, ESPColorMode.BRIGHTNESS], @@ -358,7 +354,6 @@ async def test_light_legacy_white_converted_to_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -423,7 +418,6 @@ async def test_light_legacy_white_with_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode, color_mode_2], @@ -478,7 +472,6 @@ async def test_light_brightness_on_off_with_unknown_color_mode( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -555,7 +548,6 @@ async def test_light_on_and_brightness( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -607,7 +599,6 @@ async def test_rgb_color_temp_light( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=color_modes, @@ -698,7 +689,6 @@ async def test_light_rgb( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.ON_OFF @@ -821,7 +811,6 @@ async def test_light_rgbw( object_id="mylight", key=1, name="my light", - unique_id="my_light", supported_color_modes=[ LightColorCapability.RGB | LightColorCapability.WHITE @@ -991,7 +980,6 @@ async def test_light_rgbww_with_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1200,7 +1188,6 @@ async def test_light_rgbww_without_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[ @@ -1439,7 +1426,6 @@ async def test_light_color_temp( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1514,7 +1500,6 @@ async def test_light_color_temp_no_mireds_set( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=0, max_mireds=0, supported_color_modes=[ @@ -1610,7 +1595,6 @@ async def test_light_color_temp_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1695,7 +1679,6 @@ async def test_light_rgb_legacy( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153.846161, max_mireds=370.370361, supported_color_modes=[ @@ -1795,7 +1778,6 @@ async def test_light_effects( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, effects=["effect1", "effect2"], @@ -1859,7 +1841,6 @@ async def test_only_cold_warm_white_support( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_modes], @@ -1955,7 +1936,6 @@ async def test_light_no_color_modes( object_id="mylight", key=1, name="my light", - unique_id="my_light", min_mireds=153, max_mireds=400, supported_color_modes=[color_mode], diff --git a/tests/components/esphome/test_lock.py b/tests/components/esphome/test_lock.py index eaa03947a7d..93e9c0704c3 100644 --- a/tests/components/esphome/test_lock.py +++ b/tests/components/esphome/test_lock.py @@ -34,7 +34,6 @@ async def test_lock_entity_no_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=False, requires_code=False, ) @@ -72,7 +71,6 @@ async def test_lock_entity_start_locked( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", ) ] states = [LockEntityState(key=1, state=ESPHomeLockState.LOCKED)] @@ -99,7 +97,6 @@ async def test_lock_entity_supports_open( object_id="mylock", key=1, name="my lock", - unique_id="my_lock", supports_open=True, requires_code=True, ) diff --git a/tests/components/esphome/test_media_player.py b/tests/components/esphome/test_media_player.py index 6d7a3b220d1..232f7e1f06e 100644 --- a/tests/components/esphome/test_media_player.py +++ b/tests/components/esphome/test_media_player.py @@ -55,7 +55,6 @@ async def test_media_player_entity( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, ) ] @@ -202,7 +201,6 @@ async def test_media_player_entity_with_source( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, ) ] @@ -318,7 +316,6 @@ async def test_media_player_proxy( object_id="mymedia_player", key=1, name="my media_player", - unique_id="my_media_player", supports_pause=True, supported_formats=[ MediaPlayerSupportedFormat( @@ -477,7 +474,6 @@ async def test_media_player_formats_reload_preserves_data( object_id="test_media_player", key=1, name="Test Media Player", - unique_id="test_unique_id", supports_pause=True, supported_formats=supported_formats, ) diff --git a/tests/components/esphome/test_number.py b/tests/components/esphome/test_number.py index d7a59222d47..02b58649fec 100644 --- a/tests/components/esphome/test_number.py +++ b/tests/components/esphome/test_number.py @@ -35,7 +35,6 @@ async def test_generic_number_entity( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -75,7 +74,6 @@ async def test_generic_number_nan( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -107,7 +105,6 @@ async def test_generic_number_with_unit_of_measurement_as_empty_string( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, @@ -140,7 +137,6 @@ async def test_generic_number_entity_set_when_disconnected( object_id="mynumber", key=1, name="my number", - unique_id="my_number", max_value=100, min_value=0, step=1, diff --git a/tests/components/esphome/test_repairs.py b/tests/components/esphome/test_repairs.py index fed76ac580a..f5142367432 100644 --- a/tests/components/esphome/test_repairs.py +++ b/tests/components/esphome/test_repairs.py @@ -133,7 +133,6 @@ async def test_device_conflict_migration( object_id="mybinary_sensor", key=1, name="my binary_sensor", - unique_id="my_binary_sensor", is_status_binary_sensor=True, ) ] diff --git a/tests/components/esphome/test_select.py b/tests/components/esphome/test_select.py index 6b7415889d8..14673f5ffb9 100644 --- a/tests/components/esphome/test_select.py +++ b/tests/components/esphome/test_select.py @@ -67,7 +67,6 @@ async def test_select_generic_entity( object_id="myselect", key=1, name="my select", - unique_id="my_select", options=["a", "b"], ) ] diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index e520b6ca259..6d3d59b9b4a 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -54,7 +54,6 @@ async def test_generic_numeric_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=50)] @@ -110,7 +109,6 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) @@ -147,7 +145,6 @@ async def test_generic_numeric_sensor_state_class_measurement( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", state_class=ESPHomeSensorStateClass.MEASUREMENT, device_class="power", unit_of_measurement="W", @@ -184,7 +181,6 @@ async def test_generic_numeric_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class="timestamp", ) ] @@ -212,7 +208,6 @@ async def test_generic_numeric_sensor_legacy_last_reset_convert( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", legacy_last_reset_type=LastResetType.AUTO, state_class=ESPHomeSensorStateClass.MEASUREMENT, ) @@ -242,7 +237,6 @@ async def test_generic_numeric_sensor_no_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [] @@ -269,7 +263,6 @@ async def test_generic_numeric_sensor_nan_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=math.nan, missing_state=False)] @@ -296,7 +289,6 @@ async def test_generic_numeric_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [SensorState(key=1, state=True, missing_state=True)] @@ -323,7 +315,6 @@ async def test_generic_text_sensor( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state="i am a teapot")] @@ -350,7 +341,6 @@ async def test_generic_text_sensor_missing_state( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", ) ] states = [TextSensorState(key=1, state=True, missing_state=True)] @@ -377,7 +367,6 @@ async def test_generic_text_sensor_device_class_timestamp( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.TIMESTAMP, ) ] @@ -406,7 +395,6 @@ async def test_generic_text_sensor_device_class_date( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", device_class=SensorDeviceClass.DATE, ) ] @@ -435,7 +423,6 @@ async def test_generic_numeric_sensor_empty_string_uom( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", unit_of_measurement="", ) ] @@ -493,7 +480,6 @@ async def test_suggested_display_precision_by_device_class( object_id="mysensor", key=1, name="my sensor", - unique_id="my_sensor", accuracy_decimals=expected_precision, device_class=device_class.value, unit_of_measurement=unit_of_measurement, diff --git a/tests/components/esphome/test_switch.py b/tests/components/esphome/test_switch.py index c62101125bd..2d054a7317d 100644 --- a/tests/components/esphome/test_switch.py +++ b/tests/components/esphome/test_switch.py @@ -26,7 +26,6 @@ async def test_switch_generic_entity( object_id="myswitch", key=1, name="my switch", - unique_id="my_switch", ) ] states = [SwitchState(key=1, state=True)] @@ -78,14 +77,12 @@ async def test_switch_sub_device_non_zero_device_id( object_id="main_switch", key=1, name="Main Switch", - unique_id="main_switch_1", device_id=0, # Main device ), SwitchInfo( object_id="sub_switch", key=2, name="Sub Switch", - unique_id="sub_switch_1", device_id=11111111, # Sub-device ), ] diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py index f8c1d33e224..b1e84544e3e 100644 --- a/tests/components/esphome/test_text.py +++ b/tests/components/esphome/test_text.py @@ -26,7 +26,6 @@ async def test_generic_text_entity( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -66,7 +65,6 @@ async def test_generic_text_entity_no_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, @@ -97,7 +95,6 @@ async def test_generic_text_entity_missing_state( object_id="mytext", key=1, name="my text", - unique_id="my_text", max_length=100, min_length=0, pattern=None, diff --git a/tests/components/esphome/test_time.py b/tests/components/esphome/test_time.py index 75e2a0dc664..176510d4e65 100644 --- a/tests/components/esphome/test_time.py +++ b/tests/components/esphome/test_time.py @@ -26,7 +26,6 @@ async def test_generic_time_entity( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, hour=12, minute=34, second=56)] @@ -62,7 +61,6 @@ async def test_generic_time_missing_state( object_id="mytime", key=1, name="my time", - unique_id="my_time", ) ] states = [TimeState(key=1, missing_state=True)] diff --git a/tests/components/esphome/test_update.py b/tests/components/esphome/test_update.py index 96b77281485..859189f5ed9 100644 --- a/tests/components/esphome/test_update.py +++ b/tests/components/esphome/test_update.py @@ -436,7 +436,6 @@ async def test_generic_device_update_entity( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -470,7 +469,6 @@ async def test_generic_device_update_entity_has_update( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] states = [ @@ -561,7 +559,6 @@ async def test_update_entity_release_notes( object_id="myupdate", key=1, name="my update", - unique_id="my_update", ) ] diff --git a/tests/components/esphome/test_valve.py b/tests/components/esphome/test_valve.py index aaa52551115..4f57a27708c 100644 --- a/tests/components/esphome/test_valve.py +++ b/tests/components/esphome/test_valve.py @@ -36,7 +36,6 @@ async def test_valve_entity( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=True, supports_stop=True, ) @@ -134,7 +133,6 @@ async def test_valve_entity_without_position( object_id="myvalve", key=1, name="my valve", - unique_id="my_valve", supports_position=False, supports_stop=False, ) From 3877a6211ace42af4afb537ab3b58c6d0b69abc7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 18 Jul 2025 19:56:19 +0200 Subject: [PATCH 1535/1664] Ensure Lokalise download runs as the same user as GitHub Actions (#149026) --- script/translations/download.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/script/translations/download.py b/script/translations/download.py index 3fa7065d058..6a0d6ba824c 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import os from pathlib import Path import re import subprocess @@ -20,13 +21,15 @@ DOWNLOAD_DIR = Path("build/translations-download").absolute() def run_download_docker(): """Run the Docker image to download the translations.""" print("Running Docker to download latest translations.") - run = subprocess.run( + result = subprocess.run( [ "docker", "run", "-v", f"{DOWNLOAD_DIR}:/opt/dest/locale", "--rm", + "--user", + f"{os.getuid()}:{os.getgid()}", f"lokalise/lokalise-cli-2:{CLI_2_DOCKER_IMAGE}", # Lokalise command "lokalise2", @@ -52,7 +55,7 @@ def run_download_docker(): ) print() - if run.returncode != 0: + if result.returncode != 0: raise ExitApp("Failed to download translations") From 33cc257e759fc3bbcb2c0afc0a7e78600a9302e9 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 18 Jul 2025 14:38:53 -0400 Subject: [PATCH 1536/1664] Consolidate template integration's config schemas (#149018) --- .../template/alarm_control_panel.py | 113 ++++++++---------- .../components/template/binary_sensor.py | 51 ++++---- homeassistant/components/template/button.py | 31 ++--- homeassistant/components/template/config.py | 32 ++--- homeassistant/components/template/const.py | 14 ++- homeassistant/components/template/cover.py | 6 +- homeassistant/components/template/fan.py | 6 +- homeassistant/components/template/helpers.py | 44 +++++++ homeassistant/components/template/image.py | 26 ++-- homeassistant/components/template/light.py | 6 +- homeassistant/components/template/lock.py | 3 +- homeassistant/components/template/number.py | 64 +++++----- homeassistant/components/template/select.py | 64 ++++++---- homeassistant/components/template/sensor.py | 63 ++++++---- homeassistant/components/template/switch.py | 61 +++++----- .../components/template/template_entity.py | 12 +- homeassistant/components/template/vacuum.py | 6 +- homeassistant/components/template/weather.py | 36 +++++- 18 files changed, 385 insertions(+), 253 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index cd70a7d44e0..f95fc0dbab7 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -21,7 +21,6 @@ from homeassistant.components.alarm_control_panel import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CODE, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_UNIQUE_ID, @@ -31,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -43,8 +42,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import DOMAIN from .coordinator import TriggerUpdateCoordinator from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -88,27 +95,28 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Alarm Control Panel" -ALARM_CONTROL_PANEL_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional( - CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name - ): cv.enum(TemplateCodeFormat), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } - ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, + vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( + TemplateCodeFormat + ), + vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_STATE): cv.template, + vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, + } ) +ALARM_CONTROL_PANEL_YAML_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) -LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( +ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, @@ -130,59 +138,29 @@ LEGACY_ALARM_CONTROL_PANEL_SCHEMA = vol.Schema( PLATFORM_SCHEMA = ALARM_CONTROL_PANEL_PLATFORM_SCHEMA.extend( { vol.Required(CONF_ALARM_CONTROL_PANELS): cv.schema_with_slug_keys( - LEGACY_ALARM_CONTROL_PANEL_SCHEMA + ALARM_CONTROL_PANEL_LEGACY_YAML_SCHEMA ), } ) -ALARM_CONTROL_PANEL_CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ARM_AWAY_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_CUSTOM_BYPASS_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_HOME_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_NIGHT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_ARM_VACATION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, - vol.Optional(CONF_CODE_FORMAT, default=TemplateCodeFormat.number.name): cv.enum( - TemplateCodeFormat - ), - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - vol.Optional(CONF_DISARM_ACTION): cv.SCRIPT_SCHEMA, - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TRIGGER_ACTION): cv.SCRIPT_SCHEMA, - } +ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA = ALARM_CONTROL_PANEL_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -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_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA(_options) - async_add_entities( - [ - StateAlarmControlPanelEntity( - hass, - validated_config, - config_entry.entry_id, - ) - ] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) @@ -211,11 +189,14 @@ def async_create_preview_alarm_control_panel( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateAlarmControlPanelEntity: """Create a preview alarm control panel.""" - updated_config = rewrite_options_to_modern_conf(config) - validated_config = ALARM_CONTROL_PANEL_CONFIG_SCHEMA( - updated_config | {CONF_NAME: name} + return async_setup_template_preview( + hass, + name, + config, + StateAlarmControlPanelEntity, + ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA, + True, ) - return StateAlarmControlPanelEntity(hass, validated_config, None) class AbstractTemplateAlarmControlPanel( diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index caac43712a7..e8b8efbda0a 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -22,7 +22,6 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME_TEMPLATE, CONF_ICON_TEMPLATE, @@ -38,7 +37,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -50,8 +49,16 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_AVAILABILITY_TEMPLATE -from .helpers import async_setup_template_platform -from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, +) from .trigger_entity import TriggerEntity CONF_DELAY_ON = "delay_on" @@ -64,7 +71,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -BINARY_SENSOR_SCHEMA = vol.Schema( +BINARY_SENSOR_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_AUTO_OFF): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), @@ -73,15 +80,17 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Required(CONF_STATE): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } -).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) - -BINARY_SENSOR_CONFIG_SCHEMA = BINARY_SENSOR_SCHEMA.extend( - { - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } ) -LEGACY_BINARY_SENSOR_SCHEMA = vol.All( +BINARY_SENSOR_YAML_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_SCHEMA.schema +) + +BINARY_SENSOR_CONFIG_ENTRY_SCHEMA = BINARY_SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + +BINARY_SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -106,7 +115,7 @@ LEGACY_BINARY_SENSOR_SCHEMA = vol.All( PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend( { vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( - LEGACY_BINARY_SENSOR_SCHEMA + BINARY_SENSOR_LEGACY_YAML_SCHEMA ), } ) @@ -138,11 +147,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [StateBinarySensorEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateBinarySensorEntity, + BINARY_SENSOR_CONFIG_ENTRY_SCHEMA, ) @@ -151,8 +161,9 @@ def async_create_preview_binary_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateBinarySensorEntity: """Create a preview sensor.""" - validated_config = BINARY_SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateBinarySensorEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateBinarySensorEntity, BINARY_SENSOR_CONFIG_ENTRY_SCHEMA + ) class StateBinarySensorEntity(TemplateEntity, BinarySensorEntity, RestoreEntity): diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index 26d339b7e33..d84005ccc28 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -14,9 +14,9 @@ from homeassistant.components.button import ( ButtonEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICE_ID, CONF_NAME +from homeassistant.const import CONF_DEVICE_CLASS from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -24,29 +24,31 @@ from homeassistant.helpers.entity_platform import ( from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import CONF_PRESS, DOMAIN -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import async_setup_template_entry, async_setup_template_platform +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Template Button" DEFAULT_OPTIMISTIC = False -BUTTON_SCHEMA = vol.Schema( +BUTTON_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -CONFIG_BUTTON_SCHEMA = vol.Schema( +BUTTON_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_PRESS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -73,11 +75,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = CONFIG_BUTTON_SCHEMA(_options) - async_add_entities( - [StateButtonEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateButtonEntity, + BUTTON_CONFIG_ENTRY_SCHEMA, ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 1b3e9986d36..a3311c35563 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -102,57 +102,57 @@ CONFIG_SECTION_SCHEMA = vol.All( { vol.Optional(CONF_ACTIONS): cv.SCRIPT_SCHEMA, vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys( - binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA + binary_sensor_platform.BINARY_SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_CONDITIONS): cv.CONDITIONS_SCHEMA, vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys( - sensor_platform.LEGACY_SENSOR_SCHEMA + sensor_platform.SENSOR_LEGACY_YAML_SCHEMA ), vol.Optional(CONF_TRIGGERS): cv.TRIGGER_SCHEMA, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA, vol.Optional(DOMAIN_ALARM_CONTROL_PANEL): vol.All( cv.ensure_list, - [alarm_control_panel_platform.ALARM_CONTROL_PANEL_SCHEMA], + [alarm_control_panel_platform.ALARM_CONTROL_PANEL_YAML_SCHEMA], ), vol.Optional(DOMAIN_BINARY_SENSOR): vol.All( - cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA] + cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_BUTTON): vol.All( - cv.ensure_list, [button_platform.BUTTON_SCHEMA] + cv.ensure_list, [button_platform.BUTTON_YAML_SCHEMA] ), vol.Optional(DOMAIN_COVER): vol.All( - cv.ensure_list, [cover_platform.COVER_SCHEMA] + cv.ensure_list, [cover_platform.COVER_YAML_SCHEMA] ), vol.Optional(DOMAIN_FAN): vol.All( - cv.ensure_list, [fan_platform.FAN_SCHEMA] + cv.ensure_list, [fan_platform.FAN_YAML_SCHEMA] ), vol.Optional(DOMAIN_IMAGE): vol.All( - cv.ensure_list, [image_platform.IMAGE_SCHEMA] + cv.ensure_list, [image_platform.IMAGE_YAML_SCHEMA] ), vol.Optional(DOMAIN_LIGHT): vol.All( - cv.ensure_list, [light_platform.LIGHT_SCHEMA] + cv.ensure_list, [light_platform.LIGHT_YAML_SCHEMA] ), vol.Optional(DOMAIN_LOCK): vol.All( - cv.ensure_list, [lock_platform.LOCK_SCHEMA] + cv.ensure_list, [lock_platform.LOCK_YAML_SCHEMA] ), vol.Optional(DOMAIN_NUMBER): vol.All( - cv.ensure_list, [number_platform.NUMBER_SCHEMA] + cv.ensure_list, [number_platform.NUMBER_YAML_SCHEMA] ), vol.Optional(DOMAIN_SELECT): vol.All( - cv.ensure_list, [select_platform.SELECT_SCHEMA] + cv.ensure_list, [select_platform.SELECT_YAML_SCHEMA] ), vol.Optional(DOMAIN_SENSOR): vol.All( - cv.ensure_list, [sensor_platform.SENSOR_SCHEMA] + cv.ensure_list, [sensor_platform.SENSOR_YAML_SCHEMA] ), vol.Optional(DOMAIN_SWITCH): vol.All( - cv.ensure_list, [switch_platform.SWITCH_SCHEMA] + cv.ensure_list, [switch_platform.SWITCH_YAML_SCHEMA] ), vol.Optional(DOMAIN_VACUUM): vol.All( - cv.ensure_list, [vacuum_platform.VACUUM_SCHEMA] + cv.ensure_list, [vacuum_platform.VACUUM_YAML_SCHEMA] ), vol.Optional(DOMAIN_WEATHER): vol.All( - cv.ensure_list, [weather_platform.WEATHER_SCHEMA] + cv.ensure_list, [weather_platform.WEATHER_YAML_SCHEMA] ), }, ), diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 53c0fa3af13..e3e0e4fe9f5 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -1,6 +1,9 @@ """Constants for the Template Platform Components.""" -from homeassistant.const import Platform +import voluptuous as vol + +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_ATTRIBUTE_TEMPLATES = "attribute_templates" @@ -16,6 +19,15 @@ CONF_STEP = "step" CONF_TURN_OFF = "turn_off" CONF_TURN_ON = "turn_on" +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + DOMAIN = "template" PLATFORM_STORAGE_KEY = "template_platforms" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index bceac7811f4..0bbc6b77f57 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -91,7 +91,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Cover" -COVER_SCHEMA = vol.All( +COVER_YAML_SCHEMA = vol.All( vol.Schema( { vol.Inclusive(CLOSE_ACTION, CONF_OPEN_AND_CLOSE): cv.SCRIPT_SCHEMA, @@ -110,7 +110,7 @@ COVER_SCHEMA = vol.All( cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) -LEGACY_COVER_SCHEMA = vol.All( +COVER_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -134,7 +134,7 @@ LEGACY_COVER_SCHEMA = vol.All( ) PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(LEGACY_COVER_SCHEMA)} + {vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_LEGACY_YAML_SCHEMA)} ) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 34faba353d0..13d2414aea2 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -81,7 +81,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Fan" -FAN_SCHEMA = vol.All( +FAN_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_DIRECTION): cv.template, @@ -101,7 +101,7 @@ FAN_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) -LEGACY_FAN_SCHEMA = vol.All( +FAN_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -126,7 +126,7 @@ LEGACY_FAN_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_FANS): cv.schema_with_slug_keys(LEGACY_FAN_SCHEMA)} + {vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_LEGACY_YAML_SCHEMA)} ) diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py index 514255f417a..c0177e9dd5d 100644 --- a/homeassistant/components/template/helpers.py +++ b/homeassistant/components/template/helpers.py @@ -5,14 +5,19 @@ import itertools import logging from typing import Any +import voluptuous as vol + from homeassistant.components import blueprint +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, + CONF_STATE, CONF_UNIQUE_ID, + CONF_VALUE_TEMPLATE, SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, callback @@ -20,6 +25,7 @@ from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import template from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import ( + AddConfigEntryEntitiesCallback, AddEntitiesCallback, async_get_platforms, ) @@ -228,3 +234,41 @@ async def async_setup_template_platform( discovery_info["entities"], discovery_info["unique_id"], ) + + +async def async_setup_template_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, + state_entity_cls: type[TemplateEntity], + config_schema: vol.Schema, + replace_value_template: bool = False, +) -> None: + """Setup the Template from a config entry.""" + options = dict(config_entry.options) + options.pop("template_type") + + if replace_value_template and CONF_VALUE_TEMPLATE in options: + options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE) + + validated_config = config_schema(options) + + async_add_entities( + [state_entity_cls(hass, validated_config, config_entry.entry_id)] + ) + + +def async_setup_template_preview[T: TemplateEntity]( + hass: HomeAssistant, + name: str, + config: ConfigType, + state_entity_cls: type[T], + schema: vol.Schema, + replace_value_template: bool = False, +) -> T: + """Setup the Template preview.""" + if replace_value_template and CONF_VALUE_TEMPLATE in config: + config[CONF_STATE] = config.pop(CONF_VALUE_TEMPLATE) + + validated_config = schema(config | {CONF_NAME: name}) + return state_entity_cls(hass, validated_config, None) diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py index 57e7c6ffc55..b4513fc2447 100644 --- a/homeassistant/components/template/image.py +++ b/homeassistant/components/template/image.py @@ -13,10 +13,10 @@ from homeassistant.components.image import ( ImageEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -26,8 +26,9 @@ from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_PICTURE -from .helpers import async_setup_template_platform +from .helpers import async_setup_template_entry, async_setup_template_platform from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TemplateEntity, make_template_entity_common_modern_attributes_schema, ) @@ -39,7 +40,7 @@ DEFAULT_NAME = "Template Image" GET_IMAGE_TIMEOUT = 10 -IMAGE_SCHEMA = vol.Schema( +IMAGE_YAML_SCHEMA = vol.Schema( { vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, @@ -47,14 +48,12 @@ IMAGE_SCHEMA = vol.Schema( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) -IMAGE_CONFIG_SCHEMA = vol.Schema( +IMAGE_CONFIG_ENTRY_SCHEMA = vol.Schema( { - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_URL): cv.template, vol.Optional(CONF_VERIFY_SSL, default=True): bool, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } -) +).extend(TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema) async def async_setup_platform( @@ -81,11 +80,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = IMAGE_CONFIG_SCHEMA(_options) - async_add_entities( - [StateImageEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateImageEntity, + IMAGE_CONFIG_ENTRY_SCHEMA, ) diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index fb97d95db3d..802fc145427 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -121,7 +121,7 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Light" -LIGHT_SCHEMA = vol.Schema( +LIGHT_YAML_SCHEMA = vol.Schema( { vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA, vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template, @@ -147,7 +147,7 @@ LIGHT_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -LEGACY_LIGHT_SCHEMA = vol.All( +LIGHT_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -186,7 +186,7 @@ PLATFORM_SCHEMA = vol.All( cv.removed(CONF_WHITE_VALUE_ACTION), cv.removed(CONF_WHITE_VALUE_TEMPLATE), LIGHT_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)} + {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_LEGACY_YAML_SCHEMA)} ), ) diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 581a037c3d7..a2f1f56bea2 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -54,7 +54,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -LOCK_SCHEMA = vol.All( +LOCK_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_CODE_FORMAT): cv.template, @@ -68,7 +68,6 @@ LOCK_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) ) - PLATFORM_SCHEMA = LOCK_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_CODE_FORMAT_TEMPLATE): cv.template, diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index e0b8e7594ce..31a6338f594 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -18,14 +18,13 @@ from homeassistant.components.number import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -34,8 +33,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_MAX, CONF_MIN, CONF_STEP, DOMAIN -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -45,30 +52,31 @@ CONF_SET_VALUE = "set_value" DEFAULT_NAME = "Template Number" DEFAULT_OPTIMISTIC = False -NUMBER_SCHEMA = vol.Schema( +NUMBER_COMMON_SCHEMA = vol.Schema( { - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Required(CONF_STEP): cv.template, - vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -NUMBER_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): cv.template, + vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, vol.Required(CONF_STATE): cv.template, vol.Required(CONF_STEP): cv.template, - vol.Required(CONF_SET_VALUE): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_MIN): cv.template, - vol.Optional(CONF_MAX): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), } ) +NUMBER_YAML_SCHEMA = ( + vol.Schema( + { + vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, + } + ) + .extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) + .extend(NUMBER_COMMON_SCHEMA.schema) +) + +NUMBER_CONFIG_ENTRY_SCHEMA = NUMBER_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -94,11 +102,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = NUMBER_CONFIG_SCHEMA(_options) - async_add_entities( - [StateNumberEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateNumberEntity, + NUMBER_CONFIG_ENTRY_SCHEMA, ) @@ -107,8 +116,9 @@ def async_create_preview_number( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateNumberEntity: """Create a preview number.""" - validated_config = NUMBER_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateNumberEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateNumberEntity, NUMBER_CONFIG_ENTRY_SCHEMA + ) class StateNumberEntity(TemplateEntity, NumberEntity): diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 4273af6db28..0ad99cd6ae8 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -15,9 +15,9 @@ from homeassistant.components.select import ( SelectEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, CONF_OPTIMISTIC, CONF_STATE +from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_STATE from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, selector +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -27,8 +27,16 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import DOMAIN from .entity import AbstractTemplateEntity -from .helpers import async_setup_template_platform -from .template_entity import TemplateEntity, make_template_entity_common_modern_schema +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TemplateEntity, + make_template_entity_common_modern_schema, +) from .trigger_entity import TriggerEntity _LOGGER = logging.getLogger(__name__) @@ -39,26 +47,28 @@ CONF_SELECT_OPTION = "select_option" DEFAULT_NAME = "Template Select" DEFAULT_OPTIMISTIC = False -SELECT_SCHEMA = vol.Schema( +SELECT_COMMON_SCHEMA = vol.Schema( { - vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Required(ATTR_OPTIONS): cv.template, - vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) - - -SELECT_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Required(CONF_STATE): cv.template, - vol.Required(CONF_OPTIONS): cv.template, + vol.Optional(ATTR_OPTIONS): cv.template, vol.Optional(CONF_SELECT_OPTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + vol.Optional(CONF_STATE): cv.template, } ) +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_CONFIG_ENTRY_SCHEMA = SELECT_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema +) + async def async_setup_platform( hass: HomeAssistant, @@ -84,10 +94,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SELECT_CONFIG_SCHEMA(_options) - async_add_entities([TemplateSelect(hass, validated_config, config_entry.entry_id)]) + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + TemplateSelect, + SELECT_CONFIG_ENTRY_SCHEMA, + ) @callback @@ -95,8 +108,9 @@ def async_create_preview_select( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> TemplateSelect: """Create a preview select.""" - validated_config = SELECT_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return TemplateSelect(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, TemplateSelect, SELECT_CONFIG_ENTRY_SCHEMA + ) class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity): diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 6fc0588d9c7..ff956c50c6e 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA, + STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, SensorEntity, @@ -25,7 +26,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, CONF_DEVICE_CLASS, - CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_FRIENDLY_NAME_TEMPLATE, @@ -43,19 +43,26 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, ) -from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from . import TriggerUpdateCoordinator from .const import CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE -from .helpers import async_setup_template_platform -from .template_entity import TEMPLATE_ENTITY_COMMON_SCHEMA, TemplateEntity +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) +from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, + TEMPLATE_ENTITY_COMMON_SCHEMA, + TemplateEntity, +) from .trigger_entity import TriggerEntity LEGACY_FIELDS = { @@ -77,29 +84,31 @@ def validate_last_reset(val): return val -SENSOR_SCHEMA = vol.All( +SENSOR_COMMON_SCHEMA = vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +) + +SENSOR_YAML_SCHEMA = vol.All( vol.Schema( { - vol.Required(CONF_STATE): cv.template, vol.Optional(ATTR_LAST_RESET): cv.template, } ) - .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) + .extend(SENSOR_COMMON_SCHEMA.schema) .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema), validate_last_reset, ) - -SENSOR_CONFIG_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } - ).extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema), +SENSOR_CONFIG_ENTRY_SCHEMA = SENSOR_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) -LEGACY_SENSOR_SCHEMA = vol.All( +SENSOR_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -141,7 +150,9 @@ PLATFORM_SCHEMA = vol.All( { vol.Optional(CONF_TRIGGER): cv.match_all, # to raise custom warning vol.Optional(CONF_TRIGGERS): cv.match_all, # to raise custom warning - vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(LEGACY_SENSOR_SCHEMA), + vol.Required(CONF_SENSORS): cv.schema_with_slug_keys( + SENSOR_LEGACY_YAML_SCHEMA + ), } ), extra_validation_checks, @@ -176,11 +187,12 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - validated_config = SENSOR_CONFIG_SCHEMA(_options) - async_add_entities( - [StateSensorEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSensorEntity, + SENSOR_CONFIG_ENTRY_SCHEMA, ) @@ -189,8 +201,9 @@ def async_create_preview_sensor( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateSensorEntity: """Create a preview sensor.""" - validated_config = SENSOR_CONFIG_SCHEMA(config | {CONF_NAME: name}) - return StateSensorEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, name, config, StateSensorEntity, SENSOR_CONFIG_ENTRY_SCHEMA + ) class StateSensorEntity(TemplateEntity, SensorEntity): diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 7c1abd6d852..b1d72084ae7 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, - CONF_DEVICE_ID, CONF_NAME, CONF_STATE, CONF_SWITCHES, @@ -29,7 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv, selector, template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity_platform import ( AddConfigEntryEntitiesCallback, AddEntitiesCallback, @@ -39,8 +38,13 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import TriggerUpdateCoordinator from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN -from .helpers import async_setup_template_platform +from .helpers import ( + async_setup_template_entry, + async_setup_template_platform, + async_setup_template_preview, +) from .template_entity import ( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA, TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY, TemplateEntity, make_template_entity_common_modern_schema, @@ -55,16 +59,19 @@ LEGACY_FIELDS = { DEFAULT_NAME = "Template Switch" - -SWITCH_SCHEMA = vol.Schema( +SWITCH_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_STATE): cv.template, - vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, } -).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) +) -LEGACY_SWITCH_SCHEMA = vol.All( +SWITCH_YAML_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + make_template_entity_common_modern_schema(DEFAULT_NAME).schema +) + +SWITCH_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(ATTR_ENTITY_ID), vol.Schema( { @@ -79,17 +86,11 @@ LEGACY_SWITCH_SCHEMA = vol.All( ) PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend( - {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)} + {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_LEGACY_YAML_SCHEMA)} ) -SWITCH_CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_NAME): cv.template, - vol.Optional(CONF_STATE): cv.template, - vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), - } +SWITCH_CONFIG_ENTRY_SCHEMA = SWITCH_COMMON_SCHEMA.extend( + TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA.schema ) @@ -129,12 +130,13 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Initialize config entry.""" - _options = dict(config_entry.options) - _options.pop("template_type") - _options = rewrite_options_to_modern_conf(_options) - validated_config = SWITCH_CONFIG_SCHEMA(_options) - async_add_entities( - [StateSwitchEntity(hass, validated_config, config_entry.entry_id)] + await async_setup_template_entry( + hass, + config_entry, + async_add_entities, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, ) @@ -143,9 +145,14 @@ def async_create_preview_switch( hass: HomeAssistant, name: str, config: dict[str, Any] ) -> StateSwitchEntity: """Create a preview switch.""" - updated_config = rewrite_options_to_modern_conf(config) - validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name}) - return StateSwitchEntity(hass, validated_config, None) + return async_setup_template_preview( + hass, + name, + config, + StateSwitchEntity, + SWITCH_CONFIG_ENTRY_SCHEMA, + True, + ) class StateSwitchEntity(TemplateEntity, SwitchEntity, RestoreEntity): diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index b5081189cf3..ae473854502 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.components.blueprint import CONF_USE_BLUEPRINT from homeassistant.const import ( + CONF_DEVICE_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_ICON, CONF_ICON_TEMPLATE, @@ -30,7 +31,7 @@ from homeassistant.core import ( validate_state, ) from homeassistant.exceptions import TemplateError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import ( TrackTemplate, @@ -46,7 +47,6 @@ from homeassistant.helpers.template import ( result_as_boolean, ) from homeassistant.helpers.trigger_template_entity import ( - TEMPLATE_ENTITY_BASE_SCHEMA, make_template_entity_base_schema, ) from homeassistant.helpers.typing import ConfigType @@ -57,6 +57,7 @@ from .const import ( CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE, CONF_PICTURE, + TEMPLATE_ENTITY_BASE_SCHEMA, ) from .entity import AbstractTemplateEntity @@ -91,6 +92,13 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = ( .extend(TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA.schema) ) +TEMPLATE_ENTITY_COMMON_CONFIG_ENTRY_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.template, + vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(), + } +).extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema) + def make_template_entity_common_modern_schema( default_name: str, diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 143eb837bb5..0056eca9b99 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -76,7 +76,7 @@ LEGACY_FIELDS = { CONF_VALUE_TEMPLATE: CONF_STATE, } -VACUUM_SCHEMA = vol.All( +VACUUM_YAML_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_BATTERY_LEVEL): cv.template, @@ -94,7 +94,7 @@ VACUUM_SCHEMA = vol.All( ).extend(make_template_entity_common_modern_attributes_schema(DEFAULT_NAME).schema) ) -LEGACY_VACUUM_SCHEMA = vol.All( +VACUUM_LEGACY_YAML_SCHEMA = vol.All( cv.deprecated(CONF_ENTITY_ID), vol.Schema( { @@ -119,7 +119,7 @@ LEGACY_VACUUM_SCHEMA = vol.All( ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(LEGACY_VACUUM_SCHEMA)} + {vol.Required(CONF_VACUUMS): cv.schema_with_slug_keys(VACUUM_LEGACY_YAML_SCHEMA)} ) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 671a2ad0bac..15c6fb4db9e 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -31,7 +31,12 @@ from homeassistant.components.weather import ( WeatherEntity, WeatherEntityFeature, ) -from homeassistant.const import CONF_TEMPERATURE_UNIT, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template @@ -100,7 +105,7 @@ CONF_APPARENT_TEMPERATURE_TEMPLATE = "apparent_temperature_template" DEFAULT_NAME = "Template Weather" -WEATHER_SCHEMA = vol.Schema( +WEATHER_YAML_SCHEMA = vol.Schema( { vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, @@ -126,7 +131,32 @@ WEATHER_SCHEMA = vol.Schema( } ).extend(make_template_entity_common_modern_schema(DEFAULT_NAME).schema) -PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema) +PLATFORM_SCHEMA = vol.Schema( + { + vol.Optional(CONF_APPARENT_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_ATTRIBUTION_TEMPLATE): cv.template, + vol.Optional(CONF_CLOUD_COVERAGE_TEMPLATE): cv.template, + vol.Required(CONF_CONDITION_TEMPLATE): cv.template, + vol.Optional(CONF_DEW_POINT_TEMPLATE): cv.template, + vol.Required(CONF_HUMIDITY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_HOURLY_TEMPLATE): cv.template, + vol.Optional(CONF_FORECAST_TWICE_DAILY_TEMPLATE): cv.template, + vol.Optional(CONF_OZONE_TEMPLATE): cv.template, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template, + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_PRESSURE_TEMPLATE): cv.template, + vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), + vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), + vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_GUST_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_TEMPLATE): cv.template, + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(SpeedConverter.VALID_UNITS), + } +).extend(WEATHER_PLATFORM_SCHEMA.schema) async def async_setup_platform( From 380c7379018ebdcd25a8240cc5b588d00bf3f55e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 18 Jul 2025 20:41:59 +0200 Subject: [PATCH 1537/1664] Add reorder option to entity selector (#149002) --- homeassistant/helpers/selector.py | 2 ++ tests/components/blueprint/snapshots/test_importer.ambr | 2 ++ tests/helpers/test_selector.py | 5 +++++ tests/helpers/test_service.py | 2 ++ 4 files changed, 11 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 7bd1ee9ddf3..9eaedc6f5ef 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -813,6 +813,7 @@ class EntitySelectorConfig(BaseSelectorConfig, EntityFilterSelectorConfig, total exclude_entities: list[str] include_entities: list[str] multiple: bool + reorder: bool filter: EntityFilterSelectorConfig | list[EntityFilterSelectorConfig] @@ -829,6 +830,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): vol.Optional("exclude_entities"): [str], vol.Optional("include_entities"): [str], vol.Optional("multiple", default=False): cv.boolean, + vol.Optional("reorder", default=False): cv.boolean, vol.Optional("filter"): vol.All( cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA], diff --git a/tests/components/blueprint/snapshots/test_importer.ambr b/tests/components/blueprint/snapshots/test_importer.ambr index 38cb3b485d4..fdfd3f6b285 100644 --- a/tests/components/blueprint/snapshots/test_importer.ambr +++ b/tests/components/blueprint/snapshots/test_importer.ambr @@ -203,6 +203,7 @@ 'light', ]), 'multiple': False, + 'reorder': False, }), }), }), @@ -217,6 +218,7 @@ 'binary_sensor', ]), 'multiple': False, + 'reorder': False, }), }), }), diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index dc25206177b..9e8f1b15311 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -231,6 +231,11 @@ def test_device_selector_schema_error(schema) -> None: ["sensor.abc123", "sensor.ghi789"], ), ), + ( + {"multiple": True, "reorder": True}, + ((["sensor.abc123", "sensor.def456"],)), + (None, "abc123", ["sensor.abc123", None]), + ), ( {"filter": {"domain": "light"}}, ("light.abc123", FAKE_UUID), diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index f4d0846c262..8f094536988 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1091,6 +1091,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: } ], "multiple": False, + "reorder": False, }, }, }, @@ -1113,6 +1114,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None: } ], "multiple": False, + "reorder": False, }, }, }, From f90e06fde1c8e61b5f02f3c20853457078c625df Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 18 Jul 2025 22:27:48 -0700 Subject: [PATCH 1538/1664] Add attachment support in ollama ai task (#148981) --- homeassistant/components/ollama/ai_task.py | 5 +- homeassistant/components/ollama/entity.py | 9 ++ homeassistant/components/ollama/strings.json | 5 + tests/components/ollama/test_ai_task.py | 116 ++++++++++++++++++- 4 files changed, 133 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ollama/ai_task.py b/homeassistant/components/ollama/ai_task.py index d796b28aac8..43c50abd16a 100644 --- a/homeassistant/components/ollama/ai_task.py +++ b/homeassistant/components/ollama/ai_task.py @@ -39,7 +39,10 @@ class OllamaTaskEntity( ): """Ollama AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py index 4122d0c67d8..b2f0ebbb7b8 100644 --- a/homeassistant/components/ollama/entity.py +++ b/homeassistant/components/ollama/entity.py @@ -106,9 +106,18 @@ def _convert_content( ], ) if isinstance(chat_content, conversation.UserContent): + images: list[ollama.Image] = [] + for attachment in chat_content.attachments or (): + if not attachment.mime_type.startswith("image/"): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="unsupported_attachment_type", + ) + images.append(ollama.Image(value=attachment.path)) return ollama.Message( role=MessageRole.USER.value, content=chat_content.content, + images=images or None, ) if isinstance(chat_content, conversation.SystemContent): return ollama.Message( diff --git a/homeassistant/components/ollama/strings.json b/homeassistant/components/ollama/strings.json index 87d2048a966..4f3cb3c30c0 100644 --- a/homeassistant/components/ollama/strings.json +++ b/homeassistant/components/ollama/strings.json @@ -94,5 +94,10 @@ "download": "[%key:component::ollama::config_subentries::conversation::progress::download%]" } } + }, + "exceptions": { + "unsupported_attachment_type": { + "message": "Ollama only supports image attachments in user content, but received non-image attachment." + } } } diff --git a/tests/components/ollama/test_ai_task.py b/tests/components/ollama/test_ai_task.py index ee812e7b316..cb639db0f8e 100644 --- a/tests/components/ollama/test_ai_task.py +++ b/tests/components/ollama/test_ai_task.py @@ -1,11 +1,13 @@ """Test AI Task platform of Ollama integration.""" +from pathlib import Path from unittest.mock import patch +import ollama import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -243,3 +245,115 @@ async def test_generate_invalid_structured_data( }, ), ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachment( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ) as mock_chat, + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + ], + ) + + assert result.data == "Generated test data" + + assert mock_chat.call_count == 1 + messages = mock_chat.call_args[1]["messages"] + assert len(messages) == 2 + chat_message = messages[1] + assert chat_message.role == "user" + assert chat_message.content == "Generate test data" + assert chat_message.images == [ + ollama.Image(value=Path("doorbell_snapshot.jpg")), + ] + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_unsupported_file_format( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with image attachments.""" + entity_id = "ai_task.ollama_ai_task" + + # Mock the Ollama chat response as an async iterator + async def mock_chat_response(): + """Mock streaming response.""" + yield { + "message": {"role": "assistant", "content": "Generated test data"}, + "done": True, + "done_reason": "stop", + } + + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch( + "ollama.AsyncClient.chat", + return_value=mock_chat_response(), + ), + pytest.raises( + HomeAssistantError, + match="Ollama only supports image attachments in user content", + ), + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) From 6f59aaebdd0549fe6de3bee39a00a8e13ce221c3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 13:14:20 +0200 Subject: [PATCH 1539/1664] Add extended class for OptionsFlow that automatically reloads (#146910) Co-authored-by: Erik Montnemery --- homeassistant/config_entries.py | 29 ++++++++++- tests/test_config_entries.py | 90 +++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index e76b7ae099f..1c4f2b51ac7 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -3491,7 +3491,22 @@ class OptionsFlowManager( entry = self.hass.config_entries.async_get_known_entry(flow.handler) if result["data"] is not None: - self.hass.config_entries.async_update_entry(entry, options=result["data"]) + automatic_reload = False + if isinstance(flow, OptionsFlowWithReload): + automatic_reload = flow.automatic_reload + + if automatic_reload and entry.update_listeners: + raise ValueError( + "Config entry update listeners should not be used with OptionsFlowWithReload" + ) + + if ( + self.hass.config_entries.async_update_entry( + entry, options=result["data"] + ) + and automatic_reload is True + ): + self.hass.config_entries.async_schedule_reload(entry.entry_id) result["result"] = True return result @@ -3600,6 +3615,18 @@ class OptionsFlowWithConfigEntry(OptionsFlow): return self._options +class OptionsFlowWithReload(OptionsFlow): + """Automatic reloading class for config options flows. + + Triggers an automatic reload of the config entry when the flow ends with + calling `async_create_entry` with changed options. + It's not allowed to use this class if the integration uses config entry + update listeners. + """ + + automatic_reload: bool = True + + class EntityRegistryDisabledHandler: """Handler when entities related to config entries updated disabled_by.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7fb632e18b5..9666e8ba1c4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -15,6 +15,7 @@ from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion +import voluptuous as vol from homeassistant import config_entries, data_entry_flow, loader from homeassistant.config_entries import ConfigEntry @@ -8656,6 +8657,95 @@ async def test_options_flow_config_entry( assert result["reason"] == "abort" +@pytest.mark.parametrize( + ( + "option_flow_base_class", + "number_of_update_listeners", + "expected_configure_result", + "expected_number_of_unloads", + ), + [ + (config_entries.OptionsFlow, 0, does_not_raise(), 0), + (config_entries.OptionsFlowWithReload, 0, does_not_raise(), 1), + (config_entries.OptionsFlow, 1, does_not_raise(), 0), + ( + config_entries.OptionsFlowWithReload, + 1, + pytest.raises( + ValueError, + match="Config entry update listeners should not be used with OptionsFlowWithReload", + ), + 0, + ), + ], +) +async def test_options_flow_automatic_reload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + option_flow_base_class: type[config_entries.OptionsFlow], + number_of_update_listeners: int, + expected_configure_result: AbstractContextManager, + expected_number_of_unloads: int, +) -> None: + """Test options flow with automatic reload when updated.""" + original_entry = MockConfigEntry( + domain="test", title="Test", data={}, options={"test": "first"} + ) + original_entry.add_to_hass(hass) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Mock setup entry.""" + for _ in range(number_of_update_listeners): + entry.add_update_listener(Mock()) + return True + + unload_entry_mock = AsyncMock(return_value=True) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry, + async_unload_entry=unload_entry_mock, + ), + ) + mock_platform(hass, "test.config_flow", None) + + await hass.config_entries.async_setup(original_entry.entry_id) + assert original_entry.state is config_entries.ConfigEntryState.LOADED + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Test options flow.""" + + class _OptionsFlow(option_flow_base_class): + """Test flow.""" + + async def async_step_init(self, user_input=None): + """Test user step.""" + if user_input is not None: + return self.async_create_entry(data=user_input) + return self.async_show_form( + step_id="init", data_schema=vol.Schema({"test": str}) + ) + + return _OptionsFlow() + + with mock_config_flow("test", TestFlow): + result = await hass.config_entries.options.async_init(original_entry.entry_id) + with expected_configure_result: + await hass.config_entries.options.async_configure( + result["flow_id"], {"test": "updated"} + ) + await hass.async_block_till_done(wait_background_tasks=True) + + assert len(unload_entry_mock.mock_calls) == expected_number_of_unloads + + @pytest.mark.parametrize("integration_frame_path", ["custom_components/my_integration"]) @pytest.mark.usefixtures("mock_integration_frame") async def test_options_flow_deprecated_config_entry_setter( From 3a6f23b95fdf87af8221d77c5595b88a2a34ee5d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 19 Jul 2025 01:53:51 -1000 Subject: [PATCH 1540/1664] Bump aioesphomeapi to 37.0.1 (#149035) --- 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 903aaea9980..bb1f2d28457 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==36.0.1", + "aioesphomeapi==37.0.1", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index 03019fcc39e..1529fdd306f 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==36.0.1 +aioesphomeapi==37.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0042ef7aa34..ac1be38ee4d 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==36.0.1 +aioesphomeapi==37.0.1 # homeassistant.components.flo aioflo==2021.11.0 From b3bd882a8067df2a13c582d1b3f2c56fd890aaba Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:09:54 +0200 Subject: [PATCH 1541/1664] Use OptionsFlowWithReload in Trafikverket Train (#149042) --- homeassistant/components/trafikverket_train/__init__.py | 6 ------ homeassistant/components/trafikverket_train/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 19f88817e71..7cdb0c02f5b 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> b ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -53,11 +52,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: TVTrainConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Migrate config entry.""" _LOGGER.debug("Migrating from version %s", entry.version) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index fb39e14815e..2328a7126fd 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant, callback @@ -329,7 +329,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN): ) -class TVTrainOptionsFlowHandler(OptionsFlow): +class TVTrainOptionsFlowHandler(OptionsFlowWithReload): """Handle Trafikverket Train options.""" async def async_step_init( From d7d2013ec8ea95ae52a4f2548c24138a71c3b315 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:12:25 +0200 Subject: [PATCH 1542/1664] Use OptionsFlowWithReload in sql (#149047) --- homeassistant/components/sql/__init__.py | 7 ------- homeassistant/components/sql/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py index e3e6c699d03..33ed64be2bf 100644 --- a/homeassistant/components/sql/__init__.py +++ b/homeassistant/components/sql/__init__.py @@ -87,11 +87,6 @@ def remove_configured_db_url_if_not_needed( ) -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up SQL from yaml config.""" if (conf := config.get(DOMAIN)) is None: @@ -115,8 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url: remove_configured_db_url_if_not_needed(hass, entry) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 4fe04f2401c..37a6f9ef104 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DEVICE_CLASS, @@ -209,7 +209,7 @@ class SQLConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SQLOptionsFlowHandler(OptionsFlow): +class SQLOptionsFlowHandler(OptionsFlowWithReload): """Handle SQL options.""" async def async_step_init( From 284b90d502312bab830c136856797dc7583a2397 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:14:13 +0200 Subject: [PATCH 1543/1664] Use OptionsFlowWithReload in yeelight (#149045) --- homeassistant/components/yeelight/__init__.py | 8 -------- homeassistant/components/yeelight/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 0b3ceaf2aee..cb24edae1fd 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -232,9 +232,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Wait to install the reload listener until everything was successfully initialized - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -245,11 +242,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def _async_get_device( hass: HomeAssistant, host: str, entry: ConfigEntry ) -> YeelightDevice: diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 15975ba22bd..cc3ab35f684 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback @@ -298,7 +298,7 @@ class YeelightConfigFlow(ConfigFlow, domain=DOMAIN): return MODEL_UNKNOWN -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Yeelight.""" async def async_step_init( From be6743d4fdbd2a698edb5880fce517943fe6028c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:14:38 +0200 Subject: [PATCH 1544/1664] Use OptionsFlowWithReload in yale_smart_alarm (#149040) --- homeassistant/components/yale_smart_alarm/__init__.py | 6 ------ homeassistant/components/yale_smart_alarm/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index d67e136be4a..5c481719cc9 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -22,16 +22,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: YaleConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index 1aaad2aa63a..d8c1fc80f8f 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -171,7 +171,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): ) -class YaleOptionsFlowHandler(OptionsFlow): +class YaleOptionsFlowHandler(OptionsFlowWithReload): """Handle Yale options.""" async def async_step_init( From 8a2493e9d24719538173dd6da3424b220313e5b6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:26:54 +0200 Subject: [PATCH 1545/1664] Use OptionsFlowWithReload in Workday (#149043) --- homeassistant/components/workday/__init__.py | 6 ------ homeassistant/components/workday/config_flow.py | 4 ++-- tests/components/workday/test_init.py | 1 + 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py index 60a0489ec5c..0df4224a4ca 100644 --- a/homeassistant/components/workday/__init__.py +++ b/homeassistant/components/workday/__init__.py @@ -94,16 +94,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: new_options[CONF_LANGUAGE] = default_language hass.config_entries.async_update_entry(entry, options=new_options) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Workday config entry.""" diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 7a8a8181a9f..1d91e1d5ae3 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME from homeassistant.core import callback @@ -311,7 +311,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class WorkdayOptionsFlowHandler(OptionsFlow): +class WorkdayOptionsFlowHandler(OptionsFlowWithReload): """Handle Workday options.""" async def async_step_init( diff --git a/tests/components/workday/test_init.py b/tests/components/workday/test_init.py index f288c340d9f..653b6810197 100644 --- a/tests/components/workday/test_init.py +++ b/tests/components/workday/test_init.py @@ -45,6 +45,7 @@ async def test_update_options( new_options["add_holidays"] = ["2023-04-12"] hass.config_entries.async_update_entry(entry, options=new_options) + await hass.config_entries.async_reload(entry.entry_id) await hass.async_block_till_done() entry_check = hass.config_entries.async_get_entry("1") From 665991a3c17f298e20112dcf61b92fba593bf16f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 14:27:46 +0200 Subject: [PATCH 1546/1664] Use OptionsFlowWithReload in wled (#149046) --- homeassistant/components/wled/__init__.py | 8 -------- homeassistant/components/wled/config_flow.py | 4 ++-- tests/components/wled/test_light.py | 1 + 3 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py index b4834347694..c3917507fb9 100644 --- a/homeassistant/components/wled/__init__.py +++ b/homeassistant/components/wled/__init__.py @@ -48,9 +48,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> bool # Set up all platforms for this device/entry. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when its updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True @@ -65,8 +62,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: WLEDConfigEntry) -> boo coordinator.unsub() return unload_ok - - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 2e0b7b1c793..e80760508a0 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -120,7 +120,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): return await wled.update() -class WLEDOptionsFlowHandler(OptionsFlow): +class WLEDOptionsFlowHandler(OptionsFlowWithReload): """Handle WLED options.""" async def async_step_init( diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 57635a8cb74..90e731f3fe9 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -373,6 +373,7 @@ async def test_single_segment_with_keep_main_light( hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MAIN_LIGHT: True} ) + await hass.config_entries.async_reload(init_integration.entry_id) await hass.async_block_till_done() assert (state := hass.states.get("light.wled_rgb_light_main")) From 31167f5da71db64f1d1dd57177bf4f221e824f77 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 15:16:56 +0200 Subject: [PATCH 1547/1664] Use OptionsFlowWithReload in webostv (#149054) --- homeassistant/components/webostv/__init__.py | 7 ------- homeassistant/components/webostv/config_flow.py | 10 +++++++--- tests/components/webostv/test_init.py | 1 + 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index c1a1c698f92..fb729707154 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b ) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - async def async_on_stop(_event: Event) -> None: """Unregister callbacks and disconnect.""" client.clear_state_update_callbacks() @@ -88,11 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> b return True -async def async_update_options(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: WebOsTvConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py index 2af38cb3d17..44711c2b456 100644 --- a/homeassistant/components/webostv/config_flow.py +++ b/homeassistant/components/webostv/config_flow.py @@ -9,7 +9,11 @@ from urllib.parse import urlparse from aiowebostv import WebOsClient, WebOsTvPairError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv @@ -60,7 +64,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: WebOsTvConfigEntry) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -197,7 +201,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" def __init__(self, config_entry: WebOsTvConfigEntry) -> None: diff --git a/tests/components/webostv/test_init.py b/tests/components/webostv/test_init.py index cd8f443c8fd..d7fb12c2848 100644 --- a/tests/components/webostv/test_init.py +++ b/tests/components/webostv/test_init.py @@ -54,6 +54,7 @@ async def test_update_options(hass: HomeAssistant, client) -> None: new_options = config_entry.options.copy() new_options[CONF_SOURCES] = ["Input02", "Live TV"] hass.config_entries.async_update_entry(config_entry, options=new_options) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED From 7202203f35779f8515b5d85c32283998987bd0bc Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:33:34 +0100 Subject: [PATCH 1548/1664] Update bool test in coordinator platform for Squeezebox (#149073) --- homeassistant/components/squeezebox/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 6582f143e79..8bfb952b680 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -111,7 +111,7 @@ class SqueezeBoxPlayerUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): # Only update players available at last update, unavailable players are rediscovered instead await self.player.async_update() - if self.player.connected is False: + if not self.player.connected: _LOGGER.info("Player %s is not available", self.name) self.available = False From 13434012e7e8bc50ce91f6946704f781eb0adfb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:37:37 +0200 Subject: [PATCH 1549/1664] Use OptionsFlowWithReload in netgear (#149069) --- homeassistant/components/netgear/__init__.py | 7 ------- homeassistant/components/netgear/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index fa18c3510ba..9aafa482faf 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -61,8 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: @@ -194,11 +192,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index a0a5b76eee5..3386d07cc6d 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -65,7 +65,7 @@ def _ordered_shared_schema(schema_input): } -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From 290f19dbd99e6997f4c8f82c9fb1dbe1fb669d2e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:38:28 +0200 Subject: [PATCH 1550/1664] Use OptionsFlowWithReload in motion_blinds (#149070) --- homeassistant/components/motion_blinds/__init__.py | 7 ------- homeassistant/components/motion_blinds/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 2abcc273e23..9c4d1a97f00 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -145,8 +143,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> multicast.Stop_listen() return unload_ok - - -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index 954f9e25c21..8323c0e1995 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import callback @@ -38,7 +38,7 @@ CONFIG_SCHEMA = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From 12193587c9cf2aab3bc74279a8cd5d1df548ee34 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 19 Jul 2025 16:39:38 +0200 Subject: [PATCH 1551/1664] Use OptionsFlowWithReload in fritzbox_callmonitor (#149071) --- homeassistant/components/fritzbox_callmonitor/__init__.py | 8 -------- .../components/fritzbox_callmonitor/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index b1b5db48216..ea4bf46f09c 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -48,7 +48,6 @@ async def async_setup_entry( raise ConfigEntryNotReady from ex config_entry.runtime_data = fritzbox_phonebook - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True @@ -59,10 +58,3 @@ async def async_unload_entry( ) -> bool: """Unloading the fritzbox_callmonitor platforms.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry -) -> None: - """Update listener to reload after option has changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/fritzbox_callmonitor/config_flow.py b/homeassistant/components/fritzbox_callmonitor/config_flow.py index 8435eff3e18..25e25336d57 100644 --- a/homeassistant/components/fritzbox_callmonitor/config_flow.py +++ b/homeassistant/components/fritzbox_callmonitor/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback @@ -263,7 +263,7 @@ class FritzBoxCallMonitorConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlow): +class FritzBoxCallMonitorOptionsFlowHandler(OptionsFlowWithReload): """Handle a fritzbox_callmonitor options flow.""" @classmethod From 360da4386858dcdb29cbb9908f0257248b052eb4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:40:32 +0200 Subject: [PATCH 1552/1664] Use OptionsFlowWithReload in nina (#149068) --- homeassistant/components/nina/__init__.py | 7 ------- homeassistant/components/nina/config_flow.py | 6 +++--- tests/components/nina/test_config_flow.py | 4 ---- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index e074f7ad000..f9b23faa234 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -37,8 +37,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -49,8 +47,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool async def async_unload_entry(hass: HomeAssistant, entry: NinaConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener(hass: HomeAssistant, entry: NinaConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 24c016e5e64..f7bc0914481 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -165,8 +165,8 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): - """Handle a option flow for nut.""" +class OptionsFlowHandler(OptionsFlowWithReload): + """Handle an option flow for NINA.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index 309c8860c20..06eb94d59d0 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -323,9 +323,6 @@ async def test_options_flow_entity_removal( "pynina.baseApi.BaseAPI._makeRequest", wraps=mocked_request_function, ), - patch( - "homeassistant.components.nina._async_update_listener" - ) as mock_update_listener, ): await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -352,4 +349,3 @@ async def test_options_flow_entity_removal( ) assert len(entries) == 2 - assert len(mock_update_listener.mock_calls) == 1 From 676a931c4800e37826e04eedf3b16face4bd92b2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:40:57 +0200 Subject: [PATCH 1553/1664] Use OptionsFlowWithReload in nmap_tracker (#149067) --- homeassistant/components/nmap_tracker/__init__.py | 6 ------ homeassistant/components/nmap_tracker/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 72bf9284573..2aa77e09d16 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -88,16 +88,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) await scanner.async_setup() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 1f436edd60c..e3d1ecbdb14 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -138,7 +138,7 @@ async def _async_build_schema_with_user_input( return vol.Schema(schema) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for homekit.""" def __init__(self, config_entry: ConfigEntry) -> None: From 440a20340e9d22b64bffe9658526552b7a61e766 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:41:38 +0200 Subject: [PATCH 1554/1664] Use OptionsFlowWithReload in nobo_hub (#149066) --- homeassistant/components/nobo_hub/__init__.py | 9 --------- homeassistant/components/nobo_hub/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 3bbf46f0264..7c886c534cb 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -42,8 +42,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - await hub.start() return True @@ -58,10 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def options_update_listener( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/nobo_hub/config_flow.py b/homeassistant/components/nobo_hub/config_flow.py index 7e1ae4c1d9b..05ece456f15 100644 --- a/homeassistant/components/nobo_hub/config_flow.py +++ b/homeassistant/components/nobo_hub/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import callback @@ -173,7 +173,7 @@ class NoboHubConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -187,7 +187,7 @@ class NoboHubConnectError(HomeAssistantError): self.msg = msg -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: From 05f686cb8674ff09be24311308e756a3f505e50b Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 15:42:21 +0100 Subject: [PATCH 1555/1664] Update comments in 3 Squeezebox platforms (#149065) --- .../components/squeezebox/binary_sensor.py | 2 +- homeassistant/components/squeezebox/media_player.py | 13 ++++++------- homeassistant/components/squeezebox/sensor.py | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py index 1045e526ee3..ea305d71f99 100644 --- a/homeassistant/components/squeezebox/binary_sensor.py +++ b/homeassistant/components/squeezebox/binary_sensor.py @@ -49,7 +49,7 @@ async def async_setup_entry( class ServerStatusBinarySensor(LMSStatusEntity, BinarySensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def is_on(self) -> bool: diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index dc426d76588..0dbc1b96b0c 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -226,10 +226,7 @@ def get_announce_timeout(extra: dict) -> int | None: class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): - """Representation of the media player features of a SqueezeBox device. - - Wraps a pysqueezebox.Player() object. - """ + """Representation of the media player features of a SqueezeBox device.""" _attr_supported_features = ( MediaPlayerEntityFeature.BROWSE_MEDIA @@ -286,9 +283,11 @@ class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity): @property def browse_limit(self) -> int: - """Return the step to be used for volume up down.""" - return self.coordinator.config_entry.options.get( # type: ignore[no-any-return] - CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + """Return the max number of items to return from browse.""" + return int( + self.coordinator.config_entry.options.get( + CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT + ) ) @property diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py index 11c169910dc..79390910ef7 100644 --- a/homeassistant/components/squeezebox/sensor.py +++ b/homeassistant/components/squeezebox/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( class ServerStatusSensor(LMSStatusEntity, SensorEntity): - """LMS Status based sensor from LMS via cooridnatior.""" + """LMS Status based sensor from LMS via coordinator.""" @property def native_value(self) -> StateType: From ab964c8bcabb88e5a0369bc93053ee5ffeb1186f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:43:49 +0200 Subject: [PATCH 1556/1664] Use OptionsFlowWithReload in tankerkoenig (#149063) --- homeassistant/components/tankerkoenig/__init__.py | 9 --------- homeassistant/components/tankerkoenig/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index b2b60db9675..2a85b1f31e1 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -23,8 +23,6 @@ async def async_setup_entry( entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -35,10 +33,3 @@ async def async_unload_entry( ) -> bool: """Unload Tankerkoenig config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def _async_update_listener( - hass: HomeAssistant, entry: TankerkoenigConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index b269eaaaf55..9aeb0a80173 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -229,7 +229,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" def __init__(self) -> None: From ff14f6b823a1d79cdb4a9d38167b4192596a7131 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:44:51 +0200 Subject: [PATCH 1557/1664] Use OptionsFlowWithReload in somfy_mylink (#149062) --- .../components/somfy_mylink/__init__.py | 17 +---------------- .../components/somfy_mylink/config_flow.py | 4 ++-- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py index 89796f5ce46..fdbaaf9f427 100644 --- a/homeassistant/components/somfy_mylink/__init__.py +++ b/homeassistant/components/somfy_mylink/__init__.py @@ -11,8 +11,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import CONF_SYSTEM_ID, DATA_SOMFY_MYLINK, DOMAIN, MYLINK_STATUS, PLATFORMS -UNDO_UPDATE_LISTENER = "undo_update_listener" - _LOGGER = logging.getLogger(__name__) @@ -44,12 +42,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if "result" not in mylink_status: raise ConfigEntryNotReady("The Somfy MyLink device returned an empty result") - undo_listener = entry.add_update_listener(_async_update_listener) - hass.data[DOMAIN][entry.entry_id] = { DATA_SOMFY_MYLINK: somfy_mylink, MYLINK_STATUS: mylink_status, - UNDO_UPDATE_LISTENER: undo_listener, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,18 +52,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - - if unload_ok: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index a806d581aec..91cfae87347 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback @@ -125,7 +125,7 @@ class SomfyConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler(config_entry) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for somfy_mylink.""" def __init__(self, config_entry: ConfigEntry) -> None: From cb4d17b24f0df138166ab4e7166c41e10fd7c4f4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:45:39 +0200 Subject: [PATCH 1558/1664] Use OptionsFlowWithReload in Ping (#149061) --- homeassistant/components/ping/__init__.py | 6 ------ homeassistant/components/ping/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 14203541359..f1d0113ac5e 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -50,16 +50,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -async def async_reload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PingConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ping/config_flow.py b/homeassistant/components/ping/config_flow.py index 27cb3f62bcd..d66f4beb8e5 100644 --- a/homeassistant/components/ping/config_flow.py +++ b/homeassistant/components/ping/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -71,12 +71,12 @@ class PingConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Create the options flow.""" return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Ping.""" async def async_step_init( From 69c26e5f1f8f97527b72499ecdadf25fffa658d3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:46:30 +0200 Subject: [PATCH 1559/1664] Use OptionsFlowWithReload in dnsip (#149059) --- homeassistant/components/dnsip/__init__.py | 6 ------ homeassistant/components/dnsip/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py index 37e0f60849f..3487ce83c7b 100644 --- a/homeassistant/components/dnsip/__init__.py +++ b/homeassistant/components/dnsip/__init__.py @@ -13,15 +13,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up DNS IP from a config entry.""" await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload dnsip config entry.""" diff --git a/homeassistant/components/dnsip/config_flow.py b/homeassistant/components/dnsip/config_flow.py index ab1ca42acd3..0ea2a9d092b 100644 --- a/homeassistant/components/dnsip/config_flow.py +++ b/homeassistant/components/dnsip/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_NAME, CONF_PORT from homeassistant.core import callback @@ -165,7 +165,7 @@ class DnsIPConfigFlow(ConfigFlow, domain=DOMAIN): ) -class DnsIPOptionsFlowHandler(OptionsFlow): +class DnsIPOptionsFlowHandler(OptionsFlowWithReload): """Handle a option config flow for dnsip integration.""" async def async_step_init( From 22b35030a988344c12300d91bcd8e5182d8046b2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:47:09 +0200 Subject: [PATCH 1560/1664] Use OptionsFlowWithReload in analytics_insight (#149056) --- homeassistant/components/analytics_insights/__init__.py | 8 -------- .../components/analytics_insights/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index ee7f6611c65..2d66d5149cf 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -55,7 +55,6 @@ async def async_setup_entry( entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -65,10 +64,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py index b2648f7c13c..d5c0c4a7f73 100644 --- a/homeassistant/components/analytics_insights/config_flow.py +++ b/homeassistant/components/analytics_insights/config_flow.py @@ -11,7 +11,11 @@ from python_homeassistant_analytics import ( from python_homeassistant_analytics.models import Environment, IntegrationType import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( @@ -129,7 +133,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow): +class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlowWithReload): """Handle Homeassistant Analytics options.""" async def async_step_init( From b9d19ffb296791215e58158948d446205fe6ee63 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:48:23 +0200 Subject: [PATCH 1561/1664] Use OptionsFlowWithReload in vera (#149055) --- homeassistant/components/vera/__init__.py | 6 ------ homeassistant/components/vera/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py index b8f0b702ebe..aedc174cb6d 100644 --- a/homeassistant/components/vera/__init__.py +++ b/homeassistant/components/vera/__init__.py @@ -143,7 +143,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription) ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True @@ -161,11 +160,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def map_vera_device( vera_device: veraApi.VeraDevice, remap: list[int] ) -> Platform | None: diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index f2b182cc270..f02549e7857 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback @@ -73,7 +73,7 @@ def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From 1bbd07fe48a1a44fbe99fe53ca4497625c22d44a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:48:53 +0200 Subject: [PATCH 1562/1664] Use OptionsFlowWithReload in wiffi (#149053) --- homeassistant/components/wiffi/__init__.py | 7 ------- homeassistant/components/wiffi/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 6cf216011f2..b6811190a27 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -29,8 +29,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up wiffi from a config entry, config_entry contains data from config entry database.""" - if not entry.update_listeners: - entry.add_update_listener(async_update_options) # create api object api = WiffiIntegrationApi(hass) @@ -53,11 +51,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" api: WiffiIntegrationApi = hass.data[DOMAIN][entry.entry_id] diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 308923597cd..c40bd5519e0 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -15,7 +15,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PORT, CONF_TIMEOUT from homeassistant.core import callback @@ -76,7 +76,7 @@ class WiffiFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Wiffi server setup option flow.""" async def async_step_init( From 4a5e193ebbcad62c28bca17f1c2e9013d84a6d22 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:49:19 +0200 Subject: [PATCH 1563/1664] Use OptionsFlowWithReload in ws66i (#149052) --- homeassistant/components/ws66i/__init__.py | 6 ------ homeassistant/components/ws66i/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 32c6a11f25c..23a27adeb69 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -100,7 +100,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Close the WS66i connection to the amplifier.""" ws66i.close() - entry.async_on_unload(entry.add_update_listener(_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) ) @@ -119,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index 120b7738d2e..e70dbd4e8d7 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_IP_ADDRESS from homeassistant.core import HomeAssistant, callback @@ -142,7 +142,7 @@ def _key_for_source( ) -class Ws66iOptionsFlowHandler(OptionsFlow): +class Ws66iOptionsFlowHandler(OptionsFlowWithReload): """Handle a WS66i options flow.""" async def async_step_init( From dba3d98a2b8fb094c78f728972b402c8ced43bd9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:50:13 +0200 Subject: [PATCH 1564/1664] Use OptionsFlowWithReload in xiaomi_miio (#149051) --- homeassistant/components/xiaomi_miio/__init__.py | 11 ----------- homeassistant/components/xiaomi_miio/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0e28a2900bb..8db5273174b 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -466,8 +466,6 @@ async def async_setup_gateway_entry( await hass.config_entries.async_forward_entry_setups(entry, GATEWAY_PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - async def async_setup_device_entry( hass: HomeAssistant, entry: XiaomiMiioConfigEntry @@ -481,8 +479,6 @@ async def async_setup_device_entry( await hass.config_entries.async_forward_entry_setups(entry, platforms) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True @@ -493,10 +489,3 @@ async def async_unload_entry( platforms = get_platforms(config_entry) return await hass.config_entries.async_unload_platforms(config_entry, platforms) - - -async def update_listener( - hass: HomeAssistant, config_entry: XiaomiMiioConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index b8d8b028006..95eabb0188c 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -11,7 +11,11 @@ from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TOKEN from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -56,7 +60,7 @@ DEVICE_CLOUD_CONFIG = vol.Schema( ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From c15bf097f0f86a6c6a1e4c77a3e79277f6f43cf1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 16:50:41 +0200 Subject: [PATCH 1565/1664] Use OptionsFlowWithReload in airnow (#149049) --- homeassistant/components/airnow/__init__.py | 8 -------- homeassistant/components/airnow/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py index 6fb7e90502f..2881469b968 100644 --- a/homeassistant/components/airnow/__init__.py +++ b/homeassistant/components/airnow/__init__.py @@ -45,9 +45,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bo # Store Entity and Initialize Platforms entry.runtime_data = coordinator - # Listen for option changes - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) # Clean up unused device entries with no entities @@ -88,8 +85,3 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AirNowConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py index 7cd113125a8..661e1b0a298 100644 --- a/homeassistant/components/airnow/config_flow.py +++ b/homeassistant/components/airnow/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -126,7 +126,7 @@ class AirNowConfigFlow(ConfigFlow, domain=DOMAIN): return AirNowOptionsFlowHandler() -class AirNowOptionsFlowHandler(OptionsFlow): +class AirNowOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for AirNow.""" async def async_step_init( From 7e04a7ec19a25651597a987ab1c7b7e7acc15f17 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 17:40:16 +0200 Subject: [PATCH 1566/1664] Use OptionsFlowWithReload in unifiprotect (#149064) --- .../components/unifiprotect/__init__.py | 6 ------ .../components/unifiprotect/config_flow.py | 6 +++--- tests/components/unifiprotect/test_init.py | 17 ----------------- 3 files changed, 3 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 2d75010b4e5..440250d45a3 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -114,7 +114,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) entry.runtime_data = data_service - entry.async_on_unload(entry.add_update_listener(_async_options_updated)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop) ) @@ -139,11 +138,6 @@ async def _async_setup_entry( hass.http.register_view(VideoEventProxyView(hass)) -async def _async_options_updated(hass: HomeAssistant, entry: UFPConfigEntry) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool: """Unload UniFi Protect config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 9f7f4bccd7f..c83b3f11010 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -20,7 +20,7 @@ from homeassistant.config_entries import ( ConfigEntryState, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -223,7 +223,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -372,7 +372,7 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 3064c66f009..3156327f1a5 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -12,7 +12,6 @@ from uiprotect.data import NVR, Bootstrap, CloudAccount, Light from homeassistant.components.unifiprotect.const import ( AUTH_RETRIES, CONF_ALLOW_EA, - CONF_DISABLE_RTSP, DOMAIN, ) from homeassistant.components.unifiprotect.data import ( @@ -87,22 +86,6 @@ async def test_setup_multiple( assert mock_config.unique_id == ufp.api.bootstrap.nvr.mac -async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture) -> None: - """Test updating entry reload entry.""" - - await hass.config_entries.async_setup(ufp.entry.entry_id) - await hass.async_block_till_done() - assert ufp.entry.state is ConfigEntryState.LOADED - - options = dict(ufp.entry.options) - options[CONF_DISABLE_RTSP] = True - hass.config_entries.async_update_entry(ufp.entry, options=options) - await hass.async_block_till_done() - - assert ufp.entry.state is ConfigEntryState.LOADED - assert ufp.api.async_disconnect_ws.called - - async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture, light: Light) -> None: """Test unloading of unifiprotect entry.""" From b3f049676da623e0a75d4d7bac9374b5f864e9c3 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:17:34 +0100 Subject: [PATCH 1567/1664] Move Squeezebox registry tests to test_init (#149050) --- .../squeezebox/snapshots/test_init.ambr | 79 +++++++++++++++++++ .../snapshots/test_media_player.ambr | 78 ------------------ tests/components/squeezebox/test_init.py | 32 +++++++- .../squeezebox/test_media_player.py | 25 ------ 4 files changed, 110 insertions(+), 104 deletions(-) create mode 100644 tests/components/squeezebox/snapshots/test_init.ambr diff --git a/tests/components/squeezebox/snapshots/test_init.ambr b/tests/components/squeezebox/snapshots/test_init.ambr new file mode 100644 index 00000000000..3fc65be834a --- /dev/null +++ b/tests/components/squeezebox/snapshots/test_init.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_device_registry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + 'aa:bb:cc:dd:ee:ff', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Ralph Irving & Adrian Smith', + 'model': 'SqueezeLite', + 'model_id': None, + 'name': 'Test Player', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- +# name: test_device_registry_server_merged + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + tuple( + 'mac', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'squeezebox', + '12345678-1234-1234-1234-123456789012', + ), + tuple( + 'squeezebox', + 'ff:ee:dd:cc:bb:aa', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', + 'model': 'Lyrion Music Server/SqueezeLite', + 'model_id': 'LMS', + 'name': '1.2.3.4', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': '', + 'via_device_id': , + }) +# --- diff --git a/tests/components/squeezebox/snapshots/test_media_player.ambr b/tests/components/squeezebox/snapshots/test_media_player.ambr index d86c839019c..183b5ca767f 100644 --- a/tests/components/squeezebox/snapshots/test_media_player.ambr +++ b/tests/components/squeezebox/snapshots/test_media_player.ambr @@ -1,82 +1,4 @@ # serializer version: 1 -# name: test_device_registry - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - 'aa:bb:cc:dd:ee:ff', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'Ralph Irving & Adrian Smith', - 'model': 'SqueezeLite', - 'model_id': None, - 'name': 'Test Player', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '', - 'via_device_id': , - }) -# --- -# name: test_device_registry_server_merged - DeviceRegistryEntrySnapshot({ - 'area_id': None, - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': None, - 'connections': set({ - tuple( - 'mac', - 'ff:ee:dd:cc:bb:aa', - ), - }), - 'disabled_by': None, - 'entry_type': , - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'squeezebox', - '12345678-1234-1234-1234-123456789012', - ), - tuple( - 'squeezebox', - 'ff:ee:dd:cc:bb:aa', - ), - }), - 'is_new': False, - 'labels': set({ - }), - 'manufacturer': 'https://lyrion.org/ / Ralph Irving & Adrian Smith', - 'model': 'Lyrion Music Server/SqueezeLite', - 'model_id': 'LMS', - 'name': '1.2.3.4', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'suggested_area': None, - 'sw_version': '', - 'via_device_id': , - }) -# --- # name: test_entity_registry[media_player.test_player-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/squeezebox/test_init.py b/tests/components/squeezebox/test_init.py index f70782b13da..5cb7e19abb5 100644 --- a/tests/components/squeezebox/test_init.py +++ b/tests/components/squeezebox/test_init.py @@ -1,10 +1,16 @@ """Test squeezebox initialization.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.squeezebox.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceRegistry + +from .conftest import TEST_MAC from tests.common import MockConfigEntry @@ -82,3 +88,27 @@ async def test_init_missing_uuid( mock_async_query.assert_called_once_with( "serverstatus", "-", "-", "prefs:libraryname" ) + + +async def test_device_registry( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_player: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) + assert reg_device is not None + assert reg_device == snapshot + + +async def test_device_registry_server_merged( + hass: HomeAssistant, + device_registry: DeviceRegistry, + configured_players: MagicMock, + snapshot: SnapshotAssertion, +) -> None: + """Test squeezebox device registered in the device registry.""" + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) + assert reg_device is not None + assert reg_device == snapshot diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index e1f480e33a0..1986831d827 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -68,7 +68,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util.dt import utcnow @@ -82,30 +81,6 @@ from .conftest import ( from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform -async def test_device_registry( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_player: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[0])}) - assert reg_device is not None - assert reg_device == snapshot - - -async def test_device_registry_server_merged( - hass: HomeAssistant, - device_registry: DeviceRegistry, - configured_players: MagicMock, - snapshot: SnapshotAssertion, -) -> None: - """Test squeezebox device registered in the device registry.""" - reg_device = device_registry.async_get_device(identifiers={(DOMAIN, TEST_MAC[2])}) - assert reg_device is not None - assert reg_device == snapshot - - async def test_entity_registry( hass: HomeAssistant, entity_registry: EntityRegistry, From 0cfb395ab50d0e97847ef851822b3b368782faa7 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:20:11 +0100 Subject: [PATCH 1568/1664] Remove unnecessary getattr from init for Squeezebox (#149077) --- homeassistant/components/squeezebox/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index c6cb04b5ffb..2bd845923fc 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -112,9 +112,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - if not status: # pysqueezebox's async_query returns None on various issues, # including HTTP errors where it sets lms.http_status. - http_status = getattr(lms, "http_status", "N/A") - if http_status == HTTPStatus.UNAUTHORIZED: + if lms.http_status == HTTPStatus.UNAUTHORIZED: _LOGGER.warning("Authentication failed for Squeezebox server %s", host) raise ConfigEntryAuthFailed( translation_domain=DOMAIN, @@ -128,14 +127,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) - _LOGGER.warning( "LMS %s returned no status or an error (HTTP status: %s). Retrying setup", host, - http_status, + lms.http_status, ) raise ConfigEntryNotReady( translation_domain=DOMAIN, translation_key="init_get_status_failed", translation_placeholders={ "host": str(host), - "http_status": str(http_status), + "http_status": str(lms.http_status), }, ) From a50d926e2abefbf9c8ecf92eaae71857f31088c9 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:30:15 +0100 Subject: [PATCH 1569/1664] Check for error in test_squeezebox_play_media_with_announce_volume_invalid for Squeezebox (#149044) --- tests/components/squeezebox/test_media_player.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 1986831d827..5cd007d1267 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -510,7 +510,10 @@ async def test_squeezebox_play_media_with_announce_volume_invalid( hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int ) -> None: """Test play service call with announce and volume zero.""" - with pytest.raises(ServiceValidationError): + with pytest.raises( + ServiceValidationError, + match="announce_volume must be a number greater than 0 and less than or equal to 1", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, From 7dfb54c8e86dd4e10af01bb3a3e91468c61d8131 Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 18:30:40 +0100 Subject: [PATCH 1570/1664] Paramaterize test for on/off for Squeezebox (#149048) --- .../squeezebox/test_media_player.py | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/tests/components/squeezebox/test_media_player.py b/tests/components/squeezebox/test_media_player.py index 5cd007d1267..440f682370b 100644 --- a/tests/components/squeezebox/test_media_player.py +++ b/tests/components/squeezebox/test_media_player.py @@ -145,30 +145,21 @@ async def test_squeezebox_player_rediscovery( assert hass.states.get("media_player.test_player").state == MediaPlayerState.IDLE -async def test_squeezebox_turn_on( - hass: HomeAssistant, configured_player: MagicMock +@pytest.mark.parametrize( + ("service", "state"), + [(SERVICE_TURN_ON, True), (SERVICE_TURN_OFF, False)], +) +async def test_squeezebox_turn_on_off( + hass: HomeAssistant, configured_player: MagicMock, service: str, state: bool ) -> None: """Test turn on service call.""" await hass.services.async_call( MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_ON, + service, {ATTR_ENTITY_ID: "media_player.test_player"}, blocking=True, ) - configured_player.async_set_power.assert_called_once_with(True) - - -async def test_squeezebox_turn_off( - hass: HomeAssistant, configured_player: MagicMock -) -> None: - """Test turn off service call.""" - await hass.services.async_call( - MEDIA_PLAYER_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "media_player.test_player"}, - blocking=True, - ) - configured_player.async_set_power.assert_called_once_with(False) + configured_player.async_set_power.assert_called_once_with(state) async def test_squeezebox_state( From 2577d9f108ef44e932b42dff575b645e904de382 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 19 Jul 2025 10:49:14 -0700 Subject: [PATCH 1571/1664] Fix a bug in rainbird device migration that results in additional devices (#149078) --- homeassistant/components/rainbird/__init__.py | 3 + tests/components/rainbird/test_init.py | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index f9cd751a81e..e986cc302ae 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -218,6 +218,9 @@ def _async_fix_device_id( for device_entry in device_entries: unique_id = str(next(iter(device_entry.identifiers))[1]) device_entry_map[unique_id] = device_entry + if unique_id.startswith(mac_address): + # Already in the correct format + continue if (suffix := unique_id.removeprefix(str(serial_number))) != unique_id: migrations[unique_id] = f"{mac_address}{suffix}" diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index 01e0c4458e4..520f8578c6e 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -449,3 +449,75 @@ async def test_fix_duplicate_device_ids( assert device_entry.identifiers == {(DOMAIN, MAC_ADDRESS_UNIQUE_ID)} assert device_entry.name_by_user == expected_device_name assert device_entry.disabled_by == expected_disabled_by + + +async def test_reload_migration_with_leading_zero_mac( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration and reload of a device with a mac address with a leading zero.""" + mac_address = "01:02:03:04:05:06" + mac_address_unique_id = dr.format_mac(mac_address) + serial_number = "0" + + # Setup the config entry to be in a pre-migrated state + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=serial_number, + data={ + "host": "127.0.0.1", + "password": "password", + CONF_MAC: mac_address, + "serial_number": serial_number, + }, + ) + config_entry.add_to_hass(hass) + + # Create a device and entity with the old unique id format + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, f"{serial_number}-1")}, + ) + entity_entry = entity_registry.async_get_or_create( + "switch", + DOMAIN, + f"{serial_number}-1-zone1", + suggested_object_id="zone1", + config_entry=config_entry, + device_id=device_entry.id, + ) + + # Setup the integration, which will migrate the unique ids + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity were migrated to the new format + migrated_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mac_address_unique_id}-1")} + ) + assert migrated_device_entry is not None + migrated_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert migrated_entity_entry is not None + assert migrated_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + # Reload the integration + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify the device and entity still have the correct identifiers and were not duplicated + reloaded_device_entry = device_registry.async_get(migrated_device_entry.id) + assert reloaded_device_entry is not None + assert reloaded_device_entry.identifiers == {(DOMAIN, f"{mac_address_unique_id}-1")} + reloaded_entity_entry = entity_registry.async_get(entity_entry.entity_id) + assert reloaded_entity_entry is not None + assert reloaded_entity_entry.unique_id == f"{mac_address_unique_id}-1-zone1" + + assert ( + len(dr.async_entries_for_config_entry(device_registry, config_entry.entry_id)) + == 1 + ) + assert ( + len(er.async_entries_for_config_entry(entity_registry, config_entry.entry_id)) + == 1 + ) From dbdc666a924a55384babd75c54f4ce606365171d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 19:51:01 +0200 Subject: [PATCH 1572/1664] Use OptionsFlowWithReload in control4 (#149058) --- homeassistant/components/control4/__init__.py | 24 +++++++------------ .../components/control4/config_flow.py | 8 +++++-- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/control4/__init__.py b/homeassistant/components/control4/__init__.py index 3d84d6edd69..59216e4a863 100644 --- a/homeassistant/components/control4/__init__.py +++ b/homeassistant/components/control4/__init__.py @@ -54,16 +54,20 @@ class Control4RuntimeData: type Control4ConfigEntry = ConfigEntry[Control4RuntimeData] -async def call_c4_api_retry(func, *func_args): # noqa: RET503 +async def call_c4_api_retry(func, *func_args): """Call C4 API function and retry on failure.""" - # Ruff doesn't understand this loop - the exception is always raised after the retries + exc = None for i in range(API_RETRY_TIMES): try: return await func(*func_args) except client_exceptions.ClientError as exception: - _LOGGER.error("Error connecting to Control4 account API: %s", exception) - if i == API_RETRY_TIMES - 1: - raise ConfigEntryNotReady(exception) from exception + _LOGGER.error( + "Try: %d, Error connecting to Control4 account API: %s", + i + 1, + exception, + ) + exc = exception + raise ConfigEntryNotReady(exc) from exc async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: @@ -141,21 +145,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> ui_configuration=ui_configuration, ) - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener( - hass: HomeAssistant, config_entry: Control4ConfigEntry -) -> None: - """Update when config_entry options update.""" - _LOGGER.debug("Config entry was updated, rerunning setup") - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: Control4ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 3ca96ca4e52..9d5df61b513 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -11,7 +11,11 @@ from pyControl4.director import C4Director from pyControl4.error_handling import NotFound, Unauthorized import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -153,7 +157,7 @@ class Control4ConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for Control4.""" async def async_step_init( From d266b6f6abe256c0cb5b989cbfe7458e0e84cfec Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 19 Jul 2025 20:08:20 +0200 Subject: [PATCH 1573/1664] Use OptionsFlowWithReload in AVM Fritz!Box Tools (#149085) --- homeassistant/components/fritz/__init__.py | 8 -------- homeassistant/components/fritz/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py index faf82b4b516..94f4f8ba0d8 100644 --- a/homeassistant/components/fritz/__init__.py +++ b/homeassistant/components/fritz/__init__.py @@ -75,8 +75,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> boo if FRITZ_DATA_KEY not in hass.data: hass.data[FRITZ_DATA_KEY] = FritzData() - entry.async_on_unload(entry.add_update_listener(update_listener)) - # Load the other platforms like switch await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -94,9 +92,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FritzConfigEntry) -> bo hass.data.pop(FRITZ_DATA_KEY) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: FritzConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 2c22a35c4dd..270e9870c63 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -17,7 +17,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -409,7 +413,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): ) -class FritzBoxToolsOptionsFlowHandler(OptionsFlow): +class FritzBoxToolsOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( From be644ca96e53ad2808588dd8838f8dde1ca2ac0c Mon Sep 17 00:00:00 2001 From: peteS-UK <64092177+peteS-UK@users.noreply.github.com> Date: Sat, 19 Jul 2025 19:39:22 +0100 Subject: [PATCH 1574/1664] Add type to coordinator for Squeezebox (#149087) --- homeassistant/components/squeezebox/coordinator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/squeezebox/coordinator.py b/homeassistant/components/squeezebox/coordinator.py index 8bfb952b680..9508420ec5f 100644 --- a/homeassistant/components/squeezebox/coordinator.py +++ b/homeassistant/components/squeezebox/coordinator.py @@ -30,7 +30,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): +class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): """LMS Status custom coordinator.""" config_entry: SqueezeboxConfigEntry @@ -59,13 +59,13 @@ class LMSStatusDataUpdateCoordinator(DataUpdateCoordinator): else: _LOGGER.warning("Can't query server capabilities %s", self.lms.name) - async def _async_update_data(self) -> dict: + async def _async_update_data(self) -> dict[str, Any]: """Fetch data from LMS status call. Then we process only a subset to make then nice for HA """ async with timeout(STATUS_API_TIMEOUT): - data: dict | None = await self.lms.async_prepared_status() + data: dict[str, Any] | None = await self.lms.async_prepared_status() if not data: raise UpdateFailed( From 51d38f8f05398a1e96a8d1d6ee45a01be88e18c1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:06:14 +0200 Subject: [PATCH 1575/1664] Use OptionsFlowWithReload in emoncms (#149094) --- homeassistant/components/emoncms/__init__.py | 6 ------ homeassistant/components/emoncms/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py index 012abcc8c9a..1c081dc86e6 100644 --- a/homeassistant/components/emoncms/__init__.py +++ b/homeassistant/components/emoncms/__init__.py @@ -69,16 +69,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> b await coordinator.async_config_entry_first_refresh() entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def update_listener(hass: HomeAssistant, entry: EmonCMSConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EmonCMSConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py index b14903a78f9..375077a83d4 100644 --- a/homeassistant/components/emoncms/config_flow.py +++ b/homeassistant/components/emoncms/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL from homeassistant.core import callback @@ -221,7 +221,7 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EmoncmsOptionsFlow(OptionsFlow): +class EmoncmsOptionsFlow(OptionsFlowWithReload): """Emoncms Options flow handler.""" def __init__(self, config_entry: ConfigEntry) -> None: From e885ae1b15c7052948b2b11989713664cd5296e8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:07:23 +0200 Subject: [PATCH 1576/1664] Use OptionsFlowWithReload in holiday (#149090) --- homeassistant/components/holiday/__init__.py | 6 ------ homeassistant/components/holiday/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/holiday/__init__.py b/homeassistant/components/holiday/__init__.py index b364f2c67a4..f0c340785cf 100644 --- a/homeassistant/components/holiday/__init__.py +++ b/homeassistant/components/holiday/__init__.py @@ -34,16 +34,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/holiday/config_flow.py b/homeassistant/components/holiday/config_flow.py index 538d9971109..e9f16a9e4c5 100644 --- a/homeassistant/components/holiday/config_flow.py +++ b/homeassistant/components/holiday/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_COUNTRY from homeassistant.core import callback @@ -227,7 +227,7 @@ class HolidayConfigFlow(ConfigFlow, domain=DOMAIN): ) -class HolidayOptionsFlowHandler(OptionsFlow): +class HolidayOptionsFlowHandler(OptionsFlowWithReload): """Handle Holiday options.""" async def async_step_init( From afbb0ee2f4e8dd41e89c3ef0af43c2fa16408c28 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:07:55 +0200 Subject: [PATCH 1577/1664] Use OptionsFlowWithReload in github (#149089) --- homeassistant/components/github/__init__.py | 6 ------ homeassistant/components/github/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py index dea2acf4f1b..df50039b03f 100644 --- a/homeassistant/components/github/__init__.py +++ b/homeassistant/components/github/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> bo async_cleanup_device_registry(hass=hass, entry=entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True @@ -87,8 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> b coordinator.unsubscribe() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: GithubConfigEntry) -> None: - """Handle an options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/github/config_flow.py b/homeassistant/components/github/config_flow.py index 17338119b9f..a2a7e56830f 100644 --- a/homeassistant/components/github/config_flow.py +++ b/homeassistant/components/github/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant, callback @@ -214,7 +214,7 @@ class GitHubConfigFlow(ConfigFlow, domain=DOMAIN): return OptionsFlowHandler() -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for GitHub.""" async def async_step_init( From 96766fc62a9887a400dac20c92a95b127a47d68d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:08:37 +0200 Subject: [PATCH 1578/1664] Use OptionsFlowWithReload in Synology DSM (#149086) --- homeassistant/components/synology_dsm/__init__.py | 8 -------- homeassistant/components/synology_dsm/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index e568ce5a6d1..7146d42136e 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -136,7 +136,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) coordinator_switches=coordinator_switches, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) if entry.options[CONF_BACKUP_SHARE]: @@ -172,13 +171,6 @@ async def async_unload_entry( return unload_ok -async def _async_update_listener( - hass: HomeAssistant, entry: SynologyDSMConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_config_entry_device( hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry ) -> bool: diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index f0da6f8fe47..6e3469970d1 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -24,7 +24,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_DISKS, @@ -441,7 +441,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): return None -class SynologyDSMOptionsFlowHandler(OptionsFlow): +class SynologyDSMOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" config_entry: SynologyDSMConfigEntry From d35dca377fd03e3661d5387a7d028fa44df9cb8d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:11:15 +0200 Subject: [PATCH 1579/1664] Use OptionsFlowWithReload in purpleair (#149095) --- homeassistant/components/purpleair/__init__.py | 7 ------- homeassistant/components/purpleair/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py index 78986b34351..0b7acdb1eb0 100644 --- a/homeassistant/components/purpleair/__init__.py +++ b/homeassistant/components/purpleair/__init__.py @@ -20,16 +20,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None: - """Reload config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool: """Unload config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py index 3ca7870b3cb..29139872913 100644 --- a/homeassistant/components/purpleair/config_flow.py +++ b/homeassistant/components/purpleair/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_API_KEY, @@ -312,7 +312,7 @@ class PurpleAirConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_by_coordinates() -class PurpleAirOptionsFlowHandler(OptionsFlow): +class PurpleAirOptionsFlowHandler(OptionsFlowWithReload): """Handle a PurpleAir options flow.""" def __init__(self) -> None: From d796ab8fe70b4d921c5033bc8a7e40846c8c7609 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 19 Jul 2025 21:12:17 +0200 Subject: [PATCH 1580/1664] Use OptionsFlowWithReload in kitchen_sink (#149091) --- homeassistant/components/kitchen_sink/__init__.py | 7 ------- homeassistant/components/kitchen_sink/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 2f876ca855d..8b81cd49279 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -101,19 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Start a reauth flow entry.async_start_reauth(hass) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Notify backup listeners hass.async_create_task(_notify_backup_listeners(hass), eager_start=False) return True -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" # Notify backup listeners diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py index aa722d27944..059fd11999f 100644 --- a/homeassistant/components/kitchen_sink/config_flow.py +++ b/homeassistant/components/kitchen_sink/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, - OptionsFlow, + OptionsFlowWithReload, SubentryFlowResult, ) from homeassistant.core import callback @@ -65,7 +65,7 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( From 507f29a2098c9b2b10afe0e4eb85d983f2ccc0fd Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:12:49 +0200 Subject: [PATCH 1581/1664] Bump homematicip to 2.2.0 (#149038) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 036ffa286a3..14b5ac39310 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.0.7"] + "requirements": ["homematicip==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1529fdd306f..f54d00e6fea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.7 +homematicip==2.2.0 # homeassistant.components.horizon horimote==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac1be38ee4d..e1b0d36db2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ home-assistant-frontend==20250702.3 home-assistant-intents==2025.6.23 # homeassistant.components.homematicip_cloud -homematicip==2.0.7 +homematicip==2.2.0 # homeassistant.components.remember_the_milk httplib2==0.20.4 From 1a6bfc03106d18b1082be4d8167b231d4617abd8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 10:17:09 +0200 Subject: [PATCH 1582/1664] Use OptionsFlowWithReload in knx (#149097) --- homeassistant/components/knx/__init__.py | 7 ------- homeassistant/components/knx/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 6fa4c8146ba..ead846735c9 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -120,8 +120,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[KNX_MODULE_KEY] = knx_module - entry.async_on_unload(entry.add_update_listener(async_update_entry)) - if CONF_KNX_EXPOSE in config: for expose_config in config[CONF_KNX_EXPOSE]: knx_module.exposures.append( @@ -174,11 +172,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_update_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update a given config entry.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Remove a config entry.""" diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 796c4c60201..7772f366493 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import callback @@ -899,7 +899,7 @@ class KNXConfigFlow(ConfigFlow, domain=DOMAIN): ) -class KNXOptionsFlow(OptionsFlow): +class KNXOptionsFlow(OptionsFlowWithReload): """Handle KNX options.""" def __init__(self, config_entry: ConfigEntry) -> None: From ead99c549fabacdc5dbf4649efebc7f297ef2839 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 11:12:51 +0200 Subject: [PATCH 1583/1664] Use OptionsFlowWithReload in denonavr (#149109) --- homeassistant/components/denonavr/__init__.py | 9 --------- homeassistant/components/denonavr/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index da2b601317a..8cead5f4992 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -53,8 +53,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: DenonavrConfigEntry) -> raise ConfigEntryNotReady from ex receiver = connect_denonavr.receiver - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = receiver await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -100,10 +98,3 @@ async def async_unload_entry( _LOGGER.debug("Removing zone3 from DenonAvr") return unload_ok - - -async def update_listener( - hass: HomeAssistant, config_entry: DenonavrConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 930d0e009ac..204471a13b4 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -10,7 +10,11 @@ import denonavr from denonavr.exceptions import AvrNetworkError, AvrTimoutError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_TYPE from homeassistant.core import callback from homeassistant.helpers.httpx_client import get_async_client @@ -51,7 +55,7 @@ DEFAULT_USE_TELNET_NEW_INSTALL = True CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Options for the component.""" async def async_step_init( From b262a5c9b63ec84962780b89e0ad69be72946a5a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 12:05:24 +0200 Subject: [PATCH 1584/1664] Use OptionsFlowWithReload in lastfm (#149113) --- homeassistant/components/lastfm/__init__.py | 6 ------ homeassistant/components/lastfm/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index b5a4612429e..90bee0cf4e7 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -16,7 +16,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -24,8 +23,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bo async def async_unload_entry(hass: HomeAssistant, entry: LastFMConfigEntry) -> bool: """Unload lastfm config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def update_listener(hass: HomeAssistant, entry: LastFMConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py index 422c50a5fb9..47c5b0e217e 100644 --- a/homeassistant/components/lastfm/config_flow.py +++ b/homeassistant/components/lastfm/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pylast import LastFMNetwork, PyLastError, User, WSError import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_KEY from homeassistant.core import callback from homeassistant.helpers.selector import ( @@ -155,7 +159,7 @@ class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): ) -class LastFmOptionsFlowHandler(OptionsFlow): +class LastFmOptionsFlowHandler(OptionsFlowWithReload): """LastFm Options flow handler.""" config_entry: LastFMConfigEntry From 5d653d46c3b303a793d353921013569e056e617a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 12:30:22 +0200 Subject: [PATCH 1585/1664] Remove not used config entry update listener from nut (#149096) --- homeassistant/components/nut/__init__.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py index 2f2c6badc4c..e3460f5a687 100644 --- a/homeassistant/components/nut/__init__.py +++ b/homeassistant/components/nut/__init__.py @@ -116,7 +116,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool: _LOGGER.debug("NUT Sensors Available: %s", status if status else None) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) unique_id = _unique_id_from_status(status) if unique_id is None: unique_id = entry.entry_id @@ -199,11 +198,6 @@ async def async_remove_config_entry_device( ) -async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def _manufacturer_from_status(status: dict[str, str]) -> str | None: """Find the best manufacturer value from the status.""" return ( From 0c858de1af8e6698172dbeb8726a6828925a3206 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 12:31:18 +0200 Subject: [PATCH 1586/1664] Use OptionsFlowWithReload in lamarzocco (#149119) --- homeassistant/components/lamarzocco/__init__.py | 7 ------- homeassistant/components/lamarzocco/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py index 2d68b3be345..92184b4ac51 100644 --- a/homeassistant/components/lamarzocco/__init__.py +++ b/homeassistant/components/lamarzocco/__init__.py @@ -154,13 +154,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, entry: LaMarzoccoConfigEntry - ) -> None: - await hass.config_entries.async_reload(entry.entry_id) - - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py index e352e337d0b..fb968a0b4af 100644 --- a/homeassistant/components/lamarzocco/config_flow.py +++ b/homeassistant/components/lamarzocco/config_flow.py @@ -21,7 +21,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ADDRESS, @@ -363,7 +363,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN): return LmOptionsFlowHandler() -class LmOptionsFlowHandler(OptionsFlow): +class LmOptionsFlowHandler(OptionsFlowWithReload): """Handles options flow for the component.""" async def async_step_init( From 0d42b244675b2f94404155d5ff76f805923ee2aa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:05:39 +0200 Subject: [PATCH 1587/1664] Use OptionsFlowWithReload in jewish_calendar (#149121) --- homeassistant/components/jewish_calendar/__init__.py | 7 ------- homeassistant/components/jewish_calendar/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index ec73d960140..8e01b6b6ae0 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -79,13 +79,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - async def update_listener( - hass: HomeAssistant, config_entry: JewishCalendarConfigEntry - ) -> None: - # Trigger update of states for all platforms - await hass.config_entries.async_reload(config_entry.entry_id) - - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index e896bc90c9e..f52e14537b3 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -9,7 +9,11 @@ import zoneinfo from hdate.translator import Language import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_ELEVATION, CONF_LANGUAGE, @@ -124,7 +128,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class JewishCalendarOptionsFlowHandler(OptionsFlow): +class JewishCalendarOptionsFlowHandler(OptionsFlowWithReload): """Handle Jewish Calendar options.""" async def async_step_init( From 1b8f3348b0431d6bd835c80ebb0ea0fc46ec51c2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:06:59 +0200 Subject: [PATCH 1588/1664] Use OptionsFlowWithReload in roborock (#149118) --- homeassistant/components/roborock/__init__.py | 8 -------- homeassistant/components/roborock/config_flow.py | 13 ++++--------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 6697779adf6..bc10ab7309c 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -43,8 +43,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool: """Set up roborock from a config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) - user_data = UserData.from_dict(entry.data[CONF_USER_DATA]) api_client = RoborockApiClient( entry.data[CONF_USERNAME], @@ -336,12 +334,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: - """Handle options update.""" - # Reload entry to update data - await hass.config_entries.async_reload(entry.entry_id) - - async def async_remove_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> None: """Handle removal of an entry.""" await async_remove_map_storage(hass, entry.entry_id) diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py index 62943e0dcc9..6a35bf79233 100644 --- a/homeassistant/components/roborock/config_flow.py +++ b/homeassistant/components/roborock/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_USERNAME from homeassistant.core import callback @@ -124,14 +124,9 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch(reason="wrong_account") reauth_entry = self._get_reauth_entry() - self.hass.config_entries.async_update_entry( - reauth_entry, - data={ - **reauth_entry.data, - CONF_USER_DATA: user_data.as_dict(), - }, + return self.async_update_reload_and_abort( + reauth_entry, data_updates={CONF_USER_DATA: user_data.as_dict()} ) - return self.async_abort(reason="reauth_successful") self._abort_if_unique_id_configured(error="already_configured_account") return self._create_entry(self._client, self._username, user_data) @@ -202,7 +197,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN): return RoborockOptionsFlowHandler(config_entry) -class RoborockOptionsFlowHandler(OptionsFlow): +class RoborockOptionsFlowHandler(OptionsFlowWithReload): """Handle an option flow for Roborock.""" def __init__(self, config_entry: RoborockConfigEntry) -> None: From b31e17f1f9649e38955263137f20d297a5393021 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:07:46 +0200 Subject: [PATCH 1589/1664] Use OptionsFlowWithReload in met (#149115) --- homeassistant/components/met/__init__.py | 6 ------ homeassistant/components/met/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 17fc411bf20..d5f80d442a4 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -47,7 +47,6 @@ async def async_setup_entry( config_entry.runtime_data = coordinator - config_entry.async_on_unload(config_entry.add_update_listener(async_update_entry)) config_entry.async_on_unload(coordinator.untrack_home) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -64,11 +63,6 @@ async def async_unload_entry( return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def async_update_entry(hass: HomeAssistant, config_entry: MetWeatherConfigEntry): - """Reload Met component when options changed.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def cleanup_old_device(hass: HomeAssistant) -> None: """Cleanup device without proper device identifier.""" device_reg = dr.async_get(hass) diff --git a/homeassistant/components/met/config_flow.py b/homeassistant/components/met/config_flow.py index e5db80b2997..54d528a7406 100644 --- a/homeassistant/components/met/config_flow.py +++ b/homeassistant/components/met/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_ELEVATION, @@ -147,7 +147,7 @@ class MetConfigFlowHandler(ConfigFlow, domain=DOMAIN): return MetOptionsFlowHandler() -class MetOptionsFlowHandler(OptionsFlow): +class MetOptionsFlowHandler(OptionsFlowWithReload): """Options flow for Met component.""" async def async_step_init( From 302b6f03baefd1caa9f5c9821b83d251b8e4894b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:08:42 +0200 Subject: [PATCH 1590/1664] Use OptionsFlowWithReload in speedtest (#149111) --- homeassistant/components/speedtestdotnet/__init__.py | 8 -------- homeassistant/components/speedtestdotnet/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index e4f439013c6..5f66ba380fe 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -42,7 +42,6 @@ async def async_setup_entry( async_at_started(hass, _async_finish_startup) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) return True @@ -52,10 +51,3 @@ async def async_unload_entry( ) -> bool: """Unload SpeedTest Entry from config_entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -async def update_listener( - hass: HomeAssistant, config_entry: SpeedTestConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/speedtestdotnet/config_flow.py b/homeassistant/components/speedtestdotnet/config_flow.py index 4fbca5e0d29..4bae503f85e 100644 --- a/homeassistant/components/speedtestdotnet/config_flow.py +++ b/homeassistant/components/speedtestdotnet/config_flow.py @@ -6,7 +6,11 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.core import callback from .const import ( @@ -45,7 +49,7 @@ class SpeedTestFlowHandler(ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=DEFAULT_NAME, data=user_input) -class SpeedTestOptionsFlowHandler(OptionsFlow): +class SpeedTestOptionsFlowHandler(OptionsFlowWithReload): """Handle SpeedTest options.""" def __init__(self) -> None: From 43dc73c2e1b272ad364aa6085fe6e10168493e83 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 13:09:07 +0200 Subject: [PATCH 1591/1664] Use OptionsFlowWithReload in forecast_solar (#149112) --- homeassistant/components/forecast_solar/__init__.py | 9 --------- homeassistant/components/forecast_solar/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/forecast_solar/__init__.py b/homeassistant/components/forecast_solar/__init__.py index 171341f7226..7b534b80500 100644 --- a/homeassistant/components/forecast_solar/__init__.py +++ b/homeassistant/components/forecast_solar/__init__.py @@ -47,8 +47,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_options)) - return True @@ -57,10 +55,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: ForecastSolarConfigEntry -) -> None: - """Update options.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/forecast_solar/config_flow.py b/homeassistant/components/forecast_solar/config_flow.py index 9a64ce6e1fb..031764a0d0a 100644 --- a/homeassistant/components/forecast_solar/config_flow.py +++ b/homeassistant/components/forecast_solar/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import callback @@ -88,7 +88,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN): ) -class ForecastSolarOptionFlowHandler(OptionsFlow): +class ForecastSolarOptionFlowHandler(OptionsFlowWithReload): """Handle options.""" async def async_step_init( From e3bdd12dadce4a95f14bee9b13610f03afe9c957 Mon Sep 17 00:00:00 2001 From: Thorsten Date: Sun, 20 Jul 2025 14:13:24 +0200 Subject: [PATCH 1592/1664] Add Bauknecht virtual integration (#146801) --- homeassistant/components/bauknecht/__init__.py | 1 + homeassistant/components/bauknecht/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/bauknecht/__init__.py create mode 100644 homeassistant/components/bauknecht/manifest.json diff --git a/homeassistant/components/bauknecht/__init__.py b/homeassistant/components/bauknecht/__init__.py new file mode 100644 index 00000000000..1e93f1ab0c2 --- /dev/null +++ b/homeassistant/components/bauknecht/__init__.py @@ -0,0 +1 @@ +"""Bauknecht virtual integration.""" diff --git a/homeassistant/components/bauknecht/manifest.json b/homeassistant/components/bauknecht/manifest.json new file mode 100644 index 00000000000..b875d7fbc31 --- /dev/null +++ b/homeassistant/components/bauknecht/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "bauknecht", + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 480a88e1ae4..8782d5c84b4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -665,6 +665,11 @@ "config_flow": true, "iot_class": "local_push" }, + "bauknecht": { + "name": "Bauknecht", + "integration_type": "virtual", + "supported_by": "whirlpool" + }, "bbox": { "name": "Bbox", "integration_type": "hub", From 72d5578128cf69bcbf28e2bf424f80aadbea5841 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Sun, 20 Jul 2025 14:29:18 +0200 Subject: [PATCH 1593/1664] Fix typo in `#device-discovery-payload` anchor link of `mqtt` (#149116) --- 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 1315463ebcf..8cb66270331 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -678,7 +678,7 @@ }, "data_description": { "discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.", - "discovery_payload": "The JSON [discovery payload]({url}#discovery-discovery-payload) that contains information about the MQTT device." + "discovery_payload": "The JSON [discovery payload]({url}#device-discovery-payload) that contains information about the MQTT device." } } }, From 216e89dc5e149e6f71d487eb41748ee3624d2345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 20 Jul 2025 13:50:17 +0100 Subject: [PATCH 1594/1664] Add battery charging state icons to Reolink (#149125) --- homeassistant/components/reolink/icons.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index cf3079e51e8..875af48e47c 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -402,7 +402,12 @@ "default": "mdi:thermometer" }, "battery_state": { - "default": "mdi:battery-charging" + "default": "mdi:battery-unknown", + "state": { + "discharging": "mdi:battery-minus-variant", + "charging": "mdi:battery-charging", + "chargecomplete": "mdi:battery-check" + } }, "day_night_state": { "default": "mdi:theme-light-dark" From ca48b9e375c44b38d2b973adcfff0682a1143980 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Sun, 20 Jul 2025 20:41:49 +0200 Subject: [PATCH 1595/1664] Bump uiprotect to version 7.15.1 (#149124) --- 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 8243a55d779..8d77a59955f 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.14.2", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.15.1", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index f54d00e6fea..dd50d29660a 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.14.2 +uiprotect==7.15.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e1b0d36db2b..9d8839f1b0d 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.14.2 +uiprotect==7.15.1 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 44fec53bacb8b479a91b15d1affce0c51b8f7997 Mon Sep 17 00:00:00 2001 From: Vincent Wolsink Date: Sun, 20 Jul 2025 22:50:53 +0200 Subject: [PATCH 1596/1664] Add binary_sensor for door status in Huum (#149135) --- .../components/huum/binary_sensor.py | 42 ++++++++++++++++ homeassistant/components/huum/const.py | 2 +- .../huum/snapshots/test_binary_sensor.ambr | 50 +++++++++++++++++++ tests/components/huum/test_binary_sensor.py | 29 +++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/huum/binary_sensor.py create mode 100644 tests/components/huum/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/huum/test_binary_sensor.py diff --git a/homeassistant/components/huum/binary_sensor.py b/homeassistant/components/huum/binary_sensor.py new file mode 100644 index 00000000000..a8e094dda94 --- /dev/null +++ b/homeassistant/components/huum/binary_sensor.py @@ -0,0 +1,42 @@ +"""Sensor for door state.""" + +from __future__ import annotations + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator +from .entity import HuumBaseEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HuumConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up door sensor.""" + async_add_entities( + [HuumDoorSensor(config_entry.runtime_data)], + ) + + +class HuumDoorSensor(HuumBaseEntity, BinarySensorEntity): + """Representation of a BinarySensor.""" + + _attr_name = "Door" + _attr_device_class = BinarySensorDeviceClass.DOOR + + def __init__(self, coordinator: HuumDataUpdateCoordinator) -> None: + """Initialize the BinarySensor.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_door" + + @property + def is_on(self) -> bool | None: + """Return the current value.""" + return not self.coordinator.data.door_closed diff --git a/homeassistant/components/huum/const.py b/homeassistant/components/huum/const.py index 69dea45b218..6691a2ad8b3 100644 --- a/homeassistant/components/huum/const.py +++ b/homeassistant/components/huum/const.py @@ -4,4 +4,4 @@ from homeassistant.const import Platform DOMAIN = "huum" -PLATFORMS = [Platform.CLIMATE] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CLIMATE] diff --git a/tests/components/huum/snapshots/test_binary_sensor.ambr b/tests/components/huum/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..3490ff594b6 --- /dev/null +++ b/tests/components/huum/snapshots/test_binary_sensor.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_binary_sensor[binary_sensor.huum_sauna_door-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.huum_sauna_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'huum', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'AABBCC112233_door', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor[binary_sensor.huum_sauna_door-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Huum sauna Door', + }), + 'context': , + 'entity_id': 'binary_sensor.huum_sauna_door', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/huum/test_binary_sensor.py b/tests/components/huum/test_binary_sensor.py new file mode 100644 index 00000000000..5ea2ae69a11 --- /dev/null +++ b/tests/components/huum/test_binary_sensor.py @@ -0,0 +1,29 @@ +"""Tests for the Huum climate entity.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import 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 = "binary_sensor.huum_sauna_door" + + +async def test_binary_sensor( + 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.BINARY_SENSOR] + ) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) From b8d45fba246aad36a9628657b012c74ed1710750 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 20 Jul 2025 10:53:09 -1000 Subject: [PATCH 1597/1664] Bump aioesphomeapi to 37.0.2 (#149143) --- 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 bb1f2d28457..e83ab16064c 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.1", + "aioesphomeapi==37.0.2", "esphome-dashboard-api==1.3.0", "bleak-esphome==3.1.0" ], diff --git a/requirements_all.txt b/requirements_all.txt index dd50d29660a..8aaa9817775 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.1 +aioesphomeapi==37.0.2 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d8839f1b0d..caa83b80ddb 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.1 +aioesphomeapi==37.0.2 # homeassistant.components.flo aioflo==2021.11.0 From e3577de9d888709867c7c0330c4f3bd6cafbd060 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 23:17:43 +0200 Subject: [PATCH 1598/1664] Use OptionsFlowWithReload in onkyo (#149093) --- homeassistant/components/onkyo/__init__.py | 6 ------ homeassistant/components/onkyo/config_flow.py | 6 +++--- tests/components/onkyo/test_init.py | 20 ------------------- 3 files changed, 3 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index 67ed4162778..d0f93012eb7 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -47,7 +47,6 @@ async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bool: """Set up the Onkyo config entry.""" - entry.async_on_unload(entry.add_update_listener(update_listener)) host = entry.data[CONF_HOST] @@ -82,8 +81,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo receiver.conn.close() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: OnkyoConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 85ff0de3251..2b8f9981e4a 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST from homeassistant.core import callback @@ -329,7 +329,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -357,7 +357,7 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema( ) -class OnkyoOptionsFlowHandler(OptionsFlow): +class OnkyoOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow for Onkyo.""" _data: dict[str, Any] diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 17086a3088e..4c6ddcca214 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -33,26 +33,6 @@ async def test_load_unload_entry( assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_entry( - hass: HomeAssistant, - config_entry: MockConfigEntry, -) -> None: - """Test update options.""" - - with patch.object(hass.config_entries, "async_reload", return_value=True): - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) - - # Force option change - assert hass.config_entries.async_update_entry( - config_entry, options={"option": "new_value"} - ) - await hass.async_block_till_done() - - hass.config_entries.async_reload.assert_called_with(config_entry.entry_id) - - async def test_no_connection( hass: HomeAssistant, config_entry: MockConfigEntry, From 61ca0b6b86f149735a02aef77c3a49c54a351b59 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 23:18:00 +0200 Subject: [PATCH 1599/1664] Use OptionsFlowWithReload in vodafone_station (#149131) --- homeassistant/components/vodafone_station/__init__.py | 8 -------- homeassistant/components/vodafone_station/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index 17b0fe6e501..0433199b54e 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -25,8 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(update_listener)) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -39,9 +37,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> await coordinator.api.logout() return unload_ok - - -async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None: - """Update when config_entry options update.""" - if entry.options: - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index c330a93a1a8..13e30d38926 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -12,7 +12,11 @@ from homeassistant.components.device_tracker import ( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -180,7 +184,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN): ) -class VodafoneStationOptionsFlowHandler(OptionsFlow): +class VodafoneStationOptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow.""" async def async_step_init( From 77a954df9b69e798f8b4400e57e41504132a56b5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 20 Jul 2025 23:44:39 +0200 Subject: [PATCH 1600/1664] Use OptionsFlowWithReload in reolink (#149132) --- homeassistant/components/reolink/__init__.py | 11 ---------- .../components/reolink/config_flow.py | 4 ++-- tests/components/reolink/test_init.py | 20 ------------------- 3 files changed, 2 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 3260bff44b5..236e1707461 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -243,10 +243,6 @@ async def async_setup_entry( await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload( - config_entry.add_update_listener(entry_update_listener) - ) - return True @@ -295,13 +291,6 @@ async def register_callbacks( ) -async def entry_update_listener( - hass: HomeAssistant, config_entry: ReolinkConfigEntry -) -> None: - """Update the configuration of the host entity.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: ReolinkConfigEntry ) -> bool: diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index eee8b04dfcc..2ac51792c3f 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -61,7 +61,7 @@ DEFAULT_OPTIONS = {CONF_PROTOCOL: DEFAULT_PROTOCOL} API_STARTUP_TIME = 5 -class ReolinkOptionsFlowHandler(OptionsFlow): +class ReolinkOptionsFlowHandler(OptionsFlowWithReload): """Handle Reolink options.""" async def async_step_init( diff --git a/tests/components/reolink/test_init.py b/tests/components/reolink/test_init.py index e439d3dff93..10eefccace9 100644 --- a/tests/components/reolink/test_init.py +++ b/tests/components/reolink/test_init.py @@ -180,26 +180,6 @@ async def test_credential_error_three( assert (HOMEASSISTANT_DOMAIN, issue_id) in issue_registry.issues -async def test_entry_reloading( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, -) -> None: - """Test the entry is reloaded correctly when settings change.""" - reolink_host.is_nvr = False - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 0 - assert config_entry.title == "test_reolink_name" - - hass.config_entries.async_update_entry(config_entry, title="New Name") - await hass.async_block_till_done() - - assert reolink_host.logout.call_count == 1 - assert config_entry.title == "New Name" - - @pytest.mark.parametrize( ("attr", "value", "expected_models"), [ From 0a9fbb215dba945eb206226b66852b99e1dbbf88 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 21 Jul 2025 02:22:32 +0200 Subject: [PATCH 1601/1664] Bump uiprotect to version 7.16.0 (#149146) --- 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 8d77a59955f..e5b017e0ab6 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.15.1", "unifi-discovery==1.2.0"], + "requirements": ["uiprotect==7.16.0", "unifi-discovery==1.2.0"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 8aaa9817775..8b699e56316 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.15.1 +uiprotect==7.16.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index caa83b80ddb..81a07abd45c 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.15.1 +uiprotect==7.16.0 # homeassistant.components.landisgyr_heat_meter ultraheat-api==0.5.7 From 27787e0679ce88aefafef1e6fe84351c4e0a43fb Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 21 Jul 2025 01:25:45 -0400 Subject: [PATCH 1602/1664] Bump pyschlage to 2025.7.2 (#149148) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 893c30dfd41..c5b91cefd2e 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.4.0"] + "requirements": ["pyschlage==2025.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8b699e56316..b7e3fd074b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.2 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81a07abd45c..30ad1b2e5fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.4.0 +pyschlage==2025.7.2 # homeassistant.components.sensibo pysensibo==1.2.1 From bd7cef92c7d21ed95b4aff6557e97c1a93b69fea Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 21 Jul 2025 07:26:29 +0200 Subject: [PATCH 1603/1664] Use OptionsFlowWithReload in Proximity (#149136) --- homeassistant/components/proximity/__init__.py | 8 -------- homeassistant/components/proximity/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 2338464558d..4dc87554055 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -43,17 +43,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: ProximityConfigEntry -) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/proximity/config_flow.py b/homeassistant/components/proximity/config_flow.py index 5818ec2979b..f60dcfae7b5 100644 --- a/homeassistant/components/proximity/config_flow.py +++ b/homeassistant/components/proximity/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_ZONE, UnitOfLength from homeassistant.core import State, callback @@ -87,7 +87,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + def async_get_options_flow(config_entry: ConfigEntry) -> ProximityOptionsFlow: """Get the options flow for this handler.""" return ProximityOptionsFlow() @@ -118,7 +118,7 @@ class ProximityConfigFlow(ConfigFlow, domain=DOMAIN): ) -class ProximityOptionsFlow(OptionsFlow): +class ProximityOptionsFlow(OptionsFlowWithReload): """Handle a option flow.""" def _user_form_schema(self, user_input: dict[str, Any]) -> vol.Schema: From eca80a1645ca0b5d56f9820ccf53bb7abf701962 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 21 Jul 2025 07:27:02 +0200 Subject: [PATCH 1604/1664] Use OptionsFlowWithReload in Feedreader (#149134) --- homeassistant/components/feedreader/__init__.py | 9 --------- homeassistant/components/feedreader/config_flow.py | 9 ++++----- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 57c58d3a2b1..9acec01ee6d 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -32,8 +32,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) - await coordinator.async_config_entry_first_refresh() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - return True @@ -46,10 +44,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) if len(entries) == 1: hass.data.pop(MY_KEY) return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT]) - - -async def _async_update_listener( - hass: HomeAssistant, entry: FeedReaderConfigEntry -) -> None: - """Handle reconfiguration.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/feedreader/config_flow.py b/homeassistant/components/feedreader/config_flow.py index 3d0fec1a6f5..37c627f21ba 100644 --- a/homeassistant/components/feedreader/config_flow.py +++ b/homeassistant/components/feedreader/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback @@ -44,7 +44,7 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: ConfigEntry, - ) -> OptionsFlow: + ) -> FeedReaderOptionsFlowHandler: """Get the options flow for this handler.""" return FeedReaderOptionsFlowHandler() @@ -119,11 +119,10 @@ class FeedReaderConfigFlow(ConfigFlow, domain=DOMAIN): errors={"base": "url_error"}, ) - self.hass.config_entries.async_update_entry(reconfigure_entry, data=user_input) - return self.async_abort(reason="reconfigure_successful") + return self.async_update_reload_and_abort(reconfigure_entry, data=user_input) -class FeedReaderOptionsFlowHandler(OptionsFlow): +class FeedReaderOptionsFlowHandler(OptionsFlowWithReload): """Handle an options flow.""" async def async_step_init( From bc9ad5eac64372f30fe0a1a7e1576958eefc2223 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 21 Jul 2025 08:15:32 +0200 Subject: [PATCH 1605/1664] Add device class to gardena (#149144) --- homeassistant/components/gardena_bluetooth/number.py | 6 ++++++ homeassistant/components/gardena_bluetooth/valve.py | 7 ++++++- .../gardena_bluetooth/snapshots/test_number.ambr | 11 +++++++++++ .../gardena_bluetooth/snapshots/test_valve.ambr | 2 ++ 4 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py index 41b4f1e79ba..342061c18d1 100644 --- a/homeassistant/components/gardena_bluetooth/number.py +++ b/homeassistant/components/gardena_bluetooth/number.py @@ -13,6 +13,7 @@ from gardena_bluetooth.parse import ( ) from homeassistant.components.number import ( + NumberDeviceClass, NumberEntity, NumberEntityDescription, NumberMode, @@ -54,6 +55,7 @@ DESCRIPTIONS = ( native_step=60, entity_category=EntityCategory.CONFIG, char=Valve.manual_watering_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Valve.remaining_open_time.uuid, @@ -64,6 +66,7 @@ DESCRIPTIONS = ( native_step=60.0, entity_category=EntityCategory.DIAGNOSTIC, char=Valve.remaining_open_time, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.rain_pause.uuid, @@ -75,6 +78,7 @@ DESCRIPTIONS = ( native_step=6 * 60.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.rain_pause, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=DeviceConfiguration.seasonal_adjust.uuid, @@ -86,6 +90,7 @@ DESCRIPTIONS = ( native_step=1.0, entity_category=EntityCategory.CONFIG, char=DeviceConfiguration.seasonal_adjust, + device_class=NumberDeviceClass.DURATION, ), GardenaBluetoothNumberEntityDescription( key=Sensor.threshold.uuid, @@ -153,6 +158,7 @@ class GardenaBluetoothRemainingOpenSetNumber(GardenaBluetoothEntity, NumberEntit _attr_native_min_value = 0.0 _attr_native_max_value = 24 * 60 _attr_native_step = 1.0 + _attr_device_class = NumberDeviceClass.DURATION def __init__( self, diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py index 4138c7c4472..247a85f93f1 100644 --- a/homeassistant/components/gardena_bluetooth/valve.py +++ b/homeassistant/components/gardena_bluetooth/valve.py @@ -6,7 +6,11 @@ from typing import Any from gardena_bluetooth.const import Valve -from homeassistant.components.valve import ValveEntity, ValveEntityFeature +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityFeature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -37,6 +41,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity): _attr_is_closed: bool | None = None _attr_reports_position = False _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + _attr_device_class = ValveDeviceClass.WATER characteristics = { Valve.state.uuid, diff --git a/tests/components/gardena_bluetooth/snapshots/test_number.ambr b/tests/components/gardena_bluetooth/snapshots/test_number.ambr index c89ead450d2..4bc1e7e8dcb 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_number.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_number.ambr @@ -2,6 +2,7 @@ # name: test_bluetooth_error_unavailable StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -20,6 +21,7 @@ # name: test_bluetooth_error_unavailable.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -38,6 +40,7 @@ # name: test_bluetooth_error_unavailable.2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -56,6 +59,7 @@ # name: test_bluetooth_error_unavailable.3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -110,6 +114,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -128,6 +133,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -146,6 +152,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].2 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -164,6 +171,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw1-number.mock_title_remaining_open_time].3 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Remaining open time', 'max': 86400, 'min': 0.0, @@ -182,6 +190,7 @@ # name: test_setup[98bd0f13-0b0e-421a-84e5-ddbf75dc6de4-raw2-number.mock_title_open_for] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Open for', 'max': 1440, 'min': 0.0, @@ -200,6 +209,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, @@ -218,6 +228,7 @@ # name: test_setup[98bd0f14-0b0e-421a-84e5-ddbf75dc6de4-raw0-number.mock_title_manual_watering_time].1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'duration', 'friendly_name': 'Mock Title Manual watering time', 'max': 86400, 'min': 0.0, diff --git a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr index c030332e75b..4a0da40a143 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr @@ -2,6 +2,7 @@ # name: test_setup StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), @@ -16,6 +17,7 @@ # name: test_setup.1 StateSnapshot({ 'attributes': ReadOnlyDict({ + 'device_class': 'water', 'friendly_name': 'Mock Title', 'supported_features': , }), From 00c4b097734d3b2660bf831f8e1452c3a12a4caf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 08:15:51 +0200 Subject: [PATCH 1606/1664] Use OptionsFlowWithReload in motioneye (#149130) --- homeassistant/components/motioneye/__init__.py | 6 ------ homeassistant/components/motioneye/config_flow.py | 4 ++-- tests/components/motioneye/test_config_flow.py | 4 ++-- tests/components/motioneye/test_web_hooks.py | 2 +- 4 files changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/motioneye/__init__.py b/homeassistant/components/motioneye/__init__.py index 3e4ad53d200..fec176847da 100644 --- a/homeassistant/components/motioneye/__init__.py +++ b/homeassistant/components/motioneye/__init__.py @@ -277,11 +277,6 @@ def _add_camera( ) -async def _async_entry_updated(hass: HomeAssistant, config_entry: ConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up motionEye from a config entry.""" hass.data.setdefault(DOMAIN, {}) @@ -382,7 +377,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator.async_add_listener(_async_process_motioneye_cameras) ) await coordinator.async_refresh() - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 80a6449a22d..7704fb68412 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_URL, CONF_WEBHOOK_ID from homeassistant.core import callback @@ -186,7 +186,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): return MotionEyeOptionsFlow() -class MotionEyeOptionsFlow(OptionsFlow): +class MotionEyeOptionsFlow(OptionsFlowWithReload): """motionEye options flow.""" async def async_step_init( diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 8d942e7a2a1..f3c4820ff90 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -532,7 +532,7 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert CONF_STREAM_URL_TEMPLATE not in result["data"] assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 result = await hass.config_entries.options.async_init( config_entry.entry_id, context={"show_advanced_options": True} @@ -551,4 +551,4 @@ async def test_advanced_options(hass: HomeAssistant) -> None: assert result["data"][CONF_WEBHOOK_SET_OVERWRITE] assert result["data"][CONF_STREAM_URL_TEMPLATE] == "http://moo" assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/motioneye/test_web_hooks.py b/tests/components/motioneye/test_web_hooks.py index bc345c0b66f..4e9d5e926a8 100644 --- a/tests/components/motioneye/test_web_hooks.py +++ b/tests/components/motioneye/test_web_hooks.py @@ -116,7 +116,6 @@ async def test_setup_camera_with_wrong_webhook( ) assert not client.async_set_camera.called - # Update the options, which will trigger a reload with the new behavior. with patch( "homeassistant.components.motioneye.MotionEyeClient", return_value=client, @@ -124,6 +123,7 @@ async def test_setup_camera_with_wrong_webhook( hass.config_entries.async_update_entry( config_entry, options={CONF_WEBHOOK_SET_OVERWRITE: True} ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() device = device_registry.async_get_device( From 11dd2dc374983658f2ca183ef3af480ca8c1dd95 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 08:17:12 +0200 Subject: [PATCH 1607/1664] Use OptionsFlowWithReload in file (#149108) --- homeassistant/components/file/__init__.py | 6 ------ homeassistant/components/file/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 7bc206057c8..59a08715b8e 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -29,7 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups( entry, [Platform(entry.data[CONF_PLATFORM])] ) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -41,11 +40,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate config entry.""" if config_entry.version > 2: diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index 1c4fdbe5c84..9078a4d115e 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_FILE_PATH, @@ -131,7 +131,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN): return await self._async_handle_step(Platform.SENSOR.value, user_input) -class FileOptionsFlowHandler(OptionsFlow): +class FileOptionsFlowHandler(OptionsFlowWithReload): """Handle File options.""" async def async_step_init( From c1e35cc9cfe8c74e830908eeea705c5099960b1d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 08:18:40 +0200 Subject: [PATCH 1608/1664] Use OptionsFlowWithReload in androidtv_remote (#149133) --- homeassistant/components/androidtv_remote/__init__.py | 11 ----------- .../components/androidtv_remote/config_flow.py | 10 +++++----- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index c8556b6da90..328ac863e46 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -68,7 +68,6 @@ async def async_setup_entry( entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) entry.async_on_unload(api.disconnect) return True @@ -80,13 +79,3 @@ async def async_unload_entry( """Unload a config entry.""" _LOGGER.debug("async_unload_entry: %s", entry.data) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_options( - hass: HomeAssistant, entry: AndroidTVRemoteConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug( - "async_update_options: data: %s options: %s", entry.data, entry.options - ) - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 351cae61b1d..0a236c7c9ef 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -19,7 +19,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import callback @@ -116,10 +116,10 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): pin = user_input["pin"] await self.api.async_finish_pairing(pin) if self.source == SOURCE_REAUTH: - await self.hass.config_entries.async_reload( - self._get_reauth_entry().entry_id + return self.async_update_reload_and_abort( + self._get_reauth_entry(), reload_even_if_entry_is_unchanged=True ) - return self.async_abort(reason="reauth_successful") + return self.async_create_entry( title=self.name, data={ @@ -243,7 +243,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): return AndroidTVRemoteOptionsFlowHandler(config_entry) -class AndroidTVRemoteOptionsFlowHandler(OptionsFlow): +class AndroidTVRemoteOptionsFlowHandler(OptionsFlowWithReload): """Android TV Remote options flow.""" def __init__(self, config_entry: AndroidTVRemoteConfigEntry) -> None: From 6eab118a2d5e9f64d1ded17aa45de9fe95eb7b86 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 21 Jul 2025 08:26:20 +0200 Subject: [PATCH 1609/1664] Bump airgradient to platinum (#149014) --- .../components/airgradient/manifest.json | 1 + .../components/airgradient/quality_scale.yaml | 32 ++++++++----------- script/hassfest/quality_scale.py | 1 - 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json index afaf2698ced..3011e0602c9 100644 --- a/homeassistant/components/airgradient/manifest.json +++ b/homeassistant/components/airgradient/manifest.json @@ -6,6 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/airgradient", "integration_type": "device", "iot_class": "local_polling", + "quality_scale": "platinum", "requirements": ["airgradient==0.9.2"], "zeroconf": ["_airgradient._tcp.local."] } diff --git a/homeassistant/components/airgradient/quality_scale.yaml b/homeassistant/components/airgradient/quality_scale.yaml index 7a7f8d5ee1d..ec2e200b0a7 100644 --- a/homeassistant/components/airgradient/quality_scale.yaml +++ b/homeassistant/components/airgradient/quality_scale.yaml @@ -14,9 +14,9 @@ rules: status: exempt comment: | This integration does not provide additional actions. - docs-high-level-description: todo - docs-installation-instructions: todo - docs-removal-instructions: todo + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done entity-event-setup: status: exempt comment: | @@ -34,7 +34,7 @@ rules: docs-configuration-parameters: status: exempt comment: No options to configure - docs-installation-parameters: todo + docs-installation-parameters: done entity-unavailable: done integration-owner: done log-when-unavailable: done @@ -43,23 +43,19 @@ rules: status: exempt comment: | This integration does not require authentication. - test-coverage: todo + test-coverage: done # Gold devices: done diagnostics: done - discovery-update-info: - status: todo - comment: DHCP is still possible - discovery: - status: todo - comment: DHCP is still possible - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo + 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: status: exempt comment: | diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index b5fd8c3ad7a..3008c6303ff 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -1162,7 +1162,6 @@ INTEGRATIONS_WITHOUT_SCALE = [ "aftership", "agent_dvr", "airly", - "airgradient", "airnow", "airq", "airthings", From ff9fb6228b30afd03fb3ec1e183c66194b54b0d5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 11:14:02 +0200 Subject: [PATCH 1610/1664] Use OptionsFlowWithReload in onewire (#149164) --- homeassistant/components/onewire/__init__.py | 10 --------- .../components/onewire/config_flow.py | 8 +++++-- tests/components/onewire/test_init.py | 22 ------------------- 3 files changed, 6 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index c77d87d91b9..396539d93e3 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -39,8 +39,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> b onewire_hub.schedule_scan_for_new_devices() - entry.async_on_unload(entry.add_update_listener(options_update_listener)) - return True @@ -59,11 +57,3 @@ async def async_unload_entry( ) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, _PLATFORMS) - - -async def options_update_listener( - hass: HomeAssistant, entry: OneWireConfigEntry -) -> None: - """Handle options update.""" - _LOGGER.debug("Configuration options updated, reloading OneWire integration") - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py index 2099d9aabb5..0f2a2b6c51c 100644 --- a/homeassistant/components/onewire/config_flow.py +++ b/homeassistant/components/onewire/config_flow.py @@ -8,7 +8,11 @@ from typing import Any from pyownet import protocol import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -160,7 +164,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN): return OnewireOptionsFlowHandler(config_entry) -class OnewireOptionsFlowHandler(OptionsFlow): +class OnewireOptionsFlowHandler(OptionsFlowWithReload): """Handle OneWire Config options.""" configurable_devices: dict[str, str] diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 0748481c40b..ace7afb5645 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,6 +1,5 @@ """Tests for 1-Wire config flow.""" -from copy import deepcopy from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -63,27 +62,6 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: MockConfigEntry) assert config_entry.state is ConfigEntryState.NOT_LOADED -async def test_update_options( - hass: HomeAssistant, config_entry: MockConfigEntry, owproxy: MagicMock -) -> None: - """Test update options triggers reload.""" - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 1 - - new_options = deepcopy(dict(config_entry.options)) - new_options["device_options"].clear() - hass.config_entries.async_update_entry(config_entry, options=new_options) - await hass.async_block_till_done() - - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert config_entry.state is ConfigEntryState.LOADED - assert owproxy.call_count == 2 - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_registry( hass: HomeAssistant, From c08aa744967f47dce94e140920400c2aa1263cfb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 21 Jul 2025 12:27:37 +0200 Subject: [PATCH 1611/1664] Cleanup Tuya climate/cover tests (#149157) --- tests/components/tuya/__init__.py | 2 +- tests/components/tuya/test_climate.py | 26 ++++++++++++++++---------- tests/components/tuya/test_cover.py | 7 ++++--- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 1ce7e6c47dd..d9016d18def 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -15,8 +15,8 @@ from tests.common import MockConfigEntry DEVICE_MOCKS = { "cl_am43_corded_motor_zigbee_cover": [ # https://github.com/home-assistant/core/issues/71242 - Platform.SELECT, Platform.COVER, + Platform.SELECT, ], "clkg_curtain_switch": [ # https://github.com/home-assistant/core/issues/136055 diff --git a/tests/components/tuya/test_climate.py b/tests/components/tuya/test_climate.py index d564c027cd1..9c0e3c31a26 100644 --- a/tests/components/tuya/test_climate.py +++ b/tests/components/tuya/test_climate.py @@ -8,6 +8,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from tuya_sharing import CustomerDevice +from homeassistant.components.climate import ( + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) from homeassistant.components.tuya import ManagerCompat from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -69,16 +73,17 @@ async def test_fan_mode_windspeed( mock_device: CustomerDevice, ) -> None: """Test fan mode with windspeed.""" + entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - state = hass.states.get("climate.air_conditioner") - assert state is not None, "climate.air_conditioner does not exist" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" assert state.attributes["fan_mode"] == 1 await hass.services.async_call( - Platform.CLIMATE, - "set_fan_mode", + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, { - "entity_id": "climate.air_conditioner", + "entity_id": entity_id, "fan_mode": 2, }, ) @@ -104,17 +109,18 @@ async def test_fan_mode_no_valid_code( mock_device.status_range.pop("windspeed", None) mock_device.status.pop("windspeed", None) + entity_id = "climate.air_conditioner" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - state = hass.states.get("climate.air_conditioner") - assert state is not None, "climate.air_conditioner does not exist" + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" assert state.attributes.get("fan_mode") is None with pytest.raises(ServiceNotSupported): await hass.services.async_call( - Platform.CLIMATE, - "set_fan_mode", + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, { - "entity_id": "climate.air_conditioner", + "entity_id": entity_id, "fan_mode": 2, }, blocking=True, diff --git a/tests/components/tuya/test_cover.py b/tests/components/tuya/test_cover.py index 3b190e46827..29a6d65978f 100644 --- a/tests/components/tuya/test_cover.py +++ b/tests/components/tuya/test_cover.py @@ -83,8 +83,9 @@ async def test_percent_state_on_cover( # 100 is closed and 0 is open for Tuya covers mock_device.status["percent_state"] = 100 - percent_state + entity_id = "cover.kitchen_blinds_curtain" await initialize_entry(hass, mock_manager, mock_config_entry, mock_device) - cover_state = hass.states.get("cover.kitchen_blinds_curtain") - assert cover_state is not None, "cover.kitchen_blinds_curtain does not exist" - assert cover_state.attributes["current_position"] == percent_state + state = hass.states.get(entity_id) + assert state is not None, f"{entity_id} does not exist" + assert state.attributes["current_position"] == percent_state From 8c964e64db2dcae8af227ffbea656ed1b013290b Mon Sep 17 00:00:00 2001 From: Elmo-S <71403256+Elmo-S@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:39:46 +0300 Subject: [PATCH 1612/1664] Add support for UV index attribute in template weather entity (#149015) --- homeassistant/components/template/weather.py | 26 +++++++++++++++++++ .../template/snapshots/test_weather.ambr | 1 + tests/components/template/test_weather.py | 8 ++++++ 3 files changed, 35 insertions(+) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 15c6fb4db9e..7f79adc2201 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -90,6 +90,7 @@ CONF_PRESSURE_TEMPLATE = "pressure_template" CONF_WIND_SPEED_TEMPLATE = "wind_speed_template" CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" +CONF_UV_INDEX_TEMPLATE = "uv_index_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_DAILY_TEMPLATE = "forecast_daily_template" CONF_FORECAST_HOURLY_TEMPLATE = "forecast_hourly_template" @@ -122,6 +123,7 @@ WEATHER_YAML_SCHEMA = vol.Schema( vol.Optional(CONF_PRESSURE_UNIT): vol.In(PressureConverter.VALID_UNITS), vol.Required(CONF_TEMPERATURE_TEMPLATE): cv.template, vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(TemperatureConverter.VALID_UNITS), + vol.Optional(CONF_UV_INDEX_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_VISIBILITY_UNIT): vol.In(DistanceConverter.VALID_UNITS), vol.Optional(CONF_WIND_BEARING_TEMPLATE): cv.template, @@ -201,6 +203,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._wind_speed_template = config.get(CONF_WIND_SPEED_TEMPLATE) self._wind_bearing_template = config.get(CONF_WIND_BEARING_TEMPLATE) self._ozone_template = config.get(CONF_OZONE_TEMPLATE) + self._uv_index_template = config.get(CONF_UV_INDEX_TEMPLATE) self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_daily_template = config.get(CONF_FORECAST_DAILY_TEMPLATE) self._forecast_hourly_template = config.get(CONF_FORECAST_HOURLY_TEMPLATE) @@ -228,6 +231,7 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): self._wind_speed = None self._wind_bearing = None self._ozone = None + self._uv_index = None self._visibility = None self._wind_gust_speed = None self._cloud_coverage = None @@ -275,6 +279,11 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): """Return the ozone level.""" return self._ozone + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return self._uv_index + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -369,6 +378,11 @@ class StateWeatherEntity(TemplateEntity, WeatherEntity): "_ozone", self._ozone_template, ) + if self._uv_index_template: + self.add_template_attribute( + "_uv_index", + self._uv_index_template, + ) if self._visibility_template: self.add_template_attribute( "_visibility", @@ -480,6 +494,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone: float | None last_pressure: float | None last_temperature: float | None + last_uv_index: float | None last_visibility: float | None last_wind_bearing: float | str | None last_wind_gust_speed: float | None @@ -501,6 +516,7 @@ class WeatherExtraStoredData(ExtraStoredData): last_ozone=restored["last_ozone"], last_pressure=restored["last_pressure"], last_temperature=restored["last_temperature"], + last_uv_index=restored["last_uv_index"], last_visibility=restored["last_visibility"], last_wind_bearing=restored["last_wind_bearing"], last_wind_gust_speed=restored["last_wind_gust_speed"], @@ -553,6 +569,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): CONF_FORECAST_TWICE_DAILY_TEMPLATE, CONF_OZONE_TEMPLATE, CONF_PRESSURE_TEMPLATE, + CONF_UV_INDEX_TEMPLATE, CONF_VISIBILITY_TEMPLATE, CONF_WIND_BEARING_TEMPLATE, CONF_WIND_GUST_SPEED_TEMPLATE, @@ -583,6 +600,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered[CONF_OZONE_TEMPLATE] = weather_data.last_ozone self._rendered[CONF_PRESSURE_TEMPLATE] = weather_data.last_pressure self._rendered[CONF_TEMPERATURE_TEMPLATE] = weather_data.last_temperature + self._rendered[CONF_UV_INDEX_TEMPLATE] = weather_data.last_uv_index self._rendered[CONF_VISIBILITY_TEMPLATE] = weather_data.last_visibility self._rendered[CONF_WIND_BEARING_TEMPLATE] = weather_data.last_wind_bearing self._rendered[CONF_WIND_GUST_SPEED_TEMPLATE] = ( @@ -630,6 +648,13 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): self._rendered.get(CONF_OZONE_TEMPLATE), ) + @property + def uv_index(self) -> float | None: + """Return the UV index.""" + return vol.Any(vol.Coerce(float), None)( + self._rendered.get(CONF_UV_INDEX_TEMPLATE) + ) + @property def native_visibility(self) -> float | None: """Return the visibility.""" @@ -703,6 +728,7 @@ class TriggerWeatherEntity(TriggerEntity, WeatherEntity, RestoreEntity): last_ozone=self._rendered.get(CONF_OZONE_TEMPLATE), last_pressure=self._rendered.get(CONF_PRESSURE_TEMPLATE), last_temperature=self._rendered.get(CONF_TEMPERATURE_TEMPLATE), + last_uv_index=self._rendered.get(CONF_UV_INDEX_TEMPLATE), last_visibility=self._rendered.get(CONF_VISIBILITY_TEMPLATE), last_wind_bearing=self._rendered.get(CONF_WIND_BEARING_TEMPLATE), last_wind_gust_speed=self._rendered.get(CONF_WIND_GUST_SPEED_TEMPLATE), diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr index bdda5b44e94..215a10a4f40 100644 --- a/tests/components/template/snapshots/test_weather.ambr +++ b/tests/components/template/snapshots/test_weather.ambr @@ -46,6 +46,7 @@ 'last_ozone': None, 'last_pressure': None, 'last_temperature': '15.0', + 'last_uv_index': None, 'last_visibility': None, 'last_wind_bearing': None, 'last_wind_gust_speed': None, diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 443b0aa6e77..6e2a2ab2f6b 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -15,6 +15,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_OZONE, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_GUST_SPEED, @@ -608,6 +609,7 @@ SAVED_EXTRA_DATA = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -623,6 +625,7 @@ SAVED_EXTRA_DATA_WITH_FUTURE_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -790,6 +793,7 @@ async def test_trigger_action(hass: HomeAssistant) -> None: "wind_speed_template": "{{ my_variable + 1 }}", "wind_bearing_template": "{{ my_variable + 1 }}", "ozone_template": "{{ my_variable + 1 }}", + "uv_index_template": "{{ my_variable + 1 }}", "visibility_template": "{{ my_variable + 1 }}", "pressure_template": "{{ my_variable + 1 }}", "wind_gust_speed_template": "{{ my_variable + 1 }}", @@ -864,6 +868,7 @@ async def test_trigger_weather_services( assert state.attributes["wind_speed"] == 3.0 assert state.attributes["wind_bearing"] == 3.0 assert state.attributes["ozone"] == 3.0 + assert state.attributes["uv_index"] == 3.0 assert state.attributes["visibility"] == 3.0 assert state.attributes["pressure"] == 3.0 assert state.attributes["wind_gust_speed"] == 3.0 @@ -962,6 +967,7 @@ SAVED_EXTRA_DATA_MISSING_KEY = { "last_ozone": None, "last_pressure": None, "last_temperature": 20, + "last_uv_index": None, "last_visibility": None, "last_wind_bearing": None, "last_wind_gust_speed": None, @@ -1041,6 +1047,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: "wind_speed_template": "{{ states('sensor.windspeed') }}", "wind_bearing_template": "{{ states('sensor.windbearing') }}", "ozone_template": "{{ states('sensor.ozone') }}", + "uv_index_template": "{{ states('sensor.uv_index') }}", "visibility_template": "{{ states('sensor.visibility') }}", "wind_gust_speed_template": "{{ states('sensor.wind_gust_speed') }}", "cloud_coverage_template": "{{ states('sensor.cloud_coverage') }}", @@ -1063,6 +1070,7 @@ async def test_new_style_template_state_text(hass: HomeAssistant) -> None: ("sensor.windspeed", ATTR_WEATHER_WIND_SPEED, 20), ("sensor.windbearing", ATTR_WEATHER_WIND_BEARING, 180), ("sensor.ozone", ATTR_WEATHER_OZONE, 25), + ("sensor.uv_index", ATTR_WEATHER_UV_INDEX, 3.7), ("sensor.visibility", ATTR_WEATHER_VISIBILITY, 4.6), ("sensor.wind_gust_speed", ATTR_WEATHER_WIND_GUST_SPEED, 30), ("sensor.cloud_coverage", ATTR_WEATHER_CLOUD_COVERAGE, 75), From 0dba32dbcd1cc855d48e671530e4ab62daecf80e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:11 +0200 Subject: [PATCH 1613/1664] Use OptionsFlowWithReload in keenetic_ndms2 (#149173) --- homeassistant/components/keenetic_ndms2/__init__.py | 7 ------- homeassistant/components/keenetic_ndms2/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py index 7986158ab50..358f9600845 100644 --- a/homeassistant/components/keenetic_ndms2/__init__.py +++ b/homeassistant/components/keenetic_ndms2/__init__.py @@ -33,8 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: KeeneticConfigEntry) -> router = KeeneticRouter(hass, entry) await router.async_setup() - entry.async_on_unload(entry.add_update_listener(update_listener)) - entry.runtime_data = router await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -87,11 +85,6 @@ async def async_unload_entry( return unload_ok -async def update_listener(hass: HomeAssistant, entry: KeeneticConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - def async_add_defaults(hass: HomeAssistant, entry: KeeneticConfigEntry): """Populate default options.""" host: str = entry.data[CONF_HOST] diff --git a/homeassistant/components/keenetic_ndms2/config_flow.py b/homeassistant/components/keenetic_ndms2/config_flow.py index c6095968c07..cec4796176e 100644 --- a/homeassistant/components/keenetic_ndms2/config_flow.py +++ b/homeassistant/components/keenetic_ndms2/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -153,7 +153,7 @@ class KeeneticFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_user() -class KeeneticOptionsFlowHandler(OptionsFlow): +class KeeneticOptionsFlowHandler(OptionsFlowWithReload): """Handle options.""" config_entry: KeeneticConfigEntry From c22f65bd87055dec5c9c2b845032eb4b93d70a90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:24 +0200 Subject: [PATCH 1614/1664] Use OptionsFlowWithReload in isy994 (#149174) --- homeassistant/components/isy994/__init__.py | 6 ------ homeassistant/components/isy994/config_flow.py | 6 +++--- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 5d4603cafc0..68ca63b6bb5 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -171,7 +171,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: _LOGGER.debug("ISY Starting Event Stream and automatic updates") isy.websocket.start() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop_auto_update) ) @@ -179,11 +178,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IsyConfigEntry) -> bool: return True -async def _async_update_listener(hass: HomeAssistant, entry: IsyConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - @callback def _async_get_or_create_isy_device_in_registry( hass: HomeAssistant, entry: IsyConfigEntry, isy: ISY diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 2acebee8599..4f0217fd0c6 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -18,7 +18,7 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback @@ -143,7 +143,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): @callback def async_get_options_flow( config_entry: IsyConfigEntry, - ) -> OptionsFlow: + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler() @@ -316,7 +316,7 @@ class Isy994ConfigFlow(ConfigFlow, domain=DOMAIN): ) -class OptionsFlowHandler(OptionsFlow): +class OptionsFlowHandler(OptionsFlowWithReload): """Handle a option flow for ISY/IoX.""" async def async_step_init( From 94d077ea4150f65b07337a5ce8de09479c0892a7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 12:47:38 +0200 Subject: [PATCH 1615/1664] Use OptionsFlowWithReload in honeywell (#149162) --- homeassistant/components/honeywell/__init__.py | 9 --------- homeassistant/components/honeywell/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index 6c4c7091840..d270ffec72f 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -83,18 +83,9 @@ async def async_setup_entry( config_entry.runtime_data = HoneywellData(config_entry.entry_id, client, devices) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) - return True -async def update_listener( - hass: HomeAssistant, config_entry: HoneywellConfigEntry -) -> None: - """Update listener.""" - await hass.config_entries.async_reload(config_entry.entry_id) - - async def async_unload_entry( hass: HomeAssistant, config_entry: HoneywellConfigEntry ) -> bool: diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index 15199cdda24..c18bb0296aa 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback @@ -136,7 +136,7 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN): return HoneywellOptionsFlowHandler() -class HoneywellOptionsFlowHandler(OptionsFlow): +class HoneywellOptionsFlowHandler(OptionsFlowWithReload): """Config flow options for Honeywell.""" async def async_step_init(self, user_input=None) -> ConfigFlowResult: From bf1a660dcbb91b80322a03bbf23320f993a43bed Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:02:50 +0200 Subject: [PATCH 1616/1664] Bump Lokalise docker image to v2.6.14 (#149031) --- script/translations/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 9ff8aeb2d70..18aa27b3e74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -4,6 +4,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "v2.6.8" +CLI_2_DOCKER_IMAGE = "v2.6.14" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") From 1fba61973dbee174ea0e777d00bf78d907d5ab20 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:03:53 +0200 Subject: [PATCH 1617/1664] Update pytest-asyncio to 1.1.0 (#149177) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index b758a7b517a..fa29e7053e0 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -19,7 +19,7 @@ pydantic==2.11.7 pylint==3.3.7 pylint-per-file-ignores==1.4.0 pipdeptree==2.26.1 -pytest-asyncio==1.0.0 +pytest-asyncio==1.1.0 pytest-aiohttp==1.1.0 pytest-cov==6.2.1 pytest-freezer==0.4.9 From 67c68dedbad6bd5fd22c63a4a48ba350e90452c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 13:07:52 +0200 Subject: [PATCH 1618/1664] Make async_track_state_change/report_event listeners fire in order (#148766) --- homeassistant/helpers/event.py | 2 +- tests/helpers/test_event.py | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index f2dfb7250f7..39cff22396a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -402,7 +402,7 @@ def _async_track_state_change_event( _KEYED_TRACK_STATE_REPORT = _KeyedEventTracker( key=_TRACK_STATE_REPORT_DATA, event_type=EVENT_STATE_REPORTED, - dispatcher_callable=_async_dispatch_entity_id_event, + dispatcher_callable=_async_dispatch_entity_id_event_soon, filter_callable=_async_state_filter, ) diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index c875522b943..32cf3edf010 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -4969,11 +4969,9 @@ async def test_async_track_state_report_change_event(hass: HomeAssistant) -> Non hass.states.async_set(entity_id, state) await hass.async_block_till_done() - # The out-of-order is a result of state change listeners scheduled with - # loop.call_soon, whereas state report listeners are called immediately. assert tracker_called == { - "light.bowl": ["on", "off", "on", "off"], - "light.top": ["on", "off", "on", "off"], + "light.bowl": ["on", "on", "off", "off"], + "light.top": ["on", "on", "off", "off"], } From 75a90ab568ffda4d3fd69684349210648b7b35d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:11:22 +0200 Subject: [PATCH 1619/1664] Bump actions/ai-inference from 1.1.0 to 1.2.3 (#149159) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index b01a0d68352..0facf6fdf77 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index 264b8ab9854..b1ce58c4b41 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v1.1.0 + uses: actions/ai-inference@v1.2.3 with: model: openai/gpt-4o-mini system-prompt: | From b59d8b57301ce4e40adf8d11fba5a0f8914b0222 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 21 Jul 2025 13:20:04 +0200 Subject: [PATCH 1620/1664] Improve statistics sensor tests (#149181) --- tests/components/statistics/test_sensor.py | 36 +++++++++++++--------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 1db4acf3ef8..e882909878a 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -6,7 +6,7 @@ from asyncio import Event as AsyncioEvent from collections.abc import Sequence from datetime import datetime, timedelta import statistics -from threading import Event +from threading import Event as ThreadingEvent from typing import Any from unittest.mock import patch @@ -42,8 +42,9 @@ from homeassistant.const import ( UnitOfEnergy, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, 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 @@ -1741,7 +1742,7 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) # some synchronisation is needed to prevent that loading from the database finishes too soon # we want this to take long enough to be able to try to add a value BEFORE loading is done state_changes_during_period_called_evt = AsyncioEvent() - state_changes_during_period_stall_evt = Event() + state_changes_during_period_stall_evt = ThreadingEvent() real_state_changes_during_period = history.state_changes_during_period def mock_state_changes_during_period(*args, **kwargs): @@ -1789,25 +1790,25 @@ async def test_update_before_load(recorder_mock: Recorder, hass: HomeAssistant) @pytest.mark.parametrize("force_update", [True, False]) @pytest.mark.parametrize( - ("values_attributes_and_times", "expected_state"), + ("values_attributes_and_times", "expected_states"), [ ( # Fires last reported events [(5.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (5.0, A1, 1)], - "8.33", + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], ), ( # Fires state change events [(5.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (5.0, A1, 1)], - "8.33", + ["unavailable", "5.0", "7.5", "8.33", "8.75", "8.33"], ), ( # Fires last reported events [(10.0, A1, 2), (10.0, A1, 1), (10.0, A1, 1), (10.0, A1, 2), (10.0, A1, 1)], - "10.0", + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], ), ( # Fires state change events [(10.0, A1, 2), (10.0, A2, 1), (10.0, A1, 1), (10.0, A2, 2), (10.0, A1, 1)], - "10.0", + ["unavailable", "10.0", "10.0", "10.0", "10.0", "10.0"], ), ], ) @@ -1815,12 +1816,21 @@ async def test_average_linear_unevenly_timed( hass: HomeAssistant, force_update: bool, values_attributes_and_times: list[tuple[float, dict[str, Any], float]], - expected_state: str, + expected_states: list[str], ) -> None: """Test the average_linear state characteristic with unevenly distributed values. This also implicitly tests the correct timing of repeating values. """ + events: list[Event[EventStateChangedData]] = [] + + @callback + def _capture_event(event: Event) -> None: + events.append(event) + + async_track_state_change_event( + hass, "sensor.test_sensor_average_linear", _capture_event + ) current_time = dt_util.utcnow() @@ -1856,12 +1866,8 @@ async def test_average_linear_unevenly_timed( await hass.async_block_till_done() - state = hass.states.get("sensor.test_sensor_average_linear") - assert state is not None - assert state.state == expected_state, ( - "value mismatch for characteristic 'sensor/average_linear' - " - f"assert {state.state} == {expected_state}" - ) + await hass.async_block_till_done() + assert [event.data["new_state"].state for event in events] == expected_states async def test_sensor_unit_gets_removed(hass: HomeAssistant) -> None: From 05566e1621da6f38adfd764e37d277ccf36304ee Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:23:42 +0200 Subject: [PATCH 1621/1664] Update websockets pin (#149004) --- homeassistant/package_constraints.txt | 8 ++------ script/gen_requirements_all.py | 8 ++------ 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5f72d1c4c3..157ee1420fc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -150,12 +150,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 005d97175a7..b45d48aeff4 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -176,12 +176,8 @@ protobuf==6.31.1 # 2.1.18 is the first version that works with our wheel builder faust-cchardet>=2.1.18 -# websockets 13.1 is the first version to fully support the new -# asyncio implementation. The legacy implementation is now -# deprecated as of websockets 14.0. -# https://websockets.readthedocs.io/en/13.0.1/howto/upgrade.html#missing-features -# https://websockets.readthedocs.io/en/stable/howto/upgrade.html -websockets>=13.1 +# Prevent accidental fallbacks +websockets>=15.0.1 # pysnmplib is no longer maintained and does not work with newer # python From be25a7bc70c916f171e7a024b143e6236156b4b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 13:24:15 +0200 Subject: [PATCH 1622/1664] Use OptionsFlowWithReload in ezviz (#149167) --- homeassistant/components/ezviz/__init__.py | 7 ------- homeassistant/components/ezviz/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index a93954b8a9b..65749871093 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -94,8 +94,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> boo entry.runtime_data = coordinator - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - # Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect. # Cameras are accessed via local RTSP stream with unique credentials per camera. # Separate camera entities allow for credential changes per camera. @@ -120,8 +118,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: EzvizConfigEntry) -> bo return await hass.config_entries.async_unload_platforms( entry, PLATFORMS_BY_TYPE[sensor_type] ) - - -async def _async_update_listener(hass: HomeAssistant, entry: EzvizConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 622f767443d..d90f04b403a 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -17,7 +17,11 @@ from pyezvizapi.exceptions import ( from pyezvizapi.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -386,7 +390,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EzvizOptionsFlowHandler(OptionsFlow): +class EzvizOptionsFlowHandler(OptionsFlowWithReload): """Handle EZVIZ client options.""" async def async_step_init( From d774de79db8a9c96a9fcf23f4bbf9b1d99b4c21c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:33:04 +0200 Subject: [PATCH 1623/1664] Update types packages (#149178) --- homeassistant/components/habitica/util.py | 9 +++++++-- requirements_test.txt | 8 ++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py index 35e1577ae21..4f948b9b4d2 100644 --- a/homeassistant/components/habitica/util.py +++ b/homeassistant/components/habitica/util.py @@ -5,7 +5,7 @@ from __future__ import annotations from dataclasses import asdict, fields import datetime from math import floor -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from dateutil.rrule import ( DAILY, @@ -56,7 +56,12 @@ def next_due_date(task: TaskData, today: datetime.datetime) -> datetime.date | N return dt_util.as_local(task.nextDue[0]).date() -FREQUENCY_MAP = {"daily": DAILY, "weekly": WEEKLY, "monthly": MONTHLY, "yearly": YEARLY} +FREQUENCY_MAP: dict[str, Literal[0, 1, 2, 3]] = { + "daily": DAILY, + "weekly": WEEKLY, + "monthly": MONTHLY, + "yearly": YEARLY, +} WEEKDAY_MAP = {"m": MO, "t": TU, "w": WE, "th": TH, "f": FR, "s": SA, "su": SU} diff --git a/requirements_test.txt b/requirements_test.txt index fa29e7053e0..b0affc56113 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -35,17 +35,17 @@ requests-mock==1.12.1 respx==0.22.0 syrupy==4.9.1 tqdm==4.67.1 -types-aiofiles==24.1.0.20250606 +types-aiofiles==24.1.0.20250708 types-atomicwrites==1.4.5.1 -types-croniter==6.0.0.20250411 +types-croniter==6.0.0.20250626 types-caldav==1.3.0.20250516 types-chardet==0.1.5 types-decorator==5.2.0.20250324 types-pexpect==4.9.0.20250516 -types-protobuf==6.30.2.20250516 +types-protobuf==6.30.2.20250703 types-psutil==7.0.0.20250601 types-pyserial==3.5.0.20250326 -types-python-dateutil==2.9.0.20250516 +types-python-dateutil==2.9.0.20250708 types-python-slugify==8.0.2.20240310 types-pytz==2025.2.0.20250516 types-PyYAML==6.0.12.20250516 From bc0162cf858baf76fb0cf1ea59f2151960903c6d Mon Sep 17 00:00:00 2001 From: Luuk Dobber <1858881+luukdobber@users.noreply.github.com> Date: Mon, 21 Jul 2025 13:45:57 +0200 Subject: [PATCH 1624/1664] Add select for heating circuit to Tado zones (#147902) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Abílio Costa --- homeassistant/components/tado/__init__.py | 1 + homeassistant/components/tado/coordinator.py | 62 +- homeassistant/components/tado/select.py | 108 ++++ homeassistant/components/tado/strings.json | 8 + .../tado/fixtures/heating_circuits.json | 7 + .../tado/fixtures/zone_control.json | 80 +++ .../tado/snapshots/test_diagnostics.ambr | 561 ++++++++++++++++++ tests/components/tado/test_select.py | 91 +++ tests/components/tado/util.py | 12 + 9 files changed, 927 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/tado/select.py create mode 100644 tests/components/tado/fixtures/heating_circuits.json create mode 100644 tests/components/tado/fixtures/zone_control.json create mode 100644 tests/components/tado/test_select.py diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 0513d63b893..df33845437f 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -41,6 +41,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, Platform.DEVICE_TRACKER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.WATER_HEATER, diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py index 09c6ec40208..79486ff998b 100644 --- a/homeassistant/components/tado/coordinator.py +++ b/homeassistant/components/tado/coordinator.py @@ -73,6 +73,8 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): "weather": {}, "geofence": {}, "zone": {}, + "zone_control": {}, + "heating_circuits": {}, } @property @@ -99,11 +101,14 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.home_name = tado_home["name"] devices = await self._async_update_devices() - zones = await self._async_update_zones() + zones, zone_controls = await self._async_update_zones() home = await self._async_update_home() + heating_circuits = await self._async_update_heating_circuits() self.data["device"] = devices self.data["zone"] = zones + self.data["zone_control"] = zone_controls + self.data["heating_circuits"] = heating_circuits self.data["weather"] = home["weather"] self.data["geofence"] = home["geofence"] @@ -166,7 +171,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return mapped_devices - async def _async_update_zones(self) -> dict[int, dict]: + async def _async_update_zones(self) -> tuple[dict[int, dict], dict[int, dict]]: """Update the zone data from Tado.""" try: @@ -179,10 +184,12 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise UpdateFailed(f"Error updating Tado zones: {err}") from err mapped_zones: dict[int, dict] = {} + mapped_zone_controls: dict[int, dict] = {} for zone in zone_states: mapped_zones[int(zone)] = await self._update_zone(int(zone)) + mapped_zone_controls[int(zone)] = await self._update_zone_control(int(zone)) - return mapped_zones + return mapped_zones, mapped_zone_controls async def _update_zone(self, zone_id: int) -> dict[str, str]: """Update the internal data of a zone.""" @@ -199,6 +206,24 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): _LOGGER.debug("Zone %s updated, with data: %s", zone_id, data) return data + async def _update_zone_control(self, zone_id: int) -> dict[str, Any]: + """Update the internal zone control data of a zone.""" + + _LOGGER.debug("Updating zone control for zone %s", zone_id) + try: + zone_control_data = await self.hass.async_add_executor_job( + self._tado.get_zone_control, zone_id + ) + except RequestException as err: + _LOGGER.error( + "Error updating Tado zone control for zone %s: %s", zone_id, err + ) + raise UpdateFailed( + f"Error updating Tado zone control for zone {zone_id}: {err}" + ) from err + + return zone_control_data + async def _async_update_home(self) -> dict[str, dict]: """Update the home data from Tado.""" @@ -217,6 +242,23 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): return {"weather": weather, "geofence": geofence} + async def _async_update_heating_circuits(self) -> dict[str, dict]: + """Update the heating circuits data from Tado.""" + + try: + heating_circuits = await self.hass.async_add_executor_job( + self._tado.get_heating_circuits + ) + except RequestException as err: + _LOGGER.error("Error updating Tado heating circuits: %s", err) + raise UpdateFailed(f"Error updating Tado heating circuits: {err}") from err + + mapped_heating_circuits: dict[str, dict] = {} + for circuit in heating_circuits: + mapped_heating_circuits[circuit["driverShortSerialNo"]] = circuit + + return mapped_heating_circuits + async def get_capabilities(self, zone_id: int | str) -> dict: """Fetch the capabilities from Tado.""" @@ -364,6 +406,20 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): except RequestException as exc: raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc + async def set_heating_circuit(self, zone_id: int, circuit_id: int | None) -> None: + """Set heating circuit for zone.""" + try: + await self.hass.async_add_executor_job( + self._tado.set_zone_heating_circuit, + zone_id, + circuit_id, + ) + except RequestException as exc: + raise HomeAssistantError( + f"Error setting Tado heating circuit: {exc}" + ) from exc + await self._update_zone_control(zone_id) + class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]): """Class to manage the mobile devices from Tado via PyTado.""" diff --git a/homeassistant/components/tado/select.py b/homeassistant/components/tado/select.py new file mode 100644 index 00000000000..6db765128c2 --- /dev/null +++ b/homeassistant/components/tado/select.py @@ -0,0 +1,108 @@ +"""Module for Tado select entities.""" + +import logging + +from homeassistant.components.select import SelectEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import TadoConfigEntry +from .entity import TadoDataUpdateCoordinator, TadoZoneEntity + +_LOGGER = logging.getLogger(__name__) + +NO_HEATING_CIRCUIT_OPTION = "no_heating_circuit" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: TadoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Tado select platform.""" + + tado = entry.runtime_data.coordinator + entities: list[SelectEntity] = [ + TadoHeatingCircuitSelectEntity(tado, zone["name"], zone["id"]) + for zone in tado.zones + if zone["type"] == "HEATING" + ] + + async_add_entities(entities, True) + + +class TadoHeatingCircuitSelectEntity(TadoZoneEntity, SelectEntity): + """Representation of a Tado heating circuit select entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_has_entity_name = True + _attr_icon = "mdi:water-boiler" + _attr_translation_key = "heating_circuit" + + def __init__( + self, + coordinator: TadoDataUpdateCoordinator, + zone_name: str, + zone_id: int, + ) -> None: + """Initialize the Tado heating circuit select entity.""" + super().__init__(zone_name, coordinator.home_id, zone_id, coordinator) + + self._attr_unique_id = f"{zone_id} {coordinator.home_id} heating_circuit" + + self._attr_options = [] + self._attr_current_option = None + + async def async_select_option(self, option: str) -> None: + """Update the selected heating circuit.""" + heating_circuit_id = ( + None + if option == NO_HEATING_CIRCUIT_OPTION + else self.coordinator.data["heating_circuits"].get(option, {}).get("number") + ) + await self.coordinator.set_heating_circuit(self.zone_id, heating_circuit_id) + await self.coordinator.async_request_refresh() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_callback() + super()._handle_coordinator_update() + + @callback + def _async_update_callback(self) -> None: + """Handle update callbacks.""" + # Heating circuits list + heating_circuits = self.coordinator.data["heating_circuits"].values() + self._attr_options = [NO_HEATING_CIRCUIT_OPTION] + self._attr_options.extend(hc["driverShortSerialNo"] for hc in heating_circuits) + + # Current heating circuit + zone_control = self.coordinator.data["zone_control"].get(self.zone_id) + if zone_control and "heatingCircuit" in zone_control: + heating_circuit_number = zone_control["heatingCircuit"] + if heating_circuit_number is None: + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + # Find heating circuit by number + heating_circuit = next( + ( + hc + for hc in heating_circuits + if hc.get("number") == heating_circuit_number + ), + None, + ) + + if heating_circuit is None: + _LOGGER.error( + "Heating circuit with number %s not found for zone %s", + heating_circuit_number, + self.zone_name, + ) + self._attr_current_option = NO_HEATING_CIRCUIT_OPTION + else: + self._attr_current_option = heating_circuit.get( + "driverShortSerialNo" + ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index 5d9c4237be8..ba1c9e95683 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -59,6 +59,14 @@ } } }, + "select": { + "heating_circuit": { + "name": "Heating circuit", + "state": { + "no_heating_circuit": "No circuit" + } + } + }, "switch": { "child_lock": { "name": "Child lock" diff --git a/tests/components/tado/fixtures/heating_circuits.json b/tests/components/tado/fixtures/heating_circuits.json new file mode 100644 index 00000000000..723ceb76f95 --- /dev/null +++ b/tests/components/tado/fixtures/heating_circuits.json @@ -0,0 +1,7 @@ +[ + { + "number": 1, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890" + } +] diff --git a/tests/components/tado/fixtures/zone_control.json b/tests/components/tado/fixtures/zone_control.json new file mode 100644 index 00000000000..584fe9f3c92 --- /dev/null +++ b/tests/components/tado/fixtures/zone_control.json @@ -0,0 +1,80 @@ +{ + "type": "HEATING", + "earlyStartEnabled": false, + "heatingCircuit": 1, + "duties": { + "type": "HEATING", + "leader": { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + "drivers": [ + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ], + "uis": [ + { + "deviceType": "RU01", + "serialNo": "RU1234567890", + "shortSerialNo": "RU1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:53:40.710Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "batteryState": "NORMAL" + }, + { + "deviceType": "VA01", + "serialNo": "VA1234567890", + "shortSerialNo": "VA1234567890", + "currentFwVersion": "54.20", + "connectionState": { + "value": true, + "timestamp": "2025-06-30T19:54:15.166Z" + }, + "characteristics": { + "capabilities": ["INSIDE_TEMPERATURE_MEASUREMENT", "IDENTIFY"] + }, + "mountingState": { + "value": "CALIBRATED", + "timestamp": "2025-06-09T23:25:12.678Z" + }, + "mountingStateWithError": "CALIBRATED", + "batteryState": "LOW", + "childLockEnabled": false + } + ] + } +} diff --git a/tests/components/tado/snapshots/test_diagnostics.ambr b/tests/components/tado/snapshots/test_diagnostics.ambr index eefb818a88c..34d26c222fa 100644 --- a/tests/components/tado/snapshots/test_diagnostics.ambr +++ b/tests/components/tado/snapshots/test_diagnostics.ambr @@ -62,6 +62,13 @@ 'presence': 'HOME', 'presenceLocked': False, }), + 'heating_circuits': dict({ + 'RU1234567890': dict({ + 'driverSerialNo': 'RU1234567890', + 'driverShortSerialNo': 'RU1234567890', + 'number': 1, + }), + }), 'weather': dict({ 'outsideTemperature': dict({ 'celsius': 7.46, @@ -110,6 +117,560 @@ 'repr': "TadoZone(zone_id=6, current_temp=24.3, connection=None, current_temp_timestamp='2024-06-28T22: 23: 15.679Z', current_humidity=70.9, current_humidity_timestamp='2024-06-28T22: 23: 15.679Z', is_away=False, current_hvac_action='HEATING', current_fan_speed='AUTO', current_fan_level='LEVEL3', current_hvac_mode='HEAT', current_swing_mode='OFF', current_vertical_swing_mode='ON', current_horizontal_swing_mode='ON', target_temp=25.0, available=True, power='ON', link='ONLINE', ac_power_timestamp='2022-07-13T18: 06: 58.183Z', heating_power_timestamp=None, ac_power='ON', heating_power=None, heating_power_percentage=None, tado_mode='HOME', overlay_termination_type='MANUAL', overlay_termination_timestamp=None, default_overlay_termination_type='MANUAL', default_overlay_termination_duration=None, preparation=False, open_window=False, open_window_detected=False, open_window_attr={}, precision=0.1)", }), }), + 'zone_control': dict({ + '1': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '2': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '3': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '4': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '5': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + '6': dict({ + 'duties': dict({ + 'drivers': list([ + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + 'leader': dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + 'type': 'HEATING', + 'uis': list([ + dict({ + 'batteryState': 'NORMAL', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:53:40.710Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'RU01', + 'serialNo': 'RU1234567890', + 'shortSerialNo': 'RU1234567890', + }), + dict({ + 'batteryState': 'LOW', + 'characteristics': dict({ + 'capabilities': list([ + 'INSIDE_TEMPERATURE_MEASUREMENT', + 'IDENTIFY', + ]), + }), + 'childLockEnabled': False, + 'connectionState': dict({ + 'timestamp': '2025-06-30T19:54:15.166Z', + 'value': True, + }), + 'currentFwVersion': '54.20', + 'deviceType': 'VA01', + 'mountingState': dict({ + 'timestamp': '2025-06-09T23:25:12.678Z', + 'value': 'CALIBRATED', + }), + 'mountingStateWithError': 'CALIBRATED', + 'serialNo': 'VA1234567890', + 'shortSerialNo': 'VA1234567890', + }), + ]), + }), + 'earlyStartEnabled': False, + 'heatingCircuit': 1, + 'type': 'HEATING', + }), + }), }), 'mobile_devices': dict({ 'mobile_device': dict({ diff --git a/tests/components/tado/test_select.py b/tests/components/tado/test_select.py new file mode 100644 index 00000000000..e57b7510d1b --- /dev/null +++ b/tests/components/tado/test_select.py @@ -0,0 +1,91 @@ +"""The select tests for the tado platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.select import ( + DOMAIN as SELECT_DOMAIN, + SERVICE_SELECT_OPTION, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_OPTION +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + +HEATING_CIRCUIT_SELECT_ENTITY = "select.baseboard_heater_heating_circuit" +NO_HEATING_CIRCUIT = "no_heating_circuit" +HEATING_CIRCUIT_OPTION = "RU1234567890" +ZONE_ID = 1 +HEATING_CIRCUIT_ID = 1 + + +async def test_heating_circuit_select(hass: HomeAssistant) -> None: + """Test creation of heating circuit select entity.""" + + await async_init_integration(hass) + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state is not None + assert state.state == HEATING_CIRCUIT_OPTION + assert NO_HEATING_CIRCUIT in state.attributes["options"] + assert HEATING_CIRCUIT_OPTION in state.attributes["options"] + + +@pytest.mark.parametrize( + ("option", "expected_circuit_id"), + [(HEATING_CIRCUIT_OPTION, HEATING_CIRCUIT_ID), (NO_HEATING_CIRCUIT, None)], +) +async def test_heating_circuit_select_action( + hass: HomeAssistant, option, expected_circuit_id +) -> None: + """Test selecting heating circuit option.""" + + await async_init_integration(hass) + + # Test selecting a specific heating circuit + with ( + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.set_zone_heating_circuit" + ) as mock_set_zone_heating_circuit, + patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_zone_control" + ) as mock_get_zone_control, + ): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: HEATING_CIRCUIT_SELECT_ENTITY, + ATTR_OPTION: option, + }, + blocking=True, + ) + + mock_set_zone_heating_circuit.assert_called_with(ZONE_ID, expected_circuit_id) + assert mock_get_zone_control.called + + +@pytest.mark.usefixtures("caplog") +async def test_heating_circuit_not_found( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test when a heating circuit with a specific number is not found.""" + circuit_not_matching_zone_control = 999 + heating_circuits = [ + { + "number": circuit_not_matching_zone_control, + "driverSerialNo": "RU1234567890", + "driverShortSerialNo": "RU1234567890", + } + ] + + with patch( + "homeassistant.components.tado.PyTado.interface.api.Tado.get_heating_circuits", + return_value=heating_circuits, + ): + await async_init_integration(hass) + + state = hass.states.get(HEATING_CIRCUIT_SELECT_ENTITY) + assert state.state == NO_HEATING_CIRCUIT + + assert "Heating circuit with number 1 not found for zone" in caplog.text diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 8ee7209acb2..5ef0ab5dbf2 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -20,8 +20,10 @@ async def async_init_integration( me_fixture = "me.json" weather_fixture = "weather.json" home_fixture = "home.json" + home_heating_circuits_fixture = "heating_circuits.json" home_state_fixture = "home_state.json" zones_fixture = "zones.json" + zone_control_fixture = "zone_control.json" zone_states_fixture = "zone_states.json" # WR1 Device @@ -70,6 +72,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/", text=await async_load_fixture(hass, home_fixture, DOMAIN), ) + m.get( + "https://my.tado.com/api/v2/homes/1/heatingCircuits", + text=await async_load_fixture(hass, home_heating_circuits_fixture, DOMAIN), + ) m.get( "https://my.tado.com/api/v2/homes/1/weather", text=await async_load_fixture(hass, weather_fixture, DOMAIN), @@ -178,6 +184,12 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/zones/1/state", text=await async_load_fixture(hass, zone_1_state_fixture, DOMAIN), ) + zone_ids = [1, 2, 3, 4, 5, 6] + for zone_id in zone_ids: + m.get( + f"https://my.tado.com/api/v2/homes/1/zones/{zone_id}/control", + text=await async_load_fixture(hass, zone_control_fixture, DOMAIN), + ) m.post( "https://login.tado.com/oauth2/token", text=await async_load_fixture(hass, token_fixture, DOMAIN), From 875219ccb551108feaed31f9725d54dde82665fe Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 21 Jul 2025 14:02:04 +0200 Subject: [PATCH 1625/1664] Adds support for hide_states options in state selector (#148959) --- homeassistant/helpers/selector.py | 6 ++++-- tests/helpers/test_selector.py | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 9eaedc6f5ef..2429b4b23e8 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1338,7 +1338,8 @@ class TargetSelectorConfig(BaseSelectorConfig, total=False): class StateSelectorConfig(BaseSelectorConfig, total=False): """Class to represent an state selector config.""" - entity_id: Required[str] + entity_id: str + hide_states: list[str] @SELECTORS.register("state") @@ -1349,7 +1350,8 @@ class StateSelector(Selector[StateSelectorConfig]): CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( { - vol.Required("entity_id"): cv.entity_id, + 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. diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 9e8f1b15311..50d9da501c5 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -565,6 +565,11 @@ def test_time_selector_schema(schema, valid_selections, invalid_selections) -> N ("on", "armed"), (None, True, 1), ), + ( + {"hide_states": ["unknown", "unavailable"]}, + (), + (), + ), ], ) def test_state_selector_schema(schema, valid_selections, invalid_selections) -> None: From 2d86fa079e9d462f91453cf5e3008adf2bb26b4f Mon Sep 17 00:00:00 2001 From: David Ferguson Date: Mon, 21 Jul 2025 08:14:33 -0400 Subject: [PATCH 1626/1664] SleepIQ add core climate for SleepNumber Climate 360 beds (#134718) --- homeassistant/components/sleepiq/const.py | 4 + homeassistant/components/sleepiq/number.py | 54 ++++++++++++- homeassistant/components/sleepiq/select.py | 62 ++++++++++++++- homeassistant/components/sleepiq/strings.json | 11 +++ tests/components/sleepiq/conftest.py | 17 ++++ tests/components/sleepiq/test_number.py | 39 ++++++++++ tests/components/sleepiq/test_select.py | 77 ++++++++++++++++++- 7 files changed, 261 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sleepiq/const.py b/homeassistant/components/sleepiq/const.py index 4243684cd52..7a9415bac20 100644 --- a/homeassistant/components/sleepiq/const.py +++ b/homeassistant/components/sleepiq/const.py @@ -4,6 +4,8 @@ DATA_SLEEPIQ = "data_sleepiq" DOMAIN = "sleepiq" ACTUATOR = "actuator" +CORE_CLIMATE_TIMER = "core_climate_timer" +CORE_CLIMATE = "core_climate" BED = "bed" FIRMNESS = "firmness" ICON_EMPTY = "mdi:bed-empty" @@ -15,6 +17,8 @@ FOOT_WARMING_TIMER = "foot_warming_timer" FOOT_WARMER = "foot_warmer" ENTITY_TYPES = { ACTUATOR: "Position", + CORE_CLIMATE_TIMER: "Core Climate Timer", + CORE_CLIMATE: "Core Climate", FIRMNESS: "Firmness", PRESSURE: "Pressure", IS_IN_BED: "Is In Bed", diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index 53d6c366e46..ffbcbe7a970 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -7,20 +7,28 @@ from dataclasses import dataclass from typing import Any, cast from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQSleeper, ) -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( ACTUATOR, + CORE_CLIMATE_TIMER, DOMAIN, ENTITY_TYPES, FIRMNESS, @@ -95,6 +103,27 @@ def _get_foot_warming_unique_id(bed: SleepIQBed, foot_warmer: SleepIQFootWarmer) return f"{bed.id}_{foot_warmer.side.value}_{FOOT_WARMING_TIMER}" +async def _async_set_core_climate_time( + core_climate: SleepIQCoreClimate, time: int +) -> None: + temperature = CoreTemps(core_climate.temperature) + if temperature != CoreTemps.OFF: + await core_climate.turn_on(temperature, time) + + core_climate.timer = time + + +def _get_core_climate_name(bed: SleepIQBed, core_climate: SleepIQCoreClimate) -> str: + sleeper = sleeper_for_side(bed, core_climate.side) + return f"SleepNumber {bed.name} {sleeper.name} {ENTITY_TYPES[CORE_CLIMATE_TIMER]}" + + +def _get_core_climate_unique_id( + bed: SleepIQBed, core_climate: SleepIQCoreClimate +) -> str: + return f"{bed.id}_{core_climate.side.value}_{CORE_CLIMATE_TIMER}" + + NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, @@ -132,6 +161,20 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { get_name_fn=_get_foot_warming_name, get_unique_id_fn=_get_foot_warming_unique_id, ), + CORE_CLIMATE_TIMER: SleepIQNumberEntityDescription( + key=CORE_CLIMATE_TIMER, + native_min_value=0, + native_max_value=600, + native_step=30, + name=ENTITY_TYPES[CORE_CLIMATE_TIMER], + icon="mdi:timer", + value_fn=lambda core_climate: core_climate.timer, + set_value_fn=_async_set_core_climate_time, + get_name_fn=_get_core_climate_name, + get_unique_id_fn=_get_core_climate_unique_id, + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=NumberDeviceClass.DURATION, + ), } @@ -172,6 +215,15 @@ async def async_setup_entry( ) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQNumberEntity( + data.data_coordinator, + bed, + core_climate, + NUMBER_DESCRIPTIONS[CORE_CLIMATE_TIMER], + ) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py index 7d059ba6b59..d4bc9fda3a4 100644 --- a/homeassistant/components/sleepiq/select.py +++ b/homeassistant/components/sleepiq/select.py @@ -3,9 +3,11 @@ from __future__ import annotations from asyncsleepiq import ( + CoreTemps, FootWarmingTemps, Side, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQPreset, ) @@ -15,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import DOMAIN, FOOT_WARMER +from .const import CORE_CLIMATE, DOMAIN, FOOT_WARMER from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side @@ -37,6 +39,10 @@ async def async_setup_entry( SleepIQFootWarmingTempSelectEntity(data.data_coordinator, bed, foot_warmer) for foot_warmer in bed.foundation.foot_warmers ) + entities.extend( + SleepIQCoreTempSelectEntity(data.data_coordinator, bed, core_climate) + for core_climate in bed.foundation.core_climates + ) async_add_entities(entities) @@ -115,3 +121,57 @@ class SleepIQFootWarmingTempSelectEntity( self._attr_current_option = option await self.coordinator.async_request_refresh() self.async_write_ha_state() + + +class SleepIQCoreTempSelectEntity( + SleepIQSleeperEntity[SleepIQDataUpdateCoordinator], SelectEntity +): + """Representation of a SleepIQ core climate temperature select entity.""" + + # Maps to translate between asyncsleepiq and HA's naming preference + SLEEPIQ_TO_HA_CORE_TEMP_MAP = { + CoreTemps.OFF: "off", + CoreTemps.HEATING_PUSH_LOW: "heating_low", + CoreTemps.HEATING_PUSH_MED: "heating_medium", + CoreTemps.HEATING_PUSH_HIGH: "heating_high", + CoreTemps.COOLING_PULL_LOW: "cooling_low", + CoreTemps.COOLING_PULL_MED: "cooling_medium", + CoreTemps.COOLING_PULL_HIGH: "cooling_high", + } + HA_TO_SLEEPIQ_CORE_TEMP_MAP = {v: k for k, v in SLEEPIQ_TO_HA_CORE_TEMP_MAP.items()} + + _attr_icon = "mdi:heat-wave" + _attr_options = list(SLEEPIQ_TO_HA_CORE_TEMP_MAP.values()) + _attr_translation_key = "core_temps" + + def __init__( + self, + coordinator: SleepIQDataUpdateCoordinator, + bed: SleepIQBed, + core_climate: SleepIQCoreClimate, + ) -> None: + """Initialize the select entity.""" + self.core_climate = core_climate + sleeper = sleeper_for_side(bed, core_climate.side) + super().__init__(coordinator, bed, sleeper, CORE_CLIMATE) + self._async_update_attrs() + + @callback + def _async_update_attrs(self) -> None: + """Update entity attributes.""" + sleepiq_option = CoreTemps(self.core_climate.temperature) + self._attr_current_option = self.SLEEPIQ_TO_HA_CORE_TEMP_MAP[sleepiq_option] + + async def async_select_option(self, option: str) -> None: + """Change the current preset.""" + temperature = self.HA_TO_SLEEPIQ_CORE_TEMP_MAP[option] + timer = self.core_climate.timer or 240 + + if temperature == CoreTemps.OFF: + await self.core_climate.turn_off() + else: + await self.core_climate.turn_on(temperature, timer) + + self._attr_current_option = option + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json index 634202d6da8..58a35ea914b 100644 --- a/homeassistant/components/sleepiq/strings.json +++ b/homeassistant/components/sleepiq/strings.json @@ -33,6 +33,17 @@ "medium": "[%key:common::state::medium%]", "high": "[%key:common::state::high%]" } + }, + "core_temps": { + "state": { + "off": "[%key:common::state::off%]", + "heating_low": "Heating low", + "heating_medium": "Heating medium", + "heating_high": "Heating high", + "cooling_low": "Cooling low", + "cooling_medium": "Cooling medium", + "cooling_high": "Cooling high" + } } } } diff --git a/tests/components/sleepiq/conftest.py b/tests/components/sleepiq/conftest.py index a9456bd3cc6..f52f489aec3 100644 --- a/tests/components/sleepiq/conftest.py +++ b/tests/components/sleepiq/conftest.py @@ -7,10 +7,12 @@ from unittest.mock import AsyncMock, MagicMock, create_autospec, patch from asyncsleepiq import ( BED_PRESETS, + CoreTemps, FootWarmingTemps, Side, SleepIQActuator, SleepIQBed, + SleepIQCoreClimate, SleepIQFootWarmer, SleepIQFoundation, SleepIQLight, @@ -29,6 +31,7 @@ from tests.common import MockConfigEntry BED_ID = "123456" BED_NAME = "Test Bed" BED_NAME_LOWER = BED_NAME.lower().replace(" ", "_") +CORE_CLIMATE_TIME = 240 SLEEPER_L_ID = "98765" SLEEPER_R_ID = "43219" SLEEPER_L_NAME = "SleeperL" @@ -91,6 +94,7 @@ def mock_bed() -> MagicMock: bed.foundation.lights = [light_1, light_2] bed.foundation.foot_warmers = [] + bed.foundation.core_climates = [] return bed @@ -127,6 +131,7 @@ def mock_asyncsleepiq_single_foundation( preset.options = BED_PRESETS mock_bed.foundation.foot_warmers = [] + mock_bed.foundation.core_climates = [] yield client @@ -185,6 +190,18 @@ def mock_asyncsleepiq(mock_bed: MagicMock) -> Generator[MagicMock]: foot_warmer_r.timer = FOOT_WARM_TIME foot_warmer_r.temperature = FootWarmingTemps.OFF + core_climate_l = create_autospec(SleepIQCoreClimate) + core_climate_r = create_autospec(SleepIQCoreClimate) + mock_bed.foundation.core_climates = [core_climate_l, core_climate_r] + + core_climate_l.side = Side.LEFT + core_climate_l.timer = CORE_CLIMATE_TIME + core_climate_l.temperature = CoreTemps.COOLING_PULL_MED + + core_climate_r.side = Side.RIGHT + core_climate_r.timer = CORE_CLIMATE_TIME + core_climate_r.temperature = CoreTemps.OFF + yield client diff --git a/tests/components/sleepiq/test_number.py b/tests/components/sleepiq/test_number.py index f0739aabc9d..dd45cdc2400 100644 --- a/tests/components/sleepiq/test_number.py +++ b/tests/components/sleepiq/test_number.py @@ -198,3 +198,42 @@ async def test_foot_warmer_timer( await hass.async_block_till_done() assert mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[0].timer == 300 + + +async def test_core_climate_timer( + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_asyncsleepiq +) -> None: + """Test the SleepIQ core climate number values for a bed with two sides.""" + entry = await setup_platform(hass, NUMBER_DOMAIN) + + state = hass.states.get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert state.state == "240.0" + assert state.attributes.get(ATTR_ICON) == "mdi:timer" + assert state.attributes.get(ATTR_MIN) == 0 + assert state.attributes.get(ATTR_MAX) == 600 + assert state.attributes.get(ATTR_STEP) == 30 + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate Timer" + ) + + entry = entity_registry.async_get( + f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer" + ) + assert entry + assert entry.unique_id == f"{BED_ID}_L_core_climate_timer" + + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: f"number.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate_timer", + ATTR_VALUE: 420, + }, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[0].timer == 420 diff --git a/tests/components/sleepiq/test_select.py b/tests/components/sleepiq/test_select.py index bbfb612e9cb..17d57eba7d3 100644 --- a/tests/components/sleepiq/test_select.py +++ b/tests/components/sleepiq/test_select.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock -from asyncsleepiq import FootWarmingTemps +from asyncsleepiq import CoreTemps, FootWarmingTemps from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, @@ -21,6 +21,7 @@ from .conftest import ( BED_ID, BED_NAME, BED_NAME_LOWER, + CORE_CLIMATE_TIME, FOOT_WARM_TIME, PRESET_L_STATE, PRESET_R_STATE, @@ -204,3 +205,77 @@ async def test_foot_warmer( mock_asyncsleepiq.beds[BED_ID].foundation.foot_warmers[ 1 ].turn_on.assert_called_with(FootWarmingTemps.HIGH, FOOT_WARM_TIME) + + +async def test_core_climate( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_asyncsleepiq: MagicMock, +) -> None: + """Test the SleepIQ select entity for core climate.""" + entry = await setup_platform(hass, SELECT_DOMAIN) + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert state.state == "cooling_medium" + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_L_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_L_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_L_NAME_LOWER}_core_climate", + ATTR_OPTION: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 0 + ].turn_off.assert_called_once() + + state = hass.states.get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert state.state == CoreTemps.OFF.name.lower() + assert state.attributes.get(ATTR_ICON) == "mdi:heat-wave" + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) + == f"SleepNumber {BED_NAME} {SLEEPER_R_NAME} Core Climate" + ) + + entry = entity_registry.async_get( + f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate" + ) + assert entry + assert entry.unique_id == f"{SLEEPER_R_ID}_core_climate" + + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: f"select.sleepnumber_{BED_NAME_LOWER}_{SLEEPER_R_NAME_LOWER}_core_climate", + ATTR_OPTION: "heating_high", + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_once() + mock_asyncsleepiq.beds[BED_ID].foundation.core_climates[ + 1 + ].turn_on.assert_called_with(CoreTemps.HEATING_PUSH_HIGH, CORE_CLIMATE_TIME) From 1315095b4a3aa4cf268834a9d763b0a145e7e0fc Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Mon, 21 Jul 2025 14:16:03 +0200 Subject: [PATCH 1627/1664] Make spelling of "devolo Home Network" consistent (#149165) --- homeassistant/components/devolo_home_network/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 50177a9b13b..24bf06ac59c 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -9,7 +9,7 @@ "password": "[%key:common::config_flow::data::password%]" }, "data_description": { - "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network App on the device dashboard.", + "ip_address": "IP address of your devolo Home Network device. This can be found in the devolo Home Network app on the device dashboard.", "password": "Password you protected the device with." } }, @@ -22,8 +22,8 @@ } }, "zeroconf_confirm": { - "description": "Do you want to add the devolo home network device with the hostname `{host_name}` to Home Assistant?", - "title": "Discovered devolo home network device", + "description": "Do you want to add the devolo Home Network device with the hostname `{host_name}` to Home Assistant?", + "title": "Discovered devolo Home Network device", "data": { "password": "[%key:common::config_flow::data::password%]" }, From 6b489e0ab6897176fb60c8da444a3c46e37112ea Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:34:12 +0200 Subject: [PATCH 1628/1664] Bump sigstore/cosign-installer from 3.9.1 to 3.9.2 (#148985) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 5ac2e47789b..82009751763 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -324,7 +324,7 @@ jobs: uses: actions/checkout@v4.2.2 - name: Install Cosign - uses: sigstore/cosign-installer@v3.9.1 + uses: sigstore/cosign-installer@v3.9.2 with: cosign-release: "v2.2.3" From 64f190749a5ecccee7ad57d5a0074ff467caca55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Mon, 21 Jul 2025 14:39:42 +0200 Subject: [PATCH 1629/1664] Add Demo Vacuum in entity name (#148629) --- homeassistant/components/demo/vacuum.py | 10 +++++----- tests/components/demo/test_vacuum.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 11bf3e3118b..ba00bcaedb9 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -48,11 +48,11 @@ SUPPORT_ALL_SERVICES = ( ) FAN_SPEEDS = ["min", "medium", "high", "max"] -DEMO_VACUUM_COMPLETE = "0_Ground_floor" -DEMO_VACUUM_MOST = "1_First_floor" -DEMO_VACUUM_BASIC = "2_Second_floor" -DEMO_VACUUM_MINIMAL = "3_Third_floor" -DEMO_VACUUM_NONE = "4_Fourth_floor" +DEMO_VACUUM_COMPLETE = "Demo vacuum 0 ground floor" +DEMO_VACUUM_MOST = "Demo vacuum 1 first floor" +DEMO_VACUUM_BASIC = "Demo vacuum 2 second floor" +DEMO_VACUUM_MINIMAL = "Demo vacuum 3 third floor" +DEMO_VACUUM_NONE = "Demo vacuum 4 fourth floor" async def async_setup_entry( diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 3a627efd3f1..a497bd964ec 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -37,11 +37,15 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common -ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".lower() -ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".lower() -ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".lower() -ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".lower() -ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".lower() +ENTITY_VACUUM_BASIC = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_BASIC}".replace(" ", "_").lower() +ENTITY_VACUUM_COMPLETE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_COMPLETE}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MINIMAL = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MINIMAL}".replace( + " ", "_" +).lower() +ENTITY_VACUUM_MOST = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_MOST}".replace(" ", "_").lower() +ENTITY_VACUUM_NONE = f"{VACUUM_DOMAIN}.{DEMO_VACUUM_NONE}".replace(" ", "_").lower() @pytest.fixture From af0480f2a4b808a3c2a5878a5f92052fee5521d8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:51:33 +0200 Subject: [PATCH 1630/1664] Use OptionsFlowWithReload in slide_local (#149168) --- homeassistant/components/slide_local/__init__.py | 7 ------- homeassistant/components/slide_local/config_flow.py | 8 ++++++-- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/slide_local/__init__.py b/homeassistant/components/slide_local/__init__.py index 4690fe8016c..7d2027a985a 100644 --- a/homeassistant/components/slide_local/__init__.py +++ b/homeassistant/components/slide_local/__init__.py @@ -21,16 +21,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> boo await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) - return True -async def update_listener(hass: HomeAssistant, entry: SlideConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: SlideConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/slide_local/config_flow.py b/homeassistant/components/slide_local/config_flow.py index 96aac1a135c..7593d502bec 100644 --- a/homeassistant/components/slide_local/config_flow.py +++ b/homeassistant/components/slide_local/config_flow.py @@ -14,7 +14,11 @@ from goslideapi.goslideapi import ( ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow +from homeassistant.config_entries import ( + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) from homeassistant.const import CONF_API_VERSION, CONF_HOST, CONF_MAC, CONF_PASSWORD from homeassistant.core import callback from homeassistant.helpers.device_registry import format_mac @@ -232,7 +236,7 @@ class SlideConfigFlow(ConfigFlow, domain=DOMAIN): ) -class SlideOptionsFlowHandler(OptionsFlow): +class SlideOptionsFlowHandler(OptionsFlowWithReload): """Handle a options flow for slide_local.""" async def async_step_init( From 54fa4d635bf7560240a0d02b4917cf6bc2d5c752 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:51:48 +0200 Subject: [PATCH 1631/1664] Use OptionsFlowWithReload in sonarr (#149166) --- homeassistant/components/sonarr/__init__.py | 6 ------ homeassistant/components/sonarr/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py index 960227ff0da..1c786356486 100644 --- a/homeassistant/components/sonarr/__init__.py +++ b/homeassistant/components/sonarr/__init__.py @@ -65,7 +65,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: host_configuration=host_configuration, session=async_get_clientsession(hass), ) - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = { "upcoming": CalendarDataUpdateCoordinator( hass, entry, host_configuration, sonarr @@ -126,8 +125,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index e1cedba10e7..278d3fbd7bb 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback @@ -152,7 +152,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): return data_schema -class SonarrOptionsFlowHandler(OptionsFlow): +class SonarrOptionsFlowHandler(OptionsFlowWithReload): """Handle Sonarr client options.""" async def async_step_init( From 671523feb3493aa575fe78f30710864257138f27 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 14:52:14 +0200 Subject: [PATCH 1632/1664] Use OptionsFlowWithReload in hyperion (#149163) --- homeassistant/components/hyperion/__init__.py | 6 ------ homeassistant/components/hyperion/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py index 0f49bacd1ef..60a53193acc 100644 --- a/homeassistant/components/hyperion/__init__.py +++ b/homeassistant/components/hyperion/__init__.py @@ -266,16 +266,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> assert hyperion_client if hyperion_client.instances is not None: await async_instances_to_clients_raw(hyperion_client.instances) - entry.async_on_unload(entry.add_update_listener(_async_entry_updated)) return True -async def _async_entry_updated(hass: HomeAssistant, entry: HyperionConfigEntry) -> None: - """Handle entry updates.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: HyperionConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 72e76ef8667..1ef53ad2951 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ( ConfigEntry, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_BASE, @@ -431,7 +431,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return HyperionOptionsFlow() -class HyperionOptionsFlow(OptionsFlow): +class HyperionOptionsFlow(OptionsFlowWithReload): """Hyperion options flow.""" def _create_client(self) -> client.HyperionClient: From 2476e7e47c70e2c8cd5138d20dd88d1f208bfcd6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 21 Jul 2025 15:27:29 +0200 Subject: [PATCH 1633/1664] Revert setting a user to download translations (#149190) --- script/translations/download.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/script/translations/download.py b/script/translations/download.py index 6a0d6ba824c..0c9504f44cd 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -4,7 +4,6 @@ from __future__ import annotations import json -import os from pathlib import Path import re import subprocess @@ -28,8 +27,6 @@ def run_download_docker(): "-v", f"{DOWNLOAD_DIR}:/opt/dest/locale", "--rm", - "--user", - f"{os.getuid()}:{os.getgid()}", f"lokalise/lokalise-cli-2:{CLI_2_DOCKER_IMAGE}", # Lokalise command "lokalise2", From 102ef257a07461bae22d7831188198cd69db17ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 21 Jul 2025 14:35:35 +0100 Subject: [PATCH 1634/1664] Bump hass-nabucasa from 0.107.1 to 0.108.0 (#149189) --- 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 642bece1b8e..72748efff6e 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.107.1"], + "requirements": ["hass-nabucasa==0.108.0"], "single_config_entry": true } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 157ee1420fc..aa0e1768d52 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.107.1 +hass-nabucasa==0.108.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 6c732066e41..b1b43c80cd2 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.107.1", + "hass-nabucasa==0.108.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 ed9c100fd3a..e4065bed83e 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.107.1 +hass-nabucasa==0.108.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 b7e3fd074b6..ccae2a8f8da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1127,7 +1127,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30ad1b2e5fe..b401c61739d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -988,7 +988,7 @@ habiticalib==0.4.0 habluetooth==4.0.1 # homeassistant.components.cloud -hass-nabucasa==0.107.1 +hass-nabucasa==0.108.0 # homeassistant.components.assist_satellite # homeassistant.components.conversation From f3db3ba3c8903ace7d697ffe3d7f8213ec43fce9 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 21 Jul 2025 09:36:12 -0400 Subject: [PATCH 1635/1664] Bump pyschlage to 2025.7.3 (#149184) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index c5b91cefd2e..b71afe01e56 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2025.7.2"] + "requirements": ["pyschlage==2025.7.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ccae2a8f8da..60e64a1ad27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2310,7 +2310,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2025.7.2 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b401c61739d..20c826b73e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1922,7 +1922,7 @@ pyrympro==0.0.9 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2025.7.2 +pyschlage==2025.7.3 # homeassistant.components.sensibo pysensibo==1.2.1 From 80b96b0007afeaad0b7687c8eb823017e43f15f7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 15:40:30 +0200 Subject: [PATCH 1636/1664] Use OptionsFlowWithReload in roku (#149172) --- homeassistant/components/roku/__init__.py | 7 ------- homeassistant/components/roku/config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py index be0b20c97fb..46149264e55 100644 --- a/homeassistant/components/roku/__init__.py +++ b/homeassistant/components/roku/__init__.py @@ -25,16 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True async def async_unload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_reload_entry(hass: HomeAssistant, entry: RokuConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/roku/config_flow.py b/homeassistant/components/roku/config_flow.py index 47bc86802d2..b28648589c9 100644 --- a/homeassistant/components/roku/config_flow.py +++ b/homeassistant/components/roku/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, callback @@ -202,7 +202,7 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN): return RokuOptionsFlowHandler() -class RokuOptionsFlowHandler(OptionsFlow): +class RokuOptionsFlowHandler(OptionsFlowWithReload): """Handle Roku options.""" async def async_step_init( From 40252763d702aa710a3249f95947a1572374bdf8 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Mon, 21 Jul 2025 16:31:28 +0200 Subject: [PATCH 1637/1664] Switch to a new library in Onkyo (#148613) --- homeassistant/components/onkyo/__init__.py | 26 +- homeassistant/components/onkyo/config_flow.py | 49 +- homeassistant/components/onkyo/const.py | 234 +------- homeassistant/components/onkyo/manifest.json | 5 +- .../components/onkyo/media_player.py | 510 +++++++----------- .../components/onkyo/quality_scale.yaml | 5 +- homeassistant/components/onkyo/receiver.py | 202 +++---- homeassistant/components/onkyo/services.py | 18 +- homeassistant/components/onkyo/util.py | 8 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/onkyo/__init__.py | 125 ++--- tests/components/onkyo/conftest.py | 227 +++++--- .../onkyo/snapshots/test_media_player.ambr | 203 +++++++ tests/components/onkyo/test_config_flow.py | 321 +++++------ tests/components/onkyo/test_init.py | 84 ++- tests/components/onkyo/test_media_player.py | 230 ++++++++ 17 files changed, 1255 insertions(+), 1004 deletions(-) create mode 100644 homeassistant/components/onkyo/util.py create mode 100644 tests/components/onkyo/snapshots/test_media_player.ambr create mode 100644 tests/components/onkyo/test_media_player.py diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py index d0f93012eb7..a4d1ec8f175 100644 --- a/homeassistant/components/onkyo/__init__.py +++ b/homeassistant/components/onkyo/__init__.py @@ -17,7 +17,7 @@ from .const import ( InputSource, ListeningMode, ) -from .receiver import Receiver, async_interview +from .receiver import ReceiverManager, async_interview from .services import DATA_MP_ENTITIES, async_setup_services _LOGGER = logging.getLogger(__name__) @@ -31,7 +31,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) class OnkyoData: """Config Entry data.""" - receiver: Receiver + manager: ReceiverManager sources: dict[InputSource, str] sound_modes: dict[ListeningMode, str] @@ -50,11 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo host = entry.data[CONF_HOST] - info = await async_interview(host) + try: + info = await async_interview(host) + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc if info is None: raise ConfigEntryNotReady(f"Unable to connect to: {host}") - receiver = await Receiver.async_create(info) + manager = ReceiverManager(hass, entry, info) sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources = {InputSource(k): v for k, v in sources_store.items()} @@ -62,11 +65,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()} - entry.runtime_data = OnkyoData(receiver, sources, sound_modes) + entry.runtime_data = OnkyoData(manager, sources, sound_modes) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - await receiver.conn.connect() + if error := await manager.start(): + try: + await error + except OSError as exc: + raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc return True @@ -75,9 +82,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo """Unload Onkyo config entry.""" del hass.data[DATA_MP_ENTITIES][entry.entry_id] - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + entry.runtime_data.manager.start_unloading() - receiver = entry.runtime_data.receiver - receiver.conn.close() - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index 2b8f9981e4a..75b0f92043d 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -4,12 +4,12 @@ from collections.abc import Mapping import logging from typing import Any +from aioonkyo import ReceiverInfo import voluptuous as vol from yarl import URL from homeassistant.config_entries import ( SOURCE_RECONFIGURE, - ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlowWithReload, @@ -29,6 +29,7 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from . import OnkyoConfigEntry from .const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -41,19 +42,20 @@ from .const import ( InputSource, ListeningMode, ) -from .receiver import ReceiverInfo, async_discover, async_interview +from .receiver import async_discover, async_interview +from .util import get_meaning _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -INPUT_SOURCES_DEFAULT: dict[str, str] = {} -LISTENING_MODES_DEFAULT: dict[str, str] = {} +INPUT_SOURCES_DEFAULT: list[InputSource] = [] +LISTENING_MODES_DEFAULT: list[ListeningMode] = [] INPUT_SOURCES_ALL_MEANINGS = { - input_source.value_meaning: input_source for input_source in InputSource + get_meaning(input_source): input_source for input_source in InputSource } LISTENING_MODES_ALL_MEANINGS = { - listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode + get_meaning(listening_mode): listening_mode for listening_mode in ListeningMode } STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_RECONFIGURE_SCHEMA = vol.Schema( @@ -91,6 +93,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initialized by the user.""" + _LOGGER.debug("Config flow start user") return self.async_show_menu( step_id="user", menu_options=["manual", "eiscp_discovery"] ) @@ -103,10 +106,10 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: host = user_input[CONF_HOST] - _LOGGER.debug("Config flow start manual: %s", host) + _LOGGER.debug("Config flow manual: %s", host) try: info = await async_interview(host) - except Exception: + except OSError: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: @@ -156,8 +159,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Config flow start eiscp discovery") try: - infos = await async_discover() - except Exception: + infos = list(await async_discover(self.hass)) + except OSError: _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") @@ -303,8 +306,14 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): if reconfigure_entry is None: suggested_values = { OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, - OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_INPUT_SOURCES: [ + get_meaning(input_source) + for input_source in INPUT_SOURCES_DEFAULT + ], + OPTION_LISTENING_MODES: [ + get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + ], } else: entry_options = reconfigure_entry.options @@ -325,11 +334,12 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle reconfiguration of the receiver.""" + _LOGGER.debug("Config flow start reconfigure") return await self.async_step_manual() @staticmethod @callback - def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: + def async_get_options_flow(config_entry: OnkyoConfigEntry) -> OptionsFlowWithReload: """Return the options flow.""" return OnkyoOptionsFlowHandler() @@ -372,7 +382,10 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload): entry_options: Mapping[str, Any] = self.config_entry.options entry_options = { - OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, + OPTION_LISTENING_MODES: { + listening_mode.value: get_meaning(listening_mode) + for listening_mode in LISTENING_MODES_DEFAULT + }, **entry_options, } @@ -416,11 +429,11 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload): suggested_values = { OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], OPTION_INPUT_SOURCES: [ - InputSource(input_source).value_meaning + get_meaning(InputSource(input_source)) for input_source in entry_options[OPTION_INPUT_SOURCES] ], OPTION_LISTENING_MODES: [ - ListeningMode(listening_mode).value_meaning + get_meaning(ListeningMode(listening_mode)) for listening_mode in entry_options[OPTION_LISTENING_MODES] ], } @@ -463,13 +476,13 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload): input_sources_schema_dict: dict[Any, Selector] = {} for input_source, input_source_name in self._input_sources.items(): input_sources_schema_dict[ - vol.Required(input_source.value_meaning, default=input_source_name) + vol.Required(get_meaning(input_source), default=input_source_name) ] = TextSelector() listening_modes_schema_dict: dict[Any, Selector] = {} for listening_mode, listening_mode_name in self._listening_modes.items(): listening_modes_schema_dict[ - vol.Required(listening_mode.value_meaning, default=listening_mode_name) + vol.Required(get_meaning(listening_mode), default=listening_mode_name) ] = TextSelector() return self.async_show_form( diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py index 851d80c5100..4f5be4238b4 100644 --- a/homeassistant/components/onkyo/const.py +++ b/homeassistant/components/onkyo/const.py @@ -1,10 +1,9 @@ """Constants for the Onkyo integration.""" -from enum import Enum import typing -from typing import Literal, Self +from typing import Literal -import pyeiscp +from aioonkyo import HDMIOutputParam, InputSourceParam, ListeningModeParam, Zone DOMAIN = "onkyo" @@ -21,214 +20,37 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args( OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME_DEFAULT = 100.0 - -class EnumWithMeaning(Enum): - """Enum with meaning.""" - - value_meaning: str - - def __new__(cls, value: str) -> Self: - """Create enum.""" - obj = object.__new__(cls) - obj._value_ = value - obj.value_meaning = cls._get_meanings()[value] - - return obj - - @staticmethod - def _get_meanings() -> dict[str, str]: - raise NotImplementedError - - OPTION_INPUT_SOURCES = "input_sources" OPTION_LISTENING_MODES = "listening_modes" -_INPUT_SOURCE_MEANINGS = { - "00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", - "01": "VIDEO2 ··· CBL/SAT", - "02": "VIDEO3 ··· GAME/TV ··· GAME", - "03": "VIDEO4 ··· AUX", - "04": "VIDEO5 ··· AUX2 ··· GAME2", - "05": "VIDEO6 ··· PC", - "06": "VIDEO7", - "07": "HIDDEN1 ··· EXTRA1", - "08": "HIDDEN2 ··· EXTRA2", - "09": "HIDDEN3 ··· EXTRA3", - "10": "DVD ··· BD/DVD", - "11": "STRM BOX", - "12": "TV", - "20": "TAPE ··· TV/TAPE", - "21": "TAPE2", - "22": "PHONO", - "23": "CD ··· TV/CD", - "24": "FM", - "25": "AM", - "26": "TUNER", - "27": "MUSIC SERVER ··· P4S ··· DLNA", - "28": "INTERNET RADIO ··· IRADIO FAVORITE", - "29": "USB ··· USB(FRONT)", - "2A": "USB(REAR)", - "2B": "NETWORK ··· NET", - "2D": "AIRPLAY", - "2E": "BLUETOOTH", - "2F": "USB DAC IN", - "30": "MULTI CH", - "31": "XM", - "32": "SIRIUS", - "33": "DAB", - "40": "UNIVERSAL PORT", - "41": "LINE", - "42": "LINE2", - "44": "OPTICAL", - "45": "COAXIAL", - "55": "HDMI 5", - "56": "HDMI 6", - "57": "HDMI 7", - "80": "MAIN SOURCE", +InputSource = InputSourceParam +ListeningMode = ListeningModeParam +HDMIOutput = HDMIOutputParam + +ZONES = { + Zone.MAIN: "Main", + Zone.ZONE2: "Zone 2", + Zone.ZONE3: "Zone 3", + Zone.ZONE4: "Zone 4", } -class InputSource(EnumWithMeaning): - """Receiver input source.""" - - DVR = "00" - CBL = "01" - GAME = "02" - AUX = "03" - GAME2 = "04" - PC = "05" - VIDEO7 = "06" - EXTRA1 = "07" - EXTRA2 = "08" - EXTRA3 = "09" - DVD = "10" - STRM_BOX = "11" - TV = "12" - TAPE = "20" - TAPE2 = "21" - PHONO = "22" - CD = "23" - FM = "24" - AM = "25" - TUNER = "26" - MUSIC_SERVER = "27" - INTERNET_RADIO = "28" - USB = "29" - USB_REAR = "2A" - NETWORK = "2B" - AIRPLAY = "2D" - BLUETOOTH = "2E" - USB_DAC_IN = "2F" - MULTI_CH = "30" - XM = "31" - SIRIUS = "32" - DAB = "33" - UNIVERSAL_PORT = "40" - LINE = "41" - LINE2 = "42" - OPTICAL = "44" - COAXIAL = "45" - HDMI_5 = "55" - HDMI_6 = "56" - HDMI_7 = "57" - MAIN_SOURCE = "80" - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _INPUT_SOURCE_MEANINGS - - -_LISTENING_MODE_MEANINGS = { - "00": "STEREO", - "01": "DIRECT", - "02": "SURROUND", - "03": "FILM ··· GAME RPG ··· ADVANCED GAME", - "04": "THX", - "05": "ACTION ··· GAME ACTION", - "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP", - "07": "MONO MOVIE", - "08": "ORCHESTRA ··· CLASSICAL", - "09": "UNPLUGGED", - "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW", - "0B": "TV LOGIC ··· DRAMA", - "0C": "ALL CH STEREO ··· EXTENDED STEREO", - "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND", - "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS", - "0F": "MONO", - "11": "PURE AUDIO ··· PURE DIRECT", - "12": "MULTIPLEX", - "13": "FULL MONO ··· MONO MUSIC", - "14": "DOLBY VIRTUAL/SURROUND ENHANCER", - "15": "DTS SURROUND SENSATION", - "16": "AUDYSSEY DSX", - "17": "DTS VIRTUAL:X", - "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC", - "23": "STAGE (JAPAN GENRE CONTROL)", - "25": "ACTION (JAPAN GENRE CONTROL)", - "26": "MUSIC (JAPAN GENRE CONTROL)", - "2E": "SPORTS (JAPAN GENRE CONTROL)", - "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND", - "41": "DOLBY EX/DTS ES", - "42": "THX CINEMA", - "43": "THX SURROUND EX", - "44": "THX MUSIC", - "45": "THX GAMES", - "50": "THX U(2)/S(2)/I/S CINEMA", - "51": "THX U(2)/S(2)/I/S MUSIC", - "52": "THX U(2)/S(2)/I/S GAMES", - "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE", - "81": "PLII/PLIIx MUSIC", - "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA", - "83": "NEO:6/NEO:X MUSIC", - "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA", - "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA", - "86": "PLII/PLIIx GAME", - "87": "NEURAL SURR", - "88": "NEURAL THX/NEURAL SURROUND", - "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES", - "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES", - "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC", - "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC", - "8D": "NEURAL THX CINEMA", - "8E": "NEURAL THX MUSIC", - "8F": "NEURAL THX GAMES", - "90": "PLIIz HEIGHT", - "91": "NEO:6 CINEMA DTS SURROUND SENSATION", - "92": "NEO:6 MUSIC DTS SURROUND SENSATION", - "93": "NEURAL DIGITAL MUSIC", - "94": "PLIIz HEIGHT + THX CINEMA", - "95": "PLIIz HEIGHT + THX MUSIC", - "96": "PLIIz HEIGHT + THX GAMES", - "97": "PLIIz HEIGHT + THX U2/S2 CINEMA", - "98": "PLIIz HEIGHT + THX U2/S2 MUSIC", - "99": "PLIIz HEIGHT + THX U2/S2 GAMES", - "9A": "NEO:X GAME", - "A0": "PLIIx/PLII Movie + AUDYSSEY DSX", - "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX", - "A2": "PLIIx/PLII GAME + AUDYSSEY DSX", - "A3": "NEO:6 CINEMA + AUDYSSEY DSX", - "A4": "NEO:6 MUSIC + AUDYSSEY DSX", - "A5": "NEURAL SURROUND + AUDYSSEY DSX", - "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX", - "A7": "DOLBY EX + AUDYSSEY DSX", - "FF": "AUTO SURROUND", +LEGACY_HDMI_OUTPUT_MAPPING = { + HDMIOutput.ANALOG: "no,analog", + HDMIOutput.MAIN: "yes,out", + HDMIOutput.SUB: "out-sub,sub,hdbaset", + HDMIOutput.BOTH: "both,sub", + HDMIOutput.BOTH_MAIN: "both", + HDMIOutput.BOTH_SUB: "both", } - -class ListeningMode(EnumWithMeaning): - """Receiver listening mode.""" - - _ignore_ = "ListeningMode _k _v _meaning" - - ListeningMode = vars() - for _k in _LISTENING_MODE_MEANINGS: - ListeningMode["I" + _k] = _k - - @staticmethod - def _get_meanings() -> dict[str, str]: - return _LISTENING_MODE_MEANINGS - - -ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"} - -PYEISCP_COMMANDS = pyeiscp.commands.COMMANDS +LEGACY_REV_HDMI_OUTPUT_MAPPING = { + "analog": HDMIOutput.ANALOG, + "both": HDMIOutput.BOTH_SUB, + "hdbaset": HDMIOutput.SUB, + "no": HDMIOutput.ANALOG, + "out": HDMIOutput.MAIN, + "out-sub": HDMIOutput.SUB, + "sub": HDMIOutput.BOTH, + "yes": HDMIOutput.MAIN, +} diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 6f37fb61b44..07834d4cba1 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -3,11 +3,12 @@ "name": "Onkyo", "codeowners": ["@arturpragacz", "@eclair4151"], "config_flow": true, + "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/onkyo", "integration_type": "device", "iot_class": "local_push", - "loggers": ["pyeiscp"], - "requirements": ["pyeiscp==0.0.7"], + "loggers": ["aioonkyo"], + "requirements": ["aioonkyo==0.2.0"], "ssdp": [ { "manufacturer": "ONKYO", diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index aed7c51af80..2965388236d 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -1,12 +1,12 @@ -"""Support for Onkyo Receivers.""" +"""Media player platform.""" from __future__ import annotations import asyncio -from enum import Enum -from functools import cache import logging -from typing import Any, Literal +from typing import Any + +from aioonkyo import Code, Kind, Status, Zone, command, query, status from homeassistant.components.media_player import ( MediaPlayerEntity, @@ -14,23 +14,25 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OnkyoConfigEntry from .const import ( DOMAIN, + LEGACY_HDMI_OUTPUT_MAPPING, + LEGACY_REV_HDMI_OUTPUT_MAPPING, OPTION_MAX_VOLUME, OPTION_VOLUME_RESOLUTION, - PYEISCP_COMMANDS, ZONES, InputSource, ListeningMode, VolumeResolution, ) -from .receiver import Receiver +from .receiver import ReceiverManager from .services import DATA_MP_ENTITIES +from .util import get_meaning _LOGGER = logging.getLogger(__name__) @@ -86,64 +88,6 @@ VIDEO_INFORMATION_MAPPING = [ "input_hdr", ] -type LibValue = str | tuple[str, ...] - - -def _get_single_lib_value(value: LibValue) -> str: - if isinstance(value, str): - return value - return value[-1] - - -def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]: - result: dict[T, LibValue] = {} - for k, v in cmds["values"].items(): - try: - key = cls(k) - except ValueError: - continue - result[key] = v["name"] - - return result - - -@cache -def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["SLI"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["SLZ"] - case "zone3": - cmds = PYEISCP_COMMANDS["zone3"]["SL3"] - case "zone4": - cmds = PYEISCP_COMMANDS["zone4"]["SL4"] - - return _get_lib_mapping(cmds, InputSource) - - -@cache -def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]: - return {value: key for key, value in _input_source_lib_mappings(zone).items()} - - -@cache -def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]: - match zone: - case "main": - cmds = PYEISCP_COMMANDS["main"]["LMD"] - case "zone2": - cmds = PYEISCP_COMMANDS["zone2"]["LMZ"] - case _: - return {} - - return _get_lib_mapping(cmds, ListeningMode) - - -@cache -def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]: - return {value: key for key, value in _listening_mode_lib_mappings(zone).items()} - async def async_setup_entry( hass: HomeAssistant, @@ -153,10 +97,10 @@ async def async_setup_entry( """Set up MediaPlayer for config entry.""" data = entry.runtime_data - receiver = data.receiver + manager = data.manager all_entities = hass.data[DATA_MP_ENTITIES] - entities: dict[str, OnkyoMediaPlayer] = {} + entities: dict[Zone, OnkyoMediaPlayer] = {} all_entities[entry.entry_id] = entities volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] @@ -164,29 +108,33 @@ async def async_setup_entry( sources = data.sources sound_modes = data.sound_modes - def connect_callback(receiver: Receiver) -> None: - if not receiver.first_connect: + async def connect_callback(reconnect: bool) -> None: + if reconnect: for entity in entities.values(): if entity.enabled: - entity.backfill_state() + await entity.backfill_state() + + async def update_callback(message: Status) -> None: + if isinstance(message, status.Raw): + return + + zone = message.zone - def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None: - zone, _, value = message entity = entities.get(zone) if entity is not None: if entity.enabled: entity.process_update(message) - elif zone in ZONES and value != "N/A": - # When we receive the status for a zone, and the value is not "N/A", - # then zone is available on the receiver, so we create the entity for it. + elif not isinstance(message, status.NotAvailable): + # When we receive a valid status for a zone, then that zone is available on the receiver, + # so we create the entity for it. _LOGGER.debug( "Discovered %s on %s (%s)", ZONES[zone], - receiver.model_name, - receiver.host, + manager.info.model_name, + manager.info.host, ) zone_entity = OnkyoMediaPlayer( - receiver, + manager, zone, volume_resolution=volume_resolution, max_volume=max_volume, @@ -196,25 +144,27 @@ async def async_setup_entry( entities[zone] = zone_entity async_add_entities([zone_entity]) - receiver.callbacks.connect.append(connect_callback) - receiver.callbacks.update.append(update_callback) + manager.callbacks.connect.append(connect_callback) + manager.callbacks.update.append(update_callback) class OnkyoMediaPlayer(MediaPlayerEntity): - """Representation of an Onkyo Receiver Media Player (one per each zone).""" + """Onkyo Receiver Media Player (one per each zone).""" _attr_should_poll = False _supports_volume: bool = False - _supports_sound_mode: bool = False + # None means no technical possibility of support + _supports_sound_mode: bool | None = None _supports_audio_info: bool = False _supports_video_info: bool = False - _query_timer: asyncio.TimerHandle | None = None + + _query_task: asyncio.Task | None = None def __init__( self, - receiver: Receiver, - zone: str, + manager: ReceiverManager, + zone: Zone, *, volume_resolution: VolumeResolution, max_volume: float, @@ -222,80 +172,88 @@ class OnkyoMediaPlayer(MediaPlayerEntity): sound_modes: dict[ListeningMode, str], ) -> None: """Initialize the Onkyo Receiver.""" - self._receiver = receiver - name = receiver.model_name - identifier = receiver.identifier - self._attr_name = f"{name}{' ' + ZONES[zone] if zone != 'main' else ''}" - self._attr_unique_id = f"{identifier}_{zone}" - + self._manager = manager self._zone = zone + name = manager.info.model_name + identifier = manager.info.identifier + self._attr_name = f"{name}{' ' + ZONES[zone] if zone != Zone.MAIN else ''}" + self._attr_unique_id = f"{identifier}_{zone.value}" + self._volume_resolution = volume_resolution self._max_volume = max_volume - self._options_sources = sources - self._source_lib_mapping = _input_source_lib_mappings(zone) - self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone) + zone_sources = InputSource.for_zone(zone) self._source_mapping = { - key: value - for key, value in sources.items() - if key in self._source_lib_mapping + key: value for key, value in sources.items() if key in zone_sources } self._rev_source_mapping = { value: key for key, value in self._source_mapping.items() } - self._options_sound_modes = sound_modes - self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone) - self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone) + zone_sound_modes = ListeningMode.for_zone(zone) self._sound_mode_mapping = { - key: value - for key, value in sound_modes.items() - if key in self._sound_mode_lib_mapping + key: value for key, value in sound_modes.items() if key in zone_sound_modes } self._rev_sound_mode_mapping = { value: key for key, value in self._sound_mode_mapping.items() } + self._hdmi_output_mapping = LEGACY_HDMI_OUTPUT_MAPPING + self._rev_hdmi_output_mapping = LEGACY_REV_HDMI_OUTPUT_MAPPING + self._attr_source_list = list(self._rev_source_mapping) self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) self._attr_supported_features = SUPPORTED_FEATURES_BASE - if zone == "main": + if zone == Zone.MAIN: self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME self._supports_volume = True self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._supports_sound_mode = True + elif Code.get_from_kind_zone(Kind.LISTENING_MODE, zone) is not None: + # To be detected later: + self._supports_sound_mode = False self._attr_extra_state_attributes = {} async def async_added_to_hass(self) -> None: """Entity has been added to hass.""" - self.backfill_state() + await self.backfill_state() async def async_will_remove_from_hass(self) -> None: """Cancel the query timer when the entity is removed.""" - if self._query_timer: - self._query_timer.cancel() - self._query_timer = None + if self._query_task: + self._query_task.cancel() + self._query_task = None - @callback - def _update_receiver(self, propname: str, value: Any) -> None: - """Update a property in the receiver.""" - self._receiver.conn.update_property(self._zone, propname, value) + async def backfill_state(self) -> None: + """Get the receiver to send all the info we care about. - @callback - def _query_receiver(self, propname: str) -> None: - """Cause the receiver to send an update about a property.""" - self._receiver.conn.query_property(self._zone, propname) + Usually run only on connect, as we can otherwise rely on the + receiver to keep us informed of changes. + """ + await self._manager.write(query.Power(self._zone)) + await self._manager.write(query.Volume(self._zone)) + await self._manager.write(query.Muting(self._zone)) + await self._manager.write(query.InputSource(self._zone)) + await self._manager.write(query.TunerPreset(self._zone)) + if self._supports_sound_mode is not None: + await self._manager.write(query.ListeningMode(self._zone)) + if self._zone == Zone.MAIN: + await self._manager.write(query.HDMIOutput()) + await self._manager.write(query.AudioInformation()) + await self._manager.write(query.VideoInformation()) async def async_turn_on(self) -> None: """Turn the media player on.""" - self._update_receiver("power", "on") + message = command.Power(self._zone, command.Power.Param.ON) + await self._manager.write(message) async def async_turn_off(self) -> None: """Turn the media player off.""" - self._update_receiver("power", "standby") + message = command.Power(self._zone, command.Power.Param.STANDBY) + await self._manager.write(message) async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1. @@ -307,28 +265,30 @@ class OnkyoMediaPlayer(MediaPlayerEntity): scale for the receiver. """ # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION - self._update_receiver( - "volume", round(volume * (self._max_volume / 100) * self._volume_resolution) - ) + value = round(volume * (self._max_volume / 100) * self._volume_resolution) + message = command.Volume(self._zone, value) + await self._manager.write(message) async def async_volume_up(self) -> None: """Increase volume by 1 step.""" - self._update_receiver("volume", "level-up") + message = command.Volume(self._zone, command.Volume.Param.UP) + await self._manager.write(message) async def async_volume_down(self) -> None: """Decrease volume by 1 step.""" - self._update_receiver("volume", "level-down") + message = command.Volume(self._zone, command.Volume.Param.DOWN) + await self._manager.write(message) async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - self._update_receiver( - "audio-muting" if self._zone == "main" else "muting", - "on" if mute else "off", + message = command.Muting( + self._zone, command.Muting.Param.ON if mute else command.Muting.Param.OFF ) + await self._manager.write(message) async def async_select_source(self, source: str) -> None: """Select input source.""" - if not self.source_list or source not in self.source_list: + if source not in self._rev_source_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_source", @@ -338,15 +298,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity): }, ) - source_lib = self._source_lib_mapping[self._rev_source_mapping[source]] - source_lib_single = _get_single_lib_value(source_lib) - self._update_receiver( - "input-selector" if self._zone == "main" else "selector", source_lib_single - ) + message = command.InputSource(self._zone, self._rev_source_mapping[source]) + await self._manager.write(message) async def async_select_sound_mode(self, sound_mode: str) -> None: """Select listening sound mode.""" - if not self.sound_mode_list or sound_mode not in self.sound_mode_list: + if sound_mode not in self._rev_sound_mode_mapping: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="invalid_sound_mode", @@ -356,197 +313,138 @@ class OnkyoMediaPlayer(MediaPlayerEntity): }, ) - sound_mode_lib = self._sound_mode_lib_mapping[ - self._rev_sound_mode_mapping[sound_mode] - ] - sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) - self._update_receiver("listening-mode", sound_mode_lib_single) + message = command.ListeningMode( + self._zone, self._rev_sound_mode_mapping[sound_mode] + ) + await self._manager.write(message) async def async_select_output(self, hdmi_output: str) -> None: """Set hdmi-out.""" - self._update_receiver("hdmi-output-selector", hdmi_output) + message = command.HDMIOutput(self._rev_hdmi_output_mapping[hdmi_output]) + await self._manager.write(message) async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play radio station by preset number.""" - if self.source is not None: - source = self._rev_source_mapping[self.source] - if media_type.lower() == "radio" and source in PLAYABLE_SOURCES: - self._update_receiver("preset", media_id) - - @callback - def backfill_state(self) -> None: - """Get the receiver to send all the info we care about. - - Usually run only on connect, as we can otherwise rely on the - receiver to keep us informed of changes. - """ - self._query_receiver("power") - self._query_receiver("volume") - self._query_receiver("preset") - if self._zone == "main": - self._query_receiver("hdmi-output-selector") - self._query_receiver("audio-muting") - self._query_receiver("input-selector") - self._query_receiver("listening-mode") - self._query_receiver("audio-information") - self._query_receiver("video-information") - else: - self._query_receiver("muting") - self._query_receiver("selector") - - @callback - def process_update(self, update: tuple[str, str, Any]) -> None: - """Store relevant updates so they can be queried later.""" - zone, command, value = update - if zone != self._zone: + if self.source is None: return - if command in ["system-power", "power"]: - if value == "on": + source = self._rev_source_mapping.get(self.source) + if media_type.lower() != "radio" or source not in PLAYABLE_SOURCES: + return + + message = command.TunerPreset(self._zone, int(media_id)) + await self._manager.write(message) + + def process_update(self, message: status.Known) -> None: + """Process update.""" + match message: + case status.Power(status.Power.Param.ON): self._attr_state = MediaPlayerState.ON - else: + case status.Power(status.Power.Param.STANDBY): self._attr_state = MediaPlayerState.OFF - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - self._attr_extra_state_attributes.pop(ATTR_PRESET, None) - self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) - elif command in ["volume", "master-volume"] and value != "N/A": - if not self._supports_volume: - self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME - self._supports_volume = True - # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) - volume_level: float = value / ( - self._volume_resolution * self._max_volume / 100 - ) - self._attr_volume_level = min(1, volume_level) - elif command in ["muting", "audio-muting"]: - self._attr_is_volume_muted = bool(value == "on") - elif command in ["selector", "input-selector"] and value != "N/A": - self._parse_source(value) - self._query_av_info_delayed() - elif command == "hdmi-output-selector": - self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(value) - elif command == "preset": - if self.source is not None and self.source.lower() == "radio": - self._attr_extra_state_attributes[ATTR_PRESET] = value - elif ATTR_PRESET in self._attr_extra_state_attributes: - del self._attr_extra_state_attributes[ATTR_PRESET] - elif command == "listening-mode" and value != "N/A": - if not self._supports_sound_mode: - self._attr_supported_features |= ( - MediaPlayerEntityFeature.SELECT_SOUND_MODE + + case status.Volume(volume): + if not self._supports_volume: + self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME + self._supports_volume = True + # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100)) + volume_level: float = volume / ( + self._volume_resolution * self._max_volume / 100 ) - self._supports_sound_mode = True - self._parse_sound_mode(value) - self._query_av_info_delayed() - elif command == "audio-information": - self._supports_audio_info = True - self._parse_audio_information(value) - elif command == "video-information": - self._supports_video_info = True - self._parse_video_information(value) - elif command == "fl-display-information": - self._query_av_info_delayed() + self._attr_volume_level = min(1, volume_level) + + case status.Muting(muting): + self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON) + + case status.InputSource(source): + if source in self._source_mapping: + self._attr_source = self._source_mapping[source] + else: + source_meaning = get_meaning(source) + _LOGGER.warning( + 'Input source "%s" for entity: %s is not in the list. Check integration options', + source_meaning, + self.entity_id, + ) + self._attr_source = source_meaning + + self._query_av_info_delayed() + + case status.ListeningMode(sound_mode): + if not self._supports_sound_mode: + self._attr_supported_features |= ( + MediaPlayerEntityFeature.SELECT_SOUND_MODE + ) + self._supports_sound_mode = True + + if sound_mode in self._sound_mode_mapping: + self._attr_sound_mode = self._sound_mode_mapping[sound_mode] + else: + sound_mode_meaning = get_meaning(sound_mode) + _LOGGER.warning( + 'Listening mode "%s" for entity: %s is not in the list. Check integration options', + sound_mode_meaning, + self.entity_id, + ) + self._attr_sound_mode = sound_mode_meaning + + self._query_av_info_delayed() + + case status.HDMIOutput(hdmi_output): + self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ( + self._hdmi_output_mapping[hdmi_output] + ) + self._query_av_info_delayed() + + case status.TunerPreset(preset): + self._attr_extra_state_attributes[ATTR_PRESET] = preset + + case status.AudioInformation(): + self._supports_audio_info = True + audio_information = {} + for item in AUDIO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + audio_information[item] = item_value + self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = ( + audio_information + ) + + case status.VideoInformation(): + self._supports_video_info = True + video_information = {} + for item in VIDEO_INFORMATION_MAPPING: + item_value = getattr(message, item) + if item_value is not None: + video_information[item] = item_value + self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = ( + video_information + ) + + case status.FLDisplay(): + self._query_av_info_delayed() + + case status.NotAvailable(Kind.AUDIO_INFORMATION): + # Not available right now, but still supported + self._supports_audio_info = True + + case status.NotAvailable(Kind.VIDEO_INFORMATION): + # Not available right now, but still supported + self._supports_video_info = True self.async_write_ha_state() - @callback - def _parse_source(self, source_lib: LibValue) -> None: - source = self._rev_source_lib_mapping[source_lib] - if source in self._source_mapping: - self._attr_source = self._source_mapping[source] - return - - source_meaning = source.value_meaning - - if source not in self._options_sources: - _LOGGER.warning( - 'Input source "%s" for entity: %s is not in the list. Check integration options', - source_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Input source "%s" is invalid for entity: %s', - source_meaning, - self.entity_id, - ) - - self._attr_source = source_meaning - - @callback - def _parse_sound_mode(self, mode_lib: LibValue) -> None: - sound_mode = self._rev_sound_mode_lib_mapping[mode_lib] - if sound_mode in self._sound_mode_mapping: - self._attr_sound_mode = self._sound_mode_mapping[sound_mode] - return - - sound_mode_meaning = sound_mode.value_meaning - - if sound_mode not in self._options_sound_modes: - _LOGGER.warning( - 'Listening mode "%s" for entity: %s is not in the list. Check integration options', - sound_mode_meaning, - self.entity_id, - ) - else: - _LOGGER.error( - 'Listening mode "%s" is invalid for entity: %s', - sound_mode_meaning, - self.entity_id, - ) - - self._attr_sound_mode = sound_mode_meaning - - @callback - def _parse_audio_information( - self, audio_information: tuple[str] | Literal["N/A"] - ) -> None: - # If audio information is not available, N/A is returned, - # so only update the audio information, when it is not N/A. - if audio_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) - return - - self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = { - name: value - for name, value in zip( - AUDIO_INFORMATION_MAPPING, audio_information, strict=False - ) - if len(value) > 0 - } - - @callback - def _parse_video_information( - self, video_information: tuple[str] | Literal["N/A"] - ) -> None: - # If video information is not available, N/A is returned, - # so only update the video information, when it is not N/A. - if video_information == "N/A": - self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) - return - - self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = { - name: value - for name, value in zip( - VIDEO_INFORMATION_MAPPING, video_information, strict=False - ) - if len(value) > 0 - } - def _query_av_info_delayed(self) -> None: - if self._zone == "main" and not self._query_timer: + if self._zone == Zone.MAIN and not self._query_task: - @callback - def _query_av_info() -> None: + async def _query_av_info() -> None: + await asyncio.sleep(AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME) if self._supports_audio_info: - self._query_receiver("audio-information") + await self._manager.write(query.AudioInformation()) if self._supports_video_info: - self._query_receiver("video-information") - self._query_timer = None + await self._manager.write(query.VideoInformation()) + self._query_task = None - self._query_timer = self.hass.loop.call_later( - AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME, _query_av_info - ) + self._query_task = asyncio.create_task(_query_av_info()) diff --git a/homeassistant/components/onkyo/quality_scale.yaml b/homeassistant/components/onkyo/quality_scale.yaml index 4b9fbe7c019..caf0d33fafc 100644 --- a/homeassistant/components/onkyo/quality_scale.yaml +++ b/homeassistant/components/onkyo/quality_scale.yaml @@ -77,7 +77,4 @@ rules: status: exempt comment: | This integration is not making any HTTP requests. - strict-typing: - status: todo - comment: | - The library is not fully typed yet. + strict-typing: done diff --git a/homeassistant/components/onkyo/receiver.py b/homeassistant/components/onkyo/receiver.py index cc6cbbc95fb..e4fe8bc6630 100644 --- a/homeassistant/components/onkyo/receiver.py +++ b/homeassistant/components/onkyo/receiver.py @@ -3,149 +3,149 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Iterable +from collections.abc import Awaitable, Callable, Iterable import contextlib from dataclasses import dataclass, field import logging -from typing import Any +from typing import TYPE_CHECKING -import pyeiscp +import aioonkyo +from aioonkyo import Instruction, Receiver, ReceiverInfo, Status, connect, query + +from homeassistant.components import network +from homeassistant.core import HomeAssistant from .const import DEVICE_DISCOVERY_TIMEOUT, DEVICE_INTERVIEW_TIMEOUT, ZONES +if TYPE_CHECKING: + from . import OnkyoConfigEntry + _LOGGER = logging.getLogger(__name__) @dataclass class Callbacks: - """Onkyo Receiver Callbacks.""" + """Receiver callbacks.""" - connect: list[Callable[[Receiver], None]] = field(default_factory=list) - update: list[Callable[[Receiver, tuple[str, str, Any]], None]] = field( - default_factory=list - ) + connect: list[Callable[[bool], Awaitable[None]]] = field(default_factory=list) + update: list[Callable[[Status], Awaitable[None]]] = field(default_factory=list) + + def clear(self) -> None: + """Clear all callbacks.""" + self.connect.clear() + self.update.clear() -@dataclass -class Receiver: - """Onkyo receiver.""" +class ReceiverManager: + """Receiver manager.""" - conn: pyeiscp.Connection - model_name: str - identifier: str - host: str - first_connect: bool = True - callbacks: Callbacks = field(default_factory=Callbacks) + hass: HomeAssistant + entry: OnkyoConfigEntry + info: ReceiverInfo + receiver: Receiver | None = None + callbacks: Callbacks - @classmethod - async def async_create(cls, info: ReceiverInfo) -> Receiver: - """Set up Onkyo Receiver.""" + _started: asyncio.Event - receiver: Receiver | None = None + def __init__( + self, hass: HomeAssistant, entry: OnkyoConfigEntry, info: ReceiverInfo + ) -> None: + """Init receiver manager.""" + self.hass = hass + self.entry = entry + self.info = info + self.callbacks = Callbacks() + self._started = asyncio.Event() - def on_connect(_origin: str) -> None: - assert receiver is not None - receiver.on_connect() + async def start(self) -> Awaitable[None] | None: + """Start the receiver manager run. - def on_update(message: tuple[str, str, Any], _origin: str) -> None: - assert receiver is not None - receiver.on_update(message) - - _LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) - - connection = await pyeiscp.Connection.create( - host=info.host, - port=info.port, - connect_callback=on_connect, - update_callback=on_update, - auto_connect=False, + Returns `None`, if everything went fine. + Returns an awaitable with exception set, if something went wrong. + """ + manager_task = self.entry.async_create_background_task( + self.hass, self._run(), "run_connection" ) - - return ( - receiver := cls( - conn=connection, - model_name=info.model_name, - identifier=info.identifier, - host=info.host, - ) + wait_for_started_task = asyncio.create_task(self._started.wait()) + done, _ = await asyncio.wait( + (manager_task, wait_for_started_task), return_when=asyncio.FIRST_COMPLETED ) + if manager_task in done: + # Something went wrong, so let's return the manager task, + # so that it can be awaited to error out + return manager_task - def on_connect(self) -> None: + return None + + async def _run(self) -> None: + """Run the connection to the receiver.""" + reconnect = False + while True: + try: + async with connect(self.info, retry=reconnect) as self.receiver: + if not reconnect: + self._started.set() + else: + _LOGGER.info("Reconnected: %s", self.info) + + await self.on_connect(reconnect=reconnect) + + while message := await self.receiver.read(): + await self.on_update(message) + + reconnect = True + + finally: + _LOGGER.info("Disconnected: %s", self.info) + + async def on_connect(self, reconnect: bool) -> None: """Receiver (re)connected.""" - _LOGGER.debug("Receiver (re)connected: %s (%s)", self.model_name, self.host) # Discover what zones are available for the receiver by querying the power. # If we get a response for the specific zone, it means it is available. for zone in ZONES: - self.conn.query_property(zone, "power") + await self.write(query.Power(zone)) for callback in self.callbacks.connect: - callback(self) + await callback(reconnect) - self.first_connect = False - - def on_update(self, message: tuple[str, str, Any]) -> None: + async def on_update(self, message: Status) -> None: """Process new message from the receiver.""" - _LOGGER.debug("Received update callback from %s: %s", self.model_name, message) for callback in self.callbacks.update: - callback(self, message) + await callback(message) + async def write(self, message: Instruction) -> None: + """Write message to the receiver.""" + assert self.receiver is not None + await self.receiver.write(message) -@dataclass -class ReceiverInfo: - """Onkyo receiver information.""" - - host: str - port: int - model_name: str - identifier: str + def start_unloading(self) -> None: + """Start unloading.""" + self.callbacks.clear() async def async_interview(host: str) -> ReceiverInfo | None: - """Interview Onkyo Receiver.""" - _LOGGER.debug("Interviewing receiver: %s", host) - - receiver_info: ReceiverInfo | None = None - - event = asyncio.Event() - - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver interviewed, connection not yet active.""" - nonlocal receiver_info - if receiver_info is None: - info = ReceiverInfo(host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver interviewed: %s (%s)", info.model_name, info.host) - receiver_info = info - event.set() - - timeout = DEVICE_INTERVIEW_TIMEOUT - - await pyeiscp.Connection.discover( - host=host, discovery_callback=_callback, timeout=timeout - ) - + """Interview the receiver.""" + info: ReceiverInfo | None = None with contextlib.suppress(asyncio.TimeoutError): - await asyncio.wait_for(event.wait(), timeout) - - return receiver_info + async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT): + info = await aioonkyo.interview(host) + return info -async def async_discover() -> Iterable[ReceiverInfo]: - """Discover Onkyo Receivers.""" - _LOGGER.debug("Discovering receivers") +async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]: + """Discover receivers.""" + all_infos: dict[str, ReceiverInfo] = {} - receiver_infos: list[ReceiverInfo] = [] + async def collect_infos(address: str) -> None: + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(DEVICE_DISCOVERY_TIMEOUT): + async for info in aioonkyo.discover(address): + all_infos.setdefault(info.identifier, info) - async def _callback(conn: pyeiscp.Connection) -> None: - """Receiver discovered, connection not yet active.""" - info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier) - _LOGGER.debug("Receiver discovered: %s (%s)", info.model_name, info.host) - receiver_infos.append(info) + broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass) + tasks = [collect_infos(str(address)) for address in broadcast_addrs] - timeout = DEVICE_DISCOVERY_TIMEOUT + await asyncio.gather(*tasks) - await pyeiscp.Connection.discover(discovery_callback=_callback, timeout=timeout) - - await asyncio.sleep(timeout) - - return receiver_infos + return all_infos.values() diff --git a/homeassistant/components/onkyo/services.py b/homeassistant/components/onkyo/services.py index 26a22523a0e..cfd246d9af7 100644 --- a/homeassistant/components/onkyo/services.py +++ b/homeassistant/components/onkyo/services.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import TYPE_CHECKING +from aioonkyo import Zone import voluptuous as vol from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN @@ -12,29 +13,18 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from homeassistant.util.hass_dict import HassKey -from .const import DOMAIN +from .const import DOMAIN, LEGACY_REV_HDMI_OUTPUT_MAPPING if TYPE_CHECKING: from .media_player import OnkyoMediaPlayer -DATA_MP_ENTITIES: HassKey[dict[str, dict[str, OnkyoMediaPlayer]]] = HassKey(DOMAIN) +DATA_MP_ENTITIES: HassKey[dict[str, dict[Zone, OnkyoMediaPlayer]]] = HassKey(DOMAIN) ATTR_HDMI_OUTPUT = "hdmi_output" -ACCEPTED_VALUES = [ - "no", - "analog", - "yes", - "out", - "out-sub", - "sub", - "hdbaset", - "both", - "up", -] ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_ids, - vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES), + vol.Required(ATTR_HDMI_OUTPUT): vol.In(LEGACY_REV_HDMI_OUTPUT_MAPPING), } ) SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" diff --git a/homeassistant/components/onkyo/util.py b/homeassistant/components/onkyo/util.py new file mode 100644 index 00000000000..bd2cc8a4c7b --- /dev/null +++ b/homeassistant/components/onkyo/util.py @@ -0,0 +1,8 @@ +"""Utils for Onkyo.""" + +from .const import InputSource, ListeningMode + + +def get_meaning(param: InputSource | ListeningMode) -> str: + """Get param meaning.""" + return " ··· ".join(param.meanings) diff --git a/requirements_all.txt b/requirements_all.txt index 60e64a1ad27..513422df915 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -330,6 +330,9 @@ aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.2.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -1956,9 +1959,6 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms pyemoncms==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20c826b73e9..4fac0aba573 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -312,6 +312,9 @@ aiontfy==0.5.3 # homeassistant.components.nut aionut==4.3.4 +# homeassistant.components.onkyo +aioonkyo==0.2.0 + # homeassistant.components.openexchangerates aioopenexchangerates==0.6.8 @@ -1631,9 +1634,6 @@ pyefergy==22.5.0 # homeassistant.components.energenie_power_sockets pyegps==0.2.5 -# homeassistant.components.onkyo -pyeiscp==0.0.7 - # homeassistant.components.emoncms pyemoncms==0.1.1 diff --git a/tests/components/onkyo/__init__.py b/tests/components/onkyo/__init__.py index 689711888d8..f8580c2b257 100644 --- a/tests/components/onkyo/__init__.py +++ b/tests/components/onkyo/__init__.py @@ -1,90 +1,71 @@ """Tests for the Onkyo integration.""" -from unittest.mock import AsyncMock, Mock, patch +from collections.abc import Generator, Iterable +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +from aioonkyo import ReceiverInfo -from homeassistant.components.onkyo.receiver import Receiver, ReceiverInfo -from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +RECEIVER_INFO = ReceiverInfo( + host="192.168.0.101", + ip="192.168.0.101", + model_name="TX-NR7100", + identifier="0009B0123456", +) -def create_receiver_info(id: int) -> ReceiverInfo: - """Create an empty receiver info object for testing.""" - return ReceiverInfo( - host=f"host {id}", - port=id, - model_name=f"type {id}", - identifier=f"id{id}", - ) +RECEIVER_INFO_2 = ReceiverInfo( + host="192.168.0.102", + ip="192.168.0.102", + model_name="TX-RZ50", + identifier="0009B0ABCDEF", +) -def create_connection(id: int) -> Mock: - """Create an mock connection object for testing.""" - connection = Mock() - connection.host = f"host {id}" - connection.port = 0 - connection.name = f"type {id}" - connection.identifier = f"id{id}" - return connection +@contextmanager +def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]: + """Mock discovery functions.""" + async def get_info(host: str) -> ReceiverInfo | None: + """Get receiver info by host.""" + for info in receiver_infos: + if info.host == host: + return info + return None -def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: - """Create a config entry from receiver info.""" - data = {CONF_HOST: info.host} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } + def get_infos(host: str) -> MagicMock: + """Get receiver infos from broadcast.""" + discover_mock = MagicMock() + discover_mock.__aiter__.return_value = receiver_infos + return discover_mock - return MockConfigEntry( - data=data, - options=options, - title=info.model_name, - domain="onkyo", - unique_id=info.identifier, - ) - - -def create_empty_config_entry() -> MockConfigEntry: - """Create an empty config entry for use in unit tests.""" - data = {CONF_HOST: ""} - options = { - "volume_resolution": 80, - "max_volume": 100, - "input_sources": {"12": "tv"}, - "listening_modes": {"00": "stereo"}, - } - - return MockConfigEntry( - data=data, - options=options, - title="Unit test Onkyo", - domain="onkyo", - unique_id="onkyo_unique_id", - ) - - -async def setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry, receiver_info: ReceiverInfo -) -> None: - """Fixture for setting up the component.""" - - config_entry.add_to_hass(hass) - - mock_receiver = AsyncMock() - mock_receiver.conn.close = Mock() - mock_receiver.callbacks.connect = Mock() - mock_receiver.callbacks.update = Mock() + discover_kwargs = {} + interview_kwargs = {} + if receiver_infos is None: + discover_kwargs["side_effect"] = OSError + interview_kwargs["side_effect"] = OSError + else: + discover_kwargs["new"] = get_infos + interview_kwargs["new"] = get_info with ( patch( - "homeassistant.components.onkyo.async_interview", - return_value=receiver_info, + "homeassistant.components.onkyo.receiver.aioonkyo.discover", + **discover_kwargs, + ), + patch( + "homeassistant.components.onkyo.receiver.aioonkyo.interview", + **interview_kwargs, ), - patch.object(Receiver, "async_create", return_value=mock_receiver), ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + yield + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set 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/onkyo/conftest.py b/tests/components/onkyo/conftest.py index abbe39dd966..c6459a2b1f2 100644 --- a/tests/components/onkyo/conftest.py +++ b/tests/components/onkyo/conftest.py @@ -1,74 +1,181 @@ -"""Configure tests for the Onkyo integration.""" +"""Common fixtures for the Onkyo tests.""" -from unittest.mock import patch +import asyncio +from collections.abc import Generator +from unittest.mock import AsyncMock, patch +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 create_connection +from . import RECEIVER_INFO, mock_discovery from tests.common import MockConfigEntry -@pytest.fixture(name="config_entry") +@pytest.fixture(autouse=True) +def mock_default_discovery() -> Generator[None]: + """Mock the discovery functions with default info.""" + with ( + patch.multiple( + "homeassistant.components.onkyo.receiver", + DEVICE_INTERVIEW_TIMEOUT=1, + DEVICE_DISCOVERY_TIMEOUT=1, + ), + mock_discovery([RECEIVER_INFO]), + ): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Mock integration setup.""" + with patch( + "homeassistant.components.onkyo.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_connect() -> Generator[AsyncMock]: + """Mock an Onkyo connect.""" + with patch( + "homeassistant.components.onkyo.receiver.connect", + ) as connect: + yield connect.return_value.__aenter__ + + +INITIAL_MESSAGES = [ + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON + ), + status.Power( + Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY + ), + status.Volume(Code.from_kind_zone(Kind.VOLUME, Zone.ZONE2), None, 50), + status.Muting( + Code.from_kind_zone(Kind.MUTING, Zone.MAIN), None, status.Muting.Param.OFF + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.MAIN), + None, + status.InputSource.Param("24"), + ), + status.InputSource( + Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.ZONE2), + None, + status.InputSource.Param("00"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.MAIN), + None, + status.ListeningMode.Param("01"), + ), + status.ListeningMode( + Code.from_kind_zone(Kind.LISTENING_MODE, Zone.ZONE2), + None, + status.ListeningMode.Param("00"), + ), + status.HDMIOutput( + Code.from_kind_zone(Kind.HDMI_OUTPUT, Zone.MAIN), + None, + status.HDMIOutput.Param.MAIN, + ), + status.TunerPreset(Code.from_kind_zone(Kind.TUNER_PRESET, Zone.MAIN), None, 1), + status.AudioInformation( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + auto_phase_control_phase="Normal", + ), + status.VideoInformation( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + input_color_depth="24bit", + ), + status.FLDisplay(Code.from_kind_zone(Kind.FL_DISPLAY, Zone.MAIN), None, "LALALA"), + status.NotAvailable( + Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN), + None, + Kind.AUDIO_INFORMATION, + ), + status.NotAvailable( + Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN), + None, + Kind.VIDEO_INFORMATION, + ), + status.Raw(None, None), +] + + +@pytest.fixture +def read_queue() -> asyncio.Queue[Status | None]: + """Read messages queue.""" + return asyncio.Queue() + + +@pytest.fixture +def writes() -> list[Instruction]: + """Written messages.""" + return [] + + +@pytest.fixture +def mock_receiver( + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], + writes: list[Instruction], +) -> AsyncMock: + """Mock an Onkyo receiver.""" + receiver_class = AsyncMock(Receiver, auto_spec=True) + receiver = receiver_class.return_value + + for message in INITIAL_MESSAGES: + read_queue.put_nowait(message) + + async def read() -> Status: + return await read_queue.get() + + async def write(message: Instruction) -> None: + writes.append(message) + + receiver.read = read + receiver.write = write + + mock_connect.return_value = receiver + + return receiver + + +@pytest.fixture def mock_config_entry() -> MockConfigEntry: - """Create Onkyo entry in Home Assistant.""" + """Mock a config entry.""" + data = {CONF_HOST: RECEIVER_INFO.host} + options = { + "volume_resolution": 80, + "max_volume": 100, + "input_sources": {"12": "TV", "24": "FM Radio"}, + "listening_modes": {"00": "Stereo", "04": "THX"}, + } + return MockConfigEntry( domain=DOMAIN, - title="Onkyo", - data={}, + title=RECEIVER_INFO.model_name, + unique_id=RECEIVER_INFO.identifier, + data=data, + options=options, ) - - -@pytest.fixture(autouse=True) -def patch_timeouts(): - """Patch timeouts to avoid tests waiting.""" - with patch.multiple( - "homeassistant.components.onkyo.receiver", - DEVICE_INTERVIEW_TIMEOUT=0, - DEVICE_DISCOVERY_TIMEOUT=0, - ): - yield - - -@pytest.fixture -async def default_mock_discovery(): - """Mock discovery with a single device.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(create_connection(1)) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield - - -@pytest.fixture -async def stub_mock_discovery(): - """Mock discovery with no devices.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - pass - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield - - -@pytest.fixture -async def empty_mock_discovery(): - """Mock discovery with an empty connection.""" - - async def mock_discover(host=None, discovery_callback=None, timeout=0): - await discovery_callback(None) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): - yield diff --git a/tests/components/onkyo/snapshots/test_media_player.ambr b/tests/components/onkyo/snapshots/test_media_player.ambr new file mode 100644 index 00000000000..1504952a86d --- /dev/null +++ b/tests/components/onkyo/snapshots/test_media_player.ambr @@ -0,0 +1,203 @@ +# serializer version: 1 +# name: test_entities[media_player.tx_nr7100-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'audio_information': dict({ + 'auto_phase_control_phase': 'Normal', + }), + 'friendly_name': 'TX-NR7100', + 'is_volume_muted': False, + 'preset': 1, + 'sound_mode': 'DIRECT', + 'sound_mode_list': list([ + 'Stereo', + 'THX', + ]), + 'source': 'FM Radio', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'video_information': dict({ + 'input_color_depth': '24bit', + }), + 'video_out': 'yes,out', + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100 Zone 2', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 2', + 'sound_mode': 'Stereo', + 'sound_mode_list': list([ + 'Stereo', + ]), + 'source': 'VIDEO1 ··· VCR/DVR ··· STB/DVR', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + 'volume_level': 0.625, + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'TX-NR7100 Zone 3', + 'platform': 'onkyo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '0009B0123456_zone3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[media_player.tx_nr7100_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'TX-NR7100 Zone 3', + 'source_list': list([ + 'TV', + 'FM Radio', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.tx_nr7100_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 92a4a34e8fb..df10e266982 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -1,11 +1,9 @@ """Test Onkyo config flow.""" -from unittest.mock import patch - +from aioonkyo import ReceiverInfo import pytest from homeassistant import config_entries -from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( DOMAIN, OPTION_INPUT_SOURCES, @@ -23,17 +21,15 @@ from homeassistant.helpers.service_info.ssdp import ( SsdpServiceInfo, ) -from . import ( - create_config_entry_from_info, - create_connection, - create_empty_config_entry, - create_receiver_info, - setup_integration, -) +from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration from tests.common import MockConfigEntry +def _entry_title(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( @@ -46,7 +42,7 @@ async def test_user_initial_menu(hass: HomeAssistant) -> None: assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} -async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: +async def test_manual_valid_host(hass: HomeAssistant) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -60,14 +56,16 @@ async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> select_result = await hass.config_entries.flow.async_configure( form_result["flow_id"], - user_input={CONF_HOST: "host 1"}, + user_input={CONF_HOST: RECEIVER_INFO.host}, ) assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" + assert select_result["description_placeholders"]["name"] == _entry_title( + RECEIVER_INFO + ) -async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: +async def test_manual_invalid_host(hass: HomeAssistant) -> None: """Test invalid host entered.""" init_result = await hass.config_entries.flow.async_init( DOMAIN, @@ -79,18 +77,17 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> {"next_step_id": "manual"}, ) - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + 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, empty_mock_discovery -) -> None: +async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None: """Test valid host entered.""" init_result = await hass.config_entries.flow.async_init( @@ -103,112 +100,102 @@ async def test_manual_valid_host_unexpected_error( {"next_step_id": "manual"}, ) - host_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, - ) + 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_discovery_and_no_devices_discovered( - hass: HomeAssistant, stub_mock_discovery -) -> None: - """Test initial menu.""" - init_result = await hass.config_entries.flow.async_init( +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}, ) - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "no_devices_found" - - -async def test_discovery_with_exception( - hass: HomeAssistant, empty_mock_discovery -) -> None: - """Test discovery which throws an unexpected exception.""" - 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": "eiscp_discovery"}, - ) - - assert form_result["type"] is FlowResultType.ABORT - assert form_result["reason"] == "unknown" - - -async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None: - """Test discovery with a new and an existing entry.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - ) - - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(1)) - await discovery_callback(create_connection(2)) - - with ( - patch("pyeiscp.Connection.discover", new=mock_discover), - # Fake it like the first entry was already added - patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]), - ): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], + with mock_discovery([]): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"next_step_id": "eiscp_discovery"}, ) - assert form_result["type"] is FlowResultType.FORM + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" - assert form_result["data_schema"] is not None - schema = form_result["data_schema"].schema + +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) + + result = await hass.config_entries.flow.async_init( + 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 == {"id2": "type 2 (host 2)"} + assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)} - -async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: - """Test discovery after a selection.""" - init_result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"device": RECEIVER_INFO_2.identifier}, ) - async def mock_discover(discovery_callback, timeout): - await discovery_callback(create_connection(42)) - await discovery_callback(create_connection(0)) + assert result["step_id"] == "configure_receiver" - with patch("pyeiscp.Connection.discover", new=mock_discover): - form_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - {"next_step_id": "eiscp_discovery"}, - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "volume_resolution": 200, + "input_sources": ["TV"], + "listening_modes": ["THX"], + }, + ) - select_result = await hass.config_entries.flow.async_configure( - form_result["flow_id"], - user_input={"device": "id42"}, - ) - - assert select_result["step_id"] == "configure_receiver" - assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["host"] == RECEIVER_INFO_2.host + assert result["result"].unique_id == RECEIVER_INFO_2.identifier -async def test_ssdp_discovery_success( - hass: HomeAssistant, default_mock_discovery -) -> None: +@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.1.100:8080", + 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", @@ -224,7 +211,7 @@ async def test_ssdp_discovery_success( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "configure_receiver" - select_result = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ "volume_resolution": 200, @@ -233,24 +220,19 @@ async def test_ssdp_discovery_success( }, ) - assert select_result["type"] is FlowResultType.CREATE_ENTRY - assert select_result["data"]["host"] == "192.168.1.100" - assert select_result["result"].unique_id == "id1" + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"]["host"] == RECEIVER_INFO.host + assert result["result"].unique_id == RECEIVER_INFO.identifier async def test_ssdp_discovery_already_configured( - hass: HomeAssistant, default_mock_discovery + hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> None: """Test SSDP discovery with already configured device.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "192.168.1.100"}, - unique_id="id1", - ) - config_entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) discovery_info = SsdpServiceInfo( - ssdp_location="http://192.168.1.100:8080", + 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", @@ -276,10 +258,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: ssdp_st="mock_st", ) - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - side_effect=OSError, - ): + with mock_discovery(None): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, @@ -290,9 +269,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: assert result["reason"] == "unknown" -async def test_ssdp_discovery_host_none_info( - hass: HomeAssistant, stub_mock_discovery -) -> None: +async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None: """Test SSDP discovery with host info error.""" discovery_info = SsdpServiceInfo( ssdp_location="http://192.168.1.100:8080", @@ -301,19 +278,18 @@ async def test_ssdp_discovery_host_none_info( 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"] == "cannot_connect" -async def test_ssdp_discovery_no_location( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None: """Test SSDP discovery with no location.""" discovery_info = SsdpServiceInfo( ssdp_location=None, @@ -332,9 +308,7 @@ async def test_ssdp_discovery_no_location( assert result["reason"] == "unknown" -async def test_ssdp_discovery_no_host( - hass: HomeAssistant, default_mock_discovery -) -> None: +async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None: """Test SSDP discovery with no host.""" discovery_info = SsdpServiceInfo( ssdp_location="http://", @@ -353,9 +327,7 @@ async def test_ssdp_discovery_no_host( assert result["reason"] == "unknown" -async def test_configure_no_resolution( - hass: HomeAssistant, default_mock_discovery -) -> None: +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( @@ -380,9 +352,9 @@ async def test_configure_no_resolution( ) -async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: +@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}, @@ -395,7 +367,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - user_input={CONF_HOST: "sample-host-name"}, + user_input={CONF_HOST: RECEIVER_INFO.host}, ) result = await hass.config_entries.flow.async_configure( @@ -437,9 +409,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: } -async def test_configure_invalid_resolution_set( - hass: HomeAssistant, default_mock_discovery -) -> 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( @@ -464,22 +434,23 @@ async def test_configure_invalid_resolution_set( ) -async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the reconfigure config flow.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) + await setup_integration(hass, mock_config_entry) - old_host = config_entry.data[CONF_HOST] - old_options = config_entry.options + old_host = mock_config_entry.data[CONF_HOST] + old_options = mock_config_entry.options - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) 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": receiver_info.host} + result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]} ) await hass.async_block_till_done() @@ -494,36 +465,28 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reconfigure_successful" - assert config_entry.data[CONF_HOST] == old_host - assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 + assert mock_config_entry.data[CONF_HOST] == old_host + assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 for option, option_value in old_options.items(): if option == OPTION_VOLUME_RESOLUTION: continue - assert config_entry.options[option] == option_value + assert mock_config_entry.options[option] == option_value -async def test_reconfigure_new_device(hass: HomeAssistant) -> None: +@pytest.mark.usefixtures("mock_setup_entry") +async def test_reconfigure_new_device( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test the reconfigure config flow with new device.""" - receiver_info = create_receiver_info(1) - config_entry = create_config_entry_from_info(receiver_info) - await setup_integration(hass, config_entry, receiver_info) + await setup_integration(hass, mock_config_entry) - old_unique_id = receiver_info.identifier + old_unique_id = mock_config_entry.unique_id - result = await config_entry.start_reconfigure_flow(hass) + result = await mock_config_entry.start_reconfigure_flow(hass) - mock_connection = create_connection(2) - - # Create mock discover that calls callback immediately - async def mock_discover(host, discovery_callback, timeout): - await discovery_callback(mock_connection) - - with patch( - "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", - new=mock_discover, - ): + with mock_discovery([RECEIVER_INFO_2]): result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={"host": mock_connection.host} + result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host} ) await hass.async_block_till_done() @@ -531,9 +494,10 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: assert result2["reason"] == "unique_id_mismatch" # unique id should remain unchanged - assert config_entry.unique_id == old_unique_id + assert mock_config_entry.unique_id == old_unique_id +@pytest.mark.usefixtures("mock_setup_entry") @pytest.mark.parametrize( "ignore_missing_translations", [ @@ -545,16 +509,15 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None: ] ], ) -async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: +async def test_options_flow( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: """Test options flow.""" + await setup_integration(hass, mock_config_entry) - receiver_info = create_receiver_info(1) - config_entry = create_empty_config_entry() - await setup_integration(hass, config_entry, receiver_info) + old_volume_resolution = mock_config_entry.options[OPTION_VOLUME_RESOLUTION] - old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] - - result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_init(mock_config_entry.entry_id) result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/onkyo/test_init.py b/tests/components/onkyo/test_init.py index 4c6ddcca214..144947dcbe1 100644 --- a/tests/components/onkyo/test_init.py +++ b/tests/components/onkyo/test_init.py @@ -2,51 +2,85 @@ from __future__ import annotations -from unittest.mock import patch +import asyncio +from unittest.mock import AsyncMock +from aioonkyo import Status import pytest -from homeassistant.components.onkyo import async_setup_entry from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from . import create_empty_config_entry, create_receiver_info, setup_integration +from . import mock_discovery, setup_integration from tests.common import MockConfigEntry +@pytest.mark.usefixtures("mock_receiver") async def test_load_unload_entry( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test load and unload entry.""" + await setup_integration(hass, mock_config_entry) - config_entry = create_empty_config_entry() - receiver_info = create_receiver_info(1) - await setup_integration(hass, config_entry, receiver_info) + assert mock_config_entry.state is ConfigEntryState.LOADED - assert config_entry.state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(mock_config_entry.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED -async def test_no_connection( +@pytest.mark.parametrize( + "receiver_infos", + [ + None, + [], + ], +) +async def test_initialization_failure( hass: HomeAssistant, - config_entry: MockConfigEntry, + mock_config_entry: MockConfigEntry, + receiver_infos, ) -> None: - """Test update options.""" + """Test initialization failure.""" + with mock_discovery(receiver_infos): + await setup_integration(hass, mock_config_entry) - config_entry = create_empty_config_entry() - config_entry.add_to_hass(hass) + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - with ( - patch( - "homeassistant.components.onkyo.async_interview", - return_value=None, - ), - pytest.raises(ConfigEntryNotReady), - ): - await async_setup_entry(hass, config_entry) + +async def test_connection_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, +) -> None: + """Test connection failure.""" + mock_connect.side_effect = OSError + + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +@pytest.mark.usefixtures("mock_receiver") +async def test_reconnect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_connect: AsyncMock, + read_queue: asyncio.Queue[Status | None], +) -> None: + """Test reconnect.""" + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_connect.reset_mock() + + assert mock_connect.call_count == 0 + + read_queue.put_nowait(None) # Simulate a disconnect + await asyncio.sleep(0) + + assert mock_connect.call_count == 1 diff --git a/tests/components/onkyo/test_media_player.py b/tests/components/onkyo/test_media_player.py new file mode 100644 index 00000000000..3d22e3b1af8 --- /dev/null +++ b/tests/components/onkyo/test_media_player.py @@ -0,0 +1,230 @@ +"""Test Onkyo media player platform.""" + +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, patch + +from aioonkyo import Instruction, Zone, command +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + ATTR_SOUND_MODE, + DOMAIN as MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + SERVICE_SELECT_SOUND_MODE, + SERVICE_SELECT_SOURCE, +) +from homeassistant.components.onkyo.services import ( + ATTR_HDMI_OUTPUT, + SERVICE_SELECT_HDMI_OUTPUT, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + Platform, +) +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, snapshot_platform + +ENTITY_ID = "media_player.tx_nr7100" +ENTITY_ID_ZONE_2 = "media_player.tx_nr7100_zone_2" +ENTITY_ID_ZONE_3 = "media_player.tx_nr7100_zone_3" + + +@pytest.fixture(autouse=True) +async def auto_setup_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: AsyncMock, + writes: list[Instruction], +) -> AsyncGenerator[None]: + """Auto setup integration.""" + with ( + patch( + "homeassistant.components.onkyo.media_player.AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME", + 0, + ), + patch("homeassistant.components.onkyo.PLATFORMS", [Platform.MEDIA_PLAYER]), + ): + await setup_integration(hass, mock_config_entry) + writes.clear() + yield + + +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("action", "action_data", "message"), + [ + (SERVICE_TURN_ON, {}, command.Power(Zone.MAIN, command.Power.Param.ON)), + (SERVICE_TURN_OFF, {}, command.Power(Zone.MAIN, command.Power.Param.STANDBY)), + ( + SERVICE_VOLUME_SET, + {ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + command.Volume(Zone.MAIN, 40), + ), + (SERVICE_VOLUME_UP, {}, command.Volume(Zone.MAIN, command.Volume.Param.UP)), + (SERVICE_VOLUME_DOWN, {}, command.Volume(Zone.MAIN, command.Volume.Param.DOWN)), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: True}, + command.Muting(Zone.MAIN, command.Muting.Param.ON), + ), + ( + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: False}, + command.Muting(Zone.MAIN, command.Muting.Param.OFF), + ), + ], +) +async def test_actions( + hass: HomeAssistant, + writes: list[Instruction], + action: str, + action_data: dict, + message: Instruction, +) -> None: + """Test actions.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + action, + {ATTR_ENTITY_ID: ENTITY_ID, **action_data}, + blocking=True, + ) + assert writes[0] == message + + +async def test_select_source(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test select source.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "TV"}, + blocking=True, + ) + assert writes[0] == command.InputSource(Zone.MAIN, command.InputSource.Param("12")) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "InvalidSource"}, + blocking=True, + ) + assert not writes + + +async def test_select_sound_mode( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select sound mode.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "THX"}, + blocking=True, + ) + assert writes[0] == command.ListeningMode( + Zone.MAIN, command.ListeningMode.Param("04") + ) + + writes.clear() + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_SOUND_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "InvalidMode"}, + blocking=True, + ) + assert not writes + + +async def test_play_media(hass: HomeAssistant, writes: list[Instruction]) -> None: + """Test play media (radio preset).""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert writes[0] == command.TunerPreset(Zone.MAIN, 5) + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_MEDIA_CONTENT_TYPE: "music", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_2, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + writes.clear() + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: ENTITY_ID_ZONE_3, + ATTR_MEDIA_CONTENT_TYPE: "radio", + ATTR_MEDIA_CONTENT_ID: "5", + }, + blocking=True, + ) + assert not writes + + +async def test_select_hdmi_output( + hass: HomeAssistant, writes: list[Instruction] +) -> None: + """Test select hdmi output.""" + await hass.services.async_call( + MEDIA_PLAYER_DOMAIN, + SERVICE_SELECT_HDMI_OUTPUT, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HDMI_OUTPUT: "sub"}, + blocking=True, + ) + assert writes[0] == command.HDMIOutput(command.HDMIOutput.Param.BOTH) From 3c70932357bedb48128b9d59ce7e345fc9193d05 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 21 Jul 2025 16:52:25 +0200 Subject: [PATCH 1638/1664] Use OptionsFlowWithReload in enphase_envoy (#149171) --- homeassistant/components/enphase_envoy/__init__.py | 9 --------- homeassistant/components/enphase_envoy/config_flow.py | 4 ++-- tests/components/enphase_envoy/test_init.py | 3 ++- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index f43d89aa098..e95ab1179e1 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -4,7 +4,6 @@ from __future__ import annotations from pyenphase import Envoy -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady @@ -47,17 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - # Reload entry when it is updated. - entry.async_on_unload(entry.add_update_listener(async_reload_entry)) - return True -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> bool: """Unload a config entry.""" coordinator = entry.runtime_data diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 5b7bb98527c..9ba11eafa5d 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ( SOURCE_REAUTH, ConfigFlow, ConfigFlowResult, - OptionsFlow, + OptionsFlowWithReload, ) from homeassistant.const import ( CONF_HOST, @@ -335,7 +335,7 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN): ) -class EnvoyOptionsFlowHandler(OptionsFlow): +class EnvoyOptionsFlowHandler(OptionsFlowWithReload): """Envoy config flow options handler.""" async def async_step_init( diff --git a/tests/components/enphase_envoy/test_init.py b/tests/components/enphase_envoy/test_init.py index c43be96d8b1..2aa18c991a6 100644 --- a/tests/components/enphase_envoy/test_init.py +++ b/tests/components/enphase_envoy/test_init.py @@ -509,7 +509,7 @@ async def test_coordinator_interface_information( # verify first time add of mac to connections is in log assert "added connection" in caplog.text - # trigger integration reload by changing options + # update options and reload hass.config_entries.async_update_entry( config_entry, options={ @@ -517,6 +517,7 @@ async def test_coordinator_interface_information( OPTION_DISABLE_KEEP_ALIVE: True, }, ) + await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED From 3f42911af4291c2e5d85806e394f02a5f62bc733 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 21 Jul 2025 10:33:23 -0500 Subject: [PATCH 1639/1664] 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 1640/1664] 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 1641/1664] 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 1642/1664] 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 1643/1664] 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 1644/1664] 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 1645/1664] 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 1646/1664] 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 1647/1664] 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 1648/1664] 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 1649/1664] 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 1650/1664] 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 1651/1664] 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 1652/1664] 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 1653/1664] 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 1654/1664] 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 1655/1664] 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 1656/1664] 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 1657/1664] 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 1658/1664] 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 1659/1664] 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 1660/1664] 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 1661/1664] 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 1662/1664] 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 1663/1664] 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 1664/1664] 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

F+f(fTo@D{g_dnDXJ;Q*_-uO&fJkG6?!mdaEE}nkTV%n zZ{83_{{TpCkoV&PDI>Hva_q?%fNum&?ba$LPOUA{+(haMV0F$qha_ppajzJb${}Bq z-%Lj-);o)dH9dn>+wyPKSr?;qM&AjNHz*g)B|yo*5(zB1rqm9>-xY>45R6+Z zcu&ia6G4)CDiFPdLmh=%5++JSc8iFRfa*9gZX#n>TRrn}3kY&FZcWv4&XBbFo z2PGHhvstA>wKuqH8iCpm+mIFt)o64{*O-S`RDuehJYwgGgJ30R-O71!4#I@gY;lV5 zgo1hQuXqYeSA4zG*A+EFFWaVUgs7EJeh#o^>H((Jy31PE`J1>#(g26?gdmkuv*{0v zYjbZLXD_%orI6D90J_I-;mM7%Co$qUr2hbKriFOO776j3#!u%Ks#z=p&4vE3i6g@6 zdhe{XoeS;j@rGD>-c(T+hxhOLVppTJn7{ZarztOC-|r}FB~bvMkeTjeIkX;Gv)| zc_RA-fa8}OesMrS<`nxi$YYj@*bWAP!b`o8cB!w7s7q)L7yC?SAziA!MzHh(*$($G z0_gThRVYuqN05mMpI~VG=D|r65FZ=>fQ2U@dlwDM8%w1-9-Qh*v5D2G8Vk<1hgyUDV*#KnQm4_3EjKD;9rihCr{~PRk}kEr7aD{IJ>YY{ zAAC9I9boJ>)XNYR=>crP!7Bq{+FVO4Re6pp-S9#9m`4Vx-?QvuWh?kp>(d>TT<2d=olYnbkB zO$S8A1gH%(bOgY(G;J)LFKXo))R#EK+2L^Q=%0tz89FPk&LZWkX2~O8GT=lkJ1P4Q~A<~5jkB|HLu!jFj-|mssuneji})Qh{A4$7ox{I{ z>scV6m7(FXPBIVx4G50hQ16%F>B`PrEG*gm+el?#f zhfWu)R*r`I*BI=PNGHjo_mxC64Myvq#wx^??qX5e zRW?DtzsKtVbuN$%eg6R5w)(u9AFtjrA+S;OkHM2;w#h?W1(4lk7+D{4OoLCO;lBLG z(IC?_d7X^}=eyZu;}a67Y9YEm7?xFv8m~u0d&QK1gnD~d_{Yr$V7vwi->haVup~G( zogdSZqje(7IKMep01@){dmLnJ0Erq?aJu?0vTo%A+`1;DQ%pxS^dUb<571{T*%x28%v-V`30q=j#37}sG z>EzDa^=Rx|rz9j?G>)R)CNikE+UIU>c(BP26a1F|;|A7sbC(JI>%0c{-UJf0ob13J z^TgAQaN8huxk2idy&FHAn=%UhWALc%kOH{=@I(q!YsxFfS!$(HSdS_4;)oly>CZB_ z<-tIJXeIc{+9f%T_};D@(yqYU3aa5`H9@cj)ZWi=2r4%bQUx|Ycm!>N(7*M0VUFbE_H%)8NIu?p}_F#PYxYrKt&=8=Mb=n3lz;^paR0hKF%X{Jb`Hn z;p4OFmhfQMEOZF4CHTT9B?Mhg<`uVQ(vzi_np}ER`8_ZOG$zDgwBrOq^r)kgoDD!E zkQt48L@)7Nu}2|i6Sh|uG={m7yvZ$5O#D8%YJBZ_f1Pi_c>NvfZCp)m`p@4rl| znD6_6^Ccq~CqTMh=JNFzQKUx!T^c5qoqdh~D^bzS>n8-We0*;NSPC{{m*E}6vN&84 zLWBqAd+tInEe$2}tBP&laTCXVVU~f~z`oub2&9%VRW&Dm<%Oa)osav9(5m*Fe0^f3 zXesO8w>RTbJobS5joW}YhCf$8lKGOLQbUWaU~6@Og^}YBv?>liP%)DNAiVGA6%q^k zPVr@vWd0O4Nx)&~+aef5^@0<_V%&rEDe7wyo%-RkVSk!y74+M2T7+wi3*Jn4);8dq8A|s|?BVa=o zl2Cot5OH7*01Y+D`s1)JnYMeb?o-4f_DR!*=n#)Wck_v~=qV1z0}LS(h#>5FKX)Ji zUSCiD0GP$2g(VZJ{{UF)hZ|a1^}^<=crB`_yO0-T=Z2hI)~JoYTW46+w6uP6M`0+F ze|RJg2u@X;IEdl}BltZsrP%`7SLYWHl`KE8ld==uTvS$~YWQy8aIY%GKJc^HpzvMm z;l(|MEBa^7u(lCRPxxh>1YRojGNIV!Bjjc*00#sWw-a&H71bOcyiT4#(_{5MFiIpm21-n3c>t-rr zLc|Gn3zZKdfF2ufN0_0FtLnNg@Fvl=@{;2uRd=#46E8;Z4QSE!cZhiv!^r6#v0Z`7 z;Lwk}N(~@%-=DvXfyuHp6iv17%q_0t307Q6-P82D{o?QqL!s7nBO39-){xyOyOQTF zwI&XpBlPfLOca?O+k3>Chaga0S5fO5W^m2Ruc_`VB^N-UpugU7g=bJUn&cneKBQ5z zdTNq48dfxL5CI)&z-5ewd=y47%LU2j(6jYQ}>`8Zy(>+BKI@SPk6BT)4LHPB5CaD*v} zhl-nSDIOsLch0Bd2vM|fHcf5)U=d_Y*P^)2${v88(<>`Wb)<)S#Q*^mq;EQLL^~0t z?{D0}(YB>+zbD7*9f-2z5$rc|xQJ^@AIsNxGd3ItN?Z{|q-@!I%?|g=dwrc?5Z?yv z#pb-f7$*29ymN9>73ow!4-W7z$v9qkIL$N4vFP_oIE*O5>Q9yV!$Cus{!D;xnnp8$ za4VoV8g<(W0)pk_>h(LoC1j)Q_W`63aLCSCKsS9&CmTA%jR1c@fWHWMQq4?hONZ9} zIB_Ve%U>ub&MZ~(P!DNeFdJ)k$R4YOM?eY(d>w4@%~>~pS~FlP@N|9UDMM%48gOMU zK-XO1=m6J--i)B0K-Pn6l)|NTx$`q_rFQNg#&DvYTQ=t|iL^j-(T{4;Clkzp#EWLr zuyUg@!?y}1t)!WDrR40Kt}44C`?m4C;>%QB>2|+4Le@jd(n9%gL6isN#kF4{u6_CG z!&2(IuVWJ*btBINoZ!xtvGm--P*$-yHScB>a_QFW_;Ic)wE>;nrb-n^{(HpRVL@-p zP6U`b7?AltmkBPdO`3irF;MA{OAhMvnLO%t#cy}qN(CGN543Pb)b3WHi+(gG8g%jA zQqzxQvyZ%r`FMCfj5|o5nsgY~MJb#=4}LK`8o!Idg~Zwk@sx{BYTK@`l}1YUjFjpk zZTaTlHiW&KHPy|!MWp>X03z7_cw9h8XD0XKoJc@!eB@0@4RtS3=L>XKhTM@csX6RB zc;3s90xhUnX$B3bD=D-+H|9#{SAPI+6cGb#+_ssm!Gu>qxL^fw46IWAyBs%HO{!eA zb|~AUQwfH^y~x#nSV5>aQtXN8Om(#fk`bQQ?w|Fl3z$pX-Z(AZRO1ydl729w4-oXMDxO z!kvTW_l6;e%9p2CvCem4vEup}CHhkC&(By;Ev?nj;OKD-eo)}-JUAf%c8Dvz1@GC5 z3DzDC(M}A5CeuM53!E0Zuh9N`xDfiXS=_nen<6g9AohEjHBmg3UKZZ*(LE8zd2`wVsV=Zx`Vi-WAd15z?-$M8a0+O$8NnM7WLfb1u3 zDO^^B!_5o~NIjjdmQo{J%e=3h}&Q69bhs`OUy}98}?Y zGEK7>_Yzt0)+GRhqgm?@O@(xaxbxK1ej4KvI+2S;)y1>a{{0^AF2;k(9IV1{rs{=G zJ3Ab7kRrHH-^;vG=xVpNzmFyq6(|G)m%yjEFsuq!jn9gCwo$Dn|$L^wpWuQtyZziLfYXJ!OD)?fJs(gqh&v`cJG;FO{oqgm%*%(cD;4PvygkMf+Gg!hE zd~?p#0~eJ6-b$Mt%cG)V3=?a7_GJV)7PV&X2Esg?L3upJp#yha_K%J+*u>G7uijZl zmwOkM^kwbA;(p{Zh6#Y|zd6P{n_XnkoQUlnv=*7_4YaWi5_f=DEpSqb0@OpVqYFzw za9j0pK>(P6wzik)gcRneU*pf07E=!%Z%Isy7rV2kZVviQpv`RQFl-PA0Jqv=RS2s| z_LvYsh5^`Sj7?L4LLWy4Kyib2&53YYM`2iZ$#l382Tu@PR(A)SkatUr0woNqIg!{3 zb58Hp3|NFasgQshWOcgZ$ht?0n}UAxSJ8BXe=p}Jj*Yr58;_z^%fX8Z6~B(TFp=0n zkKrC2OT|90;c_q2id2aDFD=2U(W|L&CY?`=dmMBkHabhrD&zNJ&HnSTjvn^J$zznn zjeH$blLg!#^yDX)B)*!L9SIaU^vC=V{F~%P;Mrb%PcTGE4%2+kkBr?)CqKx;peNXTJ#RE*AxUtdq%EIbgGS`?lBTgF-_oew00)Tr*D36v`E;xfzgQF_JoA*d-oS7 zLNL092}omkJ2G64o0`XmI0yqvHJXOv*U!!|DS;iyyed;c{QWucaL4+`IuRGSb^5`D z&A5IUnn}0eQQX3}Z<)xL%3Nqob>3PA#c~asFjA^QJHmk^;*CQQ>iKAUesNS%iKA{L z+osV}(082li#N1ZquGiI(^A)5849ygUB3=^&K!c+D%twVplT}VZb_DbVWXHjS2pr# z0{t1Q5EEu~13`|~Xa4|zT{^}9lviisoj4UlQ8wR)hI5LJlnbOcq(1WHcm@H(z|54A z1Hay$Fe|5Pm>7VS7vuqp3_t(_r6q}hp;}wdU8T(gicH*gRrQ;x^;0vVBM`N_F)wkv@&?EzSA|iEIf2R zZ%ipGB|_XlrkYN)mRwLIH3ciIC{)Q$WpjY!3#7jk$gNc*)A6tSj*~ESp8o)5F`>29 zYHj*pL}Jl8`9ISkn>Pcw$KwKouI*K`coOJzlXT&Jy2=#+w?D(Q&BL)35}6Y&+G2u# zV+CpuIwp?4dD0pb8sY|5#7fPL>fdu*Mw63r{H4R~=s0K?UCZ$fArjme`lx z^RsC-_y!bRLw<}}+BEd|JM$1B)_-U5kWE8i&A=&Wf_iG&OiSldDkIxZxQ`6DCd z0B-;dlJ8vHmo2W1EE^^0%@#)J^wShOF&vEZP0f7V3wYv;MeaR^g%k{ zhqOseELF3l{0M01}&27Ti033-jgftc9(qqiq5H{%G9=M=tD?(ARg2NCp z0E-V}n=GJMvC>}mgaioK3WU2odcwFAY7mhQkt+IwkP_>fLpJL^G7L2 zLvS*p9b7BWxa@lQW7;516=e6m@%VsPu)Kfqgunqcb5uSVN|DqT@AH;69hC!IGaj4h zceB-r62sJE`!ctHHt_tpT{v6a6L^z2rZu)ik{?ws8|x4+3tOCK?a=_R6-n`t3f)BB zpX(^31XPlIdzz-A5No@<=XkErL%zR^;OHrLRkfK+&OQcg0>q2Y9_J7Oo7e^dAQ&5O z%Z&S+b}7}wP@+7&4oDn5Wx28-P&RG2x^NL}yZz~K+Km*qT+EcZ6ruXC^^AKPJ~NEa z2#%hxX-0?&GWL0_+K6sTUfGf>WZaG647P{@e3xI0qkugWI0o)g_G;g$hc5hWcJYW5 zL@Ca~1;RI=>Xd}87eE`^OgL~4C`rnk8Ci&x=?ji}H`spjK?>$`t|^w9*iX?@0+rX# zcg6swAYVT4p0KE=n5iU#`FOt=K++b6iuXFo8e}^9>CO!c4w>}d&OI_j=^cG!pZO?k zp_J@$dOJSMrih<6mV9GW6i?%VJ~Gi;bo}?X0)a_Qem8*=f^1KU1}!LplA*o7oJ10~ z8oY0KrUeaoWl4{!n~FdW0s6(Q1wuhl!O~%l8J{0@-HG`--e3cYwhfhBZqCvTyfy-5IW( z8x0S)1Sp@I74ESVbi!2Iq`%{c?4bl0ja_?)jaHMo>F-!;!HEhz2=y}RARVoa8B1*j zZtuOhW{f##7)=&ILU8OumlE%>kc8h;c+2I4c4M^N6Ht5}c*2#}!<#hR0?+|(p}O~p z6h{OBw(fqgd{9&qj<(|t-zmk0-g);3s*H{iy!IJD>k3ln4Bn0dcHPeo2CV=GRL$87uWyUq%Iu4OYq0;A|9z?Je6UnJH>_$<)R`^bai$>T4#2b6bkeP5{6( zL~D}pk8_M`YigkR{1}!;AW=f|T#|6)z-lii2=sf1HAIMoUvPzT+$ijq4RhP3hI8{U zouK8;{oXEkSx5{Lc3UvCs8hU7Nq|y?M6_Oi>5ghDY*6>7;{_6W8th0OwTA*&j$$`o zIVCyl95?KkUT+s}=NIc{FGdOh)3MWTCcp(yE#{++@&VYvUofCyG1il}#%~Jtqq2|U z;bN#m%=NbreSxEraQ9c7C~urGFmEPs!8*>|hRlTPHM`M(Nib;F4h4>#<%3Ak*)E@+ zFmM~*z4)y>_K-rW@)Huf6>r-onbn+po{{SX6`Rp%VY3mxmN>`Qn^fP)9V+U;QF;t-H z01Gq&gj8rdG71U& z?)?0jA)q{uPJ<9OGJt+C^p}QwbFw{QE~!<&wqR(!jhc1O9N@;zj!F(M=NKV5KQSh6 zaAId(iMx?4ng=w!;;4!af4lt{ksK9pSqgN{Cp5vTXL@6*USx zcc>;6EOkV70ryrxrtlqFhe0JSHL|5I*Sp_m1dm$0gRs{KP;zE!K=BE1sQ} z#r$Bx1#LJ!M|@_6;+qZZ+xL!20s?vJIFGhwpB&pqf~^`xizd&2CZNMHX42=3cOU? zm&Y#{30Euzguf?FFxKvrW&r`>cldCq5ZGy@+a?|6&~^4-yn6$XJiT0|^ijQ?69fdL z>Bi5GtP2S!jBbWG9oq^hi@ML8<-!FE1rGYltH4)f*-3#w)kRI(&^yF8Zp4u2rc?n| zEJZ#hP#~0Ej@$3$#0okIM|&5HAr3-^t~cp2fE6oc3p8a)O}Dc0@R)?^8WE>|Ch>%T zR=kVL@#%^5L+Ru=osg@hfEN8?0Fz3e+8B*bCz=k%6^mer4cc=6E`qCUe$4(8q357Ferg;0MdhNtfU60rKNwaZ30 zX%{cQf9DuL6UedUr}K)dLD|_Cc>%Qm3aeac>jdZ^vxIBp{{UAVZvwEZ#eKM2QG$rI zi{=5h6=bp2o_xc%%-1{Cr|qGCCUQfRo%G zoQ^V!2i{r*NT9d9bmD|#M~NImdqi3QU!G0nE-ovvq+T0VR@-cMoRA3J``X2Vhg3h6fQ7z$`8v3$EVaf}gi2Y8Ei zR_S=Tv;YyOESH?zl?Y#B)6UZCECia`P@FhUGSW6%ys{MF2fO z&0M9s5PjFV&_#;Ze0^Z6T@t(BCQH%-js8%%iYk8=%POz0ZI&SgMwJ40Y;mbS)Mdkf` zxTu-H;t32jDgf=GTlm0Hy;Ax+m^vlOh5mY(G_X-4k%3hOPlN2iny9GzdP6CIspii& zHwYgi(&HJcpndy47$9he7JdHkagH5LbD9IKx3SCoVxmt;b>7YUjc$(A(DBQKErVs} z_{%mokM2yR*`!T>10|-%dwbQm7078Jo3iRvXuDWAr&pV1w3b zQ*OMsMDfA7L3LQi4;`k)HNXiGU^n5+O|yve6#y&+2dxW@P#ubB^ZMrsj0QY|ZO%Eyp%Nm6VG|As8W)l4pnQ34IRyZ(Yd^e6tHW8@cdy}HBLaJ?-(Y)vaII|S($bk#@nV1-{7Ct zE&>BYB@XAlVmu9Jo%Z5g#!xY_T>&z6b!~(QUb+4>=qoozv;2v@)*p0GiLhxK~XzY<&G+a!b zNiDU}Io>sZouQ-G6sn=U1P6xu=5c`)E#*`d) zvXX6!uX2lPw2o8>-f*93-q~yC%ibj_=S=UMc}Bne0~vy75w{PCqiL2P4L)Bh+m~Em zeMeSwVAmo+1J4hsTv$=o_*#vx30CYoOQ5+3$Q?p0wtQdFH8`FXhH?)t$!HFE|D^9hO z+V#t`r)B~HsW3G9Ib~l%@J$Qc#9Q8W;W=-N3Y&$!H1XqyK>ce} zwRw&@jZR&0!|>t-twlSm0SXEN4wCftWzxLxzo#??D}u5;tZr+V{{Y0vL3I_@uifHs zW0y9Mhajq+N>#>PsVY8O)CiEU^EcWP3(aCkpp(s zAa2~_4V6OiL+ZS_uxu;64+`n}#_K`>c@OJ2L@*67YxwICTCt?o?cM=x^1B1yd8PLp@!6wmtM@s?3wbvr(n;6G4=9@W z2UjY3N1@o~6L5s!_GA79vW^#Yn>Qa2%?b)h(W8N_Fo9TJTRa}zF7%M7@UG4Me4H6#frWFp+kh#;kmOJA7oKph z1k;VaI7B-+Ls_b-NSB`!;^PWirvww%_0#agq#UQi@%5aj>S6)8oobC#EuDpyk{-9^42flDp}Wbulzv$j?5yatg5!o-?-w{XQJYSMe|r zX#_QIQ}=`(yXx)FZfsn8gUgz76F27aWOsvxd|NoltzjN4y^oRfcH$FBvJC=r@L(H26SW^lIb=uywm_^o$xFWRcD;8(;5r??Wu3i- z7F?82695lqRsOI69isK6;Xl@Ldf*)gd%?)X;BqJvw*mmB(r6x6{LOPw3nd+kt9b88a8yh4OVrHOI30|coyJr6Niqrxxb~Rk1Xb37(i{h2ToEnsi832 z6xvEW-3|vbrMJ_GmV;2J$KU4|9gFN6v%ixC$6C%?4n8qLiZdn+Pe& zbI!Ip%Lk*4b;&34b%E#&i_zu4HPnxEcrtM*^EV$&)4<$599M7N{{UuGcl?;?;F5Fw z<>tC)=iTL4E-)BjLhLjpJ-90d^c#N9Ox#q+4$u3ERkOIGo8{(5NaT=%PPV#Fj1$4U zx-v^v;Jj(q9AL)nBc9uEP()Go9bd*9ZWT$!*{lV8oj(5n<{pn*4Qzj>0mIezNIl~e zps*zsnWNR)SYrWJ>^AhWOi6W-*iCcr!)j+qy5^kE=OC;J4VF5b?>WRoGqH?S01V|N zGksFMAFcHj!VjUaEPcU%Z1k#2sI##0N?A=0!6)frw*aO7{E{%u9@%NKm)M2u;BtT zOX~e*;FJRz1m(h}hKQW|GRM$E>_l+bPOmz6%LqLb2D&f}6h@n;-M`jXN>z&x{&Qq( zmy!Hsa7c@GyA<;UXMUUbVJE7gS&V2N6fL}9W6b~!KBV_y<~S0wk*D;X zX9N)FRM|n0X0kC?3&v|x%4Xxo1Geu$8HpAXEn~xzR zA+?L)^rrY-y5o2$Mzz6Ih00BMjW+FHQyiUY9bbbN8K6WV7V+M&jr8&|XoHHHzX%De zE4h@Q0E_A0SOW&N+&2c^whWp!ddwi|fI8jMPD6tOklVG9D}D=sV(Hs>oEI1XArV>8 zGLSYq;GR>50zT4(dkjz9)DZbj!d%0sAKRF*6mE}@ zd7DPeK{+%*+`fBpLWBuP`zPKIK~}53uf_qNpNwRzZ&di`RQz*B-n7dbSb3&}Z8tg~=#ig4X=X@-FY0ljtckct=* zZ2})m%|yg^vV%-z>;!%hw-hxB9vVDZ#sKXgMa8szVLDe&ntyX1^e>nh+kf|rFkCPO zM`;l1w_t?wd+HV*6j+)Ud`m%KeN>n*@?26e0!9&u@h_`tlQT@PIm*p{E+84RXFYc6QM5Cjgis z2J9kT&1D4J0(5#Gz`%$Cz`)*9Zt*cUU2L;E~9{eysoWyhD0DzUa$%UCc-58PBWNo=@EI=_YV-zfJMUR ze1pXUe@Tss3T#P(6u8`!6OYVH=G#c!y@19)o;n^v{NskF$=RBukvWF2OgEV zrazphbD|5gcvSPlyuP3~=L5n$9ll}!oHC@NsIB~B1!x~P0#6YH=&m#zGVhf6J{&;6 zLNVSV^hyp6_dCJqK%sYC@NX{VfF)fT3A2(6T_DjyWzwTER z6O`}g3L(`~rT+k|hom-uetQXd}S9MVS-N-iTB(N^rrcv?G@-)2yz-od@{ zPB(zPu}I$h{aTH%o%>zc^-DEI|R<_H#h$ zqNDr?yZYM`aMmwv6zMSObP>P=v(uK&v0K2XWqKALM9Cu(i7&9#Jve)!o1z%TM0oJ&He@` z2D-Y|y2C-22Y*)M39kYo4!B#+G)C{Ub_Tn~{Dl?biN#fKfjvCH%q4ntI(34Nt+vb}g1l{o z*uLe~q<``c7)SmaZUYTz#+b14I|YU7MrZ=qzsGqnx6PT#BU0iX^9HZ&M8yM8zm zhM;5Dx-EHI$ZHl9Xm25BA9!SFBL>^D%}fNMt%Ntt9vni*7iIcp!v6p*hhpVmX#-cs z2UrrJusbQ&`ruL5M&V%6ZB}pj_&eG2mK>l3R_(J05*KDxw%*KyA|M4e{{T*2uHMAa zt`nySb}BNk9#_#NoaWb97rpzTI29f}vI>8*-wizUzHvr)@m@rOQ@yFhB zU3<1+3*o*oRiCl`rfCEkL%D(%yQgo&%_eb?=UnxW3wn4>nOLw2ejEg81TQRd1r&H^ z&I)`FM<;XMoNPxyK`lISQCf&C`k3k-529ndf4kg%+Qi-jOz zuc7mosu4G){Nw>syR0fws~Mp-Yx=@AuvPj5CbHHFB6Pl)yrDME%Ze-tX$*DiEWy8_ zw(Eo%L$q&?;}G>Wa;EuaRzi!}7f$*n6CiOgh?Pb2tXyZ{HaA0dU~RBM?3^ZVp*L5h z`R}Y(LJjf*lTN9SSteH#XdI3yM#K?XQ;p%sQl+o5>dYwY(~-iwJI+h5u%QdQc39fD zz6#(-)u2T6Eswa0;DTt3 zLAB8g*ECa*0DjfP0#KFKo@w)ezl%#6u)ducMMzUb?#=VZ7>!GUa2x}V+cl#MpUz#q z!%-~$`2*-SELhnN2djy6ttVT@>lR2P4?8)qbaOGBt}<@h*9IE|D9cb~aO|B`0Zuvh z6}&5Dk>Fdta6pd%dMp~d#IHg^^h|KBf)?YbBEDs%=MNC!v$M=oHaqE#ap>>EciwHN z8w0s@I^J+A)+mB$HLEtbjSmj)!t%36pbClL_}&qXkTqF!U=botjC?ry#&`k>jpvP= zTzZM1@PmgErJ95UcPz-uw^~x{(eQ5*u9TpMd)fVCz?9#7Yn)l5wcme>&Q;L_1BXRl zb%^UDNZ(v5npjpZ9qK(l7+hHqwtyUuqZ-l@RZcqi!~i!?*mJxi6JR%@Z|?{TLvK&6UIIZ~r9!xBoFW31G`L#C&}ihh@qqFG5*H8-oN8EZ zARNw^r5(ajE4?`wl)<}hMfx&Gi2kECrocH7FEU`CJHyanZ1T@z^O-RM)u#6MJolUvE0gpglIx_gl%5 z9q17I_cu&Kwb}9PID=hLuYF@4s^p1wMD2@M{)_2yu31%6@Va_4D(b!(KF7#sr>U-e?J&H?m+kb4bcF0!_SD zkD<+g#1!s0`9r>VN&f&aA@jPQ?n~c^H)b(oQuDsfW*iHWpdi`Jxv<3Qpyu?c3LUVgCB!J;h;m4LkQVF9IC&6yr?QliaC$@G5Coz%sCXqCl zD%yo{O-dcVIJn93U%`HJUSd2H)|)W0OdvRQz2z`E6aWbz9%Qzcap!54`s!lvV|>|) zPnhLz4dv~{?-|S+X}k1f=_M%!a_#p7!K)5I!1(xa=QYThJNMgw+{#K4-QN1sfMQ@3 z)Z+AJwum8GhkvY_LNPu~+yaTM^s?&!HLiywYu+?kCYe6OZ$_)T0A-!mDW4zCC7FqdaQH2@+HwEhky1W2byKl}CVv3@R!zXSD zXGFB-HHcFg4b5}EF(|i`HgWPvtZ6ickiCqdv=IfARRfSPZFmdcz~Ds(lG&$`zZfs& zS7Jqm`*+R|qC*}G)6?cV5bLL*z~B&ct3%;CW)BmWV?s}T%?nc;$Z{V$#0PEF3l1B4 z&m^EPC3L?Wve1?_<8H&hR}F16Li6MCy3;Kh0~YA@&K8mDv) z)Q^x1l-9RZiF?GgDDZ4;pHDbV8wdn<&u@lD9|5dr(MB3T7v%YK<5L|(2H&I~&LD9R zq`Nh8;Zuf9vmnv|*apqt#%l~bod6ZqI}JTOt~U~B0a$V+ob>A>XzX>(9!x>fBR~w( zZV=s|LsBI^Fbl$kT{`gS#1zyJw%5PWh|xWWanq)LGKd`((Z3PS(4HP{J391&S2*FY z8gDMT#IGZRv<+}$@R8Xurm7LcXnkQJF&#Uzcx}c!SwoJ4Ciu$hFCQ5kjKB|`aAZ)O zcZWCTE$ghO{vBfTop1jDFL+|%FXsnQ?*%{Ef$hYJ(DgNr79@gu-^=prGy+;Ts9m=c zWKbefn%>)8$WL^y*M?dggQoD=~;W|y^SMx);6?BY&?gg%Nku~s+}Za>zIu( zriZ!nj6&O1g0BKFp(z7m@86GcvM*$rfXkdkMqnEbou>-%j$f#$w9VnFt*aly50Aj$ z3{iI7-&(~~A_Q!hL9ZmL1LJtPdu;$>8^yn@;DAJi)5v>(Y3OV;^Qgbb<9H*agrEJf;3e*XZBKtpBw9c}2H^hxM9jLI^~ux$$voaA59^qnZ(RtI+=djy%mB9hKgk9gl_rt_Y)A z?T9vB3%&Eah2Z6FHu7VkqX{`x{(8czO%sVtTyYDsP`Qk;!YVHa(($zK6B<&-hsAyL z$;$+SL3UiCE3=AFOoDCUa7-mS9?Vl*xn8t24#OOx?p@y=j7F%i<5>qCKXk&Z9M3q% z04(kI+#wc?4+8e&q!kG*IM$4ta|a^3Yp>2(Fw%h0)%@!iP`255EB53zghLmkSPpNL z$2!rEWXBuqM)obejsqS9fNnMvXM5Kjo^^~UfK4`s@|{0a)WZH-rYLLfEEe>Z2BKKvYn2i6XC>2`U^=emxI$0i8?R>Szg>(aCtxo z$vzBi9A`LC`EsI^_YL69a-n!p_P7qh38shH+T*ZmVBWdd^+o7$>jh1)`8eB|!J-*MOb%gsUufUq4&T#tJFk{9%9)&deR0Q5<+p z7=})Qdh72f`R^06%vE*#Ol*kjj`0G8_`}iMzT!uI@r(nDidPqW;?q0-0Lu0M0G&Cl zfA2fMuCspSc+Rtv_Z_Sdhcrd6i}Ju;2viFR@?nT~1mlL|-Vko1WHiamRO!<9;OY3p z?_|Po=9||VFz&&w+PLFx8UAY7+1(>xih}P9W92b~{{%D31jc>14rz zqhN48I9%rqVB|)-?e12$G$^7q+0Vu@hQL(O+Dt@}z(U7=&LoNiDYg4?#cUcSUAr>% zr6Z&+c9(cL%p+9tV3BndQSuiSY%~z$#Yi2&e9I9&OEEWf9c_pKOViz$uA4X7&JcBn zWP5C_f#B`QXeS9xyoO8)BJg{O(ZHN!IiN_Ldd`KZzLD$PtL?u}Av!JtHGb~x(ufVZgvB3?0e0DWWW zl?q~*gwSL@t0ljbD@ObJ*oCaveR;US~58g`Qj^2&m zzr2_M9Ckg7v5+ksI@h-drd0xKtbP`9zZY2d^wHygyfUIq5VPQMK>~U&1jAls5hJc# zM1rfrMsco8ZFjFoPOm?V36%!dUrx@jiWCQWT{XcyCkiLygrY)<<+AJg&Mt{|foB@Q z-W@>v>t@a48&OdH%Krd(=KwnB{<`nnKl=a#MGuTXR3xVRF&Hr1SuaoLSVhSM&39D5 zzU9DWQ0(pa#4f^&=Fj!#2mwOD(=giL$*{BM`PMi@1B2K^zj#a{g>~>h7=|H5(z@R; z=*4GT8E70Oge@k7ylufjvMTaAo!nO=GLxhLV@9DjVXuI6heyh~6>ji+o>@<#nm%$W@%bW-$JpWur3F1?B7J2iD|x192=|KXs7gnPf{U6Gal1k7%@Jsk(FV)} zA=tf-<<77q(7B~eB|ZAZdu(h@!nivqRNA}U_;8q{hjkVv3LrSVFyC%X9(DLNfYR5} z9k=(1X=rOw@sQ~ZBBfF0kDoZY*(F2~dOgc&bV&GJC(D(~aSI}w-&g=ZS)*ki2UrqF zYkZSsz!$R3rQ4iF)1LN}DX^OHSWdi7JIqqivNgD3LbRH!Fj@pO4k0Y0!0 zx&iWC%wx!W4^xrvSbh5rAA=Am`Ot1$f43OL!QMFO^9@0{oV0P*ksPy90HxJ#a6aqeRtp#l}A zo_=v~z!PEhiY;lH2I3L(In7cvO@p;=95>soNvvX=YhL$j)VV4O*yQZ4%u*Gx*0yNY z?C-2WdbvaCRzG>jloNE4z$}tCpCj-$-&b0F41n!Q=`Mj9!gjWU=B8365M)&=&WT&zP)1$e7a%r{R`KMTr@C1hO zzn~BYA8#1Kc~!JrCy~~Gkv|#VPql~R0_s|kv0inQdE9IGpg6}VY#Uz;78oEZeZM9g zF9xp(<~$w+b^_6T&EklIVO#4RNKo{r347F!lNV=O*@*W=-j9`|Du$>2Hv4mqQ)Sdk%OvO}ymy+QNLaJ6& z)cVJEAm$=?%tRuz-nMvSu^0n_K}VY95Fe7^i2I{TL{01_164*Wtn9i0@y9ByY57u^xXWb%=_k zK0U%_D75Fn%JdQ=5oR5lQ)#Z{J7EOYOI^+QV=7KB=de4t@sic47Vr-HF-4!EbI&tm z*x{Hc0RgqqPY*KPNrX{SMtnNNL9k)07^G;J#d@d5z;3m&*Z36_DDZVc7mwYDih*SjmOQq<|N2A$q zZt@Z!V%1&+4Kovf5}^u+7eIbn!$NPPL+pAoBdTsqx@TXWb77=`F2IS~07p&+wsAV9{_x{Q z$PK3DUe_absD4lGNs?I&byP*fd>~U8c4Rb@U$9c;Sc1ihPgCrNq zfsnW59SrLL)e*7aKer#DqTd$l^Z3E+6nF{MHhG)3AOI~MonjrP`tv0R%Wmz~B+S&W z3dtOlc8e=#AmP4GPKB>E-#H)w8GL5cb4&>tsmh%8`<~{`l{?k9u*9*ji@sI)^A6#$ z(2otmjPPCS#X1g0S`_XBBw>)=n8Ej(g4!zw>ojmYZ`1h3z2BqrdOovY^#?`wjW4)- zy*2aReY3t_qY|A0&0t0;b+y+-#=Dds#tD>CK5G-tLCK)=qYMurXfT|X z5wl<;+I5c5yB%m=zJ4=%0<9uGzlQ+>D-9bt>t;P>)7cv~^AXzKaIWi{9BnOCm~CKP z!3BeGhKtGJ>yK7~_@c(p>1-kVVe~^%6Nd%S72+G~Qj9$8i~&Ekvg>;kK-uGrM{ zHf-CDpqWr%y1U5to47c;yWEU=;D=>v&>yCu0t9#Cuz#d=q-eDnM;v&sps@wN{3MS$9V#cW_Sd z=sS~(_2Gl7c+lX$`*~%$01iu0v+D7M9H;=B_GJ-%@t_|#E<`xfUQ*!ewyBXzWm*02;I$)YJG+2nxbfz#~9nJpx!eF7NSeGg^eFsJ~2aN-QyBqf6s46-HLPIs$vfI6Q=7-dL#KxAO zczSS2uRi)<>~vk+VX#jAa8p1ShbC+7Upw@k+-2#yr9+F(H?>K%K95+NnM=xe&hi^* zMV*JItRNtV_XLt?rMu&-g@@nA$DxU-@C)3i{Vbk2LLGNjrL{6g+YN$ zBk;#se9Ea6PnG$8a6u*DmLCwnixLScoBc2l^wk9{ai(>&L?{P<{NwPlh)s>rc;g)v zMzri`m?wG#bq8c#Dzr`syse_P0%>o(bJiqjA|gOrqHWe`0es(fi{>!k^Fm%C zrW%KE9lGY!h5;ebXwmjr2P3?`h|y;d)&(kv!n+>3xT-T+5h77`{bD|ygu}vKYu}kv zCf7~cJm@}fxGL4EuOb}YuuzCYrWL$W2}A{`bE$@@T1&}ObL!)Y#1I9pON$p8B9Kph z+%yqAot>%M19o8wg%plx`h*m9*1dbi?G+b~Q-98H$s%{xF8obkR>%w4Zw2eT17B-e z8y^P-QlX{O`ZbX_4<`JbOk_nsU&w!1zyz&4*T<6>npgvp2XFXg8zQmuhF~az<6w)4 z(~v5v%^WIwniidb-qgebssNloW50%ne>=uTfFO(1xE8!iz&DLu^@5wkPVbI5M>uc` zM>V@~b(q=8>kO5xxj$JHbVk+M@fkNiaD?1UvoTJM;F1udu0g0z&ULC4A5JfZbbx); zM>i%I=M!a27*$H0H#nX7)Ar-o+c=-wl(Fy+Mr;27Xa0B3{_Fey0M|YL0M4)eC+GhF zFQ554x2L@Egso)t9EQ2iXYky`tz~z)vy%>77bQ~q?J*@rn|Vm?Z#V>?e2p9Zm|eGS z`-ZFWgtxYy1=+VgQwM@hHN%9c7SHb)qz@7PG=f_{lM#Zi;zS_`z(H zsB?T#xF8BDx=H8^xS}J>)k5?WZ+R&cU{&jmK4w6?Aikdi8hgP|P*~mZy%q-kZU)@rkm#e$(cWp!G24qeK67)W&`mkHP1t$;kD_3`As4=;5SVNS7QxDG z%Q*)G1u1^cS9Tq^?@##R(8jh)OjgO{luj`AG>soXI&lltLv>oNo-yRvg-J%w9QA^S zRw|1g{{W6~qu$j8G~K>TQ4n;WU~UQ)Rtyi-K03hafKYU`1iSdfwyCaO3(RrRP&LCQ z_%MoHLZmRKK%y)0);pF!dnYHcs#xBRdE*AjWpnS}HzyWpLogU(nF6r`V z>#R8dAIQiqM@7-y!`4#X`l>!ZGZdtD+vAJAv9pH=p}_t%gUJSA%#W;rNT17auCZ?1 zB*ZZ2G(hdtZ@9T_Fe|ALaEt0Y+ntVI*iKaqQfIY8;B zpC89b_@(D4p zpe*0PqDz#~_N2pLmrt*}WnBXoXc(`CLI-{0WEXboHZP8_?69n=%Yl&2BP*k zFu4{kA@4Vza5@I0Wur`R8EH~d5KVO9g_n7#@TB3G*R2#z2V$JwvWDHu*z7XV$OBOr zaO&UQEY|Y|NX(^(s&pR4RzqV#Z(Ewv1u%p}*67Eq0J9vwmHNwM9qHw}hHB2yg8=B) z69LkqnxU(HaV||!i98RV8O0zAOw-FcTmTXp8bE42X3YkX+G@!IHw1yE+0OH0!AcFq zJ5KVJ!Q|GG%?FuGMQV#*kKP^Nhg-EaU%lq=3m^zrwN4K*6zaZRaUD33S75#z((F*W z4zq=Yp!A$9>nx-No8i1TYP*MKHn;bIW_o$6$(FQ%S39_wIlV@9Z~4Jc!cnG%*QD<` z6pkJECU-)c#O3(Np}KZnk%49jfBsCF ztSZib!VhVtW=8kh5nPQgI7sc>^GXaG*E+P-y# z7oVBbqrhVyYN5-0etF74tpzL=@aQq5K_LW=!@rjYC_xj+47ijS^G<^il;Isu#pS5D4kf%He4?bo-4YAMOfWc*FBxH|k)Vlu8E#;AxdL^P_l_5TCc|;L=N{ z;}xnBc(mb{%mBl*{{W{rG)hftc-N%ZLbJToiKOBz1?MPf=;&@Be+zDmKmoLO_sjL> z1R5eep?CYZ$}mg$;mYW1j1Uba>n7LfJ@W-Qnv!cC69N=FtT_$6I5u;^(@Z2yzF6jw z;ZI|OPlx4>Y}Ox?K3|EI1BY7CI`A@Z%_XCe{jdQTUx>o}Fr zo_&rxRO##Z!56wDWA!dhhgX=t1{lpd5YT zi^J*y*={8*TA(zE>k&OcL8)lCvTc-lr;o#zN;d}TlTY-;BJ4yvy)N*Nf+u_hytqKG ziubOAqbhWtz&RlD=$(Ax63uU#WQ)$TXD%Sp1ASfV3zo4zMsMQYFE;{mZBD=ZbN;}U z*{(Bi88>J`_krRTbIfK36Kkl>j`Hvk>!mJEJ-65oI5b&AcB^<(5?+wIDtR9Jz=8@i zCcD&wILL>x6h`Pzirm->qu5_H^P?5(@48L{#lVsl%JBaHA2{1$DFEnc+V6RCmFU;T z5wkh?)McGf-fUYP-L37wU4Z#uPFEE4oSUy33z1Bqs3<kcJqX?Pi zn#v=TgvIA*rwjIB`~s>|@LzBcfE6Z#Wqt1{2~+h+ID6+eyCNNWYIAsT1OXAnjp*(i zxCqLqoiom#F(DhFqz)lD%-todvSnTj&@sVejNP5|RaY_}(m7lANg_@(qbO#@KT0|S#dLHg5+h}>M z;KU?X-=-L#F2hR^iTB1mW`HBEmZkFvV2YFx>3Y^u9tNi760LLy2Nd@?oeXH9x%GN8 zSqDNjt>xIm9V+qr7F!2&-j5SzV3fYQZ6Wk;5X~V?qJ%&GSc$dN!_bMZonxt@CYu?| z^E4q@da0)M^_)BGP}COhcL-|5(Z3Ctiq`1f*f7A^H{F~3U_SMUb;$AZ;?|`|ZtqSc zm}nXh+b@&62=zw@#nhbpmxi?qrt>`hOxT!>oFj)=L)@;o+Y)bh>%6+{2VZ!Gs(>rO zgp4t^sQBbP;HCM%bWNlU9 z4AAbuT4@f9%~eL*ZmK_L04TJH5a&V8u)#UPu_;-z?>VK;|6$;gK*-0^5?3^|u_I>L&m>%^sA1ohDQGyq|Y5ii3=@8l)cdVU-6eKa{2_e#3 z!RzJ4A){prZ$rTnYeZ=B516Z2sJkZ!W$OqYl6DRyoheek<~De|;AEg~#(T+(xxI2Z zCbh>B{_&TLJ(0;_`!Ik2(h|+QP2~V?vq#<#7VCo#Hva$#^ww>&X-?RTSmtP=zT!pL zhgo7Wu24hDJb=WH=A4-3&qnO$c=W`zxo^Af;E6)t=*btrx7B`jj=W6e&>|ho znuIG`m2wmBJCkr7a$>bUl5by_=74G2xR{(RKR=6?Hfh-t4QcW^WI>aH@_m@7Sob=8 zpYsJu7jDj;{;`}rjbQ1R(3V6`SLXnwy4lN=#d08YKhqv|o7uPOUAW^mHoHl`vDRy* z!_fHSETIoie}nEJETmTS{RSw!_lhezOMfv!~Qr%Gst3mb6%Z`DH0O8vc*lXtzjS-PutBR?3Ir+v7 zy3tJ7Egbc)ZUL&S?g#7HgMeU~x+9Xc(RS|KY#GGA{M`A4&4{f&a4qFOlZN3=BF-kv zu-@u`bFJpIRqfY&v#bNjiQD{SPy|ZqFA(DEIsj_)?yFeoc3q3i_pFmtRYyyNz2P+l z6%tPR^PDzh6}76l9}kR_44(p0j$BlQ;Dce;1`s}K5lgs^R|3GcyM39?G_TmH?ZV4< zYrGaiPL$_Z(XcN$dm=KG8a1@c1fuU}ZPN=>Oc^^>rDcI?IH zyfl#(Er)1*U?`wfE53o{G1fHNBTW%rvZOFHxban4ywn3+3VZ?Xe>iFY?R~28{{R`P zLMFD*cDt2#ktaM14iP7F7?N7HL05~^IWP{ofUq658N0DTZ0*Ee9Pm^@x@q5FbF36q z)@WpInHnMNi7if3w-^Ct< zd+!+6R+3scbZY3<8j(G?pfqHTX4dI9F~saBH0I+6BL!jOQ* V`FqHPA$ipJ3@%u zIp-2CO)#GWr18Xrgy);fKN+Y>n8MWBPO)aK=qN8Y8!R1(MWxMILs)D24&ON7-y;K= zM!iRQ6ozZrHhmhzk3+Q$2aAeqU@qMQs?FtFF=-Rw*N#oRFoypC;9#Ky>ZIA_@Wr%@ zDlk*1{bI>z`*C)k-aRKp7Kbc4-b&M}sN=8Eg-B~brTWTPuV6~3ZQl+oH7p9d9{PKL zl8hEx&~lxw00F;gfhc(ou?}ZuKruEA9~$Q3H)O!6Tn8(Wc+#iF7o1v+D8D!Z0BPQB zZ8MktGZXF2-~I13{{Z^(fBr6Yk1wYnwP3m#PN}>@MG#UQ#X7~bE5X-q3z%XlBo>B? z9uVF(9@It?N@A>8$589A*yk9#Pv9eQ!H}F)q>hKi2a3)6V0F7gRQ_hu8QBCCV#!;i?d0nR#72f97v7T1Le zw$AT(^D>76fezO*r}vQWwTzUf1n{)iJ!jd$`N;!~;kZz$nsmUf5m>Kjhg{t7J!oDI zxX6Zz>QOh)7$RMZm*Zyhed*rjkKQ1F1GZj!n7F}cqE~-&PDr3Px+X3UZ!wtrl}FV< z{;}ayvIgfI{AJDoyGt<@sW|3snLV`FTZvur`@b_o6-gLojsvff54g7S-&TJ0n-Nz` z!-2as3x1AFZD!n(rc`WR0l0`BLTrBaWCy@P6Ymm0ZX4_+G1Zy?>MT1iW(}#vpC^3v zfUH4H`yGGNH3}xA>)F`m){q)1)ZH^s9bE-b=pPR`R@{KirI>QEavImVa`AS*fxFZL6SjX$Ao&G`;?~bNzpa0 z96)mg-Rsxu1`$XJQ*SpUh!z5;-TweaA!Gzqp+6hJDrpeZU#Ga*@u+wnu>^KEreh^T z&gTCBoE17@_kMD-W}WG4jDmqVw0$SeG{@2WVH+l&w$7#tpjHzN#N9&h7!n^;=WQ|S zxHR7P%)m`0$u-8E;J9@)vZnQdq`XeuwS(I!9%HmG1iu(0AetND2{B~s4St1QYk^uR z2wj(4@L-9lXv<5&_wF|)rD~(Hn4I8^B!j2s;M-bWL>zpa51D{& zC6{%*96vbNM-A0F6PCH(Mj#%vG7~3wCjHR zjzbMcNw3Br#=^dn>%0!oQCLQp7waIE3XQrB&W9B_uL)Qo{PJQ34FHE5Z)WETfe%T$ zF*nt)G$~czc#f*BI9(I8^E7%ifKFfG;mSY|0lL(A>(dF=5=UZ#aOH8V;x!aqZkm}| zO5ip(>%OuA0qKd%W1S-mrRB?rTmT1l-ABd3pjuj!o*WQfg_b0Iea8%JqtiP3xCa?w zx$n1TDKSx_$a(jgA2!YVMErA&613!8qpUMeV~ioQ;q2=k(LysFv@aAfym;zO5EzFb z5ko|wijEgt1w&tbWzF;87P*>%#%WOy0=I|tj%I|q{G(bpI>Lu-0m#QH+k;gFrt8r! z#sCyVJ@V^axR4`)IxR!j5e3~n04FcTX+SDnq#3en;}Vw$JJo5%r~nLC!O#2N7U}`~ z>uorlU<^+d{H}$1n5iQYaieHISxdP#EEk=+m=bTcqOs-qE?&jfhi&sb@xuvgE7j}p z;}?`QJ!IVX6{KoNg-?6=$9l%WqAM^Ul)7@K0(JL*I4!b0Z9WV-YeT>tZO6_}G^F+L zKkhr#1rISQE1gJXkj36}%bks0fBygvf7WR_{{YLg3|CGDhq;P6KC=G+1w#=F{{Z;+ zc@E435HuDiP|Gc*{iznNCuZFGDaD(>CPoP$G~Z&Rj*gLtL4sL*Uy- z2To|5P*5)Tb&dD}05$yJ2-_PMmtl-%&#u<^g`-bZa55LC_jnslt7OMSfgtl>``U+X)B*S8rd zI}0u(E9KK_W02Z)E7sqMh~O);YR_-CZewhhICbulCZWm+cnG`TqFD#T&K=Lot17f)cAgjx^< z+seu`jSp;>4(Y|eEijn;Qfu4o^ARX%DJr|d7y|8jM#OLL1wmT__*XU&Y*D?vVvr-j z=gydf$)%pDvCBeH_TtFgYzeFYgS}xYZaDMCln;Ixu-%>Sk;;Jab^ZKdQvePJ*LNW? z9ipa=bp2u*dnFimx!+jbgQM50>fo#bI|H3`<(U(AYP~Df#3T!9)I(OM>nIXPg-NiS z^N9!$I-{5vOgJTWr=9~5G;BW&?--U4cKFxtAxhd<6(iLHUq_9HKvk(6OMZ^LzK=wKho6Zdai{z*G9IQ>>med{|DL63l| zZ0iuo6(}Pu*E+^n=%wH6)y!k!xqO4U64-hzVhk~NpDA)>v>IO z?~&ZVV~QB{J^RN6Ra>^LH2A?%l#^I`J$&U!YFiCt8MrU33x6pL$Ch;#~6Utuy>ix z51sv)LCJG{1Dc_!ba#;m?Rh3{Cn2S&n!z?6WuN$&=ChOKnMxq!8+elxLWLff5NXZ; z3KBzf@i4R%MQvY=XuuGq-P%80xLVqF7NZ?LJI1E3Lx^7b_W|;xSSE+7mhDxIm!W^G zpnaoIzY?*f1==mW>+46zQL<0j-ja0%~4esqkPL`3t9-wB)cd}LVU z=md6$)@VbuTLatoi!TpjSL5-DGh{R;`Olyn_S=L;gEi@zwAk9tzB`5>Q-$Drcg7ru zwl6f!UgT3tjaw{C9}c zlxQ(KU{Vh=E{NnEpCf=&Q(q4ZZFr8Ftlm?H@EiXCh6zJ}KjHM{4%1) z^`_s+o5VFy8moEx#{@yA<+wWcH7*W;Te+N)ajWq=`SHMUZ_H!E-1)@QO6BzUVMGoa zQ8B2cA$m3{{NbUaWjbCX@LU{rDWns_*)d%KOQ(E?Yt}FmK-%=sFb7~ZD>=rvG1CV4yL;X#Tn*D=aGr7Bf29Y& znK%S^jSmW8&R3{Y`YFt%8=rLrN%k{Y_;0W zrc_sYiO+0%xMX1@UhL@mafVtW5i^tF-Wc1GL$sP0_Q69p7Mq1!d62J`I@Ts@yV!+JJoZ2aIf*#9Y@a9iX397Yk30Iq1!(Bg*Jg9@ zeFnz`dxEO64n@!l%3<(Sq%zn6*Uo5xqLJ;_ctFov7@pg1EERMwEOkm|#Ja#Pzr!YE zM$e(w>+y=6gB3LC*~Z)u3ycm8<^CLGklm8$u%~$j1Yj*S)Fx@d2?4-*_4UOaGhxde zZ_Jv4jlFMwoEFxNc2P7R9^(E<0cjiE8P*B&0yJOG!^<;ZIS_tcFNQ!W zO4X+`5vW-XXOY7I0X)Y^wNKU?0FLX#s$N`Vwv7m!uTCd$#FXD=RFQ6L0uVnq15!?! zrqegg=_~PgXVy!DgdTBN+oX!Bq#MTuEEFNS_pLZ)04jpr5qg-BpbWM57ibV!&v;Qx zq9AB(rOnO01LdRhuZ%!c(PGN!U%aHz6A&+<#{eIE!0quBM1D;lY|l z1&u!x#o(_ulh2$o0tjflrW-mZ2KnO-y~gWCvY&yg);x+nr&+ObW2NK&0Cjo8ZmV!w zkE_q?Bs8mq>>{DG#@D6&;ypNNO^F=WEtbkhQBH7hkl1pZbA$5gw1cAhVljwEwzy11 z3&=r# zTft*hOyx&dyqCVVAH1F)j0p`f7N4{G#4SroTljszsi!FZ#vIvCG?$CC{bD0PC>n9g zoz9d1hUvoLyd43;AFnY3W}GO02Uo1x=%6g7Ye!gv>GjiksrbgClsHg4ZyK|J`(3y# z8-(v|8EbTRqc8}kJUs0e`*1c(VkdFBGM@=ecQT>C4y;|?7!)Bxu-^Xg_`tJ{-RgfGk8?qoF zz4pe)xjXpoVPRA*^!%ID!4NSfonHNA8*0!B8sm6kEEc#4!Jb_CiR-Le9fi@@nO?_X zx-U3)NpI9$+*Gk^Ww)x;{F3Sp`h(WolW4# zX+pDpQ>mH-7oCU|MAQDT^qoV{olC3?4yXYQjPhaG|k53Ow@~i9k6BWMvK}t?-0;gy?62$@-T$m8zYKCx3x8iLAqhh9naav ze^C$+4IFiF5}+9CiMx<3!1fRlN27`;A?Q1w#w&=b{kl7cI1ZOH{SnTDFixjBdbq$w z+ES3M)z!o#P&E4xbxDj3u`L3+Cug`Q)yfw3JN;l=0&k$|{{YMl)6t)pH}89%c*j32 zKT5dJ*ycE6!I9Tu7_YGJtBDPP*}b0((T6IOZ2TFm=c}OP-JJgbH!2vWQW+IhWOr0- z4AZpP{U@d_0Uf-zSh%`HLs;y1Tf5%H!B8P((tHoRF?M?49j{DoG=Ro}+;;sf$Fe0RfyB-OFzxA@I_LF9b9 z#UpMBT8*W{#!(Z}*AnPT{UJ;XAj$^>k1LJ0^v0DH4e7b;gCcsm|pBs2nh z9L4Fy1x-D!a?YLNT(doTis41r1r8eL13;jZ@?Zx#(Fi9u92^PfbmD}12?`G{ldKW- zAi6=y4|oA~O5a@Bk;ePH=I!xZ-{x?a9;8I5P8^ZsfeRqa&bw3t^o8uILN}3ede*ls2#jx(`R~Su}fCm z{5Tk1+nMe@ivG;K+mDWLt=0bkTwjwTL~vTaa|Dn8Mkm{X(WEvD`PqS@7=e4%3ewaP z@JDV!yyFNksJ*|V=LIPXv-;zw0I6o^*qWGYY@46daVg48ZS)iPz)-sq#T||z_<#Fy zlEo&}x(HyX3c*KW8sC@>CqT9zSt?9qbTbB$2Ji!;avCrga1m~=#Kl7*1JXQg{{S$Y z)@^n?6__YO7?t|O`nw%of4rlII|5Dl*@MqOR@A(>8x-QSg`Zd=5#UtytCZEY2w$tw z@ti|&{r6YH5>j9?gVo+u&eujFKBi3?p{UXm-rp#*ztM> z{!CeY$FSthf%s={Gl}ybu&LHe;pT+`?S8$FhrP^ut!+-(; zni5ft>epPnR!mfAi32RG;1exfms;7I4@5gLmY^jA=IG9qgus0fGp+{=8NnqFoVpE? zvfNc0hd98{oIT?jAv;!MK@yOTy;Hn-$>2_8rUMTd{3cW*M0*B;8NUa~wDpsMI90^> zYM9Ac4%7K-2#64jR7cPDV1Qe?b=-1h_~`<6cusK@^jo#{sFH33TruZDt`0gAY{4=}>>+=h zu=TgnzmqiBcgAfS&P6XRx4Gax_Tx5$3QB@-!x0sN9FgSBS|FGTT~qam6hI`JzYksF zKqa~-0c!l-GlyRwI2<(=M4OX+bm4+}Y>e8ka=$#{j5*TyAEpY34n&JD>*G3{<1AaG z*u65l*6^Bkj88MLT)3s)KO$?N0=@jP;xrW%C8i~aFWc`XJWWCs!&~s)N_q@z9#4n3g;6wZFD6uo z2<*IlJ;(-MM!I+2%sqA`07Q};{TLP9kcfIz_;L{h4~PUn(=RO)N|y59jyHiQoDRzJ zpBRWBj<_R@4@MR&S>Pl6SThHpm)47DAI||;?9j` zs7*9lz5Vl!uW5ND(fQ{SAQLq0=<0l8A%W-2MwjhzM1|ZzPp?xr+e6_x7w;gipht^* zPt483p-$19{NRdMgc`5!?;@m?2!iJEk!xH3ZU()s&rr-?Cpc=o^tl=UxVep zamR=!)qlLGH7zc$=h9$;5#hde7>+S%I8OOGa4_t`aLyz#a43!L4Dw;E@Ph8VxG6%{ zS|?b7Bp=(fPV;SZq8<}w638{FybwcAlm7te!dg2<+q~8AVm>l-Vrz|ZO*6bw1C?)Y zhVWXwL(7Q^YM^XPE%U-+FBhW^XcLSdmp4&gEuUB>&Ghd9bZwkv;B33f4wc5}{ab?T zwKsQc>o_eIJt=UiY($G46yf?u{@}|T1wmIqfW^&sPP&sN zS7}+N;}If>dJnsi;j?5!I!t&+liy>ku0_$Sr&Vw}P-g~><&AO7yeHO~-=jYpgeINwN9T9%P>5MqLiUFcHXe#?C*S zj{<$a6mY}8L|yq$lTI$1ylYXjH``(Ph8tLZo77{x2V*PZi_|*9y+*uu@@o*HJ@Ryo z##98ekqyOD4HP_l-ao|#vG#Ukhz5?pP3ul?-U%wOCAQ>t7AfJkITjhWd~a!#!1dIM zbsty`R?S`BcZFki;mzVhZU%vf16%DAmTZX?R|@UWCT_@4eoOxVGpkgfqG&MUJ78|0 zLtio-O`>knYc{Ro3jleEoMkpqIoUrPUnf?R zSV=v{&v=(?D>NO0?g%8Jx~7v>AH2Ie!B@Hu25Cz3cHG=o*N%_bmMVpyQSYZI?#~+$ z(|z&9;j6g%pE1)6!Vo*u&??7Kx?e>?FP80_qb{C-H~ks0Set=d8OWyHpg#S9GL^Z29$*hK}x}Hm7E= z%C(`=d3qOks0s#-hnP5t!7AF;kUh!+cB#hUg9SxZMo1r_iGruyN)1fiU{Q^L)bbM= z6LK{bf|A8V+^0@72hV#Bm$+g`K!_VS57&7`1$G||4qd>C5wiX_^^zD9b7nP^ros#l zl*UXcH*3)Vdd-a-{ARnj(13Y6)!woo29e?AF+>84q!m}2_|9MuHAj?3T>0UWwO&W9 z?Vjsb5&$LqnX>mBU|yuWod<&~sX#}{FL_=TkrDkpRX4O3CLfe5X=<*1Cj2r8Aw$Lg0J#XF(4AtCD7|R|vp+hbP&hBx+)`A4I(Vq% zNr?k~9Ab0;gT}sbm~%AF zcuon6-LF60$KQi^kw+T(_{(7D1lPm+#J6s@-Wpz9W`Qs=oa@R?@I=#OaX8R4oN?dA z3524URK;cdxG_6C(1q`!;i!WEs=l(*Y&KYmvl1fa72xjy3)~XqxSGB8Rvz(I9cIte zJ?9oqfSw(Jd3Bd?(-D5w<51JRu~j#EB*SH}Ds#u;jv%Q?XhxVf2+Ai?#lvk)YEiE* zzL;Qn2ZPhWJjQ2j?f|{-INTOH<;olVyUH3w(ljRFU)D63O`r$f&hu32^2W~>C2uj= zhZJxO@aQW)SclXcjCn>Im+x3{8=St|$mBq@1b5HgKw)--`cJ340fi!H(XqXq;8}zG zix=T>vIfMoXHVZu6~f{>9!wfmrMVYD@$%-!hV>U(r*pmomCdDd_>Hyg%UYvXMNqB* z0PF>&;Ng*fBb)}%;5{&)?6Ytf!%ekK^8$-&aAF3L3pZ$7jYZq%N0TZE0-EDyIGsC7 zE#iH6VM)w8 zc6~VN$8Y^Z1%Lp$D0Geq0b~sS0EPt`h{K*|3xpFz{c+3ZImPDQUOOf}@aZlnkP=bT z>iFk8Anc`gW*8&U2zf8KZ%)wlbVHcoqykqLp!UN>L9Ptjg$AsmpqwKe)kx)#FD@W>B!%X2u_I%VIE>)gEs-tY!z@8;-o@O7MwT?r;_tq(Sb7GBWdAW zssz{!fpmYEF}sJMq%brmm~@G*>4NY+zF>EjSZIE_!*SSk)o|h*pg)rZgsZaG!NV^B zcJFR9;Kr6Eqb~qy`DIK#TqBs77@-ktc#{mb1ySEl_P9xnBdkQ9S#2}1dQPChY?Oms zg;BpabweucZLTM{tdr7;{S%KLlZBcHr&A6m@}DIOKv95&dXfwALVU^}Q-sdOlJ#uw8F z=!duGEQp(|3RNCBq*4I7Wjh0`0X0&zeaBCkaRLb2Gy#O6s0VJK;A|{{Ij$cb7`3^` zik>G}N>+OsIY$(?N%#}5b%&_I6pldk_Xc8rIxjaEc(vF@YGsB3mwOUvzgUAxg^&oh zmY~{NcTq;T8QU-|wMSM1%ZjPdN;2@h*DC8!01i9yxVs4m0|4q?dpIi1+nB5v5B$L( zfE5eMzwR;-f{2PMruFlZ2P!>>p=V4@)@q#?7`oRu)9AK5-PfIBz+eCcQQtVMyjUqhUQCn@}kt!d-&PC)<0n^sDOjf|9 zQ|k*x$eX~pHRkXW=Mzx~ZBykQzHp;afUTTw;K{b3Y0q4%4SZ#g7i8&;YrJ4JsJzH+ z{{Wa6H%EZe&CNTMV^)56>A*InCw25w7N845f;e}B!GZ>8bRKIt2#6{-x7WbUCLo12 zin+Kt0ca2F=Nj3z+t2aVPhVrF<2W-zwEp*v9xOV;HOfsm^487oy<%xKx9yqnE3UoA zQ3zu7dB9uo&JR1pK_s2ylX*}lZ}pOh{QgYg3pe_?c49bCApZd3^_<@v7h!N#eEu!fV& za+DDjsQyRBMi@hz4X2}}&L{~uMX6i+!y0lWLuA(e@dZd~Xd&@yS2P7zW0h6s83TQ! zAKtOME$4{%a0dBqJsQdZ(dysBjd8JSC><$8`|CIDuE(8fVtKb+b)vXXwC|rUcqFNM z-&(o%8#A+v-mRkD@874aExO+z_^wr;H7GL6tUUrPB`Jhd&{i;&v8Q1$Y7t){vxmpr zDuSGD!?^ZgFh#pHSChf>G;Rq~Pq@c*2DLsMp@NioLz9e3p|@O@ z>3aKkF^@;oJ`>|L)kh-Q^z+9UBoZV)f%KSo1WV%mIEK{tL3B(?((LbR%>3XAs#-xO z<>bMrlseDB+kw4`gbTdKAC(tC(}w~ya*fb=`(TR~=#GLIW@$rfeJl z*dMEk1R9{R-2C~3TIeAhpR110u;sgax}P{PLqK%b@0a_;DNMa-G@Ln!&>I(JH#1jQ zuBD&KVqF4I^O{)e&U0!&Mx8Vz`Wi7#-z$^mzx*)3fys3f@^U*Mj}iCn#}NUKfC7*1 zyJ37L7TMA`SP`?mbCtGWJ`;=zQfet5kjtP5a47!(-tav*ZGQeRjTZ61{xU59ClEYL zTC=6Pzf{H3vhs1F!AHYNI|nv(j)+bOj@+cs;ZN^p zrW$wf%+aud1|qyUwhA)fX;fVYSe2g80ZtU?Fdr2r!bw5Cb(;SGb@DWy9y`O1#IO~2 zM;``8m55ZK>ZFV-bS<0}eEelNcd-1re7Mpr!F^7z-av`IHuGbZeZeY;srkVoy_`E< z9&&}zqTq(dzF;?S`IC;5nEr1U}V4$G5* z0ERYhU?EFh2Mn$N6<>|AeN5&Hsx&WdJD+R_{Rgw$a7~ovjcwzMq2wlpl7Eb5&j(z% z1EV{NF!DrZ{FMFF#1 zmx}#!mtsnF!99F&oddBEhwa7!6!R}FxhFjSa6r7eFIXxg<3ZmV&6HRLM>ueL!> z$!gwaUMn}g-0LrHcAu=V3A|J5F^5~wm?|H<(L|q!pIFJukQXn})>uL}iR;OnQdo0{ zHOI$z=dBxl-DfC*3uw)A`N5`y=-r&L(*#gvj)K0UoWKev5$c>iWC{p02nAAgbBIL* z4OCU@tzhnr3vG8WqATOg_!wbza*7GKO&Aiqjhp>)O<<%zL1UfD^x_i%o>B~d%8*-Z+_GJ)zUW{UpYwfeV0>nmYTj}=T!cSp4FB9h*7P&g1>AZckfOk_~ z`Ed=Ss^)3obD*S6eE8)BuwA>&r8IJPQ=HoXSXu%exhs2k8gR^U2!THE(de`BgX9zq z(|N@tv?SbcK^rvd_9g>*+#hGoNY%8)P;0M~6rKqEAJTV=WE`2ua>ON~<*-LW_G48X z90;Yj3DP>cH>@yGkdPZy-{TMP+iJIx{iM%^d`>o``<*G@O`U>$jQLReo+rTYj| z z`Mpd*eir%l-Ww8k#~}Q<75x#!w@*uugkJhj#t2fafQ}Ik+28!fv~Jq#^O~ikEOH1Y zf^{B#@Q~!T$r7~9@_Txiiv?+P^~ZBHB1H^uCD&6%HEd;KsMy`_2?&(x>JIzs5ZGH( zUBvs=^HDiFYz*1Zjx}hZtF8B*=YxU{2g>gn(@5KE@OjaS2GN?jNa%YwJF$BdO{NA= z%1hUBU4+Xha92aCXU1`wZmMiB1PB)DiKAU$lr&R{>z>Pm8Xd(^M^(Ynk4P7Q>~U%# z0+sNE+UUwBur#_nzEznjT8lUjYInvX#t3WFO*>{H9Bz%Sh4OWQv0)zh2G*whNGmcO>B9`Eq;ZUNt6%2*?2= z^!~ZTcDd?^+WO*+fH)TFU#AF3hzF{xjzDytwcYvf#cXVp+J0xo0F_l3?KlQ-Ip&JM zx_x@$6+r;-Hr45Z02UCX`}@i?oT21#2ws|5tZ$_hOiWc_YYQdBD{wC^w9hUfyNrAA z;v8EFHk#xZDu^Xiht5XLP%1}#xD-f0cSSU~jVglmvq|-uc7dO1{g^O?0cf7P@WR3h zVasch04vw?T%dvyY-zoO<>ey}vnZb3k9Khf(Qu3$_Z!%@2D!Mei?-gW~%cWg9 zKDBXm8%Qh86?J$#=)_2Jc59qrO9kXMT<({~mHK>4)2ywskj2{qUG`-gHbwnpK;Kv( zC7-zbePnXeSE^j4IChP}Dj5U|D9hZzc@x=$rD_)6>#QcX&DkeX<;+r{-`lKVK$0`u zc7-7WmmXdOnX%b>$6VuI0{50VuoT(Z_{l3r=b7u-Q0|jJg$2uMF7p z6)J5(R7o-a0E9qk7B{hi|x34n4G>G_yD*>)u*f%0J^K&+*H69wpBRRh5A_XA|GW04}qFH)@vPm#h# zBSFv31igV2BQ1sj%PII0gt^Y#P~&Nb&?Vw|4*(9U_$P-DQ-d!{7Gvgqsb5^1bV~8RS5Y z;P7nDa{+DrVR|EgfBBFlwJ+gr@R17v1e(q}Wq-@<*_z7e_Ry`)W1&~KlM!J;GMtAm z=Mts6M_iG!S#eG~8H|bX&F>E*Wm~!q_wG-lk;?e*r!;l2Bsuvp9IDXYKPEA9RO9Ch zVBAyD?Q)u~_udw7@%8b6YvGaRKaA@u-0XUo3e*1p)%n7wN!cTM-+3MFm_gHl$k>Lh z+Q+{xZIV>Ojj!CnbX5`vbX4neHv~3rlGu)b zM{3ua90aK-?B(Fzha_?h!(2^_ca3^OREYfPKJZ$mFyOK8+%OXeI(7Sbz|oXZwRWBc zZ1i)?4@*vdF$&V7GT(j0Zpa`|?u&D4pxs~G-?*%#Akdv}y?c;$jXU^S5N z)SZx8`Z!uCNuoof!!#aY1HT@zko^dF=6abFqyyh^eUCq!bU;?Be?y}i4sN$g%>Mvx zagC5Q6<3A+^L~kO>_2_D1Btzv73$~I7Y1xrPQM#+l1uRBn7wxlWJ2|;`EoD)ZNNPy z^Hi14F|`^VY3$^|2mrt+{_={5UaN(m)K>&61*BD}=5L6>=X*|GZqO`kSTpB`>??)%ok#d31&FCL5IGHj6p2PKtz{oVO zM;$uDg+-w>n~X#xyP*yUc89sdK~>m^SMzdRp*s%KS1Ozo$OcmU7?lMwd)h}0$75ny zNYPpvJ}?XTHc8r@9vCnx>YNbLMwo^qtt~U}`^c5p8+;-C=K8w?-IL|tcm{)HiWN4v ztvOl_mumC$&(#Hjzpb0rK^E>HtR!Byh#LcXzLq^vfDe*Ry)cxB4#3jJolK>sai{Y- z%Jw2Q(^nBD(jIj_p0NQAk5SoND5`8)K#TmbIphtfo{TZd3aGZ#>jF$A!TGS_$y&lv?>N*o`q@^EXvQ?T5!*l#OgCP+qevH9Ht zFJt$&pS&%b^*FewbH(&BbU%9D2)I_gKQYJO!v$2-*Wnx!(@3By_3MT6F%*BN3xyEBpLrr9?5pdYQwu2(91A^X?%a zPi-KYC+Ng8T$fA?c8?+VrwmF(jdu4z+4q%$WmqM>Vgpi}R=#)URRc?CTK8eI3X)Wj z@bv2jBgk*LNNow~?W`3LC>u(dq>e&#OjH1fKzF~K{-S*5fh2bIihwFMbb&(h=^;nTL%n?ST09yn~yW?7JOhh7|5HrFfib{(2SU3 z>CaIi#xU;TsPBUi>aKxo>DPIs;M%lC@aX{)_5@7%2`TA5a!X+gz8tY^g?ZLRuC@|M zc78C}x`Ee8M8VLzwEF855164Zk@pd?XmVm{BoFI)!YvjEpxuFkilPY+X0C8V*A7w% z8tHa?WM2imrW$Gr)&d;%#6=h!vg!W-aTcyjH}^j94PcXNe8s|IRUzGNbi_q1Ax1q= z@Z6TIo7sE9$L9flgCX^`yK=hiy0LlW}=8K7su;hNteDG%W z`m7kSqaiC!>aA?T7)?aib~wb;lxeS+l1^)o=Gx&G)k|z!V4D{cF zh>col&)x^ydyQ+3R0CXT{&0ICiB$5?lM7Ug0XK@&DzpNg;iPdEGM4m^(GE|X0u&PI zT6InwsoI2qLeiWVY@n{dPj&YQD<>cWkp>+@r_zmK{W(KR6j%%e^>Gb2%yfZ=ET_hH zL=o45i|<*ZB7lgA@ym{ZBS9pDy z6UwC^B8%gUF!EE9xDP|4Bv1qftTueS!%;<$QSSSSRw4uhOLgg%Y?XyCC-Ig7iwOko zNBYY`00ORqFoW3}YZ7mcZOy)VI4sb6hcgKckW#JS`#Evp2`z|V!{8C1yurXoNzFI! z<%I?E(~xxl?8t#g+;;1W*El6WOQ?uW$?ip6(Y>a(@Hu@0R{7-LE;ef#C}>@=*-hql zS;IM8Ewfq)B6FQAGz+(SFT2A@HILYJh`K;6uDM)=jYus8Y&G5h5RrBrKJgi&nz%b4&VN_2v>tie_vBJtGzBNhMz^#)=Ik=Ct7mSb@CuH zMES#53cDuHrxUR;-dkr~4gsP(_Z$&x$;-iVv|2h#-x;!%60J9_esC(Z5!Y!Nz2JrQ zK?-k;+@F%H>?as9o7qJUlNejB+;i&_q`i+!`(*kg9++5dUW?RRpNLP0Z=1(`%Dp$% zVl=J>y5>yR2&9PQqcrBUY@;=-XP7LCo-|>_WKC~!41{+Fw0Edr;$TZqU#z<*)oIY4 zX9@hU)eX6>;1$INgvo&B#r`Z-p^;G2z+y_y2}R8w+dj}C$Y(5^=al&OG~R=lu?57r7@-!Vy|V$9C9$iB&|MXeSXEWX zf_GzpY|c&Ijm>1oi{pgJ%=P9Lzc|f~9h*0J2R=r2N3Xt<0n%L64~GeBvN_!`!ZY%C z$6BiT*@i>nRqNzsgA?*8#BJ5oN}M!Ize6(_mdK;YqG z$stOBdq*e(xEgrd$5uTmuTb)5 z(@N?>-n$nbtelhw-W5>SYT)iU0z~p_R}<%<75LAQ>`a_W(@(O$=_7!WM|{{YN3DGL-AXg&Nf zR8ZYj&E%zY&v`TK0~#hTye$^JYYg)(;StA@!aa$rj^nK;8<(8fcM&DtmkMKs-$S09 zL0Kb*KF-W3rxf@O+)DR5;^ew={{RWjN`UWZlM~V0>dWzr-5`x6A6&OOb9=~tC-t2a zxztyZI3fxxK{y^?9$|w*OURrj_L)~~SPQm*dg~UQv2+3{wZf&Gj4-otmSA;{^b6Xmnd!;el(m$SBTVnU4Mi z0`F^%&H#uQmW%P$M?fInPqU1$SvUrFUR<>TFp-~Hjx_Pic_YHuhG*CPn!H@tgwR;Kmjf-%rK!aa6 zSZatX(5n2ni>N|HHBYc`2C+xJhesG>j))onRTm)?n^QL?_OD>-isex)=xL0nT z@JqGXS|WeA0xh~t5Y0zQ1nV^doDU>U@TKH8Pk~tI=_B%TN!|~)LXOLIb5{bnIK4Ol z6SsH@es*xn%=1{Fxkzsp%wZOEN*!~E(YFO%?*N0nw`**{9exJ*+xL|MqkEh^Wm0m_ z{{UFfx(M16n}HZWB5lF#7!gB{3_g&c3OWXsW%8L^q^H?VTGpc5zudEB1vfm^4wFknC0Ygl}aK`#wX)0epb=sCgX5RmOS`e zBX4NH4vtJhBOcG>%K~ZFIXX3t+wLuo$%)K(_s-ZJU7!y)j`;c$?}KJ+GgeA*GUkb0 zth|!&d5?9WlJ+UeIG%}(Q`9SsOJ(ER!y@Sw5!80MU<`_h&8|iq)_qbiX?mJXObY@5 zZB1L&UIrb*Jvh~KMOeJi!kZNutJ=Nm6t^0#`=-pZdIw^5_?Y!6SnYV}aDF(43OH-L z(qXZAs~z`_xn=Y(j$oN^k!&0oGDfZc04653%GrRbC=M|QXgf_Qft2hM?dBcW1RikT zLmyhk1nw1!HP6k$2$Fo={o%;v8L5pTv)%a_^@_FttG;hOHIx9D;P!2=@sAAxriD7g zPdWf8^?)^cZr4TvD5?PsMTrcEz4bVuniZhaZ0B6#D!mP&kc*JgS{l5#e8k>^z*;n1 zZBHnC7yq8>lrSBfdD5b&H!B=&=M7gPnf$Bhh4m>l6aB<@%T!!A^is=kEne!Qn?~^Okw4 zn+63)t-qKTw=QjKO@!t+GXPtLy$?*`1@@KehY-RGK_E?xha*n$Fj+J$o8A{TKpN5< z1(f@A0bOC-17quU&h7#|J80>`Bs-Ub zO=;!CHakUp?aF8yCx*^kE_vj`_v0XhWbIFIXUvU0d;| zpS*;$S;YF74yADLxbMdTf*ZeSky;BOB26-NQnj&Nhx9VoL`m5@JUD1pF2Tik8C!q= z$@p*O&Oo>UVlmD_0wmRdObTj12TIL*2Ot~0CiUtS14p_nhZTSwQ z>l!w|RS5A&V!X`@AuQqR>msyww&vKy{OmH9>3OaV~8LH@;dWrVqp-Pj@ zYfs_BGeN#VTJVNIxw#wG>U4&1kaA7zW84@?XHtCtVO0%)iOvOaD2O9mQ@isq6tE%n zYx21g4o23CP|3;~g+xw{+>RO`h}s-{OO6#w%i*W$;voVN<@GU<8aWylB>3^jv|9`N zn#V;E*f|FRz|<_{ImBs8FUaw7f1xS&gTZHn2l* zg4Ve)(?wQpi+OU#F{{hrG4$k`-d=94$Ag=iB;Ej37DKEi?>aot#Tp!ic@9@CE2LMK zMky%BNRf-cd>Y>f5s-W;XE9s7UxRpyBHd3L)08I!K--y!gh>L-}CLgENtbdZi7VSp6{_hrS7hNwpZ>9=&9OmV)@Wgq2BqDg0t@lap{%M`~pL0Fr;q8>bi>bUPQXz{db`f0u|# zq&iipjO7!bOa~~-NvnhZ04AYS>~9wy#9g3r{8t4msez#@k#45XcPp)Z4a*~nD{il5 z$Y$Fh(@=GrRwdGY*gI)YFj*5_Js99B8-?)1w~wd>-PSpN@wEJ0)oLIJgpM8!sexj3 z-VCko&^(Nd@KsRWKN;A@y)ftq6=VDhiP9$<xS*e*q{Izv-9T59 zePbf&3NxI@nX^96J8oz@fJwRl%{si|+G}guuW9ZFm4ZI7QxpztrsS5*$d- z3p)7TaX?QiN4=b8ofDI4>v6+PWt}JCa)St=_vfy#;nHHZZDttmdLUt_Zx(upYz=QF z001V^JOt|?>ouMii1vYgO(r-U2#nFlmqVjJ^Vth=ld?+O5uO{XV(8Z@e5MZXFRKJ;SY61~cOL#uaWzY%wI?3G>mI8mS z7og~7!Pn`QI+hl4iOHZyN>I4rIP)3^6NFiH9x%uYDjI~q@lZLh5k4F-Ivtju68DeW zkZdD>zZgIOQCsADE|{9#l8h^Q=x>X#tL!9mc| zgxwXwlo&VAMOKxE$#a5(D5|?1AD*%iDk(ifeYgptP>at3bUHG%aD3}zHv4f!1D9qz zynp;)&da50SA^>cjI7*&4PS?&4#&r|1V5GMCx}px(N(_UboUW;)uxPEu2c={I!t&+ z3B#Z**S+FJsOhaW&iQji+9V}!3cP$|_*Gj#o)gCsSF{O2CohaNO@u1$@qGUBtQ-}v z?dO5TE4Bjep|0@>wsx-}I^zJVz?8flk;^HFf9rc5~<(XbBP9mOIUd|B+-hJ z^7JZFl*P#3y)vt}hrSigIQ2aJ(7%{Jw2){%4^s|IqYbiG_# z#;63N@%VYgv}|^&u51rja=;xKzU!65?-n`I5SWfV(v``rbwH8(WH2D<-m;{rPgo? zNFvS7@URT)buV~k<5^_6O(u%h4cqh9KqzCl^VECIzV8RdN5+QTT$m~WqO}O%W2ElD zlO%Xr9}QWl(1_b+XE2NR&LNOf}@2?9(?c0KT>5^X~(i zA#hql$~#v)!r?B>dZSzABct3Lt_9geYOud~$*0x}Qg^i{?TBbb&NEJO(2voK=4n&ya3}C^7LprvqaM)G zP)Sd$kem8@au!4Xmc>55bQ4s#z zH0lF-uBRHvnur0jf9%0WWZZo++|@OPXGQ? zN}Yx6Tr06a5Q}a2m?FGLH$2ubBmqTNit&c@FRB_$tyPFpPoDV5Fs!ql{l>%&UYpf% zADeh1$@7%hK%kZbh&{@rTUV;^d%TCO%_Vfdc^DGx6GF{y=$H(4zDG*;8cUa4*33#T zI5oOAOi*3@^rirm9RC1VQ&o%nn#SDPOxk#RFq=pShQDuzCKC(U?Zil8#);j)*4ReX zfy$9*bqfILrQSNCqTXg&qCk3)Fvx|n%ZrMy{cxaTz7s*tsROr+Bw&XQs9;qENCD(= zQ3XEzN>Aj&K!AntaEgjP(Z#giYS3-n^A1JASCgJym?tD|V{S|$39k7B`M{PG31zOg zzv+x(*4+oktROBuj(^a>bPY7B>g{mc!%;}8h0vqt6dI@( zKsZ<5i~*k{H?4NOxT)NA+Ne|)qd4@U*9kaJaM4h-?gr8_o^XUigyQHjY={kH;mfuJ zqhyh2?ZBXXOlZ8_aJjnzrE8{z(X17s|o3G!@)N zD15pxijk@(iM9D_HL60k0bJad`N`VBuHU16`HGS>Xrf=59n3&(2tq2lFv25Llvm)$>2q%TbrPRM*>kSG?v??zp|0|M{o8jP@^HBjr^a(K|_K7So!l_)X0JmoA z@7Dt(p;eF`0z&SM(USd2_f zW)Z_ZA2FGPy)<95C7Ye*elv{k*dec15Ddu>93cpN!YvaEEOF)*w;mHfeIihP3ygw3xuslk&6_ZDd!kLn%kPZ$o1*U^&eZ9l& z#GP7X(+qX>ce4qHfCTw9ayKWbg??fV zHdnI;!+_g5y?$_M0t^j8?^DdP2I|XaL=L*b6bxu@L>BU3se%YL#>DEFsz4T`VcyJQ zC@i6Ar4FBSP|y)}5SVUMO1Jg{tW`=-3jp`83_&1IfMwvX7Xu_Vf+1%Mvjr%sm4n_i zb(dN}wi|Shez2RHpdxurj2aRbNd#;h&>LRPoFdMr7?6A*iom9)e>h;%LT}tQ&WAf$ z*@(Nbgcn#Lj`Ru16sfK;3sfBgTme|)9U3*UN$tUgoQC;Rlj|r-3QMPdyr4m*r4^Dc zv0{NjtFO101XsI4BZ~6?fj2|Y$)SY+7-PrEM~u>qwXG+cJHM>f7#jF=HIg)-vD$fu z&VKNU7eVA~y7D}vCqkj6b8oday&SIJ;!C>soB$x zSP80u7hk-9jgk@5PtH@cXf*13{AJjw$wY5AH3S^MfTSOk)==28a+9~k%snxxycKJe6n19Fr@mo58L`Sy;1$zz&e>O1Ap95934*H1ZQ~Wfq-!AQ zsIO#WRCA}yxN!+*ie-%{@9BJE@&ZP@QfzZ@8%>;M>+HzjrH{P)$oyvhM4E9-5(1Zc zF-6Fg3Z)U0nC#mRE);KBsN@RMAXW#Pfg6nIG5`RC#npxmR6`44+IwwR07DmJm5a#K zayD9|z%&I;yUjI zZw+kh#t^Cihv~|mhdVZA=}oKq7a7jUBOPxs!fWSv)M}Qg{bBo9Romwe9XK8?`PX~M z^Ti!;^MKs77jWM3h*6}RGh6I&aMnZAJaW7XX-{uiKOUVf`@&#x$FJv%w6Odg^Sl8- zzjWdKW!9m9TCp0{S~52u(0Vrkb$_~1L}UU^px z&>N~g4@bP@Ji^+tbALxzqwn>XQP9&jy{D8B|)d zR@;K0h$CC`ugL88_7+mZ~R zwt|OvJF12E($5d>Y`v=pc3E`0nwNN@z|*^}#^cjt79T z-W435I`!5MSph+N-t_Mp0yP4=a$*qmeJn90ZF|~e5dmO!g>0Q%?V`>Xn=vkpKt`{5 zyF5z`D=hC7K8Q|B%rFx2XVc4+l2{wS6?G8O`Alm7pp%b(i<&A$suZcudUKE&9Ss}p zh=?IVZSdcm0xBAfz+z65tQGVi>}^ks0%6GQmOAOigY41J;=GzpW#lq?atv%H%HR&A zZ;O;*5IqC2_3jl3M@hb1Nn(%>o$=O9nh-$QNW)BH&Cd<=^ zND9jH>DERkw2{{i`nj$s2PtSXuJV@n3aDG(SdS&rhRq=3)}OhsX76v$ZU87H z(|Qfzc$xJdF~Qx)bv}0GJEAtJcm>gbaRe<<^vY*9M7=W~g?3aC?81FeqkMwmt-m@G8b$oGD1Jl~<#sWGo1TgBk=`OxAR8Erhh$?bJM;|x_O`!YarWZs{QtpEi}jZ>+$Z5HxlZ)$yHyxrUHOFG98psEr3%~xRFBzVBZ#2X#2 zb-*b!&@gv}+74DrFW#o6VifC4Yki$!;sX!AN-g;CXg5TJ8=X7 zF=^9ZaLNm@ATQguvmn~MA$A&6tq5(2Z?z{NJoZa=qp*V2d!`Q32N%|Za zFoYy-o9EwghJhZn7QWnZg|28%$oXL4)O|dr&x4Es>LHII!rmN&2m(Vx#JS9x(i{`B zdQ71*0Kx@MFgnAxf0S&#V@xEvAWdlFof#K^KmvwDaf|kZv%7if{AU@ZOT7*bQ*J5Q ziJCy1+(2|%^l?XRRv{f1>lqs09zPfuO^mfvw3tGK^GIL-;v(`Rq&v6ucL)^pZK+Lv_il(|t) z8yk-t`7muNwya0x?7(Ozc)adY=LYn#CvUg|AX8jgM-mMjX6!9)0K|W!+ngN|`Ym|w z<1H#L;qEx^K?2UcTuZAkhNAWIPI95Xua`&D-Ur|?Lf+eY&ktfw(NDXH3pE5CnL`~U zK1co-A(;c=9ck`jofQv>c-{a-fCDjp#;x4N(Ajme^q(dVT2(t_r|T#&k-B?!VwL7S z#8Drn^P5o}T|upUGY3BZ0DyGf6hd^38lPFGsr5?WMfTLzP>d5qhaR(#*F$67z->4f zh4>$=8>Az){h1I-fB0OlOI(ex;yvTR1!XRYEWja3ff3gl{NX?-_PlvMyTH)&;Q{S$ zSR6zdz+#_RD1b9W1^LHlji7g-n%KsKp0D6#nL!4T-TwfcMI5GD?ARDpM2#J< z1`SS7Z-PJRfgwZeqhd3Oiy#xywNvfu8WaL1sK%t`tSp~b7#nn8EgqgvaYQ1GYV{lu zK#NhK;D2lpgTH!P0fo!FQ0#Ndk6a>_+ZCE@zb>;v5Zy+l(^h%LNu#p%AYBe87|puY zm##}+E+T7`S_7tbdSaq6MNXWDS1Ej4lWFF-Fs_os9Xl`Q;g&$A zibCCvs&S0dOK)Vl;u^p$)q%$pHvQ(XKnmbQOZDD4-9#JPgpc7m${>%Dff=seF}z7Y zT`v@z<05FG1NDUyksc`Cpy=Kmh7dq#Ku>b+(vAIbY3@=h_IHCN2J0P;Hh~z+)M#60*X5rDU5Oi z(9(HVVUS8J4b!lmVlX4@_dILzaglwk*4Li&?-ve)H(5(jsjRoUoW(?Ndg1(SXoyC! z*(a$0(mx9hD`gcYLGor%t0=$c+l^2)k7?LUvg#I;@u<$RuXyMcd2cVckrW48Kmd;V z>lmQn76Xd8b@!H$!gOd(N1^D%rL9p4Uhm!ogQiqaHNG#n^3X>?ahXW6xoYWgek2x( zI$`&k6iw|@u}-p;JCxcNkur9R3u0edCk=;?vfR&}mXTG>9piOP> zeVC@eIus3b;wTtaCir1+y^slM-UG6DC`q6-3?#RL)H=Z60A7MUesQC1R}pou*G5U~ zkPXv>F+wo1a0=)QMV^v?Q-gf#2Yk>a(<#G&QHUi7JZ?l6BY+#^=jWU!PF20Uy6uW7 zPQapf&lsJW76zzuH{0onz?F%nYZ2DYJuK4Xvj-a1ZAr3*a6im67Z4i6!=N`!)n{fh z@}N5`)xKxG6m{R}jMx<$(ROFQ)v>~Xp6-#PWdfWWJJ$aITwsO@!NMkn=F3*5 zR~&!L24p0?mA4Wd$A)~kQ>8pKJ)N)42VrouE!4l>5eZ!26+`Orm11ty(qySCas{nW z!yTd9Y)$?Sbx2(%nFs3xd$fSGc$k~6j<-#labT&S2C+E6Y=Y4w18H%ZEfh6W@*ZnO zED`|DMPLb1Bbdq@r^%$rXrd{Ff#v0PE}Q#0X?~e9S>Ark4Tpn?RDjCQ#L&>f`8$ z>twi=P!yh=7?f-l0i-^1lHUzX7fC2_Odt-=SQaAQ9evX%p?VPy&KN2XrkpMm4J@mV z!={|F^5R6pRY)h1GGl1sVAZW48d7i=->R^5hmJ-;(76fh>f@^UQfQbwP$_RhUKm9x zjeHYdyqebXtSt8F#K2eRCQ<@a3qM2OapkHP;Km&|c_bejR{{ubqwTBrg{B);oO}4d zYMrDN=b3@gMMyH1;ovS?75TW5bzU6}7&(52Nq8^&fM)7J<5h7Ic3tkR2BhkNcZ-9(LOAt5Fb6kLH#dz?jJm8x`q+;giMvR8R;@@aZNTg2e4-2+ldl5~FuWt@!ZA zM@0=5xiMjIn?pJafWizTK@R5J4VML@TBEe*S+PUepq#GscxT8I1#Iff(gb*EUQJ{K z2f~xSZt&c!oEnZa7}kgi`Mlu=7T2xe%cMcK0^P=984E#$dVS<%f%9KZ{_uV+IxYV4 zo)~Fpn|IaWGmu@-(9@A|)=C1az>WzJ#u^uj;+RpSGTtyH7*!fJSl@6(3gkp6EdAvJ z(^|vec55%Dh<4+avZLjtBYw|jXrz>#B+z3q2H;aoy4ZMNhcg5_V*wV~vc!9?DZ-NK zDrsC}YKVJH^YZIhEjxD_51W!BU=0sx*twuG5)-m|d#n)%m9+u-t~8Jw2vra1F+_uP z6c>APWegWZNAf?Qz=f+qSJU;AI_h@1H`92UV}oHHQwu^11-lg7>lMx#gF6)py@oXu z@Bm&qOg0D&Lt1+80wii0mg{D)-J#(_pj7?wf+|ca$n4p(tlS?3Hj|If^24rzgKNSx z@9~h?wKS2^@8>HzK?si4b@7Km0tO+?Q){exz)@y}%5z-PL>jya*Vj0jNP>n`-#1gE zI%HHEbgR5%Dp49X1>+4Ka>Z5BCA#Rr0)TDo5~e7PIwb!97R+G@7zBA8(8R{Z7TDPQ zpUx!8$!PN5$KDX)I{5r}aibu4F3VD2L!8rqJRb4titzyX92#`eP2X4VIc$xDNR_!s zDB5`%Q|pX27N`h?dcr*<3X8-?*_AEQvszoD^^|!62W|#z_;Ib~Y_wf)Vj`^&Q>5Ph z@CG`PCe9C$b8KzGOH-5MosyD0BUXEgZa9QpqrkX|U36VumHNq7Ad;bt!3p80V^Zy{ zWM$G$0~u1@!+|kUyb^U->lTg+F9PEQT51H{nnTP%NmXD(dB-S2xVuKW(l;qJ3e+D` z^h_~ofUe!~zD?XK1TLGSQJ9nf(xQWf_YmtnI&r@;_zRQR4iZ_XM_r~44;B73Fb3QP z1Yc-jxE&zvDqKcXa9;aNNwr(oU|WT9m$0=x4d(K-t5&BD2wVjy{f}|POBG|V;Gy`( z$gLuZ=)_>4C@9KvigAnC0lnd6Ly!?i-VPNaj>AE|<}jh)s#9%}%-4d-XuT818je7# zdj}yEqw*(TtOWIiI)5K-Sl5sa=QI3_an>LTzn39q#^}?%K8cV_g+i)meszo;G@O%i z;Tzz(Bl3BK(45n+(V7cX5z@_ElecT4BkwMqz6Re5j)Jt?9zmNy>e$>XvN^*f?NB;S zcZxBcM$f)^ze0F2}1#Yw5P?Z=$72Zw9kSXp}=HO@8yM>LVN*m4tsyj(6w)89iOav3hJ7hd6Emu+kYjMCcE zxqEO9w1Bss90!Rd_5DEj!8N3;NjxRvG*LAxO)Ix?{yoGX@M64(qmMO9}U0Xyx?uEHR+$LkJb~@^H*d<_{*IFR(gTo7|2a?NyoH2V`WXWho=k&k_V!6 zk9;wkq%AbLn|sO4%dBBEpyxUuL0_5kCtTxJ$`aH#3jl4v0&Vh4JEed-2%CnJj3fe| zNcNidth;1@fM~3pUb302Kn+gFJfYVZL8Ru%!7C)f60fHl_?Uz)6?q|1ff7Oj2EeJ5 zGyrH0>GzW$e8i6MSwuEjc#}C~Jh*!L&7FlU3i3Uftu{>z9T6!}Pyuh_|Nh&-rJ8l8!2H7su zGQy&vpy+Vg2~xFV=fKK@9S6M{{o_Q6`Wt!pxjbzuG@Y0T;F7Ae>S7x$dl>ucF2IFR zyjOk>BvWHmqrDxw%5-hr@4@=sAT25#k;b!0ECL46vmhyetmCg4MN?*`DduHL(hKua z<$`!C#{0n}lUsDhLfGi{TEl4x2&Wzxu}~xgTk_~Qae@USw{+yVar_R=iG57-5ODOd z^}vu3Ps>t%Jir&eA;YV;3Lroup>)pXYn?;~Z_zry1RTSFwJs6n2pW9)n2?0QM;gc=mD`@w`%*Au4ze-0RcMP%FW$E;SxU5d^ljk|!k8fEbs3v0Hg)WK!smHR$2 z0&`&GFqEKx9p8}3LeWvrEyy71p|HKj6(EUj(LbXB4%V&LjxmJA$YPoD8&4_oy zaYzahnp@q&WZKmr(CTGO9a+Dp3N-FrOS}5_G^V-Wv%US{#RqZu$I5jW)dzh0g8{>{ znDRKS0RqIPkJc?w)6z_nA+Z4oPFaZU%-$-J5<{M~3xw=uppL~bsFsWEaMhhu;AJE< zmV4*pHYtve156664eNQsVK_+1>4vD1Ivt4t@3~HZ%^Ozp`{2bi6r`)n^v%kdv;*T= zL>G@AM!xdubZamNDu5#J(;2K`BT-4Q=I~%ZR66DyNADX}cUfz?dSZaUf>lS0o2vkI z2!~qa&5J<0yLZM0fg>m{#mCex=w0?p-?#%tl3oL6^OVBtlpn2gh^v7^ta}|}B@u3$ z6Y&24S*>L$ttTCOjP%vuVsmqVjX*(ojv^(Y2ZZKe3~DZt*I9GI0ib9ncnp?0il@H> z#7HK(1*`hN+`6hK6@K&5GTO8J4@%eIN}7}7w1T02VP z3rduke6nZ-8^Zh1&-aGU$UF(DZ$9A=&26wukqAQdUN;uPf+Kv6-Z5HfCa?+#kWETf ztei{xVxiZd(Ij3Yya$^KN-^Px0J;eeVw)#fZ4#*^;YV!5g%WZUdYF!kqHHXC=T(SGY3u3MX*61zSC8A?GU6Lcf~m?!|lPAFl+(20r6t}ua>yn?)+c@R>OMuB_`k*iV!C2jK#CWwon64Rr+ z8(2u>Da10c7eUw<0Z`tB<@!1E2#3L@y2ug;*P7s|4*`{UBZeGNHmYt3fKrt1Bp(i3 z46e>W+w<_^G1xUfN@FbBWjRW3W+_q?s2ltL05AdyjIDv#I{afU6ex#JVg2BNpp5pD z_lKg6^=m9#nv1%82n+X!X(~dYcQ5A%L;{Ia@x?<>@oyvx=CPKI_MMBeesV!kfU9?R z_nS8GArNh;gvv!Zb#iJ8L?!o5ft=vC zZ~9x*{^kq!LZq~>aTHKPI{a9FB*-|qmiyAXu#_MKywK}tJys{Cgm_y_3C z(+zNxTYC2ZBQ-WMJ4gG0no*9&#wys#x_L}wvr2HHZ299I^nwnbm)9C@tN0@{I_Clt zlTFn+n38fZ@0D-~X^QYx54p>94@f?~=7?%p7T!|O%yHRGfM1UB#8_3QF7dvyih&pKo;gzPt*zf|`N?)tx{_WWoDE91PMh_P^FWw_ z#7+v~1Ay6x6wu6_IU6|H07p%{b&bs0+%MXa>U)r~=SjTQ+Of=kIlkz3q6>%w1E!nv z(%wtLmrzY}b0bFfbp~PHhXJQ8!<>*=i<<4kT9XmC2Pd&j*Iz6aq!$1wL z_w5%X`5I0AdxC|sULJFr(|&Op)o|8`8>bnbZNta&D}A?%^Q?_8 zz#PQiI?d8E5<9a_XBoUvM2JVs91Yfw4!4#c1c4wfz1}1XFEl809FCl$+6$4XD!|k~ z#~$;sD^2q2vlr4YNLTs6Y~nOV{{6`|9!kByKir{!Y6<&pMLWqgzi+q#fJC zrtalb4K=*E9hC-~9k%oFlsm14yl{wPK#Pqc+rD_5;@qT0qBm)MpLyNtG)@(-^x&vr z3wa-x6r5ER(u|&BZN5q)A8wqpfffQZE*Gzp2d2>13pg$e1q1@O4Xm`m15x4OOtrV|4(6LUIDQ*}$&fl?3d? z-g(M7A^^qI&+5lHLEvm$|@I1Lo}OuVq~0-NI`aY6~`5z=wI8i8h! z*wFkx{;@33aFsot=OELWu=T_yi2g%*zTm1YU^2iC+>60AiKK2_+TOz!ssbjcJ#?6Q zbegqMuCj^&qun&=`uN0Ha_Rtf_^(;Ytn14AD*y8TjOYc$(?p>!VeeontgmaFe{{8YZhyI=^_FPm{u@Q==Ntd9M#&W=aOaSEahaTL(ay0kh!7 z+=ZdSJz{FfZ|AffUay%<2MKMv<;7-$!^S#P zffUsMU2nxYKd`OaMfM^M??`*}a{oMzdjrQ4aOzzZg^nIzXRBAX+sT z)gMX8fJh33Ow)kVdJ*#gQyaQzI1GC_IrL5w6r0=z!OG-6J0qaULqI-lWkD{3S{RkB zXnRa0lECg@go^2TRqkOz&i0ajIZ_2meQd?E9-DPcHfu_q&(00>Lr$!iDR~bfkHL*m zI#jv!&se0X-B{X;V|Xvb&17?=%Ay|wYJTy#tYK^9!s^siZr>P+f=cdeeRSvl!~iA{ z00RI50s;d800RL50RR91009vYAu&NwVIXmVkuagL!Qs*W+5iXv0s#R(5M7Cz+rgd= z3WdG~ix)^Ur^$|RWR z<7pv6PKR7FSqPpE4TY?fPD!R+sziFl@;A$4N6v)dQzctNO>&s-k&y5G6?B@AxO=t0QFcC!7AW+5U^!i7pJ}_9J_3i|9|n*>twOjM=kufm^gJQM5-0 z$%eI~(Tiya+tALBBO6DrBlIgnL8_YUV}r0u$zzX(I+ll8lVfCq@NCx5krY)4VY1ZG z82Bl&MP|v%Uqr7ANc8sniZ~~{v9AnCmC_e5X)PG69o>oKMmBn)=7&>B6)sTuscRM# zI?@p=JEBBT)MT8oyKv2wP>RsiZUs$w8{W{{ajphLH?%0{P~pH)!#zO)Rc}I6eUL`a zj6vL?)Qd-@N@IS+>d@)K6A7ln{vFOlH$^83%6T-3#6i+M?Bhh7V*Fjm9#Rdl1*axK_oONV%el zjtJj@iy8{6QCEoRwkznk)9l}9PB4H#nX!NELFG6yLi()kt$4szEIYg$hG&&?jgs~Q7PBBQCI+(Kt zinBx{qBzk864AWKNbu0okc93Nyc-lxGYPXP=xL)u@BTh5Tl18w(?KPTkld3^VYlGf znp&k#fntt?$h-`LIaJEf(1wMEA#gRJEV}CjQMhbsjcMVFP}Y`(bV4yqQzRAaOLU(F znzH8P$=aWZj3wJPyF1GGX^=BRk(-+kljuj~373Bg;R!f*V3BVd{;>!_imQ)$sXvoB zmKZ`>7O=eGvgNlP!*3n=5*uV<(1h~l7|u}Oh~zdww8Z|g5ZkC&xZVn|i4?7tx!9V$ z{3*zmV#k2kuh+aIuoN|jy}+cLtYJ`VU58IMPqNs@(YEOREMNYO71cIQ)3AV(v2ag?#2=# zoiR){L>HSlTn+mvG)UqWkiJdKwd`tzVK!205S7U(7?Zh(N_-ijc4=8kyl6=ndbWi= z6nEf2ZPCOehOPTq;CzYTsF#z|NWNQxV)Dh8*p))67F!U|sn%w^j!SZ*p;#m|awPU` zq5VNLjf-zhQ7%moC6>&C(qgAAyjAgPoCe9fnuC|IB@uEiF#Hi-=-b%gp*Ff8zZZ@V z!L18Tu}0;gKZg&_CK9GU+_Y$7iTMcFn%c`6BTJ8LpBttpMYr@{j6+QtHRPzRy|`>P z5iEETofQavMMW6I;t+<%pwU*vT(*}&j>p=gf22Zl1ap?JD=k!MD;crHiL&Wj#*{-> z2JVHjP-$sz9AcArn-ggoPNJjJ6SsC$sUZy$_%uQK4XsV=i3N3{--KJk_;DJq$Ka`? zF}R3TX1S;9681Ob6e%}K&auF{MB@xeoDb-RPh(Av9FK;Mts>Ug)~wmLPx>ToTi}JJ zUnD+YQrhH1hIm6`XjP3m&WbiP`elvIM3+KqUd@X!xwv_M(L7YjtRR$BzDqtXu__=- z6B7;j8oV*4zJ{vkfjBROj8>OFQjMx4 z)viUqp`@d=x+Nm;DTfy#n-VUG7h_u%TS#1tqe?`I8cigA1lX2*3uIW9+%)`~^e0gh z!&jHKc?fEVEkm-B9YWkgUm3q8`6P=g$x2d`uO$h6jo%`NV4X7jA#1_AbZtlACepK) zcoArxsl{p@bx~H|3&F=?Ns(`3L`I&Ccc7jl-9k27Z1Hr2^vmbURA5p(LYh+G=RsmG&l^5}0(lJ+n~4 zh|t1O9!=kAXsro`=;uYarp8V7Hf1X-DM}XAwnUL>mu1Zf+p%rR#FJBG(D#gOZQIonP5VS9mjil`UD!o+6KZqN{MbaAZnnp=(D&J|Ck+w_ zFq0AHLyHy}vMpleq*Rd0KBX0u)*>#6+c2>tAw8U1NJkqDw(HF)N^Js1 zrGXq0w59(56JJ3Htl7LOQ9M%NF(~&Z$fOfZylo;!u^(bg>--$Hk$nFELUT=MwcQgd zcnt+fxT_OLs;(KKcpms>)0K)nK2Vxni&MfJxg>AkPfFz~vlbf`5?b!a#fDo9QI|0N z!;MBgs@oKW^=N76HEdGh8aU3n$5E=^2&?6B`;?n$8hxc7`d{%@QsS2j$m|L7L+RP% zW18?e4O!UiRsEVmaf_&5_%Vd(G)$?XD6D?bjFUTmGMQ#!Hi=c#hS8CqG^xoN0)AC1>6kBEerSTKD1izv+4}z0SfA#`+;BlL?L?bI4!X+25v24b z1e#5+C}TFKE&(RmNO3(TzRs)U*q@A2vBPB&iM{shyb5Z#c%LPe1+zpGf@!U?88l5K zK~y2FvWq?jsM|vP1t0$ag4jtmdO0N1fijK4j654J$3#PmG$dHzeZK;Uu6&KQ_RhHw znEQJXv%}gcvg7chL7PGHRs0(_Yv_3V2Cde3h;M6wJ*Zf5J#|LgHto5sif35oTcb>h zGGVBLVIGLukqq{q11R*xsq17o{@DblBhb8)%OV^74Sp9N1~!JC8p;;F36O?1ndEP~ z1+u^O3UE*A>_gy@sA%rS{9@}XMbTPigA{CRrOal}!i~IBL{GYenvY|r7}QZYIl`$L z6GO-N9igTr*!B{{#`mUU;Wewk)HPG1O=)%{{58z5XhK;t{(mf;tO(&*} zljvz)Fgr&!spxGtdKpXEN?wHD+k@0zK35&oUPpJ8if|z!WqS#vCtcbaZ*@Ul5WPg1 zwaB2~rXwE1s7(#88XJ6#qT#jhxU1Q%@;zrnDMs(NOm^akB8k+(KdC?J_{NMU?Gfc| ziChafN#OQJ+dNj3caO7U{J**rdHOsFl@x8gmf_f_Lb1PXl;hMKy6g7p5rClBQ`bNdk_EYh+7oo)bqX zA`S5rNcL8BijhjbG=F78CO7kHoA!sau*I8|!iDD%!;cNGX_X7e)zcj3NL*z`-^O86 zR7o>bKWYs)@%TVfJ=U0{Gb$!HlDgR1X{#Hy6%g9x8l!q`=(UbS8yXrQyZRBUWjS_RChRu}D8d>^8qucdBsCFesMzyQWc~38546NcT^d5zK2#`{ z;xC-pQj2yRM2pC~PezoRtbYlMDU_;ZHH64Qq%_(7q4k?8ZHzV94fY&Q7ZVi~mHEqN z%l;aBu%>Fx5pf2b9>1M6_-W@!Sx4wa7g3!)m%-68uRkbktWEqb9BMtH5+sn~N5Gn@ zmkfs@aWHLS)T2t-qLq9elPo`?X(ZeiH@ZfQLR72#dJvfoP8jwtaQTA`j(8izDOl+q zDOxLH`1kNbdo88NfSNTl;<{Xtq?8=;AtGdJm5=OY{248F9bBT~9V%* zqgac{9!P^@MP82zl*wuB`V%MQR~*vfJf|8j>%q%ROJm_#p{|FtA++p8Hkhncp}t6! z7Npu5y`iAvnz2jlUt?&k7IZy^Ea-cK2y9-)h(cm`6L+DNSl;&<@GkiJVz#0heej